diff --git a/.gitignore b/.gitignore index 210b381..782ac5c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ config.ini __pycache__/ *.pyc .cache/ +.autoanime-v3/ +config.v3.ini +.cache.bak*/ logs/ .venv/ venv/ @@ -17,4 +20,3 @@ docs/plans/ *.aam.bak.* Thumbs.db Desktop.ini -.cache/ diff --git a/AutoAnimeMv.py b/AutoAnimeMv.py deleted file mode 100644 index 8b7d35f..0000000 --- a/AutoAnimeMv.py +++ /dev/null @@ -1,3484 +0,0 @@ -#!/usr/bin/python3 -#coding:utf-8 -""" -AutoAnimeMv - 番剧文件自动整理工具 -Copyright (C) 2024 AutoAnimeMv Contributors - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . - -""" -import argparse -import json -from dataclasses import dataclass,field -from pathlib import Path as PathlibPath -from sys import argv,executable #获取外部传参和外置配置更新 -from os import environ,path,name,makedirs,listdir,link,remove,removedirs,renames # os操作 -from time import sleep,strftime,localtime,time # 时间相关 -from datetime import datetime # 时间相减用 -from re import compile,findall,match,search,sub,I # 匹配相关 -from shutil import move # 移动File -from ast import literal_eval # srt转化 -from typing import Optional -from zhconv.zhconv import convert # 繁化简 -import zhconv.zhconv as zhconv_module -from urllib.parse import quote,unquote # url encode -from requests import get,post,exceptions # 网络部分 -from urllib.request import getproxies # 获取系统代理 -#from random import randint # 随机数生成 -#from threading import Thread # 多线程 -from importlib import import_module # 动态加载模块 - - -WINDOWS_RESERVED_NAMES = { - 'CON','PRN','AUX','NUL', - 'COM1','COM2','COM3','COM4','COM5','COM6','COM7','COM8','COM9', - 'LPT1','LPT2','LPT3','LPT4','LPT5','LPT6','LPT7','LPT8','LPT9' -} - -TMDBTvSeasonLayoutMemoryCache = {} -TMDBTvSeriesIdMemoryCache = {} - -class _OpenAISkipSpIdentification: - '''OpenAI 将文件识别为 SP 特典时的哨兵,主流程跳过整理''' - pass - -OPENAI_SKIP_SP_IDENTIFICATION = _OpenAISkipSpIdentification() - -LastOpenAIIdentifyFailure = None - - -@dataclass -class Config: - naming_style: str = 'default' - dry_run: bool = False - cache_dir: str = '.cache' - cache_ttl_seconds: int = 86400 - tmdb_token_env: str = 'TMDB_BEARER_TOKEN' - openai_key_env: str = 'OPENAI_API_KEY' - openai_identify_all: bool = True - strict_mode: bool = True - output_path: str = '' - - -@dataclass -class RuntimeContext: - source_path: PathlibPath = PathlibPath('.') - output_path: PathlibPath = PathlibPath('.') - category_name: str = '' - config: Config = field(default_factory=Config) - operation_log_path: Optional[PathlibPath] = None - rollback_log_path: Optional[PathlibPath] = None - operation_records: list = field(default_factory=list) -#Start 开始部分进行程序的初始化 - - -def Auxiliary_InitZhconvDictionarySafely(): - '''安全预加载 zhconv 词典,避免第三方资源句柄泄漏警告''' - try: - if getattr(zhconv_module, 'zhcdicts', None) is not None: - return - DictFile = getattr(zhconv_module, 'DICTIONARY', 'zhcdict.json') - DefaultDictFile = getattr(zhconv_module, '_DEFAULT_DICT', 'zhcdict.json') - RawBytes = b'' - ResourceStream = None - if DictFile == DefaultDictFile and hasattr(zhconv_module, 'get_module_res'): - ResourceStream = zhconv_module.get_module_res(DictFile) - if ResourceStream not in [None, '']: - try: - RawBytes = ResourceStream.read() - finally: - if hasattr(ResourceStream, 'close'): - try: - ResourceStream.close() - except Exception: - pass - else: - with open(DictFile, 'rb') as f: - RawBytes = f.read() - if RawBytes in [None, b'']: - return - DictData = json.loads(RawBytes.decode('utf-8')) - DictData['SIMPONLY'] = frozenset(DictData.get('SIMPONLY', [])) - DictData['TRADONLY'] = frozenset(DictData.get('TRADONLY', [])) - zhconv_module.zhcdicts = DictData - except Exception: - # 回退到 zhconv 默认懒加载逻辑,不中断主流程 - return - -def Start_PATH(**kwargs) -> dict: - '''初始化''' - # 版本 数据库缓存 Api数据缓存 Log数据集 分隔符 - global Versions,AimeListCache,BgmAPIDataCache,TMDBAPIDataCache,BangumiAPIDataCache,OpenAIAPIDataCache,OpenAIIdentifyFileMemoryCache,ShowOrganizationIndexDataCache,TitleAliasIndexDataCache,CanonicalTitleIndexDataCache,EpisodeDecisionDataCache,LastOpenAIFileInfoMeta,LogData,Separator,Proxy,TgBotMsgData,PyPath,Runtime,PersistentApiCache,PersistentApiCacheDirty,CurrentRunID,LastIdentificationFromAI,LastOpenAIIdentifyFailure,ManualTitleWhitelistDataCache,ManualTitleWhitelistMTime,TMDBTvSeasonLayoutMemoryCache,TMDBTvSeriesIdMemoryCache - Versions = '3.(4.5).6' - AimeListCache = None - BgmAPIDataCache = {} - TMDBAPIDataCache = {} - BangumiAPIDataCache = {} - OpenAIAPIDataCache = {} - OpenAIIdentifyFileMemoryCache = {} - ShowOrganizationIndexDataCache = {} - TitleAliasIndexDataCache = {} - CanonicalTitleIndexDataCache = {} - EpisodeDecisionDataCache = {} - LastOpenAIFileInfoMeta = {} - LastIdentificationFromAI = False - LastOpenAIIdentifyFailure = None - PersistentApiCache = {} - PersistentApiCacheDirty = False - ManualTitleWhitelistDataCache = {} - ManualTitleWhitelistMTime = 0.0 - TMDBTvSeasonLayoutMemoryCache = {} - TMDBTvSeriesIdMemoryCache = {} - LogData = f'\n\n[{strftime("%Y-%m-%d %H:%M:%S",localtime(time()))}] INFO: Running....' - Separator = '\\' if name == 'nt' else '/' - TgBotMsgData = '' - PyPath = str(PathlibPath(__file__).resolve().parent) - CurrentRunID = strftime('%Y%m%d_%H%M%S',localtime(time())) - Runtime = RuntimeContext() - Auxiliary_InitZhconvDictionarySafely() - - global USEMODULE,USEPROXY,USESYSPROXY,HTTPPROXY,HTTPSPROXY,ALLPROXY,USEBGMAPI,USETMDBAPI,USEBANGUMIAPI,USEOPENAIAPI,OPENAI_BASE_URL,OPENAI_BASE_URLS,OPENAI_API_KEY,OPENAI_API_KEYS,OPENAI_API_KEY_ENV,OPENAI_MODEL,OPENAI_TIMEOUT_SECONDS,OPENAI_PRIORITY_FIRST,OPENAI_IDENTIFY_ALL,OPENAI_KEY_ROTATE_ON_STATUS,OPENAI_KEY_MAX_CONSECUTIVE_FAILURES,TMDB_BEARER_TOKEN,TMDB_BEARER_TOKEN_ENV,USELINK,STRICT_MODE,LINKFAILSUSEMOVEFLAGS,USETITLTOEP,PRINTLOGFLAG,RMLOGSFLAG,USEBOTFLAG,TIMELAPSE,SEEPSINGLECHARACTER,JELLYFINFORMAT,NOTLOADEXTLIST,MANDATORYCOVER,NETERRRECTRYTIMS,APIREQUESTSONLYUSECH,USEANIMETAG,NAMING_STYLE,CACHE_DIR,CACHE_TTL_SECONDS,CACHE_FLUSH_INTERVAL_SECONDS,DRY_RUN,MAX_FILENAME_LENGTH,OPERATION_LOG_DIR,OPERATION_LOG_ENABLE,OUTPUT_PATH,RUN_COMMAND,ROLLBACK_LOG_PATH,SCAN_SKIP_PATH_MARKERS,SCAN_SKIP_NAME_REGEX,LastPersistentCacheFlushTime,LastIdentificationIsMovie - USEMODULE = None - USEPROXY = True # 使用代理 - USESYSPROXY = True # 使用系统代理 - HTTPPROXY = 'http://127.0.0.1:7890' # Http代理 - HTTPSPROXY = 'http://127.0.0.1:7890' # Https代理 - ALLPROXY = '' # 全部代理 - USEBGMAPI = True # 使用BgmApi - USETMDBAPI = True # 使用TMDBApi - USEBANGUMIAPI = True # 使用BangumiApi (中文优化) - USEOPENAIAPI = True # 使用OpenAI兼容Api进行名称识别 - OPENAI_BASE_URL = 'https://api.longcat.chat/openai' # OpenAI兼容接口地址 - OPENAI_BASE_URLS = '' # 多接口:逗号或 | 分隔;空则仅用 OPENAI_BASE_URL - OPENAI_API_KEY = '' # 不建议写入仓库,请改用环境变量 - OPENAI_API_KEYS = '' # 多 Key:逗号或 | 分隔;空则仅用 OPENAI_API_KEY / 环境变量 - OPENAI_API_KEY_ENV = 'OPENAI_API_KEY' # 默认读取该环境变量 - OPENAI_MODEL = 'LongCat-Flash-Chat' # 模型名称 - OPENAI_TIMEOUT_SECONDS = 60 # OpenAI接口超时时间 - OPENAI_PRIORITY_FIRST = True # True时优先使用AI识别 - OPENAI_IDENTIFY_ALL = True # True时由AI直接识别剧名/季/集 - OPENAI_KEY_ROTATE_ON_STATUS = '401,429' # 触发切换下一接口/Key 的 HTTP 状态码 - OPENAI_KEY_MAX_CONSECUTIVE_FAILURES = 3 # 连续异常达此次数后切换槽位 - TMDB_BEARER_TOKEN = '' # 不建议写入仓库,请改用环境变量 - TMDB_BEARER_TOKEN_ENV = 'TMDB_BEARER_TOKEN' # 默认读取该环境变量 - USELINK = True # 使用硬链接开关 - STRICT_MODE = True # 严格模式:硬链接失败时不降级移动 - JELLYFINFORMAT = False # jellyfin 使用 ISO/639 标准 简体和繁体都使用chi做标识\ - USETITLTOEP = True # 给每个番剧视频加上番剧Title - LINKFAILSUSEMOVEFLAGS = False # 硬链接失败时是否使用MOVE - PRINTLOGFLAG = True if __name__ == '__main__' else False# 打印log开关 - RMLOGSFLAG = 7 # 日志文件超时删除,填数字代表删除多久前的 - USEBOTFLAG = False # 使用TgBot进行通知 - TIMELAPSE = 0 # 延时处理番剧 - SEEPSINGLECHARACTER = False # SE EP单字符模式 01 -> 1 - NOTLOADEXTLIST = [] # 模块排除列表,格式 exmaple.py,XXXX.py + , - MANDATORYCOVER = True # 强制覆盖文件 - NETERRRECTRYTIMS = 2 # 网络请求错误时的重试次数 - APIREQUESTSONLYUSECH = False # Api请求只搜索中文部分 - USEANIMETAG = False # 使用番剧tag,带有anime标签的文件才会处理 - NAMING_STYLE = 'default' # default|emby - CACHE_DIR = '.cache' # 持久化缓存目录 - CACHE_TTL_SECONDS = 86400 # 缓存有效期 - CACHE_FLUSH_INTERVAL_SECONDS = 60 # api_cache.json 定时刷盘秒数;0 表示仅退出时写入 - SCAN_SKIP_PATH_MARKERS = [] # 扫描忽略路径段;空列表使用内置默认 - SCAN_SKIP_NAME_REGEX = [] # 扫描忽略文件名正则字符串列表;空则使用内置默认 - LastPersistentCacheFlushTime = 0.0 - LastIdentificationIsMovie = False - DRY_RUN = False # 仅预览不落盘 - MAX_FILENAME_LENGTH = 180 # Windows 路径留余量 - OPERATION_LOG_DIR = 'logs' # 操作日志目录 - OPERATION_LOG_ENABLE = True # 记录操作日志 - OUTPUT_PATH = '' # 可选输出目录,空值表示使用扫描目录 - RUN_COMMAND = 'process' # process|rollback - ROLLBACK_LOG_PATH = '' - - Auxiliary_READConfig() - Auxiliary_ApplyConfig() - Auxiliary_InitRuntimeContext() - Auxiliary_LoadManualWhitelist(force=True) - Auxiliary_LoadPersistentCache() - Auxiliary_Log((f'当前工具版本为{Versions}',f'当前操作系统识别码为{name},posix/nt/java对应linux/windows/java虚拟机'),'INFO') - if DRY_RUN: - Auxiliary_Log('当前处于 DRY_RUN 模式,所有操作仅预览不落盘','WARNING') - if int(TIMELAPSE) != 0: - Auxiliary_Log(f'正在{TIMELAPSE}秒延时中') - sleep(int(TIMELAPSE)) - if USEMODULE == True: - Auxiliary_LoadModule() - if kwargs != {}: - for i in kwargs: - exec(f'global {i};{i} = {kwargs[i]}') - return globals() - -def AUxiliary_GetTag(): - '''获取Tag信息,判断处理模式''' - def A(tag): - if tag == 'anime': - global USEANIMETAG - USEANIMETAG = False - elif (X := search(r'AAM-(.*)',tag,flags=I)) != None: - global animename - animename = X.group(1) - Auxiliary_Log(f'tag中指定了番剧名称 > {animename}') - - if tag and tag != '': - if ',' not in tag : - A(tag) - elif ',' in tag: - for i in tag.split(','): - A(i) - if USEANIMETAG == True: - Auxiliary_Exit('已开启USEANIMETAG配置,但不存在番剧Tag,正常退出') - -def Start_GetArgv(): - '''获取参数,判断处理模式''' - - global filepath,filename,number,categoryname,animename,tag,DRY_RUN,NAMING_STYLE,OUTPUT_PATH,STRICT_MODE,USELINK,RUN_COMMAND,ROLLBACK_LOG_PATH - if len(argv) == 1: - Auxiliary_Help() - - if argv[1].lower() == 'rollback': - RollbackParser = argparse.ArgumentParser(prog='AutoAnimeMv.py rollback', description='根据操作日志执行回滚') - RollbackParser.add_argument('log_path', nargs='?', help='操作日志路径') - RollbackParser.add_argument('--log', dest='log_opt', help='操作日志路径') - Args = RollbackParser.parse_args(argv[2:]) - RollbackPath = Args.log_opt if Args.log_opt else Args.log_path - if RollbackPath in [None, '']: - Auxiliary_Exit('rollback 模式需要传入日志路径,例如: python AutoAnimeMv.py rollback --log operation.json') - RUN_COMMAND = 'rollback' - ROLLBACK_LOG_PATH = RollbackPath - Auxiliary_InitRuntimeContext() - return RollbackPath - - Parser = argparse.ArgumentParser(add_help=False) - Parser.add_argument('filepath_pos', nargs='?') - Parser.add_argument('filename_pos', nargs='?') - Parser.add_argument('number_pos', nargs='?') - Parser.add_argument('categoryname_pos', nargs='?') - Parser.add_argument('tag_pos', nargs='?') - Parser.add_argument('--filepath', dest='filepath_opt') - Parser.add_argument('--filename', dest='filename_opt') - Parser.add_argument('--number', dest='number_opt') - Parser.add_argument('--categoryname', dest='categoryname_opt') - Parser.add_argument('--animename', dest='animename_opt') - Parser.add_argument('--tag', dest='tag_opt') - Parser.add_argument('--dry-run', dest='dry_run_opt', action='store_true') - Parser.add_argument('--naming-style', dest='naming_style_opt', choices=['default', 'emby']) - Parser.add_argument('--output-path', dest='output_path_opt', help='整理输出目录路径') - Parser.add_argument('--strict-mode', dest='strict_mode_opt', choices=['true', 'false'], help='严格模式开关') - Parser.add_argument('--use-link', dest='force_use_link', action='store_true', help='强制使用硬链接') - Parser.add_argument('--no-link', dest='force_no_link', action='store_true', help='禁用硬链接,使用移动') - Parser.add_argument('-h', '--help', dest='show_help', action='store_true') - Args, _ = Parser.parse_known_args(argv[1:]) - - if Args.show_help: - Auxiliary_Help() - - filepath = Args.filepath_opt if Args.filepath_opt else Args.filepath_pos - filename = Args.filename_opt if Args.filename_opt else Args.filename_pos - number = Args.number_opt if Args.number_opt else Args.number_pos - categoryname = Args.categoryname_opt if Args.categoryname_opt else Args.categoryname_pos - animename = Args.animename_opt if Args.animename_opt else None - tag = Args.tag_opt if Args.tag_opt else Args.tag_pos - - if Args.naming_style_opt not in [None, '']: - NAMING_STYLE = Args.naming_style_opt - if Args.dry_run_opt: - DRY_RUN = True - if Args.output_path_opt not in [None, '']: - OUTPUT_PATH = Args.output_path_opt - if Args.strict_mode_opt not in [None, '']: - STRICT_MODE = True if str(Args.strict_mode_opt).lower() == 'true' else False - if Args.force_use_link: - USELINK = True - if Args.force_no_link: - USELINK = False - - for Key in ['filepath','filename','number','categoryname','animename','tag','NAMING_STYLE','DRY_RUN','OUTPUT_PATH','STRICT_MODE','USELINK']: - if Key in globals(): - Auxiliary_Log(f'{Key} < {globals()[Key]}') - - if filepath in [None, ''] or path.exists(filepath) == False: - Auxiliary_Exit('请输入正确的处理目录路径') - - AUxiliary_GetTag() - if filename not in [None, ''] and number not in [None, '']: - return filepath,filename,number - return filepath - - -# Processing 进行程序的开始工作,进行核心处理 -def Processing_Mode(ArgvData:list): - '''模式选择''' - - ArgvNumber = len(ArgvData) if type(ArgvData) in [list, tuple] else 1 - global Path,CategoryName - Path = filepath - CategoryName = categoryname - Auxiliary_InitRuntimeContext() - if path.exists(Path) == True: - # 批处理模式(非分类|分类) or Qb下载模式 - if type(ArgvData) in [list, tuple] and ArgvNumber >= 3 and str(ArgvData[2]) == '1' and ArgvData[1] not in [None, '']: - FileListTuporList = [ArgvData[1]] - else: - FileListTuporList = Auxiliary_ScanDIR(Path) - Auxiliary_DeleteLogs() - if CategoryName : - Auxiliary_Log(f'当前分类 >> {CategoryName}') - - if type(FileListTuporList) == tuple: - return FileListTuporList # 文件列表元组(视频文件列表,字幕文件列表) - else: - valid_files = [] - skipped_incomplete_files = [] - for i in FileListTuporList: - if path.isfile(f'{Path}{Separator}{i}') == True: - if Auxiliary_IsIncompleteDownloadFile(i): - skipped_incomplete_files.append(i) - Auxiliary_Log(f'跳过未完成下载文件: {i}','INFO') - continue - valid_files.append(i) - else: - Auxiliary_Log(f'{Path}{Separator}{i} 不存在的文件','WARNING') - if valid_files != []: - return valid_files # 元组中唯一有效的文件列表 - if skipped_incomplete_files != []: - Auxiliary_Log('本次仅检测到未完成下载文件,已全部跳过','INFO') - return [] - Auxiliary_Exit('没有有效的番剧文件') - else: - Auxiliary_Exit(f'不存在 {Path} 目录') - -def Processing_Main(LorT): - '''核心处理''' - global LastIdentificationFromAI,LastOpenAIFileInfoMeta,EpisodeDecisionDataCache - - SubtitleFiles = [] - if type(LorT) == tuple: # (视频文件列表,字幕文件列表) - VideoFiles = LorT[0] - SubtitleFiles = LorT[1] - else: # 唯一有效的文件列表 - VideoFiles = LorT - - VideoFiles = sorted(VideoFiles, key=lambda X: Auxiliary_GetSourceFileMTime(X)) - for SourceFile in VideoFiles: - File = path.basename(SourceFile) - SourceAbsPath = Auxiliary_GetAbsoluteSourcePath(SourceFile) - SourceMTime = Auxiliary_GetSourceFileMTime(SourceFile) - LastOpenAIFileInfoMeta = {} - if Auxiliary_IsIncompleteDownloadFile(File): - Auxiliary_Log(f'跳过未完成下载文件: {SourceFile}','INFO') - continue - if Auxiliary_FileType(File) == 'ASS': - Auxiliary_Log(f'跳过仅字幕文件主处理: {SourceFile}','INFO') - continue - PreDetectHint = Auxiliary_PreDetectEpisodeHint(File) - if type(PreDetectHint) == dict and PreDetectHint.get('EpisodeKey') in EpisodeDecisionDataCache: - ExistingDecision = EpisodeDecisionDataCache[PreDetectHint.get('EpisodeKey')] - ExistingMTime = float(ExistingDecision.get('source_mtime', 0.0)) - if SourceMTime >= ExistingMTime: - ExistingDst = ExistingDecision.get('dst', '') - Auxiliary_Log(f'同集已保留更早文件,跳过较新重复资源: {SourceFile}','INFO') - Auxiliary_RecordOperation('skip',SourceAbsPath,ExistingDst,'skipped','newer_duplicate_kept_oldest') - continue - flag = Processing_Identification(File) - if flag == None: - continue - SE,EP,RAWSE,RAWEP,RAWName = flag - NameEN = LastOpenAIFileInfoMeta.get('NameEN', '') - NameRomaji = LastOpenAIFileInfoMeta.get('NameRomaji', '') - CanonicalID = LastOpenAIFileInfoMeta.get('CanonicalID', '') - HintCanonicalID = PreDetectHint.get('CanonicalID', '') if type(PreDetectHint) == dict else '' - HintApiName = PreDetectHint.get('ApiName', '') if type(PreDetectHint) == dict else '' - if 'animename' in globals() and animename not in ['',None]: - ApiName = animename - Auxiliary_Log('当前文件已由 OpenAI 识别季集,剧名使用手动指定 animename','INFO') - else: - ApiName = LastOpenAIFileInfoMeta.get('CanonicalZh') or RAWName - if HintCanonicalID not in [None, ''] and CanonicalID not in [None, ''] and HintCanonicalID != CanonicalID: - Auxiliary_Log(f'检测到单集剧名漂移,采用历史别名映射纠偏: {File}','WARNING') - CanonicalID = HintCanonicalID - if HintApiName not in [None, '']: - ApiName = HintApiName - RAWName = HintApiName - elif HintCanonicalID not in [None, ''] and CanonicalID in [None, '']: - CanonicalID = HintCanonicalID - if HintApiName not in [None, ''] and Auxiliary_HasChineseText(str(ApiName)) == False: - ApiName = HintApiName - if Auxiliary_HasChineseText(str(ApiName)) == False: - Auxiliary_Log('剧名未收敛到中文','WARNING') - else: - Auxiliary_Log('OpenAI 识别与剧名链已完成','INFO') - if NameEN in [None, ''] and Auxiliary_HasChineseText(RAWName) == False: - NameEN = RAWName - CanonicalSourceTag = 'openai_identify' - CanonicalFromMainID, CanonicalFromMainZh = Auxiliary_UpsertCanonicalTitle( - ApiName, - NameEN, - NameRomaji, - CanonicalSourceTag, - [RAWName, ApiName, File] - ) - if CanonicalFromMainZh not in [None, '']: - ApiName = CanonicalFromMainZh - if CanonicalID in [None, ''] and CanonicalFromMainID not in [None, '']: - CanonicalID = CanonicalFromMainID - - if CanonicalID not in [None, ''] and Auxiliary_ShowHasOrganizedEpisode(CanonicalID, SE, EP): - Auxiliary_Log( - f'跳过已整理剧集(ShowOrganizationIndex): {Auxiliary_FormatOrganizedEpisodeTag(SE, EP)} << {ApiName}', - 'INFO' - ) - Auxiliary_RecordOperation('skip', SourceAbsPath, '', 'skipped', 'already_organized_show_cache') - continue - - EpisodeKey = Auxiliary_BuildEpisodeDecisionKey(ApiName, SE, EP, File) - if EpisodeKey not in [None, ''] and EpisodeKey in EpisodeDecisionDataCache: - ExistingDecision = EpisodeDecisionDataCache[EpisodeKey] - ExistingMTime = float(ExistingDecision.get('source_mtime', 0.0)) - if SourceMTime >= ExistingMTime: - ExistingDst = ExistingDecision.get('dst', '') - Auxiliary_Log(f'同集已保留更早文件,跳过较新重复资源: {SourceFile}','INFO') - Auxiliary_RecordOperation('skip',SourceAbsPath,ExistingDst,'skipped','newer_duplicate_kept_oldest') - continue - ASSList = Auxiliary_IDEASS(RAWName,RAWSE,RAWEP,SubtitleFiles) if SubtitleFiles != [] else None - MainOperationResult = Sorting_Mv(File,RAWName,SE,EP,ASSList,ApiName,SourceFilePath=SourceFile) - if Auxiliary_ShouldCacheResolvedFileInfo(MainOperationResult) and CanonicalID not in [None, '']: - Auxiliary_ShowMarkOrganizedEpisode(CanonicalID, ApiName, NameEN, NameRomaji, SE, EP) - if EpisodeKey not in [None, '']: - ExistingDecision = EpisodeDecisionDataCache.get(EpisodeKey, {}) - ExistingMTime = float(ExistingDecision.get('source_mtime', 0.0)) if type(ExistingDecision) == dict else 0.0 - if type(ExistingDecision) != dict or ExistingDecision == {} or SourceMTime <= ExistingMTime: - EpisodeDecisionDataCache[EpisodeKey] = { - 'source_mtime': SourceMTime, - 'src': str(SourceAbsPath), - 'dst': MainOperationResult.get('dst', '') if type(MainOperationResult) == dict else '', - 'resolved': { - 'SE': str(SE), - 'EP': str(EP), - 'RAWSE': str(RAWSE), - 'RAWEP': str(RAWEP), - 'RAWName': str(RAWName), - 'ApiName': str(ApiName), - 'NameEN': str(NameEN) if NameEN not in [None, ''] else '', - 'NameRomaji': str(NameRomaji) if NameRomaji not in [None, ''] else '', - 'CanonicalID': str(CanonicalID) if CanonicalID not in [None, ''] else '' - } - } - -def Processing_Identification(File:str): - '''识别:仅 OpenAI 全信息(季/集 + 剧名线索),失败则跳过当前文件并记入告警 JSON''' - global LastIdentificationFromAI, LastOpenAIIdentifyFailure - LastIdentificationFromAI = False - - NewFile = Auxiliary_RMSubtitlingTeam(Auxiliary_RMOTSTR(Auxiliary_UniformOTSTR(File))) - AnimeFileCheckFlag = Auxiliary_AnimeFileCheck(NewFile) - if AnimeFileCheckFlag != True: - Auxiliary_Log(f'当前文件属于{AnimeFileCheckFlag},跳过处理','INFO') - return None - Auxiliary_Log('-'*80,'INFO') - if USEOPENAIAPI != True or OPENAI_IDENTIFY_ALL != True: - Auxiliary_Exit('必须启用 USEOPENAIAPI 与 OPENAI_IDENTIFY_ALL,由 OpenAI 识别季集与剧名线索') - LastOpenAIIdentifyFailure = None - OpenAIIdentifyData = Auxiliary_OpenAIIdentifyFileInfo(File) - if OpenAIIdentifyData == None: - BaseRow = { - 'input_basename': File, - 'stage': 'Processing_Identification', - } - if type(LastOpenAIIdentifyFailure) == dict: - BaseRow.update(LastOpenAIIdentifyFailure) - else: - BaseRow['reason'] = 'openai_identify_returned_none' - BaseRow['detail'] = 'Auxiliary_OpenAIIdentifyFileInfo 返回 None(可能为 mock 或未记录原因)' - Auxiliary_AppendOpenAIIdentifyWarningLog(BaseRow) - Auxiliary_Log( - f'OpenAI 全信息识别失败,已跳过文件: {File}(明细已追加至 {Auxiliary_GetOpenAIIdentifyWarningLogPath().name})', - 'WARNING' - ) - return None - LastIdentificationFromAI = True - return OpenAIIdentifyData - -def Auxiliary_SanitizePathComponent(Name, MaxLen=None): - '''清洗文件名/目录名,避免 Windows 非法字符与保留名''' - if Name in [None, '']: - Name = 'Unknown' - Name = Auxiliary_NormalizeDisplayTitle(Name).replace('\n', ' ').replace('\r', ' ') - Name = sub(r'[<>:"/\\|?*\x00-\x1f]','_',Name) - Name = sub(r'\s+',' ',Name).strip(' .') - if Name == '': - Name = 'Unknown' - if Name.upper() in WINDOWS_RESERVED_NAMES: - Name = f'{Name}_' - Limit = Auxiliary_ParseInt(MaxLen, 180) if MaxLen not in [None, ''] else 180 - if Limit < 16: - Limit = 16 - if len(Name) > Limit: - Name = Name[:Limit].rstrip(' .') - Name = sub(r'[\s_\-–—]+$', '', Name).strip(' .') - return Name if Name != '' else 'Unknown' - - -def Auxiliary_FormatSEEPToken(Token): - Token = str(Token) - if Token.isdigit(): - return Token.zfill(2) - return Token - - -def Auxiliary_SubtitleLanguageSuffixForEmby(ASSFileName): - RawSuffix = Auxiliary_ASSFileCA(ASSFileName) - Mapping = { - '.chs': '.zh-CN', - '.cht': '.zh-TW', - '.jp': '.ja', - '.other': '.und' - } - return Mapping.get(RawSuffix,'.und') - - -def Auxiliary_IsSamePhysicalFile(LeftPath, RightPath) -> bool: - '''判断两个路径是否指向同一个物理文件(含硬链接)''' - LeftPath = PathlibPath(LeftPath) - RightPath = PathlibPath(RightPath) - try: - if LeftPath.exists() and RightPath.exists(): - return LeftPath.samefile(RightPath) - except Exception: - return False - return False - - -def Auxiliary_MakeOperationResult(Action, SrcPath, DstPath, Status, Message='', BackupPath=''): - return { - 'action':Action, - 'src':str(SrcPath), - 'dst':str(DstPath), - 'status':Status, - 'message':Message, - 'backup':str(BackupPath) if BackupPath not in [None, ''] else '' - } - - -def Auxiliary_ExecuteFileOperation(SrcPath, DstPath): - '''执行 move/link,支持 dry-run 与回滚备份''' - SrcPath = PathlibPath(SrcPath) - DstPath = PathlibPath(DstPath) - BackupPath = '' - ActionName = 'link' if USELINK == True else 'move' - DryRunMode = Runtime.config.dry_run if 'Runtime' in globals() and Runtime else Auxiliary_ParseBool(DRY_RUN) - StrictMode = Runtime.config.strict_mode if 'Runtime' in globals() and Runtime else Auxiliary_ParseBool(STRICT_MODE) - - if SrcPath.is_file() == False: - Auxiliary_Log(f'源文件不存在,跳过: {SrcPath}','WARNING') - Auxiliary_RecordOperation(ActionName,SrcPath,DstPath,'skipped','src_not_found') - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'skipped', 'src_not_found') - - if DstPath.exists(): - if Auxiliary_IsSamePhysicalFile(SrcPath, DstPath): - Auxiliary_Log(f'目标文件已与源文件一致,跳过重复整理: {DstPath}','INFO') - Auxiliary_RecordOperation(ActionName,SrcPath,DstPath,'skipped','same_file') - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'skipped', 'same_file') - if USELINK == True: - Auxiliary_Log(f'目标文件已存在,保留原有硬链接,跳过替换: {DstPath}','INFO') - Auxiliary_RecordOperation(ActionName,SrcPath,DstPath,'skipped','existing_link_kept') - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'skipped', 'existing_link_kept') - if MANDATORYCOVER != True: - Auxiliary_Log(f'{DstPath}已存在,故跳过','WARNING') - Auxiliary_RecordOperation(ActionName,SrcPath,DstPath,'skipped','target_exists') - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'skipped', 'target_exists') - BackupPath = DstPath.with_name(f'{DstPath.name}.aam.bak.{CurrentRunID}') - if DryRunMode == True: - Auxiliary_Log(f'DRY_RUN: 预览覆盖备份 {DstPath} -> {BackupPath}','INFO') - else: - DstPath.parent.mkdir(parents=True,exist_ok=True) - move(str(DstPath),str(BackupPath)) - Auxiliary_Log(f'覆盖前备份: {DstPath} -> {BackupPath}','INFO') - - if DryRunMode == True: - Auxiliary_Log(f'DRY_RUN: 预览{ActionName.upper()} {SrcPath} -> {DstPath}','INFO') - Auxiliary_RecordOperation(ActionName,SrcPath,DstPath,'dry-run','preview',BackupPath) - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'dry-run', 'preview', BackupPath) - - try: - DstPath.parent.mkdir(parents=True,exist_ok=True) - if USELINK == True: - try: - link(str(SrcPath),str(DstPath)) - except OSError as err: - if '[WinError 1]' in str(err): - if StrictMode == True: - Auxiliary_Log('严格模式开启:硬链接失败后不会降级移动,已跳过当前文件','ERROR') - Auxiliary_RecordOperation(ActionName,SrcPath,DstPath,'failed','strict_mode_link_failed',BackupPath) - if BackupPath not in ['',None] and PathlibPath(BackupPath).exists(): - try: - move(str(BackupPath),str(DstPath)) - except Exception: - pass - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'failed', 'strict_mode_link_failed', BackupPath) - if LINKFAILSUSEMOVEFLAGS == True: - Auxiliary_Log('当前文件系统不支持硬链接,自动回退到 move','WARNING') - move(str(SrcPath),str(DstPath)) - ActionName = 'move' - else: - raise err - else: - raise err - else: - move(str(SrcPath),str(DstPath)) - Auxiliary_Log(f'{ActionName.upper()}-{DstPath} << {SrcPath}','INFO') - Auxiliary_RecordOperation(ActionName,SrcPath,DstPath,'success','',BackupPath) - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'success', '', BackupPath) - except Exception as err: - if BackupPath not in ['',None] and PathlibPath(BackupPath).exists() and DstPath.exists() == False: - try: - move(str(BackupPath),str(DstPath)) - except Exception: - pass - Auxiliary_Log(f'文件操作失败 {SrcPath} -> {DstPath}: {err}','ERROR') - Auxiliary_RecordOperation(ActionName,SrcPath,DstPath,'failed',str(err),BackupPath) - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'failed', str(err), BackupPath) - - -# Sorting 进行整理工作 -def Sorting_Mv(FileName,RAWName,SE,EP,ASSList,ApiName,SourceFilePath=None): - '''文件处理''' - - global CategoryName - SourceFilePath = FileName if SourceFilePath in [None, ''] else SourceFilePath - CategoryName = CategoryName if CategoryName else '' - ApiName = ApiName if ApiName else RAWName - NamingStyle = Runtime.config.naming_style if 'Runtime' in globals() and Runtime else str(NAMING_STYLE).strip().lower() - NamingStyle = NamingStyle if NamingStyle in ['default','emby'] else 'default' - DryRunMode = Runtime.config.dry_run if 'Runtime' in globals() and Runtime else Auxiliary_ParseBool(DRY_RUN) - - def PcSanitize(Component): - return Auxiliary_SanitizePathComponent(Auxiliary_NormalizeChinesePunctuation(Component), MAX_FILENAME_LENGTH) - - SafeCategory = PcSanitize(CategoryName) if CategoryName != '' else '' - SafeApiName = PcSanitize(ApiName) - SEPad = Auxiliary_FormatSEEPToken(SE) - EPPad = Auxiliary_FormatSEEPToken(EP) - - BaseDir = Runtime.output_path if 'Runtime' in globals() and Runtime else PathlibPath(Path) - if SafeCategory != '': - BaseDir = BaseDir / SafeCategory - - SeasonDirName = f'Season {SEPad}' if NamingStyle == 'emby' else f'Season{SE}' - NewDir = BaseDir / SafeApiName / PcSanitize(SeasonDirName) - if DryRunMode != True: - NewDir.mkdir(parents=True,exist_ok=True) - elif NewDir.exists(): - Auxiliary_Log(f'{NewDir}已存在','INFO') - - if NamingStyle == 'emby': - EpisodeBaseName = f'{SafeApiName} - S{SEPad}E{EPPad}' - else: - EpisodeBaseName = f'S{SE}E{EP}' if USETITLTOEP != True else f'S{SE}E{EP}.{SafeApiName}' - EpisodeBaseName = PcSanitize(EpisodeBaseName) - - if ASSList != None: - for ASSFile in ASSList: - FileType = path.splitext(ASSFile)[1].lower() - ASSBaseName = path.basename(ASSFile) - if NamingStyle == 'emby': - NewASSName = PcSanitize(f'{SafeApiName} - S{SEPad}E{EPPad}{Auxiliary_SubtitleLanguageSuffixForEmby(ASSBaseName)}') - else: - NewASSName = PcSanitize(EpisodeBaseName + Auxiliary_ASSFileCA(ASSBaseName)) - DstPath = NewDir / f'{NewASSName}{FileType}' - SrcPath = PathlibPath(Path) / ASSFile - Auxiliary_ExecuteFileOperation(SrcPath,DstPath) - - FileType = path.splitext(FileName)[1].lower() - if FileType in ['.ass','.srt']: - if NamingStyle == 'emby': - NewName = PcSanitize(f'{SafeApiName} - S{SEPad}E{EPPad}{Auxiliary_SubtitleLanguageSuffixForEmby(FileName)}') - else: - NewName = PcSanitize(EpisodeBaseName + Auxiliary_ASSFileCA(FileName)) - else: - NewName = EpisodeBaseName - DstPath = NewDir / f'{NewName}{FileType}' - SrcPath = PathlibPath(Path) / SourceFilePath - return Auxiliary_ExecuteFileOperation(SrcPath,DstPath) - -# Auxiliary 其他辅助 -def Auxiliary_Help(): # Help - global HelpMessages - Logo = ''' - █████╗ ██╗ ██╗████████╗ ██████╗ █████╗ ███╗ ██╗██╗███╗ ███╗███████╗███╗ ███╗██╗ ██╗ - ██╔══██╗██║ ██║╚══██╔══╝██╔═══██╗██╔══██╗████╗ ██║██║████╗ ████║██╔════╝████╗ ████║██║ ██║ - ███████║██║ ██║ ██║ ██║ ██║███████║██╔██╗ ██║██║██╔████╔██║█████╗ ██╔████╔██║██║ ██║ - ██╔══██║██║ ██║ ██║ ██║ ██║██╔══██║██║╚██╗██║██║██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║╚██╗ ██╔╝ - ██║ ██║╚██████╔╝ ██║ ╚██████╔╝██║ ██║██║ ╚████║██║██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║ ╚████╔╝ - ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═══╝ - ''' - HelpMessages = '\n* 欢迎使用 AutoAnimeMv,这是一个用于番剧文件识别、重命名和整理的工具\n* 支持本地批处理、qBittorrent 回调、dry-run 预览与回滚\n* ' - print(Logo + '\n' + '-'*100 + HelpMessages) - quit() - -def Auxiliary_LoadModule(): - ModuleFileList = [] - if path.exists('./Ext') == True: - for FileName in listdir('./Ext'): - File = path.splitext(FileName) - if File[-1] == '.py' or File[-1] == '.PY': - if File[0] in NOTLOADEXTLIST: - Auxiliary_Log(f'排除模块:{File[0]}') - else: - Module = import_module(f'Ext.{File[0]}') - Auxiliary_Log(f'模块 << {File[0]}-v{Module.Versions}') - if '#' + File[0] in ConfigMagdict: - Module.main(globals(),ConfigMagdict[f'#{File[0]}']) - else: - Module.main(globals()) - ModuleFileList.append(File[0]) - # AAF = [] - # for i in Module.ApplyAccessFun: - # AAF.append(globals()[i]) - # ReturnFun = Module.main(AAF) - #ModuleFileList[File[0]] = {'Versions':Module.Versions,'ApplyAccessFun':Module.ApplyAccessFun,'ApplyChangeFun':Module.ApplyChangeFun,'Module':Module,'ReturnFun':ReturnFun} - #elif File[-1] == '.ini' or File[-1] == '.INI': - if ModuleFileList != {}: - Auxiliary_Log(f'加载{len(ModuleFileList)}个可加载模块 >> {ModuleFileList}') - else: - Auxiliary_Log('无扩展') - else: - Auxiliary_Log('不存在扩展文件夹 ./Ext') - -def Auxiliary_NormalizeConfigSection(section_name): - '''规范化配置分区名称''' - section_name = section_name.strip() - if section_name.lower() in ['settings', 'config', '#config']: - return '#Config' - return section_name - -def Auxiliary_ParseConfigValue(ConfigValue): - '''解析配置值,兼容字符串/布尔/数字/列表''' - ConfigValue = ConfigValue.strip() - if ConfigValue == '': - return '' - LowerValue = ConfigValue.lower() - if LowerValue == 'true': - return True - if LowerValue == 'false': - return False - if LowerValue in ['none', 'null']: - return None - try: - return literal_eval(ConfigValue) - except Exception: - return ConfigValue - -def Auxiliary_MaskConfigValue(ConfigName, ConfigValue): - '''日志打印时对敏感配置做脱敏''' - if search(r'key|token|secret|password', ConfigName, flags=I) != None: - ConfigValue = '' if ConfigValue is None else str(ConfigValue) - if ConfigValue == '': - return '' - if len(ConfigValue) <= 8: - return '***' - return f'{ConfigValue[:3]}***{ConfigValue[-2:]}' - return ConfigValue - -def Auxiliary_READConfig(): - '''读取外置Config.ini文件并更新''' - - global ConfigMagdict - if path.isfile((X := f'{PyPath}{Separator}config.ini')): - with open(X,'r',encoding='UTF-8') as ff: - Auxiliary_Log('正在读取外置ini文件','INFO') - ConfigMagdict = {} - KeyName = None - for i in ff.readlines(): - i = i.strip('\n').strip() - if i == '' or i[0] == ';': - continue - if findall(r'\[(.*?)\]',i) != []: - KeyName = Auxiliary_NormalizeConfigSection(findall(r'\[(.*?)\]',i)[0]) - if KeyName not in ConfigMagdict: - ConfigMagdict[KeyName] = {} - elif i[0] != '#': - if KeyName == None: - Auxiliary_Log(f'跳过未归属分区的配置行: {i}','WARNING') - continue - if '=' not in i: - Auxiliary_Log(f'跳过不合法配置行: {i}','WARNING') - continue - ConfigItem = i.split("=",1) - ConfigMagdict[KeyName][ConfigItem[0].strip('- ')] = ConfigItem[1].strip('- ') - if ConfigMagdict != {}: - ConfigSummary = {section:list(values.keys()) for section,values in ConfigMagdict.items()} - Auxiliary_Log(f'读取到配置分区: {ConfigSummary}') - else: - Auxiliary_Log('外置ini文件没有配置','WARNING') - -def Auxiliary_ApplyConfig(): - if 'ConfigMagdict' in globals() and '#Config' in ConfigMagdict: - for ConfigName in ConfigMagdict['#Config']: - ConfigValue = Auxiliary_ParseConfigValue(ConfigMagdict['#Config'][ConfigName]) - globals()[ConfigName] = ConfigValue - Auxiliary_Log(f'配置 < {ConfigName} = {Auxiliary_MaskConfigValue(ConfigName,ConfigValue)}','INFO') - Auxiliary_PROXY() - - -def Auxiliary_ParseBool(Value) -> bool: - if type(Value) == bool: - return Value - if type(Value) in [int, float]: - return Value != 0 - if Value is None: - return False - return str(Value).strip().lower() in ['true', '1', 'yes', 'y', 'on'] - - -def Auxiliary_ParseInt(Value, DefaultValue) -> int: - try: - Parsed = int(Value) - return Parsed - except Exception: - return DefaultValue - - -def Auxiliary_InitRuntimeContext(): - '''初始化运行时上下文''' - global Runtime - CacheTTL = Auxiliary_ParseInt(CACHE_TTL_SECONDS, 86400) - if CacheTTL < 0: - CacheTTL = 86400 - NamingStyle = str(NAMING_STYLE).strip().lower() if NAMING_STYLE not in [None, ''] else 'default' - if NamingStyle not in ['default', 'emby']: - NamingStyle = 'default' - CategoryNameValue = categoryname if 'categoryname' in globals() and categoryname not in [None, ''] else '' - SourcePath = filepath if 'filepath' in globals() and filepath not in [None, ''] else PyPath - OutputPathValue = OUTPUT_PATH if 'OUTPUT_PATH' in globals() and OUTPUT_PATH not in [None, ''] else SourcePath - OutputPathObj = PathlibPath(OutputPathValue) - if OutputPathObj.is_absolute() == False: - OutputPathObj = PathlibPath(SourcePath) / OutputPathObj - Runtime = RuntimeContext( - source_path=PathlibPath(SourcePath), - output_path=OutputPathObj, - category_name=CategoryNameValue, - config=Config( - naming_style=NamingStyle, - dry_run=Auxiliary_ParseBool(DRY_RUN), - cache_dir=str(CACHE_DIR).strip() if CACHE_DIR not in [None, ''] else '.cache', - cache_ttl_seconds=CacheTTL, - tmdb_token_env=str(TMDB_BEARER_TOKEN_ENV).strip() if TMDB_BEARER_TOKEN_ENV not in [None, ''] else 'TMDB_BEARER_TOKEN', - openai_key_env=str(OPENAI_API_KEY_ENV).strip() if OPENAI_API_KEY_ENV not in [None, ''] else 'OPENAI_API_KEY', - openai_identify_all=Auxiliary_ParseBool(OPENAI_IDENTIFY_ALL), - strict_mode=Auxiliary_ParseBool(STRICT_MODE), - output_path=str(OutputPathObj) - ) - ) - if OPERATION_LOG_ENABLE: - LogBasePath = Runtime.source_path - if LogBasePath.exists() == False: - LogBasePath = PathlibPath(PyPath) - OpDirName = str(OPERATION_LOG_DIR).strip() if OPERATION_LOG_DIR not in [None, ''] else 'logs' - Runtime.operation_log_path = LogBasePath / OpDirName / f'AutoAnime_operations_{CurrentRunID}.json' - if RUN_COMMAND == 'rollback' and ROLLBACK_LOG_PATH not in [None, '']: - Runtime.rollback_log_path = PathlibPath(ROLLBACK_LOG_PATH) - - -def Auxiliary_GetCacheStorePath() -> PathlibPath: - if 'Runtime' in globals() and Runtime and Runtime.config and Runtime.config.cache_dir not in [None, '']: - CacheDir = Runtime.config.cache_dir - else: - CacheDir = '.cache' - CacheBasePath = PathlibPath(CacheDir) - if CacheBasePath.is_absolute() == False: - CacheBasePath = PathlibPath(PyPath) / CacheBasePath - if CacheBasePath.exists() == False: - CacheBasePath.mkdir(parents=True, exist_ok=True) - return CacheBasePath / 'api_cache.json' - - -def Auxiliary_ParseDelimitedConfigList(ConfigValue): - if ConfigValue in [None, '']: - return [] - if type(ConfigValue) == list: - return [str(Item).strip() for Item in ConfigValue if str(Item).strip() not in [None, '']] - RawText = str(ConfigValue).strip() - if RawText == '': - return [] - for Delimiter in ['|', ',', '\n', ';']: - if Delimiter in RawText: - return [Part.strip() for Part in RawText.replace('\r', '').split(Delimiter) if Part.strip() not in [None, '']] - return [RawText] - - -def Auxiliary_DefaultScanSkipPathMarkers(): - return [ - 'SP', 'SPs', 'OP', 'ED', 'PV', 'PVs', 'NCOP', 'NCED', 'NCOPs', 'NCEDs', - 'Special', 'Specials', 'Extra', 'Extras', 'Bonus', 'Menus', 'Menu', - 'Creditless', 'Clean', 'CM', 'Preview', 'Previews', 'Trailer', 'Teasers', - 'Scans', 'Scan', 'Making', 'Interview', 'Tokuten', 'Drama' - ] - - -def Auxiliary_DefaultScanSkipNameRegexStrings(): - return [ - r'(?i)\bNCOP\d*\b', - r'(?i)\bNCED\d*\b', - r'(?i)Non-?Credit', - r'(?i)\bMenu\d+\b', - r'(?i)\b(PV|Preview|CM)\d*\b', - ] - - -def Auxiliary_GetScanSkipPathMarkers(): - Markers = SCAN_SKIP_PATH_MARKERS if 'SCAN_SKIP_PATH_MARKERS' in globals() else [] - if type(Markers) != list or Markers == []: - return Auxiliary_DefaultScanSkipPathMarkers() - return [str(M).strip() for M in Markers if str(M).strip() not in [None, '']] - - -def Auxiliary_GetScanSkipNameRegexList(): - Patterns = SCAN_SKIP_NAME_REGEX if 'SCAN_SKIP_NAME_REGEX' in globals() else [] - if type(Patterns) != list or Patterns == []: - PatternStrings = Auxiliary_DefaultScanSkipNameRegexStrings() - else: - PatternStrings = [str(P).strip() for P in Patterns if str(P).strip() not in [None, '']] - CompiledList = [] - for PatternStr in PatternStrings: - try: - CompiledList.append(compile(PatternStr)) - except Exception: - continue - return CompiledList - - -def Auxiliary_ScanEntryShouldSkip(RelativeFileNormalized, BaseName): - PathLower = RelativeFileNormalized.lower() - Segments = [Seg for Seg in PathLower.split('/') if Seg not in [None, '']] - MarkerList = [M.lower() for M in Auxiliary_GetScanSkipPathMarkers()] - for Segment in Segments[:-1]: - for Marker in MarkerList: - if Marker in [None, '']: - continue - if Segment == Marker or Segment.startswith(Marker + '.') or Segment.startswith(Marker + '_'): - return True - for RegexObj in Auxiliary_GetScanSkipNameRegexList(): - try: - if RegexObj.search(BaseName) != None: - return True - except Exception: - continue - return False - - -def Auxiliary_GetOpenAIRuntimeStatePath() -> PathlibPath: - return Auxiliary_GetCacheStorePath().parent / 'openai_runtime_state.json' - - -def Auxiliary_LoadOpenAIRuntimeState(): - StatePath = Auxiliary_GetOpenAIRuntimeStatePath() - if StatePath.is_file() == False: - return {'active_slot_index': 0, 'updated_at': 0.0} - try: - with open(StatePath, 'r', encoding='UTF-8') as StateFile: - Data = json.load(StateFile) - if type(Data) != dict: - return {'active_slot_index': 0, 'updated_at': 0.0} - IndexValue = Auxiliary_ParseInt(Data.get('active_slot_index', 0), 0) - if IndexValue < 0: - IndexValue = 0 - return {'active_slot_index': IndexValue, 'updated_at': float(Data.get('updated_at', 0.0) or 0.0)} - except Exception: - return {'active_slot_index': 0, 'updated_at': 0.0} - - -def Auxiliary_SaveOpenAIRuntimeState(StateDict): - StatePath = Auxiliary_GetOpenAIRuntimeStatePath() - try: - StatePath.parent.mkdir(parents=True, exist_ok=True) - Payload = { - 'active_slot_index': int(StateDict.get('active_slot_index', 0)), - 'updated_at': time() - } - with open(StatePath, 'w', encoding='UTF-8') as StateFile: - json.dump(Payload, StateFile, ensure_ascii=False, indent=2) - except Exception as err: - Auxiliary_Log(f'OpenAI 运行时状态写入失败: {err}', 'WARNING') - - -def Auxiliary_GetOpenAIEndpointSlots(): - UrlList = Auxiliary_ParseDelimitedConfigList(OPENAI_BASE_URLS if 'OPENAI_BASE_URLS' in globals() else '') - if UrlList == []: - BaseFallback = OPENAI_BASE_URL if 'OPENAI_BASE_URL' in globals() and OPENAI_BASE_URL not in [None, ''] else '' - UrlList = [str(BaseFallback).strip()] if str(BaseFallback).strip() not in [None, ''] else [] - KeyList = Auxiliary_ParseDelimitedConfigList(OPENAI_API_KEYS if 'OPENAI_API_KEYS' in globals() else '') - if KeyList == []: - SingleKey = Auxiliary_GetOpenAIApiKey() - KeyList = [SingleKey] if SingleKey not in [None, ''] else [] - if UrlList == [] or KeyList == []: - return [] - SlotCount = max(len(UrlList), len(KeyList)) - Slots = [] - for SlotIndex in range(SlotCount): - UrlItem = UrlList[SlotIndex % len(UrlList)].rstrip('/') - KeyItem = KeyList[SlotIndex % len(KeyList)] - Slots.append((UrlItem, KeyItem)) - return Slots - - -def Auxiliary_ParseOpenAIRotateStatusCodes(): - RawText = str(OPENAI_KEY_ROTATE_ON_STATUS).strip() if 'OPENAI_KEY_ROTATE_ON_STATUS' in globals() else '401,429' - Codes = set() - for Part in RawText.replace('|', ',').split(','): - Part = Part.strip() - if Part.isdigit(): - Codes.add(int(Part)) - if Codes == set(): - Codes = {401, 429} - return Codes - - -def Auxiliary_OpenAIHttpBodyIndicatesQuota(ResponseText): - if ResponseText in [None, '']: - return False - LowerText = str(ResponseText).lower() - return 'insufficient_quota' in LowerText or 'rate_limit' in LowerText or 'billing' in LowerText - - -def Auxiliary_OpenAIChatCompletionsPost(RequestJson): - Slots = Auxiliary_GetOpenAIEndpointSlots() - if Slots == []: - return None - StateSnapshot = Auxiliary_LoadOpenAIRuntimeState() - StartIndex = Auxiliary_ParseInt(StateSnapshot.get('active_slot_index', 0), 0) % len(Slots) - TimeoutSeconds = Auxiliary_ParseInt(OPENAI_TIMEOUT_SECONDS, 60) if 'OPENAI_TIMEOUT_SECONDS' in globals() else 60 - if TimeoutSeconds <= 0: - TimeoutSeconds = 60 - RetryTimes = Auxiliary_ParseInt(NETERRRECTRYTIMS, 2) if 'NETERRRECTRYTIMS' in globals() else 2 - if RetryTimes < 0: - RetryTimes = 0 - RotateCodes = Auxiliary_ParseOpenAIRotateStatusCodes() - MaxConsecutive = Auxiliary_ParseInt(OPENAI_KEY_MAX_CONSECUTIVE_FAILURES, 3) if 'OPENAI_KEY_MAX_CONSECUTIVE_FAILURES' in globals() else 3 - if MaxConsecutive <= 0: - MaxConsecutive = 1 - - for SlotOffset in range(len(Slots)): - SlotIndex = (StartIndex + SlotOffset) % len(Slots) - BaseUrl, ApiKey = Slots[SlotIndex] - if ApiKey in [None, '']: - continue - ConsecutiveFailures = 0 - for RetryIndex in range(RetryTimes + 1): - HttpData = None - try: - HttpData = post( - f'{BaseUrl.rstrip("/")}/v1/chat/completions', - json=RequestJson, - headers={ - 'Authorization': f'Bearer {ApiKey}', - 'Content-Type': 'application/json', - 'User-Agent': f'AutoAnimeMv/{Versions}' - }, - timeout=TimeoutSeconds - ) - except exceptions.RequestException as err: - ConsecutiveFailures += 1 - if RetryIndex < RetryTimes: - Auxiliary_Log(f'OpenAI 请求异常,槽位 {SlotIndex+1}/{len(Slots)} 第{RetryIndex+1}/{RetryTimes+1}次重试: {err}', 'WARNING') - continue - Auxiliary_Log(f'OpenAI 请求失败,槽位 {SlotIndex+1}/{len(Slots)}: {err}', 'WARNING') - break - if HttpData.status_code == 200: - StateSnapshot['active_slot_index'] = SlotIndex - Auxiliary_SaveOpenAIRuntimeState(StateSnapshot) - return HttpData - ResponseText = '' - try: - ResponseText = HttpData.text - except Exception: - ResponseText = '' - if HttpData.status_code in RotateCodes or Auxiliary_OpenAIHttpBodyIndicatesQuota(ResponseText): - Auxiliary_Log(f'OpenAI 槽位 {SlotIndex+1}/{len(Slots)} 返回 {HttpData.status_code},切换下一槽位', 'WARNING') - break - ConsecutiveFailures += 1 - if RetryIndex < RetryTimes: - Auxiliary_Log(f'OpenAI 槽位 {SlotIndex+1}/{len(Slots)} 状态码 {HttpData.status_code},重试 {RetryIndex+1}/{RetryTimes+1}', 'WARNING') - continue - Auxiliary_Log(f'OpenAI 槽位 {SlotIndex+1}/{len(Slots)} 状态码 {HttpData.status_code},放弃本槽位', 'WARNING') - break - if ConsecutiveFailures >= MaxConsecutive: - Auxiliary_Log(f'OpenAI 槽位 {SlotIndex+1}/{len(Slots)} 连续失败达 {MaxConsecutive},尝试下一槽位', 'WARNING') - return None - - -def Auxiliary_MaybeFlushPersistentCache(): - global LastPersistentCacheFlushTime - Interval = Auxiliary_ParseInt(CACHE_FLUSH_INTERVAL_SECONDS, 60) if 'CACHE_FLUSH_INTERVAL_SECONDS' in globals() else 60 - if Interval <= 0: - return - if PersistentApiCacheDirty != True: - return - NowTs = time() - if NowTs - float(LastPersistentCacheFlushTime or 0.0) < float(Interval): - return - Auxiliary_SavePersistentCache(force=True) - LastPersistentCacheFlushTime = NowTs - - -def Auxiliary_GetManualWhitelistPath() -> PathlibPath: - CacheDirPath = Auxiliary_GetCacheStorePath().parent - return CacheDirPath / 'manual_title_whitelist.json' - - -def Auxiliary_LoadManualWhitelist(force=False): - global ManualTitleWhitelistDataCache,ManualTitleWhitelistMTime - DefaultWhitelist = { - 'mao': '摩绪', - } - WhitelistPath = Auxiliary_GetManualWhitelistPath() - if WhitelistPath.exists() == False: - try: - with open(WhitelistPath, 'w', encoding='UTF-8') as f: - json.dump(DefaultWhitelist, f, ensure_ascii=False, indent=2) - ManualTitleWhitelistDataCache = DefaultWhitelist.copy() - ManualTitleWhitelistMTime = float(WhitelistPath.stat().st_mtime) - Auxiliary_Log(f'已创建手工白名单文件: {WhitelistPath}','INFO') - return ManualTitleWhitelistDataCache - except Exception as err: - Auxiliary_Log(f'创建手工白名单文件失败,将使用内置默认值: {err}','WARNING') - ManualTitleWhitelistDataCache = DefaultWhitelist.copy() - ManualTitleWhitelistMTime = 0.0 - return ManualTitleWhitelistDataCache - - try: - FileMTime = float(WhitelistPath.stat().st_mtime) - except Exception: - FileMTime = 0.0 - if force != True and type(ManualTitleWhitelistDataCache) == dict and ManualTitleWhitelistDataCache != {} and ManualTitleWhitelistMTime == FileMTime: - return ManualTitleWhitelistDataCache - - try: - with open(WhitelistPath, 'r', encoding='UTF-8') as f: - RawData = json.load(f) - if type(RawData) != dict: - raise ValueError('手工白名单文件格式应为 JSON 对象') - LoadedWhitelist = {} - for RawAlias, RawTitle in RawData.items(): - AliasKey = Auxiliary_NormalizeAliasKey(RawAlias) - TitleValue = Auxiliary_NormalizeApiTitle(RawTitle) - if AliasKey in [None, ''] or TitleValue in [None, '']: - continue - LoadedWhitelist[AliasKey] = TitleValue - if LoadedWhitelist == {}: - LoadedWhitelist = DefaultWhitelist.copy() - ManualTitleWhitelistDataCache = LoadedWhitelist - ManualTitleWhitelistMTime = FileMTime - return ManualTitleWhitelistDataCache - except Exception as err: - Auxiliary_Log(f'读取手工白名单文件失败,将使用内置默认值: {err}','WARNING') - ManualTitleWhitelistDataCache = DefaultWhitelist.copy() - ManualTitleWhitelistMTime = 0.0 - return ManualTitleWhitelistDataCache - - -def Auxiliary_LoadPersistentCache(): - global PersistentApiCache,PersistentApiCacheDirty - CacheFilePath = Auxiliary_GetCacheStorePath() - PersistentApiCache = {} - PersistentApiCacheDirty = False - if CacheFilePath.is_file(): - try: - with open(CacheFilePath,'r',encoding='UTF-8') as CacheFile: - CacheData = json.load(CacheFile) - if type(CacheData) == dict: - PersistentApiCache = CacheData - Auxiliary_Log(f'已加载持久化缓存文件 {CacheFilePath}','INFO') - except Exception as err: - Auxiliary_Log(f'缓存文件读取失败,将使用空缓存: {err}','WARNING') - PersistentApiCache = {} - Auxiliary_RebuildCanonicalIndexesFromPersistentCache() - - -def Auxiliary_SavePersistentCache(force=False): - global PersistentApiCacheDirty - if force != True and PersistentApiCacheDirty != True: - return - CacheFilePath = Auxiliary_GetCacheStorePath() - try: - with open(CacheFilePath,'w',encoding='UTF-8') as CacheFile: - json.dump(PersistentApiCache,CacheFile,ensure_ascii=False,indent=2,sort_keys=True) - PersistentApiCacheDirty = False - Auxiliary_Log(f'持久化缓存写入完成 {CacheFilePath}','INFO') - except Exception as err: - Auxiliary_Log(f'持久化缓存写入失败: {err}','WARNING') - - -def Auxiliary_GetPersistentCache(CacheGroup, CacheKey): - global PersistentApiCache,PersistentApiCacheDirty - if CacheGroup not in PersistentApiCache: - return None - GroupCache = PersistentApiCache[CacheGroup] - if type(GroupCache) != dict or CacheKey not in GroupCache: - return None - CacheRecord = GroupCache[CacheKey] - if type(CacheRecord) != dict: - return None - CacheValue = CacheRecord.get('value') - CacheTimestamp = CacheRecord.get('ts', 0) - NeverExpireGroups = {'TitleAliasIndex','CanonicalTitleIndex','ShowOrganizationIndex'} - if CacheGroup in NeverExpireGroups: - TTLValue = 0 - else: - TTLValue = Runtime.config.cache_ttl_seconds if 'Runtime' in globals() and Runtime else 86400 - if TTLValue > 0 and (time() - float(CacheTimestamp)) > TTLValue: - try: - del GroupCache[CacheKey] - PersistentApiCacheDirty = True - except Exception: - pass - return None - return CacheValue - - -def Auxiliary_SetPersistentCache(CacheGroup, CacheKey, CacheValue): - global PersistentApiCache,PersistentApiCacheDirty - if CacheGroup not in PersistentApiCache or type(PersistentApiCache[CacheGroup]) != dict: - PersistentApiCache[CacheGroup] = {} - PersistentApiCache[CacheGroup][CacheKey] = {'value':CacheValue,'ts':time()} - PersistentApiCacheDirty = True - - -def Auxiliary_GetTMDBBearerToken(): - TokenValue = TMDB_BEARER_TOKEN if 'TMDB_BEARER_TOKEN' in globals() else '' - if TokenValue not in [None, '']: - return str(TokenValue).strip() - EnvName = Runtime.config.tmdb_token_env if 'Runtime' in globals() and Runtime else TMDB_BEARER_TOKEN_ENV - EnvName = str(EnvName).strip() if EnvName not in [None, ''] else 'TMDB_BEARER_TOKEN' - return str(environ.get(EnvName, '')).strip() - - -def Auxiliary_GetOpenAIApiKey(): - ApiKey = OPENAI_API_KEY if 'OPENAI_API_KEY' in globals() else '' - if ApiKey not in [None, '']: - return str(ApiKey).strip() - EnvName = Runtime.config.openai_key_env if 'Runtime' in globals() and Runtime else OPENAI_API_KEY_ENV - EnvName = str(EnvName).strip() if EnvName not in [None, ''] else 'OPENAI_API_KEY' - return str(environ.get(EnvName, '')).strip() - - -def Auxiliary_QueryTMDBChineseTitle(QueryName, CandidateEn='', CandidateRomaji='', AliasList=None): - '''仅通过 TMDB 查询中文标题;未命中中文时返回 None''' - global USETMDBAPI,TMDBAPIDataCache - QueryName = Auxiliary_NormalizeDisplayTitle(QueryName) - CandidateEn = Auxiliary_NormalizeDisplayTitle(CandidateEn) - CandidateRomaji = Auxiliary_NormalizeDisplayTitle(CandidateRomaji) - if QueryName in [None, ''] or USETMDBAPI != True: - return None - - CanonicalZh, _, _ = Auxiliary_ResolveCanonicalTitleByAliases( - QueryName, - CandidateEn, - CandidateRomaji - ) - if CanonicalZh not in [None, '']: - return CanonicalZh - if Auxiliary_GetTMDBBearerToken() in [None, '']: - Auxiliary_Log('TMDBApi 已启用但未配置 token,跳过 TMDB 查询','WARNING') - return None - - CandidateKeys = Auxiliary_GetStandardTitleCacheCandidates(QueryName) - if QueryName not in CandidateKeys: - CandidateKeys.insert(0, QueryName) - for CacheKey in CandidateKeys: - CacheValue = None - if type(TMDBAPIDataCache) == dict and CacheKey in TMDBAPIDataCache: - CacheValue = TMDBAPIDataCache.get(CacheKey) - Auxiliary_Log(f'{CacheValue} << TMDB内存缓存查询结果') - else: - CacheValue = Auxiliary_GetPersistentCache('TMDB', CacheKey) - if CacheValue not in [None, '']: - if type(TMDBAPIDataCache) != dict: - TMDBAPIDataCache = {} - TMDBAPIDataCache[CacheKey] = CacheValue - Auxiliary_Log(f'{CacheValue} << TMDB持久化缓存查询结果') - CacheValue = Auxiliary_NormalizeApiTitle(CacheValue) - if CacheValue in [None, ''] or Auxiliary_HasChineseText(CacheValue) == False: - continue - return CacheValue - - TMDBApiData = Auxiliary_Http( - f'https://api.themoviedb.org/3/search/tv?query={quote(QueryName)}&include_adult=true&language=zh&page=1', - ResponseType='json', - Timeout=20 - ) - if type(TMDBApiData) != dict: - Auxiliary_Log(f'TMDBApi返回异常: {QueryName}','WARNING') - return None - ResultList = TMDBApiData.get('results', []) - if type(ResultList) != list or ResultList == []: - Auxiliary_Log(f'TMDBApi没有检索到关于 {QueryName} 内容','WARNING') - return None - - ApiTitle = '' - for ResultItem in ResultList: - if type(ResultItem) != dict: - continue - CandidateTitle = Auxiliary_NormalizeApiTitle(ResultItem.get('name') or ResultItem.get('original_name') or '') - if CandidateTitle not in [None, ''] and Auxiliary_HasChineseText(CandidateTitle): - ApiTitle = CandidateTitle - break - if ApiTitle in [None, '']: - Auxiliary_Log(f'TMDBApi命中结果但未返回中文标题: {QueryName}','WARNING') - return None - - CandidateEnForUpsert = CandidateEn - if CandidateEnForUpsert in [None, ''] and Auxiliary_HasChineseText(QueryName) == False: - CandidateEnForUpsert = QueryName - CandidateAliases = [QueryName, CandidateEn, CandidateRomaji] - if type(AliasList) == list: - CandidateAliases.extend(AliasList) - CandidateAliases = [Auxiliary_NormalizeDisplayTitle(Item) for Item in CandidateAliases if Item not in [None, '']] - - _, CanonicalTitle = Auxiliary_UpsertCanonicalTitle( - ApiTitle, - CandidateEnForUpsert, - CandidateRomaji, - 'TMDB', - CandidateAliases - ) - if CanonicalTitle not in [None, ''] and Auxiliary_HasChineseText(CanonicalTitle): - ApiTitle = CanonicalTitle - for CacheKey in CandidateKeys: - TMDBAPIDataCache[CacheKey] = ApiTitle - Auxiliary_SetPersistentCache('TMDB', CacheKey, ApiTitle) - Auxiliary_Log(f'{ApiTitle} << TMDBApi查询结果') - return ApiTitle - - -def Auxiliary_QueryTMDBEnglishTitle(QueryName, CandidateEn='', CandidateRomaji='', AliasList=None): - '''TMDB en-US 检索,返回英文剧名(不要求中文)''' - global USETMDBAPI,TMDBAPIDataCache - QueryName = Auxiliary_NormalizeDisplayTitle(QueryName) - CandidateEn = Auxiliary_NormalizeDisplayTitle(CandidateEn) - CandidateRomaji = Auxiliary_NormalizeDisplayTitle(CandidateRomaji) - if QueryName in [None, ''] or USETMDBAPI != True: - return None - if Auxiliary_GetTMDBBearerToken() in [None, '']: - Auxiliary_Log('TMDBApi 已启用但未配置 token,跳过 TMDB 英文查询','WARNING') - return None - CandidateKeys = Auxiliary_GetStandardTitleCacheCandidates(QueryName) - if QueryName not in CandidateKeys: - CandidateKeys.insert(0, QueryName) - for CacheKey in CandidateKeys: - RawVal = None - if type(TMDBAPIDataCache) == dict and f'en:{CacheKey}' in TMDBAPIDataCache: - RawVal = TMDBAPIDataCache.get(f'en:{CacheKey}') - else: - Group = PersistentApiCache.get('TMDB_EN', {}) if type(PersistentApiCache) == dict else {} - Rec = Group.get(CacheKey) if type(Group) == dict else None - if type(Rec) == dict and Rec.get('value') not in [None, '']: - RawVal = Rec.get('value') - if RawVal not in [None, '']: - return Auxiliary_NormalizeDisplayTitle(str(RawVal)) - TMDBApiData = Auxiliary_Http( - f'https://api.themoviedb.org/3/search/tv?query={quote(QueryName)}&include_adult=true&language=en-US&page=1', - ResponseType='json', - Timeout=20 - ) - if type(TMDBApiData) != dict: - Auxiliary_Log(f'TMDBApi(EN)返回异常: {QueryName}','WARNING') - return None - ResultList = TMDBApiData.get('results', []) - if type(ResultList) != list or ResultList == []: - Auxiliary_Log(f'TMDBApi(EN)没有检索到关于 {QueryName} 内容','WARNING') - return None - ApiTitle = '' - for ResultItem in ResultList: - if type(ResultItem) != dict: - continue - ApiTitle = Auxiliary_NormalizeDisplayTitle(ResultItem.get('name') or ResultItem.get('original_name') or '') - if ApiTitle not in [None, '']: - break - if ApiTitle in [None, '']: - return None - CandidateAliases = [QueryName, CandidateEn, CandidateRomaji] - if type(AliasList) == list: - CandidateAliases.extend(AliasList) - CandidateAliases = [Auxiliary_NormalizeDisplayTitle(Item) for Item in CandidateAliases if Item not in [None, '']] - EnForUpsert = CandidateEn if CandidateEn not in [None, ''] else ApiTitle - Auxiliary_UpsertCanonicalTitle( - '', - EnForUpsert if EnForUpsert not in [None, ''] else ApiTitle, - CandidateRomaji, - 'TMDB', - CandidateAliases + [ApiTitle] - ) - if type(TMDBAPIDataCache) != dict: - TMDBAPIDataCache = {} - for CacheKey in CandidateKeys: - TMDBAPIDataCache[f'en:{CacheKey}'] = ApiTitle - Auxiliary_SetPersistentCache('TMDB_EN', CacheKey, ApiTitle) - Auxiliary_Log(f'{ApiTitle} << TMDBApi(EN)查询结果') - return ApiTitle - - -def Auxiliary_OpenAITranslateForeignTitleToChinese(ForeignTitle): - '''将外文剧名译为简体中文(剧名链最后一步)''' - global USEOPENAIAPI - ForeignTitle = Auxiliary_NormalizeDisplayTitle(ForeignTitle) - if ForeignTitle in [None, '']: - return None - if USEOPENAIAPI != True: - return None - ApiKey = Auxiliary_GetOpenAIApiKey() - if ApiKey in [None,'']: - Auxiliary_Log('OpenAI 译名需要密钥','WARNING') - return None - BaseUrl = OPENAI_BASE_URL if OPENAI_BASE_URL not in [None,''] else 'https://api.longcat.chat/openai' - ModelName = OPENAI_MODEL if OPENAI_MODEL not in [None,''] else 'LongCat-Flash-Chat' - TimeoutSeconds = Auxiliary_ParseInt(OPENAI_TIMEOUT_SECONDS, 60) - if TimeoutSeconds <= 0: - TimeoutSeconds = 60 - try: - HttpData = post( - f'{BaseUrl.rstrip("/")}/v1/chat/completions', - json={ - 'model':ModelName, - 'temperature':0, - 'messages':[ - {'role':'system','content':'你是番剧译名助手。输入为一部动画的日文/英文或罗马音标题,请只输出一个最常用的简体中文官方译名,不要季数、集数、引号或解释。无法确定则只输出空字符串。'}, - {'role':'user','content':ForeignTitle} - ] - }, - headers={ - 'Authorization':f'Bearer {ApiKey}', - 'Content-Type':'application/json', - 'User-Agent':f'AutoAnimeMv/{Versions}' - }, - timeout=TimeoutSeconds - ) - if HttpData.status_code != 200: - Auxiliary_Log(f'OpenAI 译名请求失败,状态码 {HttpData.status_code}','WARNING') - return None - OpenAIData = HttpData.json() - if type(OpenAIData) != dict: - return None - Choices = OpenAIData.get('choices', []) - if type(Choices) != list or Choices == []: - return None - Message = Choices[0].get('message', {}) - RawText = Message.get('content', '') if type(Message) == dict else '' - Parsed = Auxiliary_ParseJsonFromAIContent(RawText) - if type(Parsed) == dict: - ApiTitle = Auxiliary_NormalizeApiTitle( - Parsed.get('anime_name_zh') or Parsed.get('anime_name') or Parsed.get('title') or '' - ) - else: - ApiTitle = Auxiliary_NormalizeApiTitle(RawText) - if ApiTitle in ['', 'None', 'none', 'null', '未知', '无法识别', '无法判断', '不确定']: - return None - if Auxiliary_HasChineseText(ApiTitle) != True: - return None - return ApiTitle - except Exception as err: - Auxiliary_Log(f'OpenAI 译名失败: {err}','WARNING') - return None - - -def Auxiliary_ResolvePlannedTitleChain(AINameZH, NameEN, NameRomaji, QueryFileName): - ''' - 剧名:TMDB 中文 → Bangumi 中文 → TMDB 英文 → OpenAI 译中文。 - 返回 (中文主名, CanonicalID, NameEN, NameRomaji);失败则 Auxiliary_Exit。 - ''' - global USETMDBAPI,USEBANGUMIAPI,USEOPENAIAPI - AINameZH = Auxiliary_NormalizeApiTitle(AINameZH or '') - NameEN = Auxiliary_NormalizeDisplayTitle(NameEN or '') - NameRomaji = Auxiliary_NormalizeDisplayTitle(NameRomaji or '') - BaseName = path.basename(str(QueryFileName)) - queries = [] - for q in [AINameZH, NameRomaji, NameEN, BaseName]: - qn = Auxiliary_NormalizeDisplayTitle(q or '') - if qn not in [None, ''] and qn not in queries: - queries.append(qn) - AliasBundle = queries.copy() - - if (ManualWhitelistedTitle := Auxiliary_GetManualWhitelistedTitle(*queries)) not in [None, '']: - cid, zh = Auxiliary_UpsertCanonicalTitle( - ManualWhitelistedTitle, NameEN, NameRomaji, 'manual', AliasBundle - ) - return (zh if zh not in [None, ''] else ManualWhitelistedTitle), (cid or ''), NameEN, NameRomaji - - for q in queries: - if USETMDBAPI == True: - zh = Auxiliary_QueryTMDBChineseTitle(q, CandidateEn=NameEN or q, CandidateRomaji=NameRomaji, AliasList=AliasBundle) - if zh not in [None, ''] and Auxiliary_HasChineseText(zh): - cid, final = Auxiliary_UpsertCanonicalTitle(zh, NameEN, NameRomaji, 'TMDB', AliasBundle) - return (final if final not in [None, ''] else zh), (cid or ''), NameEN, NameRomaji - for q in queries: - if USEBANGUMIAPI == True: - zh = Auxiliary_QueryBangumiChineseTitle(q, CandidateEn=NameEN or q, CandidateRomaji=NameRomaji, AliasList=AliasBundle) - if zh not in [None, ''] and Auxiliary_HasChineseText(zh): - cid, final = Auxiliary_UpsertCanonicalTitle(zh, NameEN, NameRomaji, 'Bangumi', AliasBundle) - return (final if final not in [None, ''] else zh), (cid or ''), NameEN, NameRomaji - - EnTitle = None - for q in queries: - if USETMDBAPI == True: - EnTitle = Auxiliary_QueryTMDBEnglishTitle(q, CandidateEn=NameEN or q, CandidateRomaji=NameRomaji, AliasList=AliasBundle) - if EnTitle not in [None, '']: - if NameEN in [None, '']: - NameEN = EnTitle - break - ForeignForTranslate = EnTitle or NameEN or NameRomaji or '' - if ForeignForTranslate in [None, ''] and queries: - ForeignForTranslate = queries[0] - if USEOPENAIAPI == True: - Translated = Auxiliary_OpenAITranslateForeignTitleToChinese(ForeignForTranslate) - if Translated not in [None, '']: - cid, final = Auxiliary_UpsertCanonicalTitle(Translated, NameEN or ForeignForTranslate, NameRomaji, 'OpenAI', AliasBundle) - return (final if final not in [None, ''] else Translated), (cid or ''), NameEN, NameRomaji - if AINameZH not in [None, ''] and Auxiliary_HasChineseText(AINameZH): - cid, final = Auxiliary_UpsertCanonicalTitle(AINameZH, NameEN, NameRomaji, 'OpenAI', AliasBundle) - return (final if final not in [None, ''] else AINameZH), (cid or ''), NameEN, NameRomaji - Auxiliary_Exit('剧名解析链失败:TMDB 中文、Bangumi、TMDB 英文与 OpenAI 译中文均未得到可用简体中文剧名,已中止整理') - - -def Auxiliary_FormatOrganizedEpisodeTag(SE, EP): - SEValue = Auxiliary_FormatSEEPToken(SE) - EPValue = Auxiliary_FormatSEEPToken(EP) - return f'S{SEValue}E{EPValue}' - - -def Auxiliary_GetShowOrganizationRecord(CanonicalID): - global ShowOrganizationIndexDataCache - CanonicalID = '' if CanonicalID in [None, ''] else str(CanonicalID) - if CanonicalID == '': - return None - if CanonicalID in ShowOrganizationIndexDataCache: - return ShowOrganizationIndexDataCache[CanonicalID] - Raw = Auxiliary_GetPersistentCache('ShowOrganizationIndex', CanonicalID) - if type(Raw) != dict: - return None - ShowOrganizationIndexDataCache[CanonicalID] = Raw - return Raw - - -def Auxiliary_OrderedShowRecordDict(Record): - if type(Record) != dict: - Record = {} - return { - 'canonical_id': str(Record.get('canonical_id', '')), - 'organized_episodes': list(Record.get('organized_episodes', [])) if type(Record.get('organized_episodes')) == list else [], - 'title_en': str(Record.get('title_en', '')), - 'title_romaji': str(Record.get('title_romaji', '')), - 'title_zh': str(Record.get('title_zh', '')), - 'v': int(Record.get('v', 1)), - } - - -def Auxiliary_SetShowOrganizationRecord(CanonicalID, Record): - global ShowOrganizationIndexDataCache,PersistentApiCacheDirty - CanonicalID = '' if CanonicalID in [None, ''] else str(CanonicalID) - if CanonicalID == '': - return - Record = Record.copy() - Record['canonical_id'] = CanonicalID - if 'organized_episodes' not in Record or type(Record['organized_episodes']) != list: - Record['organized_episodes'] = [] - Record['v'] = int(Record.get('v', 1)) - Ordered = Auxiliary_OrderedShowRecordDict(Record) - ShowOrganizationIndexDataCache[CanonicalID] = Ordered - Auxiliary_SetPersistentCache('ShowOrganizationIndex', CanonicalID, Ordered) - - -def Auxiliary_ShowHasOrganizedEpisode(CanonicalID, SE, EP): - Rec = Auxiliary_GetShowOrganizationRecord(CanonicalID) - if type(Rec) != dict: - return False - Tag = Auxiliary_FormatOrganizedEpisodeTag(SE, EP) - EpList = Rec.get('organized_episodes', []) - if type(EpList) != list: - return False - return Tag in EpList - - -def Auxiliary_ShowMarkOrganizedEpisode(CanonicalID, title_zh, title_en, title_romaji, SE, EP): - CanonicalID = '' if CanonicalID in [None, ''] else str(CanonicalID) - if CanonicalID == '': - return - Rec = Auxiliary_GetShowOrganizationRecord(CanonicalID) - if type(Rec) != dict: - Rec = { - 'canonical_id': CanonicalID, - 'title_zh': '', - 'title_en': '', - 'title_romaji': '', - 'organized_episodes': [], - 'v': 1 - } - EpList = list(Rec.get('organized_episodes', [])) if type(Rec.get('organized_episodes')) == list else [] - Tag = Auxiliary_FormatOrganizedEpisodeTag(SE, EP) - if Tag not in EpList: - EpList.append(Tag) - Rec['organized_episodes'] = sorted(EpList) - Rec['title_zh'] = Auxiliary_NormalizeApiTitle(title_zh or Rec.get('title_zh', '')) - Rec['title_en'] = Auxiliary_NormalizeDisplayTitle(title_en or Rec.get('title_en', '')) - Rec['title_romaji'] = Auxiliary_NormalizeDisplayTitle(title_romaji or Rec.get('title_romaji', '')) - Auxiliary_SetShowOrganizationRecord(CanonicalID, Rec) - - -def Auxiliary_IDE_ParseSeasonTokensFromFile(File): - '''仅从文件名解析季号,不截断剧名。返回 (SE, RAWSE, RomanSeasonToken)''' - SeasonMatchData = r'(季(.*?)第)|(([0-9]{0,1}[0-9]{1})S)|(([0-9]{0,1}[0-9]{1})nosaeS)|(([0-9]{0,1}[0-9]{1}) nosaeS)|(([0-9]{0,1}[0-9]{1})-nosaeS)|(nosaeS-dn([0-9]{1}))|(nosaeS-dr([0-9]{1}))' - SE = None - RAWSE = '' - RomanToken = '' - if (X := findall(SeasonMatchData,File[::-1],flags=I)) != []: - SEData = X - SEList = [] - for sedata in SEData: - for se in sedata: - if se != '' and se.isnumeric() == False: - RomanToken = se[::-1] - elif se.isnumeric() == True: - SEList.append(se) - for i in range(len(SEList)): - if SEList[i].isdecimal() == True: - SE = SEList[i][::-1] - elif '\u0e00' <= SEList[i] <= '\u9fa5': - digit = {'一':'01', '二':'02', '三':'03', '四':'04', '五':'05', '六':'06', '七':'07', '八':'08', '九':'09','壹':'01','贰':'02','叁':'03','肆':'04','伍':'05','陆':'06','柒':'07','捌':'08','玖':'09'} - SE = digit.get(SEList[i], '01') - if SE is not None: - RAWSE = str(SE).lstrip('0') or str(SE) - SE = str(SE) - return SE, RAWSE, RomanToken - elif (X := findall(r'[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫ]',File[::-1],flags=I)) != []: - A = {'Ⅰ':'01','Ⅱ':'02','Ⅲ':'03','Ⅳ':'04','Ⅴ':'05','Ⅵ':'06','Ⅶ':'07','Ⅷ':'08','Ⅸ':'09','Ⅹ':'10','Ⅺ':'11','Ⅻ':'12'} - SE = A[X[0]] - return SE, str(int(SE)), X[0] - return '01', '1', '' - - -def Auxiliary_ParseTMDBTvDetailsSeasonLayout(DetailsData): - '''从 TMDB tv/{id} 详情中解析正片分季集数列表,忽略第 0 季特典。''' - if type(DetailsData) != dict: - return [] - RawSeasons = DetailsData.get('seasons', []) - if type(RawSeasons) != list: - return [] - Pairs = [] - for Item in RawSeasons: - if type(Item) != dict: - continue - try: - Sn = int(Item.get('season_number', -1)) - Ec = int(Item.get('episode_count', 0)) - except (TypeError, ValueError): - continue - if Sn < 1 or Ec < 1: - continue - Pairs.append((Sn, Ec)) - Pairs.sort(key=lambda X: X[0]) - return Pairs - - -def Auxiliary_MapAbsoluteEpisodeUsingTMDBSeasonLayout(AbsEp, SeasonPairs): - ''' - 将「全剧累计集号」映射到 (季号, 该季内的集号)。 - 最后一季若累计集超出 TMDB 已登记的 episode_count,仍归入最后一季并顺延集号(应对新番未更新完)。 - ''' - if AbsEp < 1 or type(SeasonPairs) != list or SeasonPairs == []: - return None - Prefix = 0 - LastIndex = len(SeasonPairs) - 1 - for Idx, (SeasonNum, EpCount) in enumerate(SeasonPairs): - if Idx == LastIndex: - return SeasonNum, AbsEp - Prefix - if AbsEp <= Prefix + EpCount: - return SeasonNum, AbsEp - Prefix - Prefix += EpCount - return None - - -def Auxiliary_GetTMDBTvSeasonLayoutBySeriesId(tv_id): - global TMDBTvSeasonLayoutMemoryCache - try: - TvIdInt = int(tv_id) - except (TypeError, ValueError): - return [] - if TvIdInt in TMDBTvSeasonLayoutMemoryCache: - return TMDBTvSeasonLayoutMemoryCache[TvIdInt] - CachedRaw = Auxiliary_GetPersistentCache('TMDBTvSeasons', f'id:{TvIdInt}') - if type(CachedRaw) == list and CachedRaw != []: - Pairs = [] - for Row in CachedRaw: - if type(Row) in (list, tuple) and len(Row) >= 2: - try: - Pairs.append((int(Row[0]), int(Row[1]))) - except (TypeError, ValueError): - continue - if Pairs != []: - TMDBTvSeasonLayoutMemoryCache[TvIdInt] = Pairs - return Pairs - if USETMDBAPI != True or Auxiliary_GetTMDBBearerToken() in [None, '']: - return [] - Details = Auxiliary_Http( - f'https://api.themoviedb.org/3/tv/{TvIdInt}', - ResponseType='json', - Timeout=25, - ) - Pairs = Auxiliary_ParseTMDBTvDetailsSeasonLayout(Details) - if Pairs != []: - TMDBTvSeasonLayoutMemoryCache[TvIdInt] = Pairs - Auxiliary_SetPersistentCache( - 'TMDBTvSeasons', - f'id:{TvIdInt}', - [[Sn, Ec] for Sn, Ec in Pairs], - ) - return Pairs - - -def Auxiliary_ResolveTMDBTvSeriesIdFromEnglishQuery(QueryName): - global TMDBTvSeriesIdMemoryCache - QueryName = Auxiliary_NormalizeDisplayTitle(QueryName) - if QueryName in [None, '']: - return None - AliasKey = Auxiliary_NormalizeAliasKey(QueryName) - if AliasKey in [None, '']: - return None - if AliasKey in TMDBTvSeriesIdMemoryCache: - return TMDBTvSeriesIdMemoryCache[AliasKey] - CachedId = Auxiliary_GetPersistentCache('TMDBTvSeriesId', AliasKey) - try: - CachedId = int(CachedId) - except (TypeError, ValueError): - CachedId = 0 - if CachedId > 0: - TMDBTvSeriesIdMemoryCache[AliasKey] = CachedId - return CachedId - if USETMDBAPI != True or Auxiliary_GetTMDBBearerToken() in [None, '']: - return None - SearchData = Auxiliary_Http( - f'https://api.themoviedb.org/3/search/tv?query={quote(QueryName)}&include_adult=false&language=en-US&page=1', - ResponseType='json', - Timeout=20, - ) - if type(SearchData) != dict: - return None - ResultList = SearchData.get('results', []) - if type(ResultList) != list or ResultList == [] or type(ResultList[0]) != dict: - return None - Tid = ResultList[0].get('id') - try: - Tid = int(Tid) - except (TypeError, ValueError): - return None - if Tid < 1: - return None - TMDBTvSeriesIdMemoryCache[AliasKey] = Tid - Auxiliary_SetPersistentCache('TMDBTvSeriesId', AliasKey, Tid) - return Tid - - -def Auxiliary_ResolveTMDBTvIdForJujutsuKaisen(NameEN, NameRomaji): - QueryList = [] - for Q in (NameEN, NameRomaji, 'Jujutsu Kaisen'): - Qn = Auxiliary_NormalizeDisplayTitle(Q or '') - if Qn != '' and Qn not in QueryList: - QueryList.append(Qn) - for Qn in QueryList: - Tid = Auxiliary_ResolveTMDBTvSeriesIdFromEnglishQuery(Qn) - if Tid not in [None, ''] and int(Tid) > 0: - return int(Tid) - return None - - -def Auxiliary_QueryBangumiChineseTitle(QueryName, CandidateEn='', CandidateRomaji='', AliasList=None): - '''仅通过 Bangumi 查询中文标题;未命中中文时返回 None''' - global USEBANGUMIAPI,BangumiAPIDataCache - QueryName = Auxiliary_NormalizeDisplayTitle(QueryName) - CandidateEn = Auxiliary_NormalizeDisplayTitle(CandidateEn) - CandidateRomaji = Auxiliary_NormalizeDisplayTitle(CandidateRomaji) - if QueryName in [None, ''] or USEBANGUMIAPI != True: - return None - - CanonicalZh, _, _ = Auxiliary_ResolveCanonicalTitleByAliases( - QueryName, - CandidateEn, - CandidateRomaji - ) - if CanonicalZh not in [None, '']: - return CanonicalZh - - CandidateKeys = Auxiliary_GetStandardTitleCacheCandidates(QueryName) - if QueryName not in CandidateKeys: - CandidateKeys.insert(0, QueryName) - for CacheKey in CandidateKeys: - CacheValue = None - if type(BangumiAPIDataCache) == dict and CacheKey in BangumiAPIDataCache: - CacheValue = BangumiAPIDataCache.get(CacheKey) - Auxiliary_Log(f'{CacheValue} << Bangumi内存缓存查询结果') - else: - CacheValue = Auxiliary_GetPersistentCache('Bangumi', CacheKey) - if CacheValue not in [None, '']: - if type(BangumiAPIDataCache) != dict: - BangumiAPIDataCache = {} - BangumiAPIDataCache[CacheKey] = CacheValue - Auxiliary_Log(f'{CacheValue} << Bangumi持久化缓存查询结果') - CacheValue = Auxiliary_NormalizeApiTitle(CacheValue) - if CacheValue in [None, ''] or Auxiliary_HasChineseText(CacheValue) == False: - continue - return CacheValue - - BangumiApiData = Auxiliary_Http( - f"https://api.bgm.tv/search/subject/{quote(QueryName)}?type=2&responseGroup=medium&max_results=1", - ResponseType='json', - Timeout=20 - ) - if type(BangumiApiData) != dict: - Auxiliary_Log(f'BangumiApi查询失败: {QueryName}','WARNING') - return None - ResultList = BangumiApiData.get('list', []) - if type(ResultList) != list or ResultList == [] or type(ResultList[0]) != dict: - Auxiliary_Log(f'BangumiApi没有检索到关于 {QueryName} 内容','WARNING') - return None - - AnimeData = ResultList[0] - ApiTitle = Auxiliary_NormalizeApiTitle(AnimeData.get('name_cn') or AnimeData.get('name') or '') - if ApiTitle in [None, ''] or Auxiliary_HasChineseText(ApiTitle) == False: - Auxiliary_Log(f'BangumiApi未返回可用中文标题: {QueryName}','WARNING') - return None - - CandidateEnForUpsert = CandidateEn - if CandidateEnForUpsert in [None, ''] and Auxiliary_HasChineseText(QueryName) == False: - CandidateEnForUpsert = QueryName - CandidateAliases = [QueryName, CandidateEn, CandidateRomaji] - if type(AliasList) == list: - CandidateAliases.extend(AliasList) - CandidateAliases = [Auxiliary_NormalizeDisplayTitle(Item) for Item in CandidateAliases if Item not in [None, '']] - - _, CanonicalTitle = Auxiliary_UpsertCanonicalTitle( - ApiTitle, - CandidateEnForUpsert, - CandidateRomaji, - 'Bangumi', - CandidateAliases - ) - if CanonicalTitle not in [None, ''] and Auxiliary_HasChineseText(CanonicalTitle): - ApiTitle = CanonicalTitle - for CacheKey in CandidateKeys: - BangumiAPIDataCache[CacheKey] = ApiTitle - Auxiliary_SetPersistentCache('Bangumi', CacheKey, ApiTitle) - Auxiliary_Log(f'{ApiTitle} << BangumiApi查询结果') - return ApiTitle - - -def Auxiliary_ParseJsonFromAIContent(Text): - Text = '' if Text in [None, ''] else str(Text).strip() - if Text == '': - return None - Text = sub(r'^```[a-zA-Z0-9_-]*\s*','',Text) - Text = sub(r'\s*```$','',Text) - try: - return json.loads(Text) - except Exception: - pass - if (X := findall(r'\{[\s\S]*\}', Text)) != []: - try: - return json.loads(X[0]) - except Exception: - return None - return None - - -def Auxiliary_HasChineseText(TextValue): - TextValue = '' if TextValue in [None, ''] else str(TextValue) - return search(r'[\u4e00-\u9fff]', TextValue) != None - - -def Auxiliary_AsciiDoubleQuotesToCjk(Title): - if '"' not in Title: - return Title - Parts = Title.split('"') - Out = [Parts[0]] - for Idx in range(1, len(Parts)): - Q = '\u201c' if (Idx % 2 == 1) else '\u201d' - Out.append(Q + Parts[Idx]) - return ''.join(Out) - - -def Auxiliary_AsciiSingleQuotesToCjk(Title): - if "'" not in Title: - return Title - Parts = Title.split("'") - Out = [Parts[0]] - for Idx in range(1, len(Parts)): - Q = '\u2018' if (Idx % 2 == 1) else '\u2019' - Out.append(Q + Parts[Idx]) - return ''.join(Out) - - -def Auxiliary_ConvertAsciiPunctuationToFullwidthCn(Title): - '''含汉字的标题中将常见英文标点转为中文全角标点(英文纯拉丁标题不改动)。''' - Title = '' if Title in [None, ''] else str(Title) - if Title == '' or Auxiliary_HasChineseText(Title) != True: - return Title - TStrip = Title.strip() - if match(r'^https?://', TStrip, I) != None: - return Title - T = Title - T = sub(r'(?<=[\u4e00-\u9fff\u3000-\u303f\uff01-\uff60]):(?=[\u4e00-\u9fff])', ':', T) - T = sub(r'(?<=[\u4e00-\u9fff]),(?=[\u4e00-\u9fff])', ',', T) - T = sub(r'(?<=[\u4e00-\u9fff]),(\s+)(?=[\u4e00-\u9fff])', r',\1', T) - T = sub(r'(?<=[\u4e00-\u9fff]);(?=[\u4e00-\u9fff])', ';', T) - T = sub(r'(?<=[\u4e00-\u9fff])!', '!', T) - T = sub(r'!(?=[\u4e00-\u9fff])', '!', T) - T = sub(r'(?<=[\u4e00-\u9fff])\?', '?', T) - T = sub(r'\?(?=[\u4e00-\u9fff])', '?', T) - T = sub(r'(?<=[\u4e00-\u9fff])/(?=[\u4e00-\u9fff])', '/', T) - T = sub(r'(?<=[\u4e00-\u9fff])\(', '(', T) - T = sub(r'\((?=[\u4e00-\u9fff])', '(', T) - T = sub(r'(?<=[\u4e00-\u9fff])\)', ')', T) - T = sub(r'\)(?=[\u4e00-\u9fff])', ')', T) - T = Auxiliary_AsciiDoubleQuotesToCjk(T) - T = Auxiliary_AsciiSingleQuotesToCjk(T) - T = sub(r'(?<=[\u4e00-\u9fff])\.(?=\s*$)', '。', T) - return T - - -def Auxiliary_NormalizeDisplayTitle(Title): - Title = '' if Title in [None, ''] else str(Title) - if Title == '': - return '' - Title = convert(Title, 'zh-hans') - Title = Title.replace('\u3000', ' ') - Title = Auxiliary_ConvertAsciiPunctuationToFullwidthCn(Title) - Title = Title.strip().split('\n')[0].strip('`"\' ') - Title = sub(r'\s+',' ',Title).strip() - Title = Title.replace('?', '?') - return Title - - -def Auxiliary_NormalizeChinesePunctuation(Text): - '''路径与展示名中文标点统一入口(移动/建目录前对路径分量调用)''' - Text = '' if Text in [None, ''] else str(Text) - if Text == '': - return '' - Text = convert(Text, 'zh-hans') - Text = Text.replace('\u3000', ' ') - Text = Auxiliary_ConvertAsciiPunctuationToFullwidthCn(Text) - Text = sub(r'\s+', ' ', Text).strip() - Text = Text.replace('?', '?') - return Text - - -def Auxiliary_NormalizeAliasKey(Title): - Title = Auxiliary_NormalizeDisplayTitle(Title).lower() - if Title == '': - return '' - Title = sub(r'第\s*[0-9]{1,3}\s*季','',Title,flags=I) - Title = sub(r'[0-9]{1,3}(st|nd|rd|th)\s*season','',Title,flags=I) - Title = sub(r'season\s*[0-9]{1,3}','',Title,flags=I) - Title = sub(r'(^|[^a-z0-9])s\s*[0-9]{1,3}([^a-z0-9]|$)',' ',Title,flags=I) - Title = sub(r'[\[\]【】\(\)()]',' ',Title) - Title = sub(r'[\-_/\\::\.,,。!!??~~]+',' ',Title) - Title = sub(r'\s+','',Title) - Title = sub(r'[^0-9a-z\u4e00-\u9fff]+','',Title) - return Title - - -def Auxiliary_GetManualWhitelistedTitle(*AliasCandidates): - '''返回手工白名单中文标题(按别名归一匹配)''' - ManualWhitelist = Auxiliary_LoadManualWhitelist() - if type(ManualWhitelist) != dict or ManualWhitelist == {}: - return None - for Candidate in AliasCandidates: - AliasKey = Auxiliary_NormalizeAliasKey(Candidate) - if AliasKey in ManualWhitelist: - return ManualWhitelist.get(AliasKey) - return None - - -def Auxiliary_GetTitleSourcePriority(SourceTag): - SourceTag = '' if SourceTag in [None, ''] else str(SourceTag) - PriorityMap = { - 'manual': 100, - 'Bangumi': 95, - 'BGM': 90, - 'TMDB': 80, - 'openai_identify': 75, - 'OpenAI': 70, - 'legacy': 50, - 'unknown': 40 - } - return PriorityMap.get(SourceTag, 45) - - -def Auxiliary_ShouldPreferChineseTitle(OldTitle, NewTitle, OldSource='unknown', NewSource='unknown'): - NewTitle = Auxiliary_NormalizeDisplayTitle(NewTitle) - OldTitle = Auxiliary_NormalizeDisplayTitle(OldTitle) - if NewTitle == '': - return False - if NewTitle in ['未知','无法识别','无法判断','不确定']: - return False - OldHasChinese = Auxiliary_HasChineseText(OldTitle) - NewHasChinese = Auxiliary_HasChineseText(NewTitle) - if NewHasChinese == False: - return False - if OldTitle == '': - return True - if NewHasChinese and OldHasChinese == False: - return True - if NewHasChinese == False and OldHasChinese: - return False - if OldTitle in ['未知','无法识别','无法判断','不确定']: - return True - NewPriority = Auxiliary_GetTitleSourcePriority(NewSource) - OldPriority = Auxiliary_GetTitleSourcePriority(OldSource) - if NewPriority >= OldPriority + 5 and NewTitle != OldTitle: - return True - if NewHasChinese and OldHasChinese and len(NewTitle) >= len(OldTitle) + 3: - return True - if Auxiliary_ShouldPreferShorterJujutsuMainTitle(OldTitle, NewTitle): - return True - return False - - -def Auxiliary_ShouldPreferShorterJujutsuMainTitle(OldTitle, NewTitle): - OldTitle = Auxiliary_NormalizeDisplayTitle(OldTitle) - NewTitle = Auxiliary_NormalizeDisplayTitle(NewTitle) - if NewTitle != '咒术回战': - return False - if Auxiliary_HasChineseText(OldTitle) == False or ('咒术' in OldTitle and '回战' in OldTitle) == False: - return False - if any(Fragment in OldTitle for Fragment in ('怀玉', '玉折', '涩谷', '渋谷', '死灭')): - return True - return False - - -def Auxiliary_IsJujutsuKaisenSeries(NameEN='', NameRomaji='', NameZH=''): - Key = Auxiliary_NormalizeAliasKey(NameEN or NameRomaji or '') - if Key == 'jujutsukaisen': - return True - Zh = NameZH or '' - if Auxiliary_HasChineseText(Zh) and '咒术' in Zh and '回战' in Zh: - return True - return False - - -def Auxiliary_ContractJujutsuKaisenChineseTitle(ChineseTitle): - ChineseTitle = Auxiliary_NormalizeApiTitle(ChineseTitle) - if ChineseTitle in [None, ''] or Auxiliary_HasChineseText(ChineseTitle) == False: - return ChineseTitle - if ('咒术' in ChineseTitle and '回战' in ChineseTitle) == False: - return ChineseTitle - if any(Fragment in ChineseTitle for Fragment in ('怀玉', '玉折', '涩谷', '渋谷', '死灭')): - return '咒术回战' - return ChineseTitle - - -def Auxiliary_RemappedJujutsuKaisenSeasonEpisode(RAWSE, RAWEP, SE, EP, NameEN, NameRomaji, NameZH): - global SEEPSINGLECHARACTER - if Auxiliary_IsJujutsuKaisenSeries(NameEN, NameRomaji, NameZH) == False: - return None - RAWEP = str(RAWEP or '').strip() - if RAWEP == '' or RAWEP.split('.')[0].isdigit() == False: - return None - AbsEp = int(RAWEP.split('.')[0]) - SeasonPairs = [] - TvId = Auxiliary_ResolveTMDBTvIdForJujutsuKaisen(NameEN, NameRomaji) - if TvId not in [None, '']: - SeasonPairs = Auxiliary_GetTMDBTvSeasonLayoutBySeriesId(TvId) - FirstSeasonCap = SeasonPairs[0][1] if SeasonPairs else 24 - if AbsEp <= FirstSeasonCap: - return None - Mapped = Auxiliary_MapAbsoluteEpisodeUsingTMDBSeasonLayout(AbsEp, SeasonPairs) if SeasonPairs else None - if Mapped == None: - if AbsEp <= 47: - NewRAWSE = '2' - NewRAWEP = str(AbsEp - 24) - else: - NewRAWSE = '3' - NewRAWEP = str(AbsEp - 47) - else: - NewSeasonNum, NewEpInSeason = Mapped - NewRAWSE = str(int(NewSeasonNum)) - NewRAWEP = str(int(NewEpInSeason)) - NewSE = NewRAWSE.zfill(2) if SEEPSINGLECHARACTER == False else NewRAWSE.lstrip('0') - if NewSE in [None, '']: - NewSE = '1' if SEEPSINGLECHARACTER == True else '01' - NewEP = '0' + NewRAWEP if (len(NewRAWEP) < 2 or ('.' in NewRAWEP and NewRAWEP[0] != '0')) and (SEEPSINGLECHARACTER == False) else NewRAWEP - if SEEPSINGLECHARACTER == True: - NewSE = NewSE.lstrip('0') - NewEP = NewEP.lstrip('0') - NewSE = NewSE if NewSE not in [None, ''] else '0' - NewEP = NewEP if NewEP not in [None, ''] else '0' - return NewRAWSE, NewRAWEP, NewSE, NewEP - - -def Auxiliary_NormalizeEpisodeToken(RawEpisode, FileName=''): - RawEpisode = '' if RawEpisode in [None, ''] else str(RawEpisode).strip() - if RawEpisode == '': - return '', True - RawEpisode = RawEpisode.replace('.', '.') - DecimalMatch = match(r'^([0-9]{1,4})\.([0-9]{1,2})$', RawEpisode) - if DecimalMatch != None: - IntPart = DecimalMatch.group(1) - DecimalPart = DecimalMatch.group(2) - if DecimalPart.strip('0') == '': - RawEpisode = str(int(IntPart)) - elif DecimalPart == '5' and search(r'(?i)v[2-9]', str(FileName)) != None: - # 例如 02v2,避免被错误解释成 2.5 特典 - RawEpisode = str(int(IntPart)) - IsSpecial = (RawEpisode in ['0', '00']) or ('.' in RawEpisode) - return RawEpisode, IsSpecial - - -def Auxiliary_CoalesceEpisodeFromParsed(ParsedData): - '''从模型 JSON 取剧集字段;不能用 `or` 链(episode 为整数 0 时会被当成假值丢弃)''' - if type(ParsedData) != dict: - return '' - for Key in ('episode', 'ep'): - if Key not in ParsedData: - continue - Val = ParsedData[Key] - if Val is None: - continue - Raw = str(Val).strip() - if Raw != '': - return Raw - return '' - - -def Auxiliary_CoalesceSeasonFromParsed(ParsedData, DefaultSeason='1'): - if type(ParsedData) != dict: - return DefaultSeason - for Key in ('season', 'se'): - if Key not in ParsedData: - continue - Val = ParsedData[Key] - if Val is None: - continue - Raw = str(Val).strip() - if Raw != '': - return Raw - return DefaultSeason - - -def Auxiliary_NoteOpenAIIdentifyFailure(reason, detail='', **extra): - global LastOpenAIIdentifyFailure - Pack = {'reason': str(reason), 'detail': str(detail)} - for Key, Val in extra.items(): - Pack[Key] = Val - LastOpenAIIdentifyFailure = Pack - - -def Auxiliary_GetOpenAIIdentifyWarningLogPath(): - if 'Runtime' in globals() and Runtime and getattr(Runtime, 'source_path', None): - LogBasePath = Runtime.source_path - if LogBasePath.exists() == False: - LogBasePath = PathlibPath(PyPath) - else: - LogBasePath = PathlibPath(PyPath if 'PyPath' in globals() else '.') - OpDirName = str(OPERATION_LOG_DIR).strip() if 'OPERATION_LOG_DIR' in globals() and OPERATION_LOG_DIR not in [None, ''] else 'logs' - return LogBasePath / OpDirName / 'AutoAnime_openai_identify_warnings.json' - - -def Auxiliary_AppendOpenAIIdentifyWarningLog(entry: dict): - '''追加 OpenAI 全信息识别失败记录到 logs/AutoAnime_openai_identify_warnings.json,records 按 timestamp 排序''' - LogPath = Auxiliary_GetOpenAIIdentifyWarningLogPath() - try: - LogPath.parent.mkdir(parents=True, exist_ok=True) - Records = [] - if LogPath.is_file(): - with open(LogPath, 'r', encoding='UTF-8') as LogFile: - try: - OldPayload = json.load(LogFile) - if type(OldPayload) == dict and type(OldPayload.get('records')) == list: - Records = OldPayload['records'] - except Exception: - Records = [] - Row = dict(entry) if type(entry) == dict else {'detail': str(entry)} - if 'timestamp' not in Row: - Row['timestamp'] = strftime('%Y-%m-%d %H:%M:%S', localtime(time())) - if 'run_id' not in Row and 'CurrentRunID' in globals(): - Row['run_id'] = CurrentRunID - Records.append(Row) - Records.sort(key=lambda r: (str(r.get('timestamp', '')), str(r.get('run_id', '')), str(r.get('input_basename', '')))) - with open(LogPath, 'w', encoding='UTF-8') as LogFile: - json.dump({'records': Records}, LogFile, ensure_ascii=False, indent=2) - except Exception as err: - Auxiliary_Log(f'OpenAI 识别告警日志写入失败: {err}', 'WARNING') - - -def Auxiliary_RemoveEpisodeSuffixFromTitle(Title, RawEpisode): - Title = Auxiliary_NormalizeDisplayTitle(Title) - EpisodeValue, _ = Auxiliary_NormalizeEpisodeToken(RawEpisode) - if Title == '' or EpisodeValue == '' or EpisodeValue.isdigit() == False: - return Auxiliary_NormalizeApiTitle(Title) - EpisodeInt = str(int(EpisodeValue)) - CandidateTitle = Title - CandidateTitle = sub(rf'[\s\-_]+0*{EpisodeInt}$', '', CandidateTitle, flags=I).strip(' -_') - CandidateTitle = sub(rf'第\s*0*{EpisodeInt}\s*[话話集]$', '', CandidateTitle, flags=I).strip(' -_') - CandidateTitle = sub(rf'[\(\[(【]\s*0*{EpisodeInt}\s*[\)\])】]$', '', CandidateTitle, flags=I).strip(' -_') - if CandidateTitle not in [None, '']: - return Auxiliary_NormalizeApiTitle(CandidateTitle) - return Auxiliary_NormalizeApiTitle(Title) - - -def Auxiliary_GetAliasCanonicalID(AliasTitle): - global TitleAliasIndexDataCache - AliasKey = Auxiliary_NormalizeAliasKey(AliasTitle) - if AliasKey == '': - return None - if AliasKey in TitleAliasIndexDataCache: - return TitleAliasIndexDataCache[AliasKey] - CanonicalID = Auxiliary_GetPersistentCache('TitleAliasIndex', AliasKey) - if CanonicalID not in [None, '']: - TitleAliasIndexDataCache[AliasKey] = CanonicalID - return CanonicalID - return None - - -def Auxiliary_GetCanonicalTitleRecord(CanonicalID): - global CanonicalTitleIndexDataCache - CanonicalID = '' if CanonicalID in [None, ''] else str(CanonicalID) - if CanonicalID == '': - return None - Record = None - if CanonicalID in CanonicalTitleIndexDataCache: - Record = CanonicalTitleIndexDataCache.get(CanonicalID) - else: - Record = Auxiliary_GetPersistentCache('CanonicalTitleIndex', CanonicalID) - if Record not in [None, '']: - CanonicalTitleIndexDataCache[CanonicalID] = Record - if type(Record) != dict: - return None - FixedRecord = { - 'zh': Auxiliary_NormalizeDisplayTitle(Record.get('zh', '')), - 'en': Auxiliary_NormalizeDisplayTitle(Record.get('en', '')), - 'romaji': Auxiliary_NormalizeDisplayTitle(Record.get('romaji', '')), - 'source': str(Record.get('source', 'unknown')), - 'last_updated': str(Record.get('last_updated', '')), - 'confidence': Auxiliary_ParseInt(Record.get('confidence', 0), 0), - } - return FixedRecord - - -def Auxiliary_LinkAliasToCanonical(AliasTitle, CanonicalID): - global TitleAliasIndexDataCache - AliasKey = Auxiliary_NormalizeAliasKey(AliasTitle) - CanonicalID = '' if CanonicalID in [None, ''] else str(CanonicalID) - if AliasKey == '' or CanonicalID == '': - return - if TitleAliasIndexDataCache.get(AliasKey) == CanonicalID: - return - TitleAliasIndexDataCache[AliasKey] = CanonicalID - Auxiliary_SetPersistentCache('TitleAliasIndex', AliasKey, CanonicalID) - - -def Auxiliary_ResolveCanonicalTitleByAliases(*AliasTitleList): - CheckedAliasSet = set() - FallbackCanonicalID = None - FallbackCanonicalRecord = None - for AliasTitle in AliasTitleList: - AliasKey = Auxiliary_NormalizeAliasKey(AliasTitle) - if AliasKey == '' or AliasKey in CheckedAliasSet: - continue - CheckedAliasSet.add(AliasKey) - CanonicalID = Auxiliary_GetAliasCanonicalID(AliasTitle) - if CanonicalID in [None, '']: - continue - CanonicalRecord = Auxiliary_GetCanonicalTitleRecord(CanonicalID) - if type(CanonicalRecord) != dict: - continue - if FallbackCanonicalID in [None, '']: - FallbackCanonicalID = CanonicalID - FallbackCanonicalRecord = CanonicalRecord - CanonicalZh = Auxiliary_NormalizeApiTitle(CanonicalRecord.get('zh', '')) - if CanonicalZh not in [None, '']: - return CanonicalZh, CanonicalID, CanonicalRecord - if FallbackCanonicalID not in [None, '']: - return None, FallbackCanonicalID, FallbackCanonicalRecord - return None, None, None - - -def Auxiliary_UpsertCanonicalTitle(ChineseTitle='', EnglishTitle='', RomajiTitle='', SourceTag='unknown', AliasList=None): - global CanonicalTitleIndexDataCache - ChineseTitle = Auxiliary_NormalizeApiTitle(ChineseTitle) - EnglishTitle = Auxiliary_NormalizeDisplayTitle(EnglishTitle) - RomajiTitle = Auxiliary_NormalizeDisplayTitle(RomajiTitle) - AllAliases = [ChineseTitle, EnglishTitle, RomajiTitle] - if type(AliasList) in [list, tuple]: - for OneAlias in AliasList: - if OneAlias not in [None, '']: - AllAliases.append(Auxiliary_NormalizeDisplayTitle(OneAlias)) - CandidateCanonicalIDs = [] - for AliasTitle in AllAliases: - if (MatchedCanonicalID := Auxiliary_GetAliasCanonicalID(AliasTitle)) not in [None, '']: - if MatchedCanonicalID not in CandidateCanonicalIDs: - CandidateCanonicalIDs.append(MatchedCanonicalID) - if CandidateCanonicalIDs == []: - SeedTitle = ChineseTitle if ChineseTitle not in [None, ''] else (EnglishTitle if EnglishTitle not in [None, ''] else RomajiTitle) - CanonicalID = Auxiliary_NormalizeAliasKey(SeedTitle) - if CanonicalID in [None, '']: - return None, ChineseTitle - else: - CanonicalID = CandidateCanonicalIDs[0] - BestRecord = Auxiliary_GetCanonicalTitleRecord(CanonicalID) - for OneCanonicalID in CandidateCanonicalIDs[1:]: - OneRecord = Auxiliary_GetCanonicalTitleRecord(OneCanonicalID) - if type(OneRecord) == dict and type(BestRecord) == dict: - if Auxiliary_HasChineseText(OneRecord.get('zh', '')) and Auxiliary_HasChineseText(BestRecord.get('zh', '')) == False: - CanonicalID = OneCanonicalID - BestRecord = OneRecord - ExistingRecord = Auxiliary_GetCanonicalTitleRecord(CanonicalID) - if type(ExistingRecord) != dict: - CanonicalRecord = { - 'zh': '', - 'en': '', - 'romaji': '', - 'source': 'unknown', - 'last_updated': '', - 'confidence': 0 - } - ChangedFlag = True - else: - CanonicalRecord = ExistingRecord.copy() - ChangedFlag = False - if Auxiliary_ShouldPreferChineseTitle(CanonicalRecord.get('zh', ''), ChineseTitle, CanonicalRecord.get('source', 'unknown'), SourceTag): - CanonicalRecord['zh'] = ChineseTitle - CanonicalRecord['source'] = SourceTag - ChangedFlag = True - elif CanonicalRecord.get('source', '') in [None, '']: - CanonicalRecord['source'] = SourceTag - ChangedFlag = True - if EnglishTitle not in [None, ''] and CanonicalRecord.get('en', '') in [None, '']: - CanonicalRecord['en'] = EnglishTitle - ChangedFlag = True - if RomajiTitle not in [None, ''] and CanonicalRecord.get('romaji', '') in [None, '']: - CanonicalRecord['romaji'] = RomajiTitle - ChangedFlag = True - NewConfidence = max( - Auxiliary_ParseInt(CanonicalRecord.get('confidence', 0), 0), - Auxiliary_GetTitleSourcePriority(SourceTag) - ) - if NewConfidence != Auxiliary_ParseInt(CanonicalRecord.get('confidence', 0), 0): - CanonicalRecord['confidence'] = NewConfidence - ChangedFlag = True - if ChangedFlag: - CanonicalRecord['last_updated'] = strftime("%Y-%m-%d %H:%M:%S",localtime(time())) - CanonicalTitleIndexDataCache[CanonicalID] = CanonicalRecord - if ChangedFlag: - Auxiliary_SetPersistentCache('CanonicalTitleIndex', CanonicalID, CanonicalRecord) - for OneAlias in AllAliases + [CanonicalRecord.get('zh', ''), CanonicalRecord.get('en', ''), CanonicalRecord.get('romaji', '')]: - Auxiliary_LinkAliasToCanonical(OneAlias, CanonicalID) - return CanonicalID, CanonicalRecord.get('zh', '') - - -def Auxiliary_RebuildCanonicalIndexesFromPersistentCache(): - global PersistentApiCacheDirty - if type(PersistentApiCache) != dict: - return - - def IterateRawGroupValue(CacheGroup): - GroupData = PersistentApiCache.get(CacheGroup, {}) - if type(GroupData) != dict: - return [] - ReturnList = [] - for CacheKey, CacheRecord in GroupData.items(): - if type(CacheRecord) == dict and 'value' in CacheRecord: - ReturnList.append((CacheKey, CacheRecord.get('value'))) - return ReturnList - - ChangedFlag = False - for CacheGroup in ['Bangumi','TMDB']: - for QueryName, CacheValue in IterateRawGroupValue(CacheGroup): - if CacheValue in [None, '']: - continue - CandidateZh = Auxiliary_NormalizeApiTitle(CacheValue) - CandidateEn = Auxiliary_NormalizeDisplayTitle(QueryName if QueryName not in [None, ''] else '') - if Auxiliary_HasChineseText(CandidateZh) == False: - if CandidateEn in [None, '']: - CandidateEn = Auxiliary_NormalizeDisplayTitle(CacheValue) - CandidateZh = '' - CanonicalID, CanonicalZh = Auxiliary_UpsertCanonicalTitle(CandidateZh, CandidateEn, '', CacheGroup, [QueryName, CacheValue]) - if CanonicalID not in [None, '']: - if CanonicalZh not in [None, ''] and Auxiliary_HasChineseText(CanonicalZh): - if type(PersistentApiCache.get(CacheGroup, {}).get(QueryName)) == dict: - if PersistentApiCache[CacheGroup][QueryName].get('value') != CanonicalZh: - PersistentApiCache[CacheGroup][QueryName]['value'] = CanonicalZh - ChangedFlag = True - - for QueryName, CacheValue in IterateRawGroupValue('TMDB_EN'): - if CacheValue in [None, '']: - continue - EnTitle = Auxiliary_NormalizeDisplayTitle(str(CacheValue)) - if EnTitle in [None, '']: - continue - Auxiliary_UpsertCanonicalTitle('', EnTitle, '', 'TMDB', [QueryName, EnTitle]) - - for CanonicalKey, CacheValue in IterateRawGroupValue('ShowOrganizationIndex'): - if type(CacheValue) != dict: - continue - zh = Auxiliary_NormalizeApiTitle(CacheValue.get('title_zh', '')) - en = Auxiliary_NormalizeDisplayTitle(CacheValue.get('title_en', '')) - romaji = Auxiliary_NormalizeDisplayTitle(CacheValue.get('title_romaji', '')) - if zh not in [None, ''] or en not in [None, ''] or romaji not in [None, '']: - Auxiliary_UpsertCanonicalTitle(zh, en, romaji, 'unknown', [CanonicalKey]) - if ChangedFlag == True: - PersistentApiCacheDirty = True - - -def Auxiliary_NormalizeApiTitle(ApiTitle): - ApiTitle = Auxiliary_NormalizeDisplayTitle(ApiTitle) - if ApiTitle == '': - return '' - ApiTitle = sub(r'第.*?季|Season\s*[0-9]+|S[0-9]{1,2}$','',ApiTitle,flags=I).strip('- []【】 ') - return ApiTitle - - -def Auxiliary_GetAbsoluteSourcePath(SourceFilePath): - SourceFilePath = '' if SourceFilePath in [None, ''] else str(SourceFilePath) - SourcePathObj = PathlibPath(SourceFilePath) - if SourcePathObj.is_absolute(): - return SourcePathObj - BasePath = PathlibPath(Path) if 'Path' in globals() else ( - Runtime.source_path if 'Runtime' in globals() and Runtime else PathlibPath('.') - ) - return BasePath / SourcePathObj - - -def Auxiliary_GetSourceFileMTime(SourceFilePath): - SourcePathObj = Auxiliary_GetAbsoluteSourcePath(SourceFilePath) - try: - return float(SourcePathObj.stat().st_mtime) - except Exception: - return 0.0 - - -def Auxiliary_BuildEpisodeDecisionKey(CanonicalTitle, SE, EP, FileName): - CanonicalAliasKey = Auxiliary_NormalizeAliasKey(CanonicalTitle) - if CanonicalAliasKey == '': - return None - SEValue = Auxiliary_FormatSEEPToken(SE) - EPValue = Auxiliary_FormatSEEPToken(EP) - FileExt = str(path.splitext(path.basename(str(FileName)))[1]).lower() - if FileExt in ['.mp4', '.mkv']: - ExtBucket = 'video' - elif FileExt in ['.ass', '.srt']: - ExtBucket = 'subtitle' - else: - ExtBucket = FileExt if FileExt not in [None, ''] else 'unknown' - return f'{CanonicalAliasKey}|{SEValue}|{EPValue}|{ExtBucket}' - - -def Auxiliary_PreDetectEpisodeHint(FileName): - QueryName = path.basename(str(FileName)) - NewFile = Auxiliary_RMSubtitlingTeam(Auxiliary_RMOTSTR(Auxiliary_UniformOTSTR(QueryName))) - if Auxiliary_AnimeFileCheck(NewFile) != True: - return None - try: - RAWEP = Auxiliary_IDEEP(NewFile) - except Exception: - return None - RAWEP, EpisodeSpecialFlag = Auxiliary_NormalizeEpisodeToken(RAWEP, QueryName) - if RAWEP in [None, '']: - return None - BaseTitle = path.splitext(NewFile)[0] - RAWName = Auxiliary_NormalizeApiTitle(BaseTitle) - EP = '0' + RAWEP if (len(RAWEP) < 2 or ('.' in RAWEP and RAWEP[0] != '0')) and (SEEPSINGLECHARACTER == False) else RAWEP - if EpisodeSpecialFlag: - SE = '00' if SEEPSINGLECHARACTER == False else '0' - RAWSE = '' - else: - SERaw, RSE, _ = Auxiliary_IDE_ParseSeasonTokensFromFile(NewFile) - SE = '0' + str(SERaw) if len(str(SERaw)) == 1 and SEEPSINGLECHARACTER == False else str(SERaw) - RAWSE = RSE - if SEEPSINGLECHARACTER == True: - SE = SE.lstrip('0') - EP = EP.lstrip('0') - SE = SE if SE not in [None, ''] else '0' - EP = EP if EP not in [None, ''] else '0' - CanonicalZh, CanonicalID, _ = Auxiliary_ResolveCanonicalTitleByAliases(RAWName) - CanonicalTitle = CanonicalZh if CanonicalZh not in [None, ''] else RAWName - EpisodeKey = Auxiliary_BuildEpisodeDecisionKey(CanonicalTitle, SE, EP, QueryName) - if EpisodeKey in [None, '']: - return None - return { - 'EpisodeKey': EpisodeKey, - 'SE': str(SE), - 'EP': str(EP), - 'RAWName': RAWName, - 'ApiName': CanonicalTitle, - 'CanonicalID': CanonicalID if CanonicalID not in [None, ''] else '' - } - - -def Auxiliary_GetStandardTitleCacheCandidates(QueryName): - QueryName = Auxiliary_NormalizeDisplayTitle(QueryName) - if QueryName == '': - return [] - - CandidateList = [] - - def AddCandidate(Value): - Value = Auxiliary_NormalizeDisplayTitle(Value) - if Value not in [None, ''] and Value not in CandidateList: - CandidateList.append(Value) - - CompactName = sub(r'\s+',' ',QueryName).strip() - AddCandidate(QueryName) - AddCandidate(CompactName) - AddCandidate(CompactName.replace(' ', '-')) - AddCandidate(CompactName.replace('-', ' ')) - AddCandidate(CompactName.replace(' ', '')) - AddCandidate(CompactName.replace('-', '')) - return CandidateList - - -def Auxiliary_GetStandardTitleFromCache(QueryName): - QueryName = Auxiliary_NormalizeDisplayTitle(QueryName) - if QueryName == '': - return None - CanonicalZh, _, _ = Auxiliary_ResolveCanonicalTitleByAliases(QueryName) - if CanonicalZh not in [None, '']: - return CanonicalZh - CacheGroupList = [ - ('Bangumi', globals().get('BangumiAPIDataCache', {})), - ('TMDB', globals().get('TMDBAPIDataCache', {})), - ] - for CacheKey in Auxiliary_GetStandardTitleCacheCandidates(QueryName): - for CacheGroup, InMemoryCache in CacheGroupList: - if type(InMemoryCache) == dict and CacheKey in InMemoryCache: - CacheValue = InMemoryCache[CacheKey] - else: - CacheValue = Auxiliary_GetPersistentCache(CacheGroup, CacheKey) - if CacheValue not in [None, ''] and type(InMemoryCache) == dict: - InMemoryCache[CacheKey] = CacheValue - CacheValue = Auxiliary_NormalizeDisplayTitle(CacheValue) - if CacheValue in [None, '']: - continue - CanonicalZh, _, _ = Auxiliary_ResolveCanonicalTitleByAliases(QueryName, CacheValue, CacheKey) - if CanonicalZh not in [None, '']: - return CanonicalZh - CandidateZh = Auxiliary_NormalizeApiTitle(CacheValue) - CandidateEn = CacheKey if Auxiliary_HasChineseText(CacheKey) == False else '' - if Auxiliary_HasChineseText(CandidateZh) == False: - CandidateZh = '' - if CandidateEn in [None, '']: - CandidateEn = CacheValue - _, CanonicalZh = Auxiliary_UpsertCanonicalTitle( - CandidateZh, - CandidateEn, - '', - CacheGroup, - [QueryName, CacheKey, CacheValue] - ) - if CanonicalZh not in [None, '']: - return CanonicalZh - if CandidateZh not in [None, '']: - return CandidateZh - return None - - -def Auxiliary_ApplyStandardTitleCacheToFileInfoRecord(CacheRecord): - if type(CacheRecord) != dict or all([Key in CacheRecord for Key in ['SE','EP','RAWSE','RAWEP','RAWName']]) != True: - return CacheRecord, False - FixedRecord = CacheRecord.copy() - FixedRecord['RAWEP'] = str(FixedRecord.get('RAWEP', '')) - FixedRecord['RAWEP'], _ = Auxiliary_NormalizeEpisodeToken(FixedRecord['RAWEP']) - FixedRecord['RAWName'] = Auxiliary_NormalizeApiTitle(FixedRecord.get('RAWName')) - FixedRecord['NameEN'] = Auxiliary_NormalizeDisplayTitle(FixedRecord.get('NameEN') or FixedRecord.get('RAWNameEN') or '') - FixedRecord['NameRomaji'] = Auxiliary_NormalizeDisplayTitle(FixedRecord.get('NameRomaji') or FixedRecord.get('RAWNameRomaji') or '') - FixedRecord['CanonicalID'] = str(FixedRecord.get('CanonicalID') or '') - ChangedFlag = False - - CanonicalZh, CanonicalID, _ = Auxiliary_ResolveCanonicalTitleByAliases( - FixedRecord.get('RAWName'), - FixedRecord.get('NameEN'), - FixedRecord.get('NameRomaji') - ) - if CanonicalZh in [None, '']: - CachedTitle = Auxiliary_GetStandardTitleFromCache( - FixedRecord.get('RAWName') or FixedRecord.get('NameEN') or FixedRecord.get('NameRomaji') - ) - if CachedTitle not in [None, '']: - CanonicalZh = CachedTitle - if CanonicalZh not in [None, ''] and CanonicalZh != FixedRecord.get('RAWName'): - FixedRecord['RAWName'] = CanonicalZh - ChangedFlag = True - if CanonicalID not in [None, ''] and CanonicalID != FixedRecord.get('CanonicalID'): - FixedRecord['CanonicalID'] = CanonicalID - ChangedFlag = True - UpsertCanonicalID, UpsertCanonicalZh = Auxiliary_UpsertCanonicalTitle( - FixedRecord.get('RAWName', ''), - FixedRecord.get('NameEN', ''), - FixedRecord.get('NameRomaji', ''), - 'openai_identify', - [FixedRecord.get('RAWName'), FixedRecord.get('NameEN'), FixedRecord.get('NameRomaji')] - ) - if UpsertCanonicalID not in [None, ''] and FixedRecord.get('CanonicalID') != UpsertCanonicalID: - FixedRecord['CanonicalID'] = UpsertCanonicalID - ChangedFlag = True - if UpsertCanonicalZh not in [None, ''] and FixedRecord.get('RAWName') != UpsertCanonicalZh: - FixedRecord['RAWName'] = UpsertCanonicalZh - ChangedFlag = True - ContractedZh = Auxiliary_ContractJujutsuKaisenChineseTitle(FixedRecord.get('RAWName', '')) - if ContractedZh not in [None, ''] and ContractedZh != FixedRecord.get('RAWName'): - FixedRecord['RAWName'] = ContractedZh - ChangedFlag = True - ReUpsertID, ReUpsertZh = Auxiliary_UpsertCanonicalTitle( - ContractedZh, - FixedRecord.get('NameEN', ''), - FixedRecord.get('NameRomaji', ''), - 'openai_identify', - [ContractedZh, FixedRecord.get('NameEN', ''), FixedRecord.get('NameRomaji', '')] - ) - if ReUpsertID not in [None, '']: - FixedRecord['CanonicalID'] = ReUpsertID - ChangedFlag = True - if ReUpsertZh not in [None, ''] and ReUpsertZh != FixedRecord.get('RAWName'): - FixedRecord['RAWName'] = ReUpsertZh - ChangedFlag = True - RemapTuple = Auxiliary_RemappedJujutsuKaisenSeasonEpisode( - FixedRecord.get('RAWSE'), - FixedRecord.get('RAWEP'), - FixedRecord.get('SE'), - FixedRecord.get('EP'), - FixedRecord.get('NameEN', ''), - FixedRecord.get('NameRomaji', ''), - FixedRecord.get('RAWName', '') - ) - if RemapTuple != None: - NewRAWSE, NewRAWEP, NewSE, NewEP = RemapTuple - if ( - NewRAWSE != str(FixedRecord.get('RAWSE', '')) - or NewRAWEP != str(FixedRecord.get('RAWEP', '')) - or NewSE != str(FixedRecord.get('SE', '')) - or NewEP != str(FixedRecord.get('EP', '')) - ): - FixedRecord['RAWSE'] = NewRAWSE - FixedRecord['RAWEP'] = NewRAWEP - FixedRecord['SE'] = NewSE - FixedRecord['EP'] = NewEP - ChangedFlag = True - return FixedRecord, ChangedFlag - - -def Auxiliary_ShouldCacheResolvedFileInfo(OperationResult): - if type(OperationResult) != dict: - return False - Status = OperationResult.get('status') - Message = OperationResult.get('message') - if Status == 'success': - return True - if Status == 'dry-run': - return True - if Status == 'skipped' and Message in ['same_file','existing_link_kept','target_exists','newer_duplicate_kept_oldest']: - return True - return False - - -def Auxiliary_OpenAIIdentifyFileInfo(FileName): - '''通过 OpenAI 一次性识别剧名/剧季/剧集;剧名经 TMDB 中文→Bangumi→TMDB 英文→OpenAI 译中文''' - global USEOPENAIAPI,OPENAI_IDENTIFY_ALL,OpenAIIdentifyFileMemoryCache,LastOpenAIFileInfoMeta,LastOpenAIIdentifyFailure - LastOpenAIFileInfoMeta = {} - LastOpenAIIdentifyFailure = None - if USEOPENAIAPI != True or OPENAI_IDENTIFY_ALL != True: - return None - QueryFileName = path.basename(FileName) - PromptBaseName = Auxiliary_StripLeadingBracketReleaseTags(QueryFileName) - InvalidNameSet = {'', 'None', 'none', 'null', '未知', '无法识别', '无法判断', '不确定'} - - def BuildMetaFromRecord(CacheRecord): - return { - 'NameEN': CacheRecord.get('NameEN', ''), - 'NameRomaji': CacheRecord.get('NameRomaji', ''), - 'CanonicalID': CacheRecord.get('CanonicalID', ''), - 'CanonicalZh': CacheRecord.get('RAWName', '') - } - - if QueryFileName in OpenAIIdentifyFileMemoryCache: - CacheRecord = OpenAIIdentifyFileMemoryCache[QueryFileName] - if type(CacheRecord) == dict and all([Key in CacheRecord for Key in ['SE','EP','RAWSE','RAWEP','RAWName']]): - FixedRecord, Updated = Auxiliary_ApplyStandardTitleCacheToFileInfoRecord(CacheRecord) - if Updated == True: - OpenAIIdentifyFileMemoryCache[QueryFileName] = FixedRecord - CacheRecord = FixedRecord - if CacheRecord.get('RAWName') in [None, '']: - OpenAIIdentifyFileMemoryCache.pop(QueryFileName, None) - CacheRecord = None - if CacheRecord != None: - Auxiliary_Log(f'OpenAI文件识别内存缓存命中 << {CacheRecord}','INFO') - LastOpenAIFileInfoMeta = BuildMetaFromRecord(CacheRecord) - return CacheRecord['SE'],CacheRecord['EP'],CacheRecord['RAWSE'],CacheRecord['RAWEP'],CacheRecord['RAWName'] - - ApiKey = Auxiliary_GetOpenAIApiKey() - if ApiKey in [None, '']: - Auxiliary_Log('OpenAI文件识别需要 OPENAI_API_KEY','WARNING') - Auxiliary_NoteOpenAIIdentifyFailure('missing_api_key', '未配置 OPENAI_API_KEY', input_basename=QueryFileName) - return None - - BaseUrl = OPENAI_BASE_URL if OPENAI_BASE_URL not in [None,''] else 'https://api.longcat.chat/openai' - ModelName = OPENAI_MODEL if OPENAI_MODEL not in [None,''] else 'LongCat-Flash-Chat' - TimeoutSeconds = Auxiliary_ParseInt(OPENAI_TIMEOUT_SECONDS, 60) - if TimeoutSeconds <= 0: - TimeoutSeconds = 60 - RetryTimes = Auxiliary_ParseInt(NETERRRECTRYTIMS, 2) - if RetryTimes < 0: - RetryTimes = 0 - HttpData = None - try: - for RetryIndex in range(RetryTimes + 1): - try: - HttpData = post( - f'{BaseUrl.rstrip("/")}/v1/chat/completions', - json={ - 'model':ModelName, - 'temperature':0, - 'messages':[ - { - 'role':'system', - 'content':( - '你是番剧文件识别助手。请根据用户提供的单个文件名,识别并仅输出 JSON:{"anime_name_zh":"简体中文番剧名","anime_name_en":"英文名或常见英文写法","anime_name_romaji":"罗马音","season":"季数字(未知填1)","episode":"集数字或小数","special":false}。anime_name_zh 必须尽量返回简体中文标准名称;若当前无法确定中文,请保持 anime_name_zh 为空字符串,同时尽可能给出 anime_name_en 或 anime_name_romaji。anime_name_zh、anime_name_en、anime_name_romaji 只允许填写番剧主标题,禁止包含季信息(如 S2、Season 2、2nd Season、第二季等)。不要输出解释文本。' - '文件名最前面的半角方括号 […] 与全角书名号式标签 【…】 中多为字幕组/发行方标记,不是番剧标题;anime_name_zh、anime_name_en、anime_name_romaji 只填作品主标题。' - ) - }, - {'role':'user','content':PromptBaseName} - ] - }, - headers={ - 'Authorization':f'Bearer {ApiKey}', - 'Content-Type':'application/json', - 'User-Agent':f'AutoAnimeMv/{Versions}' - }, - timeout=TimeoutSeconds - ) - except exceptions.RequestException as err: - if RetryIndex < RetryTimes: - Auxiliary_Log(f'OpenAI文件识别请求超时/失败,第{RetryIndex+1}/{RetryTimes+1}次重试: {err}','WARNING') - continue - Auxiliary_Log(f'OpenAI文件识别请求失败: {err}','WARNING') - Auxiliary_NoteOpenAIIdentifyFailure('http_request_failed', str(err), input_basename=QueryFileName) - return None - if HttpData.status_code == 200: - break - if RetryIndex < RetryTimes: - Auxiliary_Log(f'OpenAI文件识别请求失败,状态码 {HttpData.status_code},第{RetryIndex+1}/{RetryTimes+1}次重试','WARNING') - continue - Auxiliary_Log(f'OpenAI文件识别请求失败,状态码 {HttpData.status_code}','WARNING') - Auxiliary_NoteOpenAIIdentifyFailure('http_status', f'status={HttpData.status_code}', input_basename=QueryFileName) - return None - if HttpData in [None, '']: - Auxiliary_Log('OpenAI文件识别请求失败,未获得有效响应','WARNING') - Auxiliary_NoteOpenAIIdentifyFailure('no_http_response', '', input_basename=QueryFileName) - return None - OpenAIData = HttpData.json() - if type(OpenAIData) != dict: - Auxiliary_Log('OpenAI文件识别返回数据结构异常','WARNING') - Auxiliary_NoteOpenAIIdentifyFailure('response_not_dict', '', input_basename=QueryFileName) - return None - Choices = OpenAIData.get('choices', []) - if type(Choices) != list or Choices == []: - Auxiliary_Log('OpenAI文件识别返回格式异常: 缺少 choices','WARNING') - Auxiliary_NoteOpenAIIdentifyFailure('no_choices', '', input_basename=QueryFileName) - return None - Message = Choices[0].get('message', {}) - ParsedData = Auxiliary_ParseJsonFromAIContent(Message.get('content', '') if type(Message) == dict else '') - if type(ParsedData) != dict: - Auxiliary_Log('OpenAI文件识别返回内容不是有效 JSON','WARNING') - RawPreview = Message.get('content', '') if type(Message) == dict else '' - if type(RawPreview) == str and len(RawPreview) > 800: - RawPreview = RawPreview[:800] + '…' - Auxiliary_NoteOpenAIIdentifyFailure('content_not_json', 'choices[0].message.content 无法解析为对象', input_basename=QueryFileName, raw_content_preview=RawPreview) - return None - - NameZH = Auxiliary_NormalizeApiTitle( - ParsedData.get('anime_name_zh') - or ParsedData.get('anime_name') - or ParsedData.get('title') - or ParsedData.get('name') - or '' - ) - NameEN = Auxiliary_NormalizeDisplayTitle( - ParsedData.get('anime_name_en') - or ParsedData.get('english_title') - or ParsedData.get('title_en') - or ParsedData.get('name_en') - or '' - ) - NameRomaji = Auxiliary_NormalizeDisplayTitle( - ParsedData.get('anime_name_romaji') - or ParsedData.get('romaji_title') - or ParsedData.get('title_romaji') - or ParsedData.get('name_romaji') - or '' - ) - if NameZH in InvalidNameSet: - NameZH = '' - if NameEN in InvalidNameSet: - NameEN = '' - if NameRomaji in InvalidNameSet: - NameRomaji = '' - if NameZH not in [None, ''] and Auxiliary_HasChineseText(NameZH) == False: - NameZH = '' - AINameZH = NameZH - - RAWEP = Auxiliary_CoalesceEpisodeFromParsed(ParsedData) - RAWEP, EpisodeSpecialFlag = Auxiliary_NormalizeEpisodeToken(RAWEP, QueryFileName) - if RAWEP in [None, '']: - Auxiliary_Log(f'OpenAI文件识别未返回可用剧集: {QueryFileName}','WARNING') - Snap = {} - for Key in ('anime_name_zh', 'anime_name_en', 'anime_name_romaji', 'season', 'episode', 'ep', 'se', 'special'): - if Key in ParsedData: - Snap[Key] = ParsedData.get(Key) - Auxiliary_NoteOpenAIIdentifyFailure( - 'episode_missing', - 'episode/ep 缺失、为空或归一后不可用(注意:整数 0 是合法第 0 集)', - input_basename=QueryFileName, - openai_parsed_snapshot=Snap - ) - return None - - NameZH_out, CanonicalID, NameEN, NameRomaji = Auxiliary_ResolvePlannedTitleChain(AINameZH, NameEN, NameRomaji, FileName) - RAWName = NameZH_out - HintInfo = Auxiliary_PreDetectEpisodeHint(QueryFileName) - if type(HintInfo) == dict: - HintCanonicalID = str(HintInfo.get('CanonicalID') or '') - if HintCanonicalID != '': - HintRecord = Auxiliary_GetCanonicalTitleRecord(HintCanonicalID) - if type(HintRecord) == dict: - HintZh = Auxiliary_NormalizeApiTitle(HintRecord.get('zh', '')) - if HintZh not in [None, '']: - RAWName = HintZh - CanonicalID = HintCanonicalID - - SpecialFlag = Auxiliary_ParseBool(ParsedData.get('special', False)) - if SpecialFlag != True: - SpecialFlag = EpisodeSpecialFlag - if SpecialFlag == True: - SE = '00' if SEEPSINGLECHARACTER == False else '0' - RAWSE = '' - else: - SeasonValue = Auxiliary_CoalesceSeasonFromParsed(ParsedData, '1') - SeasonValue = sub(r'[^0-9]','',str(SeasonValue).strip()) if SeasonValue not in [None, ''] else '1' - SeasonValue = '1' if SeasonValue in [None, '', '0'] else SeasonValue - RAWSE = SeasonValue - SE = SeasonValue.zfill(2) if SEEPSINGLECHARACTER == False else SeasonValue.lstrip('0') - if SE in [None, '']: - SE = '1' if SEEPSINGLECHARACTER == True else '01' - - EP = '0' + RAWEP if (len(RAWEP) < 2 or ('.' in RAWEP and RAWEP[0] != '0')) and (SEEPSINGLECHARACTER == False) else RAWEP - if SEEPSINGLECHARACTER == True: - SE = SE.lstrip('0') - EP = EP.lstrip('0') - SE = SE if SE not in [None, ''] else '0' - EP = EP if EP not in [None, ''] else '0' - - CacheRecord = { - 'SE':SE, - 'EP':EP, - 'RAWSE':RAWSE, - 'RAWEP':RAWEP, - 'RAWName':RAWName, - 'NameEN':NameEN, - 'NameRomaji':NameRomaji, - 'CanonicalID':CanonicalID if CanonicalID not in [None, ''] else '' - } - CacheRecord, _ = Auxiliary_ApplyStandardTitleCacheToFileInfoRecord(CacheRecord) - SE = CacheRecord.get('SE', SE) - EP = CacheRecord.get('EP', EP) - RAWSE = CacheRecord.get('RAWSE', RAWSE) - RAWEP = CacheRecord.get('RAWEP', RAWEP) - RAWName = CacheRecord.get('RAWName', RAWName) - OpenAIIdentifyFileMemoryCache[QueryFileName] = CacheRecord - LastOpenAIFileInfoMeta = BuildMetaFromRecord(CacheRecord) - Auxiliary_Log(f'OpenAI文件识别成功 => 剧名:{RAWName} 季:{SE} 集:{EP}','INFO') - return SE,EP,RAWSE,RAWEP,RAWName - except Exception as err: - Auxiliary_Log(f'OpenAI文件识别处理失败: {err}','WARNING') - Auxiliary_NoteOpenAIIdentifyFailure('exception', str(err), input_basename=path.basename(FileName)) - return None - - -def Auxiliary_RecordOperation(Action, SrcPath, DstPath, Status, Message='',BackupPath=''): - if 'Runtime' not in globals() or Runtime is None: - return - Runtime.operation_records.append({ - 'timestamp':strftime("%Y-%m-%d %H:%M:%S",localtime(time())), - 'action':Action, - 'src':str(SrcPath), - 'dst':str(DstPath), - 'status':Status, - 'message':Message, - 'backup':str(BackupPath) if BackupPath not in [None, ''] else '' - }) - - -def Auxiliary_WriteOperationLog(): - if OPERATION_LOG_ENABLE != True or 'Runtime' not in globals() or Runtime is None: - return - if RUN_COMMAND == 'rollback': - return - if Runtime.operation_log_path in [None, '']: - return - try: - Runtime.operation_log_path.parent.mkdir(parents=True,exist_ok=True) - Payload = { - 'run_id': CurrentRunID, - 'dry_run': Runtime.config.dry_run, - 'naming_style': Runtime.config.naming_style, - 'records': Runtime.operation_records - } - with open(Runtime.operation_log_path,'w',encoding='UTF-8') as LogFile: - json.dump(Payload,LogFile,ensure_ascii=False,indent=2) - Auxiliary_Log(f'操作日志已写入 {Runtime.operation_log_path}','INFO') - except Exception as err: - Auxiliary_Log(f'操作日志写入失败: {err}','WARNING') - - -def Auxiliary_RollbackFromLog(LogPath): - '''根据操作日志回滚文件''' - RollbackFile = PathlibPath(LogPath) - if RollbackFile.is_file() == False: - Auxiliary_Exit(f'回滚日志不存在: {RollbackFile}') - try: - with open(RollbackFile,'r',encoding='UTF-8') as ff: - Data = json.load(ff) - except json.JSONDecodeError: - with open(RollbackFile,'r',encoding='UTF-8-sig') as ff: - Data = json.load(ff) - Records = Data.get('records', []) - if type(Records) != list or Records == []: - Auxiliary_Exit(f'回滚日志内无可用记录: {RollbackFile}') - for Record in Records[::-1]: - if type(Record) != dict: - continue - if Record.get('status') not in ['success']: - continue - Action = Record.get('action') - SrcPath = PathlibPath(Record.get('src', '')) - DstPath = PathlibPath(Record.get('dst', '')) - BackupPath = PathlibPath(Record.get('backup')) if Record.get('backup') not in [None, ''] else None - try: - if Action == 'move': - if DstPath.exists(): - DstPath.parent.mkdir(parents=True,exist_ok=True) - move(str(DstPath), str(SrcPath)) - if BackupPath and BackupPath.exists(): - move(str(BackupPath), str(DstPath)) - elif Action == 'link': - if DstPath.exists(): - remove(str(DstPath)) - if BackupPath and BackupPath.exists(): - move(str(BackupPath), str(DstPath)) - elif Action == 'remove': - if BackupPath and BackupPath.exists(): - move(str(BackupPath), str(DstPath)) - Auxiliary_Log(f'回滚成功: {Action} {DstPath} -> {SrcPath}','INFO') - except Exception as err: - Auxiliary_Log(f'回滚失败: {Action} {DstPath} -> {SrcPath}, {err}','WARNING') - -def Auxiliary_ShouldPrintConsoleLog(OneMsg, MsgFlag='INFO', flag=None): - '''控制终端输出,只保留与番剧整理直接相关的信息''' - - if flag == 'PRINT': - return True - if MsgFlag != 'INFO': - return True - OneMsg = str(OneMsg).strip() - if OneMsg == '': - return False - if set(OneMsg) == {'-'}: - return False - - SilentPrefixes = ( - '正在读取外置ini文件', - '读取到配置分区:', - '配置 < ', - '当前工具版本为', - '当前操作系统识别码为', - 'filepath < ', - 'filename < ', - 'number < ', - 'categoryname < ', - 'animename < ', - 'tag < ', - 'NAMING_STYLE < ', - 'DRY_RUN < ', - 'OUTPUT_PATH < ', - 'STRICT_MODE < ', - 'USELINK < ', - '当前分类 >> ', - '排除模块:', - '模块 << ', - '无扩展', - '不存在扩展文件夹 ./Ext', - '已加载持久化缓存文件 ', - '持久化缓存写入完成 ', - 'OpenAI文件识别缓存标题已按标准化缓存修正:', - 'OpenAI文件识别缓存命中 << ', - 'OpenAI文件识别持久化缓存标题已按标准化缓存修正:', - 'OpenAI文件识别持久化缓存命中 << ', - '没有使用OpenAIApi进行检索', - '没有使用BgmApi进行检索', - '没有使用TMDBApi进行检索', - '没有使用BangumiApi进行检索', - '代理功能开启', - '使用系统代理' - ) - SilentSubstrings = ( - '秒延时中', - '个可加载模块', - '内存缓存查询结果', - '持久化缓存查询结果', - 'OpenAIApi查询结果', - 'BgmApi查询结果', - 'TMDBApi查询结果', - 'BangumiApi查询结果', - 'API获取到结果' - ) - if any(OneMsg.startswith(Prefix) for Prefix in SilentPrefixes): - return False - if any(Keyword in OneMsg for Keyword in SilentSubstrings): - return False - return True - -def Auxiliary_Log(Msg:str,MsgFlag='INFO',flag=None,end='\n'): - '''日志''' - - global LogData,PRINTLOGFLAG - Msg = Msg if type(Msg) == tuple else (Msg,) - for OneMsg in Msg: - Msg = f'[{strftime("%Y-%m-%d %H:%M:%S",localtime(time()))}] {MsgFlag}: {OneMsg}' - if (PRINTLOGFLAG == True or flag == 'PRINT') and Auxiliary_ShouldPrintConsoleLog(OneMsg,MsgFlag,flag): - print(Msg,end=end) - LogData = '' + Msg if 'LogData' not in globals() else LogData + '\n' + Msg - -def Auxiliary_FormatListPreview(FileList, preview_count=12): - '''列表日志预览,避免一次性输出过长内容拖慢终端''' - - if type(FileList) != list: - return str(FileList) - TotalCount = len(FileList) - if TotalCount <= preview_count: - return str(FileList) - PreviewList = FileList[:preview_count] - return f'{PreviewList} ... 省略{TotalCount - preview_count}项' - -def Auxiliary_DeleteLogs(): - '''日志清理''' - - RmLogsList = [] - if RMLOGSFLAG != False and 'LogsFileList' in globals() and LogsFileList != []: - ToDay = datetime.strptime(datetime.now().strftime('%Y-%m-%d'),"%Y-%m-%d").date() - for Logs in LogsFileList: - LogFileName = path.basename(Logs) - if match(r'^\d{4}-\d{2}-\d{2}\.log$', LogFileName, flags=I) == None: - continue - LogDate = datetime.strptime(LogFileName.replace('.log',''),"%Y-%m-%d").date() - if (ToDay - LogDate).days >= int(RMLOGSFLAG): - remove(f'{Path}{Separator}{Logs}') - RmLogsList.append(Logs) - if RmLogsList != []: - Auxiliary_Log(f'清理了保存时间达到和超过{RMLOGSFLAG}天的日志文件 << {RmLogsList}') - -def Auxiliary_WriteLog(): - '''写log文件''' - - LogPath = filepath if 'filepath' in globals() and path.exists(filepath) == True else PyPath - if LogPath in [None, '']: - LogPath = str(PathlibPath('.').resolve()) - if path.exists(LogPath) == False: - makedirs(LogPath, exist_ok=True) - if LogPath == PyPath: - Auxiliary_Log(f'Log文件保存在工具目录下','WARNING') - with open(f'{LogPath}{Separator}{strftime("%Y-%m-%d",localtime(time()))}.log','a+',encoding='UTF-8') as LogFile: - LogFile.write(LogData) - -def Auxiliary_UniformOTSTR(File): - '''统一意外字符''' - - NewFile = convert(File,'zh-hans')# 繁化简 - NewUSTRFile = sub(r',|,| ','-',NewFile,flags=I) - # 修复:保留~字符(包括全角和半角),不要替换成= - NewUSTRFile = sub(r'[^a-z0-9\s&/::.\-\(\)()《》\u4e00-\u9fa5\u3040-\u309F\u30A0-\u30FF\u31F0-\u31FF°ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫ~~]','=',NewUSTRFile,flags=I) - #异种剧集统一 - OtEpisodesMatchData = [r'第(\d{1,4})集',r'(\d{1,4})集',r'第(\d{1,4})话',r'(\d{1,4})END',r'(\d{1,4}) END',r'(\d{1,4})E'] - for i in OtEpisodesMatchData: - i = f'[^0-9a-z]{i}[^0-9a-z]' - if search(i,NewUSTRFile,flags=I) != None: - a = search(i,NewUSTRFile,flags=I) - NewUSTRFile = NewUSTRFile.replace(a.group(),'='+a.group(1).strip('\u4e00-\u9fa5')+'=') - return NewUSTRFile - -def Auxiliary_RMOTSTR(File): - '''剔除意外字符''' - - global FuzzyMatchData - global PreciseMatchData - NewPSTRFile = File - #匹配待去除列表 - FuzzyMatchData = [r'(.*?|=)月新番(.*?|=)',r'\d{4}.\d{2}.\d{2}',r'20\d{2}',r'v[2-9]',r'\d{4}年\d{1,2}月番'] - #精准待去除列表 - PreciseMatchData = [r'仅限港澳台地区',r'年龄限制版',r'国漫',r'x264',r'1080p',r'720p',r'4k',r'(-)'] - for i in PreciseMatchData: - NewPSTRFile = sub(r'%s'%i,'=',NewPSTRFile,flags=I) - for i in FuzzyMatchData: - NewPSTRFile = sub(i,'=',NewPSTRFile,flags=I) - return NewPSTRFile - -def Auxiliary_IDESE(File): - '''识别剧季并截断Name''' - - SeasonMatchData = r'(季(.*?)第)|(([0-9]{0,1}[0-9]{1})S)|(([0-9]{0,1}[0-9]{1})nosaeS)|(([0-9]{0,1}[0-9]{1}) nosaeS)|(([0-9]{0,1}[0-9]{1})-nosaeS)|(nosaeS-dn([0-9]{1}))|(nosaeS-dr([0-9]{1}))' - if (X := findall(SeasonMatchData,File[::-1],flags=I)) != []: - SEData = X - SENamelist = [] - SEList = [] - for sedata in SEData: - for se in sedata:# 取值 - if se != '' and se.isnumeric() == False: - SENamelist.append(se[::-1]) - #elif len(se) == 1: - # SEList.append(se) - elif se.isnumeric() == True: # 判断数字 - SEList.append(se) - for i in SENamelist:# 截断Name - File = sub(r'%s.*'%i,'',File,flags=I).strip('=') #通过剧季截断文件名 - for i in range(len(SEList)): - if SEList[i].isdecimal() == True: # 判断纯数字 - SE = SEList[i][::-1] - elif '\u0e00' <= SEList[i] <= '\u9fa5':# 中文剧季转化 - digit = {'一':'01', '二':'02', '三':'03', '四':'04', '五':'05', '六':'06', '七':'07', '八':'08', '九':'09','壹':'01','贰':'02','叁':'03','肆':'04','伍':'05','陆':'06','柒':'07','捌':'08','玖':'09'} - SE = digit[SEList[i]] - if SE is not None: - return SE,File,SENamelist[0] - elif (X := findall(r'[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫ]',File[::-1],flags=I)) != []: - A = {'Ⅰ':'01','Ⅱ':'02','Ⅲ':'03','Ⅳ':'04','Ⅴ':'05','Ⅵ':'06','Ⅶ':'07','Ⅷ':'08','Ⅸ':'09','Ⅹ':'10','Ⅺ':'11','Ⅻ':'12'} - return A[X[0]],File,X[0] - else: - return '01',File,'' - -def Auxiliary_IDEEP(File): - '''识别剧集''' - - try: - if findall(r'[^0-9.\u4e00-\u9fa5\u0800-\u4e00]([0-9.]{1,4}-[0-9.]{1,4})[^0-9.\u4e00-\u9fa5\u0800-\u4e00]',File[::-1],flags=I) != []: - Auxiliary_Log('剧集包不予处理','WARNING') - raise Exception() - elif (X := findall(r'[^0-9a-z.\u4e00-\u9fa5\u0800-\u4e00]([0-9.]{1,5})[^0-9a-uw-z.\u4e00-\u9fa5\u0800-\u4e00]',File[::-1],flags=I)) != []: - Episodes = X[0][::-1].strip(" =-_eEv") - else: - Episodes = findall(r'[^0-9a-z.\u4e00-\u9fa5\u0800-\u4e00]([0-9]{1,4})[^0-9a-uw-z.\u4e00-\u9fa5\u0800-\u4e00]',File[::-1],flags=I)[0][::-1].strip(" =-_eEv") - - except IndexError: - Auxiliary_Log('未匹配出剧集,请检查(程序目前不支持电影动漫)','WARNING') - raise Exception() - except : - raise Exception() - else: - #Auxiliary_Log(f'匹配出的剧集 ==> {Episodes}','INFO') - return Episodes - -def Auxiliary_RMSubtitlingTeam(File): - '''剔除字幕组信息''' - - #File = File.strip('=') - if File[0] == '《':# 判断有无字幕组信息 - File = sub(r'《|》','',File,flags=I) - else: - File = sub(r'^=.*?=','',File,flags=I) - return File - -_STRIP_LEADING_BRACKET_RELEASE_TAGS = compile(r'^(?:(?:\s*\[[^\]]+\]|\s*【[^】]+】))+\s*') - -def Auxiliary_StripLeadingBracketReleaseTags(basename): - """仅用于展示/LLM 输入:去掉基名首部的连续半角 […] 或全角 【…】 发行/字幕组标签。剥空则回退为原串。""" - - if basename is None: - return None - if basename == '': - return '' - s = str(basename) - t = _STRIP_LEADING_BRACKET_RELEASE_TAGS.sub('', s, count=1) - t = t.lstrip() if t else t - if t == '': - return s - return t - -def Auxiliary_IDEVDName(File,RAWEP): - '''识别剧名''' - - try: - #VDName = sub(r'.*%s'%RAWEP[::-1],'',File[::-1],count=0,flags=I).strip('=-=-=-')[::-1] - match_result = search(r'[=|-]%s[=|-](.*)'%RAWEP[::-1],File[::-1],flags=I) - if match_result: - VDName = match_result.group(1).strip('=-=-=-')[::-1] - else: - # 如果无法通过剧集截断,尝试其他方法 - VDName = sub(r'.*%s'%RAWEP[::-1],'',File[::-1],count=0,flags=I).strip('=-=-=-')[::-1] - if not VDName or VDName == File: - # 如果还是无法识别,使用原始文件名(去除扩展名) - VDName = path.splitext(File)[0] - Auxiliary_Log(f'通过剧集截断文件名 ==> {VDName}','INFO') - return VDName - except Exception as e: - Auxiliary_Log(f'剧名识别失败,使用原始文件名: {e}','WARNING') - return path.splitext(File)[0] - -def Auxiliary_IDEASS(File,SE,EP,ASSList): - '''识别当前番剧视频的所属字幕文件''' - - ASSFileList = [] - for ASSFile in ASSList: - ASSName = Auxiliary_UniformOTSTR(path.basename(ASSFile)) - try: - ASSEP = Auxiliary_IDEEP(ASSName) - except Exception: - Auxiliary_Log(f'字幕文件无法提取剧集,跳过匹配: {ASSFile}','WARNING') - continue - if File in ASSName and EP == ASSEP and SE in ASSName: - ASSFileList.append(ASSFile) - ASSFileList = None if ASSFileList == [] else ASSFileList - return ASSFileList - -def Auxiliary_FileType(FileName): - '''识别文件类型''' - - SuffixList = {'.ass':'ASS','.srt':'ASS','.mp4':'MP4','.mkv':'MP4','.log':'LOG'} - for FileType in SuffixList: - if match(FileType[::-1],FileName[::-1],flags=I) != None: - try : - return SuffixList[FileType.lower()] - except : - Auxiliary_Exit('文件类型不正确') - -def Auxiliary_IsIncompleteDownloadFile(FileName) -> bool: - '''判断是否为未完成下载文件''' - - BaseName = path.basename(str(FileName)).lower() - IncompleteSuffixes = ('.!qb','.part','.partial','.aria2','.crdownload') - return BaseName.endswith(IncompleteSuffixes) - -def Auxiliary_ScanDIR(Dir,Flag=0) -> list: - '''扫描文件目录,返回文件列表''' - - def Scan(RelativeFile): - FileSuffix = path.splitext(RelativeFile)[1].lower() - if FileSuffix == '.ass' or FileSuffix == '.srt': - AssFileList.append(RelativeFile) - elif FileSuffix == '.log': - LogsFileList.append(RelativeFile) - elif FileSuffix == '.mp4' or FileSuffix == '.mkv': - VDFileList.append(RelativeFile) - - global LogsFileList - SuffixList = ['.ass','.srt','.mp4','.mkv','.log'] - AssFileList = [] - VDFileList = [] - LogsFileList = [] - RootPath = PathlibPath(Dir) - OutputRelativePrefix = None - if 'Runtime' in globals() and Runtime and getattr(Runtime, 'output_path', None): - try: - OutputRelativePrefix = str(PathlibPath(Runtime.output_path).resolve().relative_to(RootPath.resolve())).replace('\\','/') - if OutputRelativePrefix in ['', '.']: - OutputRelativePrefix = None - except Exception: - OutputRelativePrefix = None - for Entry in RootPath.rglob('*'): # 递归扫描目录,支持子文件夹内文件 - if Entry.is_file() == False: - continue - RelativeFile = str(Entry.relative_to(RootPath)) - RelativeFileNormalized = RelativeFile.replace('\\','/') - if OutputRelativePrefix not in [None, ''] and ( - RelativeFileNormalized == OutputRelativePrefix or RelativeFileNormalized.startswith(f'{OutputRelativePrefix}/') - ): - continue - BaseName = path.basename(RelativeFile) - if path.splitext(BaseName)[1].lower() not in SuffixList: - continue - if Auxiliary_ScanEntryShouldSkip(RelativeFileNormalized, BaseName): - continue - if Flag == 0 and search(r'S\d{1,2}E\d{1,4}',BaseName,flags=I) == None: - Scan(RelativeFile) - elif Flag == 1 and search(r'S\d{1,2}E\d{1,4}',BaseName,flags=I) != None: - Scan(RelativeFile) - - if VDFileList != []:# 判断模式,处理字幕还是视频 - if AssFileList != []: - Auxiliary_Log( - ( - f'发现{len(AssFileList)}个字幕文件 ==> {Auxiliary_FormatListPreview(AssFileList)}', - f'发现{len(VDFileList)}个视频文件 ==> {Auxiliary_FormatListPreview(VDFileList)}' - ), - 'INFO' - ) - return VDFileList,AssFileList - else: - Auxiliary_Log( - f'发现{len(VDFileList)}个视频文件,没有发现字幕文件 ==> {Auxiliary_FormatListPreview(VDFileList)}', - 'INFO' - ) - return VDFileList - elif AssFileList != []: - Auxiliary_Log( - ( - f'没有发现任何番剧视频文件,但发现{len(AssFileList)}个字幕文件 ==> {Auxiliary_FormatListPreview(AssFileList)}', - '只有字幕文件需要处理' - ), - 'INFO' - ) - return AssFileList - else: - Auxiliary_Exit('没有任何番剧文件') - -def Auxiliary_AnimeFileCheck(File): - '''检查番剧文件''' - - Checklist = ['OP','CM','SP','PV'] - for i in Checklist: - if search(f'[-=]{i}[-=]',File,flags=I) != None: - return i - return True - -def Auxiliary_ASSFileCA(ASSFileName): - '''字幕文件的语言分类''' - - ASSFileName = path.basename(ASSFileName) - SubtitleList = [['简','簡','簡體','sc','chs','GB'],['繁','tc','cht','BIG5'],['日','jp']] - for i in range(len(SubtitleList)): - for ii in SubtitleList[i]: - if search(f'[^0-9a-z]{ii[::-1]}[^0-9a-z]',ASSFileName[::-1],flags=I) != None: - if i == 0: - return '.chs' if JELLYFINFORMAT == False else '.简体中文.chi' - elif i == 1: - return '.cht' if JELLYFINFORMAT == False else '.繁体中文.chi' - elif i == 2: - return '.jp' - return '.other' - -def Auxiliary_PROXY(): - '''代理''' - if USEPROXY == True: - global HTTPPROXY - global HTTPSPROXY - global ALLPROXY - Auxiliary_Log('代理功能开启') - if USESYSPROXY == True: - Auxiliary_Log('使用系统代理') - HTTPPROXY,HTTPSPROXY,a = X if (X:= tuple(getproxies().values())) != () else ('','','') - environ['http_proxy'] = HTTPPROXY - environ['https_proxy'] = HTTPSPROXY - environ['all_proxy'] = ALLPROXY - - - -def Auxiliary_Http(Url,flag='GET',JsonData=None,ExtraHeaders=None,Timeout=30,ResponseType='text'): - '''网络请求,支持 JSON 解析与字段校验前置''' - - headers = {'User-Agent':f'AutoAnimeMv/{Versions}'} - if type(ExtraHeaders) == dict: - headers.update(ExtraHeaders) - if 'themoviedb' in Url: - TMDBToken = Auxiliary_GetTMDBBearerToken() - if TMDBToken not in [None, '']: - headers['Authorization'] = f'Bearer {TMDBToken}' - else: - Auxiliary_Log('TMDB token 未配置,TMDBApi 将不可用。请设置环境变量 TMDB_BEARER_TOKEN','WARNING') - - RetryTimes = Auxiliary_ParseInt(NETERRRECTRYTIMS, 1) - if RetryTimes < 0: - RetryTimes = 0 - for i in range(RetryTimes + 1): - try: - if str(flag).upper() != 'GET': - HttpData = post(Url,json=JsonData,headers=headers,timeout=Timeout) - else: - HttpData = get(Url,headers=headers,timeout=Timeout) - if HttpData.status_code == 200: - if ResponseType == 'json': - try: - return HttpData.json() - except ValueError: - Auxiliary_Log(f'接口返回不是合法 JSON: {Url}','WARNING') - return None - return HttpData.text.replace(r'\/',r'/') - Auxiliary_Log(f'HttpData Status Code = {HttpData.status_code}','WARNING') - except exceptions.ConnectionError: - Auxiliary_Log(f'访问 {Url} 失败,请检查代理与网络连通性','WARNING') - except exceptions.RequestException as err: - Auxiliary_Log(f'访问 {Url} 失败: {err}','WARNING') - except Exception as err: - Auxiliary_Log(f'访问 {Url} 失败,未能获取到内容: {err}','WARNING') - Auxiliary_Log(f'第{i+1}/{RetryTimes+1}次尝试失败','WARNING') - return None - -def Auxiliary_Api(Name): - """按 TMDB 中文→Bangumi→TMDB 英文→OpenAI 译中文 解析剧名(失败则中止整理)""" - Name = Auxiliary_NormalizeDisplayTitle(Name) - if Name in [None, '']: - Auxiliary_Exit('Auxiliary_Api: 空名称') - if (ManualWhitelistedTitle := Auxiliary_GetManualWhitelistedTitle(Name)) not in [None, '']: - _, CanonicalManualWhitelisted = Auxiliary_UpsertCanonicalTitle( - ManualWhitelistedTitle, - Name if Auxiliary_HasChineseText(Name) == False else '', - '', - 'manual', - [Name, ManualWhitelistedTitle] - ) - return CanonicalManualWhitelisted if CanonicalManualWhitelisted not in [None, ''] else ManualWhitelistedTitle - if (CanonicalByInput := Auxiliary_GetStandardTitleFromCache(Name)) not in [None, '']: - return CanonicalByInput - if 'animename' in globals() and animename not in ['',None]: - ManualName = Auxiliary_NormalizeApiTitle(animename) - _, CanonicalManualName = Auxiliary_UpsertCanonicalTitle(ManualName, '', '', 'manual', [Name, animename]) - Auxiliary_Log(f'使用指定的番剧名称 > {CanonicalManualName if CanonicalManualName not in [None, ""] else ManualName}') - return CanonicalManualName if CanonicalManualName not in [None, ''] else ManualName - if APIREQUESTSONLYUSECH and (X := search(r'([一-龥]+)',Name.replace('=','').replace('-',''),flags=I)) != None: - search_name = X.group(1) - else: - search_name = Name - if (CanonicalBySearch := Auxiliary_GetStandardTitleFromCache(search_name)) not in [None, '']: - return CanonicalBySearch - AINameZH = search_name if Auxiliary_HasChineseText(search_name) else '' - NameEN = search_name if Auxiliary_HasChineseText(search_name) == False else '' - zh, _, _, _ = Auxiliary_ResolvePlannedTitleChain(AINameZH, NameEN, '', search_name) - return Auxiliary_NormalizeApiTitle(zh) - - -def Auxiliary_Exit(LogMsg): - '''因可预见错误离场''' - - Auxiliary_Log(LogMsg,'EXIT',flag='PRINT') - exit() - -if __name__ == '__main__': - start = time() - try: - Start_PATH() - ArgvData = Start_GetArgv() - if RUN_COMMAND == 'rollback': - Auxiliary_RollbackFromLog(ROLLBACK_LOG_PATH) - else: - Processing_Main(Processing_Mode(ArgvData)) - except Exception as err: - Auxiliary_Log(f'没有预料到的错误 > {err}','ERROR',flag='PRINT') - else: - end = time() - Auxiliary_Log(f'一切工作已经完成,用时{end - start}','INFO',flag='PRINT') - finally: - Auxiliary_SavePersistentCache() - Auxiliary_WriteOperationLog() - if 'HelpMessages' not in globals(): - Auxiliary_WriteLog() diff --git a/AutoAnimeMv2.py b/AutoAnimeMv2.py deleted file mode 100644 index 77c471f..0000000 --- a/AutoAnimeMv2.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/python3 -# coding:utf-8 -""" -AutoAnimeMv2 - 模块化重构后的新入口 - -与旧入口 `AutoAnimeMv.py` 并存,复用 `autoanime/` 包提供的模块化实现: - -- `python AutoAnimeMv2.py ` 原目录扫描 -- `python AutoAnimeMv2.py ` 单文件整理(自动拆 parent + basename,收同目录同集字幕) -- `python AutoAnimeMv2.py --file ` 目录模式 + 明确指定文件 -- `python AutoAnimeMv2.py 1` 原 qB 回调兼容 -- `python AutoAnimeMv2.py rollback --log ...` 回滚模式 - -本文件本身只做入口薄壳,具体逻辑都在 `autoanime.cli.main`。 -""" - -from autoanime.cli import main - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/AutoAnimeMv3.py b/AutoAnimeMv3.py new file mode 100644 index 0000000..ddf0388 --- /dev/null +++ b/AutoAnimeMv3.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# coding: utf-8 +"""AutoAnime v3 独立入口。 + +旧版 ``AutoAnimeMv.py``、``AutoAnimeMv2.py`` 与 ``autoanime/`` 包均不参与运行。 +""" + +from autoanime_v3.cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/AutoAnimeWeb.py b/AutoAnimeWeb.py new file mode 100644 index 0000000..33bd778 --- /dev/null +++ b/AutoAnimeWeb.py @@ -0,0 +1,31 @@ +"""Run the AutoAnime LAN Web console.""" + +import argparse +from pathlib import Path + +import uvicorn + +from autoanime_v3.api.app import ServerSettings, create_app + + +def main(argv=None): + parser = argparse.ArgumentParser(description="AutoAnime Web Console") + parser.add_argument("--data-dir", type=Path, default=Path("C:/ProgramData/AutoAnime")) + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--insecure-http", action="store_true") + args = parser.parse_args(argv) + data_directory = args.data_dir.resolve() + settings = ServerSettings( + database_path=data_directory / "data" / "library.sqlite3", + data_directory=data_directory, + host=args.host, + port=args.port, + secure_cookies=not args.insecure_http, + frontend_directory=Path(__file__).resolve().parent / "webui" / "dist", + ) + uvicorn.run(create_app(settings), host=settings.host, port=settings.port, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/AutoAnimeWorker.py b/AutoAnimeWorker.py new file mode 100644 index 0000000..debf19a --- /dev/null +++ b/AutoAnimeWorker.py @@ -0,0 +1,75 @@ +"""Run the persistent AutoAnime Worker.""" + +import argparse +import os +import socket +import time +from pathlib import Path + +from autoanime_v3.jobs.queue import JobQueue +from autoanime_v3.jobs.worker import Worker +from autoanime_v3.services.automation import AutomationRuntime +from autoanime_v3.services.operations import OperationService +from autoanime_v3.services.scans import ScanService + + +def main(argv=None): + parser = argparse.ArgumentParser(description="AutoAnime Worker") + parser.add_argument("--data-dir", type=Path, default=Path("C:/ProgramData/AutoAnime")) + parser.add_argument("--once", action="store_true") + parser.add_argument("--poll-seconds", type=float, default=1.0) + args = parser.parse_args(argv) + data_directory = args.data_dir.resolve() + database = data_directory / "data" / "library.sqlite3" + queue = JobQueue(database) + + def scan_handler(job): + queue.append_event(job.id, "phase", {"name": "scan"}, "开始扫描") + outcome = ScanService(database).run(int(job.payload["profile_id"]), job.payload.get("paths") or None) + queue.append_event(job.id, "scan_completed", {"plan_id": outcome.plan_id}, "扫描完成") + + def execute_handler(job): + queue.append_event(job.id, "phase", {"name": "execute"}, "开始执行计划") + batch = OperationService(database, data_directory / "operations").execute(int(job.payload["plan_id"])) + queue.append_event(job.id, "execution_completed", {"batch_id": batch.id}, "执行完成") + + def rollback_handler(job): + queue.append_event(job.id, "phase", {"name": "rollback"}, "开始安全回滚") + batch = OperationService(database, data_directory / "operations").rollback( + int(job.payload["batch_id"]), job.payload.get("requested_by") + ) + queue.append_event(job.id, "rollback_completed", {"batch_id": batch.id}, "回滚完成") + + worker = Worker( + "%s:%s" % (socket.gethostname(), os.getpid()), + queue, + { + "scan": scan_handler, + "execute_plan": execute_handler, + "rollback_operation": rollback_handler, + }, + ) + automation = AutomationRuntime( + database, + queue=queue, + watch_poll_seconds=max(0.05, args.poll_seconds), + observer_reload_seconds=max(0.25, args.poll_seconds), + ) + automation.start() + try: + if args.once: + automation.tick() + worker.run_once(lease_seconds=120) + return + while True: + automation.tick() + if worker.run_once(lease_seconds=120) is None: + time.sleep(max(0.1, args.poll_seconds)) + except KeyboardInterrupt: + return + finally: + automation.stop() + + +if __name__ == "__main__": + main() diff --git a/README.md b/README.md index c31825a..dc3d868 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,261 @@ -# AutoAnimeMv +# AutoAnime 简体中文 | [English](./README_en.md) -`AutoAnimeMv` 是一个用于番剧视频和字幕自动识别、重命名、整理的 Python 工具,支持本地批处理和 `qBittorrent` 回调两种工作方式,适合在 Emby、Jellyfin、Plex 等媒体库入库前做标准化整理。 +AutoAnime 是一个面向 Emby、Jellyfin、Plex 等媒体库的番剧识别与整理工具。仓库已经收敛为单一 v3.1.1 实现:入口为 `AutoAnimeMv3.py`,核心代码位于 `autoanime_v3/`。 -## 功能概览 -- 支持 OpenAI 兼容接口优先识别剧名、季、集 -- 支持用 AI 罗马音/英文优先查询 `TMDB` 回填中文名,未命中再回退 AI 中文名 -- 支持 `Bangumi`、`BGM`、`TMDB` 作为 AI 失败时的回退数据源 -- 支持视频与字幕联动整理 -- 支持 `default` / `emby` 两种命名风格 -- 支持硬链接、`--dry-run` 预览、操作日志和回滚 -- 支持递归扫描子目录,并可指定独立输出目录 -- 硬链接模式下若目标已存在,默认保留原文件,并缓存新重复资源的识别结果以减少后续重复识别 +## v3 的设计重点 + +- 同时支持季度/合集文件夹和单个视频文件。 +- 本地解析、人工别名目录、可选远程 agent 分层运行,每个结果都保存证据与置信度。 +- 默认只预览;只有加 `--apply` 才会改动文件。 +- 未达到阈值、季集缺失或证据冲突时进入待确认,不会猜测后移动。 +- 同一集的 Baha、friDay、LINETV、CR、V2/V3、无修版、配音版等使用由文件自身决定的稳定标签;没有发布信息的文件使用稳定 `version-xxxxxxxx` 键,支持分批增量加入且不覆盖文件。 +- 支持 `link`、`copy`、`move`;move 会先把源原子重命名为同卷 staging,再校验并只删除 staging,避免误删下载器刚重建的新文件。操作日志保存 SHA-256,批次失败会安全回滚已完成项。 +- 使用 SQLite 资料库,不依赖分散、难维护的 JSON 缓存。 +- 提供 FastAPI + React Web 管理控制台,支持单管理员登录、局域网访问、配置修改、任务审核、计划批准、真实文件执行与安全回滚。 + +## 项目结构 + +```text +AutoAnimeMv3.py CLI 入口 +AutoAnimeWeb.py Web/API 入口 +AutoAnimeWorker.py 持久任务 Worker 入口 +start-autoanime.bat Windows 一键启动(根目录) +stop-autoanime.bat Windows 一键停止 +install-autostart.bat 登录自启 +uninstall-autostart.bat 取消自启 +autoanime_v3/ +├─ scanner.py 单文件/季度目录扫描 +├─ parser.py 文件名与季集解析 +├─ catalog.py 别名和季度布局规则 +├─ resolver.py agent 编排与安全判定 +├─ planner.py 生成无覆盖的整理计划 +├─ executor.py link/copy/move、日志与回滚 +├─ db/ Web Schema、迁移和 repositories +├─ services/ Web/Worker 共用应用服务 +├─ api/ FastAPI 应用与安全边界 +├─ jobs/ 持久队列、Worker、定时器和监听器 +├─ repository.py / cache.py CLI 兼容资料库边界 +└─ data/aliases.json 可维护的标题与季集规则 +webui/ React/Vite 管理控制台 +deploy/windows/ WinSW、Caddy 与兼容包装脚本 +tests/ v3 自动化测试 +docs/ 架构与 WebUI 规划 +``` ## 安装 + +生产运行需要 Python 3.11 或更高版本。构建 WebUI 需要 Node.js 20 或更高版本及 pnpm 10 或更高版本: + ```bash python -m pip install -r requirements.txt ``` +复制配置模板(可选): + +```powershell +Copy-Item config.v3.ini.Template config.v3.ini +``` + +配置文件不会自动读取;需要时显式传入 `--config config.v3.ini`。密钥应放在环境变量中,不要提交到仓库。 + ## 快速开始 -1. 复制 `config.ini.Template` 为本地 `config.ini` -2. 按需修改识别、命名、代理和落盘策略 -3. 通过环境变量注入真实密钥,不要把凭据写入仓库 -4. 先运行 `--dry-run` 预览,再执行真实整理 -### PowerShell 示例 +### 预览整个下载目录 + +不加 `--apply` 时绝不会移动、复制或创建媒体文件: + ```powershell -$env:OPENAI_API_KEY="your-openai-key" -$env:TMDB_BEARER_TOKEN="your-tmdb-token" -python AutoAnimeMv.py "D:\Anime" --dry-run -python AutoAnimeMv.py "D:\Anime" +python AutoAnimeMv3.py "F:\下载" --output "F:\动漫库" ``` -## 常用命令 -```bash -# 本地批处理 -python AutoAnimeMv.py "D:\Anime" +### 预览季度文件夹或单个文件 + +```powershell +python AutoAnimeMv3.py "F:\下载\Grand.Blue.Dreaming.S03" --output "F:\动漫库" +python AutoAnimeMv3.py "F:\下载\[SubGroup] Anime Title - 03.mkv" --output "F:\动漫库" +``` -# Emby 风格命名 -python AutoAnimeMv.py "D:\Anime" --naming-style emby +### 确认后实际整理 -# 指定输出目录 -python AutoAnimeMv.py "D:\Anime" --output-path "D:\AnimeLibrary" +```powershell +# 移动 +python AutoAnimeMv3.py "F:\下载" --output "F:\动漫库" --mode move --apply -# 回滚最近一次整理 -python AutoAnimeMv.py rollback --log ".\logs\AutoAnime_operations_xxx.json" +# 硬链接(适合保种) +python AutoAnimeMv3.py "F:\下载" --output "F:\动漫库" --mode link --apply + +# 复制 +python AutoAnimeMv3.py "F:\下载" --output "F:\动漫库" --mode copy --apply ``` -## qBittorrent 回调示例 -```bash -python AutoAnimeMv.py "%D" "%N" "%C" "%L" +目标文件已存在时不会覆盖。 + +### 导出审核报告 + +```powershell +python AutoAnimeMv3.py "F:\下载" --output "F:\动漫库" --report-json ".\report.json" +``` + +报告包含源路径、目标路径、统一番名、季集、置信度、agent 证据、警告和动作类型。 + +## 输出结构 + +```text +动漫库/ +└─ 番剧中文名/ + └─ Season 03/ + ├─ S03E01 - 番剧中文名 [Baha].mkv + └─ S03E01 - 番剧中文名 [friDay].mkv ``` -未完成下载的临时文件(如 `.!qB`、`.part`、`.partial`、`.aria2`、`.crdownload`)会自动跳过,不参与番剧整理。 +电影放在番剧/电影名目录下。PV、TVSP、OVA 等没有集号的单文件必须在别名目录中显式声明,通常整理到 `Season 00`。 + +## 识别 agent 管线 + +1. **文件名解析 agent**:提取字幕组、标题、季、集、电影/特殊项和发布源。 +2. **目录上下文 agent**:季度文件夹作为辅助证据,但不会覆盖更明确的文件名。 +3. **别名目录 agent**:将罗马音、英文、繁简译名和不同官方译名合并到同一番剧。 +4. **季集规则 agent**:处理绝对集数,例如史莱姆 87 → S04E15、我的英雄学院 171 → S08E12。 +5. **可选 OpenAI agent**:仅处理本地未收敛项;结果若与明确季集冲突会被拒绝。 +6. **安全策略**:标题、季度、集数、证据与阈值全部通过后才进入执行计划。 + +内置目录位于 `autoanime_v3/data/aliases.json`。可通过 `--aliases my_aliases.json` 加载用户覆盖文件,无需修改 Python。目录内容变化会改变决策版本,旧识别记录自动失效。 -## 关键配置 -- `USEOPENAIAPI` / `OPENAI_PRIORITY_FIRST` / `OPENAI_IDENTIFY_ALL`: AI 识别链路开关 -- `OPENAI_API_KEY_ENV` / `TMDB_BEARER_TOKEN_ENV`: 凭据环境变量名 -- `USELINK` / `STRICT_MODE` / `LINKFAILSUSEMOVEFLAGS`: 文件整理策略 -- `NAMING_STYLE` / `OUTPUT_PATH` / `MAX_FILENAME_LENGTH`: 命名与输出控制 -- `DRY_RUN` / `OPERATION_LOG_ENABLE` / `OPERATION_LOG_DIR`: 预览、审计与回滚 +## v3 资料库 -## 公开仓库建议 -- `config.ini` 只保留在本地,不提交到仓库 -- 真实 `API Key` / `Token` 只通过环境变量注入 -- `docs/plans/`、`.cache/`、`logs/`、本地日志和虚拟环境建议忽略 -- 依赖安装统一使用 `requirements.txt`,不再保留 `get-pip.py` -- 如果要公开完整 Git 历史,请额外检查历史提交中的作者邮箱和旧仓库痕迹 +默认路径是 `.autoanime-v3/library.sqlite3`。它不是不可编辑的缓存黑盒,而是未来 CLI/WebUI 共用的资料库: -## 项目文档 -- 文档总入口:`docs/00_文档总目录.md` -- 架构说明:`docs/01_项目架构与模块职责.md` -- 部署说明:`docs/02_开发环境与构建部署.md` -- 接口与依赖:`docs/04_接口协议与外部依赖.md` +- `shows`、`seasons`、`episodes`:番剧、季度和剧集; +- `media_files`:以规范化 `source_key` 表示同一物理来源的当前路径、当前剧集归属、发布版本和状态; +- `resolutions`:按决策指纹保存识别结果、证据、规则版本和置信度;规则更新时历史记录可保留,但不会重复污染当前媒体事实; +- `operations`:每次 link/copy/move/自动回滚; +- `corrections`:人工纠正草案与文件迁移计划; +- `show_progress`:季度已识别/已整理进度视图。 -## 反馈 -如需反馈问题或提交改进建议,请直接使用当前仓库的 Issue 或 Pull Request。 +同一路径如果被一个大小或修改时间不同的新下载复用,会自动重置为 `identified`,不会继承旧文件的已整理位置。 + +清空 v3 资料库: + +```powershell +python AutoAnimeMv3.py --database-reset +``` + +这只会清空 v3 SQLite 资料库,不会操作媒体文件。 + +## 回滚 + +每次预览和执行都会生成 JSONL 操作日志。实际执行日志会保存目标文件大小、修改时间和 SHA-256。手动回滚会同步恢复 SQLite 中的文件位置和状态;如果目标文件在整理后又被修改,回滚会拒绝删除或移动它。旧 copy/link 日志若没有摘要,也会拒绝破坏性删除: + +```powershell +python AutoAnimeMv3.py --rollback ".\.autoanime-v3\operations\20260722_xxxxxx.jsonl" +``` + +批次执行中途失败时,v3 会先自动回滚本批次已经完成的文件。 + +## 配置 + +主要配置见 `config.v3.ini.Template`: + +- `database_path`:SQLite 资料库路径; +- `alias_file`:内置/自定义别名目录; +- `min_confidence`:允许自动整理的最低置信度; +- `output_root`、`mode`、`operation_dir`:输出与文件策略; +- `openai_*`:只用于本地无法收敛时的可选远程 agent。 + +命令行的 `--output`、`--mode` 会覆盖配置文件。 + +## Web 管理控制台 + +WebUI 面向 Windows 常驻服务器和局域网内的单管理员使用。它可以查看并修改: + +- 多下载根、多媒体库根及逐 profile 的 link/copy/move、审核策略、置信度、稳定窗口和目录监听开关; +- 持久扫描任务、任务事件、审核项、不可变整理计划和操作批次; +- 番剧资料、海报/简介/放送状态(可选元数据),以及带修订检查的人工标题纠正; +- 版本化 JSON 规则的草稿、校验、激活和回退; +- 普通 JSON 设置、DPAPI 加密密钥状态和 SQLite 在线备份; +- 本机免密登录与本机 Hook 信任策略(可在系统设置中关闭)。 + +### Windows 一键启动 + +打开项目文件夹后,根目录即可看到: + +| 文件 | 作用 | +|------|------| +| `start-autoanime.bat` | 启动 Web + Worker | +| `stop-autoanime.bat` | 停止服务 | +| `install-autostart.bat` | 登录后自动启动 | +| `uninstall-autostart.bat` | 取消开机自启 | + +默认: + +- 控制台:`http://127.0.0.1:8765` +- 数据目录:`C:\ProgramData\AutoAnime` +- 日志:`C:\ProgramData\AutoAnime\logs\` + +### 默认账号与本机免密 + +首次启动 Web 服务时会自动创建默认管理员: + +| 项目 | 值 | +|------|----| +| 账号 | `admin` | +| 密码 | `AutoAnime-Admin-ChangeMe!` | + +安全策略: + +- **本机免密登录(默认开启)**:从 `127.0.0.1` / `::1` 打开控制台时自动建立会话,无需输入密码。 +- **局域网仍需密码**:非 loopback 访问必须使用账号密码。 +- 可在 WebUI「系统设置 → 本机访问与 Hook」关闭「本机免密登录」。 +- **本机 Hook 信任(默认开启)**:本机可调用 `POST /api/v1/hooks/local` 触发扫描;关闭后仅允许带 token 的下载器 webhook。 +- 建议上线后尽快修改默认密码,并在不需要免密时关闭本机免验证。 + +首次构建前端: + +```powershell +pnpm --dir webui install +pnpm --dir webui build +``` + +本机或可信局域网内直接使用 HTTP: + +```powershell +# 推荐:双击根目录 start-autoanime.bat +# 或手动: +# 终端 1:Web/API;--insecure-http 只用于没有 HTTPS 的可信内网 +python AutoAnimeWeb.py --data-dir C:\ProgramData\AutoAnime --insecure-http + +# 终端 2:Worker +python AutoAnimeWorker.py --data-dir C:\ProgramData\AutoAnime +``` + +访问 `http://127.0.0.1:8765` 即可进入控制台(默认本机免密)。局域网访问使用 `http://服务器IP:8765` 并输入默认账号密码。生产部署建议让 Web 仅监听 `127.0.0.1`,通过 `deploy/windows/Caddyfile.example` 提供局域网 HTTPS;Caddy 示例也会拒绝远程 bootstrap 请求,此时不要传 `--insecure-http`。WinSW 服务模板位于 `deploy/windows/`;应使用可访问下载目录和媒体库、但权限尽量小的专用 Windows 服务账号,并限制防火墙只允许可信子网访问。 + +完整安全和数据设计见 [docs/11_v3_WebUI与数据层规划.md](./docs/11_v3_WebUI与数据层规划.md)。密钥只返回“是否已配置”和更新时间,永不通过 API 或页面回显明文/密文。 + +## 文档与测试 + +- [v3 架构与迁移](./docs/12_v3_架构与迁移.md) +- [WebUI 与数据层规划](./docs/11_v3_WebUI与数据层规划.md) +- [文档总目录](./docs/00_文档总目录.md) + +```powershell +python -m unittest discover -s tests -p "test_v3_*.py" -v +pnpm --dir webui test --run +pnpm --dir webui build +pnpm --dir webui e2e +pnpm --dir webui audit --prod --audit-level high +``` + +可选的真实大文件三模式验证使用隔离目录,不会触碰正式媒体库: + +```powershell +$env:AUTOANIME_REAL_TEST_ROOT = 'F:\AutoAnime-WebUI-Validation' +$env:AUTOANIME_REAL_SAMPLE = 'F:\Samples\episode.mkv' +pnpm --dir webui e2e real-file-modes.spec.ts +``` ## 许可证 + 本项目使用 [GPL-3.0](./LICENSE) 许可证。 diff --git a/README_en.md b/README_en.md index 33293f7..40e6496 100644 --- a/README_en.md +++ b/README_en.md @@ -1,79 +1,117 @@ -# AutoAnimeMv +# AutoAnime -English | [简体中文](./README.md) +[简体中文](./README.md) | English -`AutoAnimeMv` is a Python tool for identifying anime titles, seasons, and episodes, then renaming and organizing video and subtitle files into a cleaner library structure. It supports both local batch processing and `qBittorrent` callback workflows. +AutoAnime identifies and organizes anime files for Emby, Jellyfin, Plex, and similar media libraries. The repository now contains one implementation only: v3.1.1, entered through `AutoAnimeMv3.py` with its core in `autoanime_v3/`. -## Features -- OpenAI-compatible title, season, and episode recognition -- Fallback sources via `Bangumi`, `BGM`, and `TMDB` -- Linked handling for video and subtitle files -- `default` and `emby` naming styles -- Hard link support for seeding-friendly workflows -- `--dry-run`, operation logs, and rollback support -- Recursive scanning and optional separate output directory +Production requires Python 3.11 or newer. Building the WebUI requires Node.js 20 or newer and pnpm 10 or newer. -## Installation -```bash +## Highlights + +- Handles a season/batch directory or one video file. +- Defaults to preview mode; files change only with `--apply`. +- Rejects low-confidence or conflicting identification instead of guessing. +- Uses intrinsic stable labels for known releases and a stable `version-xxxxxxxx` key when metadata is absent, so incremental imports do not overwrite each other. +- Supports hard-link, copy, and move modes. Move first atomically claims the source as a same-volume staging file, verifies it, and deletes only that staging path, so a newly recreated download path is never removed. Logs include SHA-256 and failed batches roll back automatically. +- Uses a normalized SQLite library database instead of fragmented JSON cache files. +- Includes a FastAPI + React single-administrator LAN console for configuration, review, immutable plan approval, real file execution, and safe rollback. + +## Quick start + +```powershell python -m pip install -r requirements.txt + +# Preview a download directory +python AutoAnimeMv3.py "F:\Downloads" --output "F:\AnimeLibrary" + +# Preview one season directory or one file +python AutoAnimeMv3.py "F:\Downloads\Some.Show.S03" --output "F:\AnimeLibrary" +python AutoAnimeMv3.py "F:\Downloads\Some.Show.S03E02.mkv" --output "F:\AnimeLibrary" + +# Apply after reviewing the plan +python AutoAnimeMv3.py "F:\Downloads" --output "F:\AnimeLibrary" --mode move --apply +python AutoAnimeMv3.py "F:\Downloads" --output "F:\AnimeLibrary" --mode link --apply + +# Export an auditable JSON report +python AutoAnimeMv3.py "F:\Downloads" --output "F:\AnimeLibrary" --report-json report.json ``` -## Quick Start -1. Copy `config.ini.Template` to local `config.ini` -2. Adjust recognition, naming, proxy, and file handling options as needed -3. Inject real credentials through environment variables instead of storing them in the repository -4. Run a preview with `--dry-run` before doing actual file operations +Copy `config.v3.ini.Template` to `config.v3.ini` if a config file is desired, then pass it explicitly with `--config config.v3.ini`. Keep API credentials in environment variables. + +## Library database + +The default database is `.autoanime-v3/library.sqlite3`. It contains normalized shows, seasons, episodes, media files, identification evidence, operation history, and correction drafts. A normalized source key keeps one current media fact while historical resolution decisions remain auditable. Alias/rule changes are included in the decision fingerprint, so stale decisions are invalidated without duplicating the current media row. Manual rollback restores database state and verifies the logged SHA-256 before destructive actions. -### PowerShell Example ```powershell -$env:OPENAI_API_KEY="your-openai-key" -$env:TMDB_BEARER_TOKEN="your-tmdb-token" -python AutoAnimeMv.py "D:\Anime" --dry-run -python AutoAnimeMv.py "D:\Anime" +python AutoAnimeMv3.py --database-reset +python AutoAnimeMv3.py --rollback ".\.autoanime-v3\operations\run.jsonl" ``` -## Common Commands -```bash -# Local batch processing -python AutoAnimeMv.py "D:\Anime" +Database reset does not modify media files. -# Emby-style naming -python AutoAnimeMv.py "D:\Anime" --naming-style emby +## Web console -# Use a separate output directory -python AutoAnimeMv.py "D:\Anime" --output-path "D:\AnimeLibrary" +### Windows one-click start -# Roll back a previous run -python AutoAnimeMv.py rollback --log ".\logs\AutoAnime_operations_xxx.json" -``` +Root-level scripts (visible as soon as you open the project folder): + +| File | Purpose | +|------|---------| +| `start-autoanime.bat` | Start Web + Worker | +| `stop-autoanime.bat` | Stop services | +| `install-autostart.bat` | Start on user logon | +| `uninstall-autostart.bat` | Remove autostart | + +Defaults: `http://127.0.0.1:8765`, data under `C:\ProgramData\AutoAnime`. -## qBittorrent Callback Example -```bash -python AutoAnimeMv.py "%D" "%N" "%C" "%L" +### Default credentials and local passwordless login + +On first Web start a default administrator is created: + +| Field | Value | +|------|-------| +| Username | `admin` | +| Password | `AutoAnime-Admin-ChangeMe!` | + +Security policy: + +- **Local passwordless login (on by default)** for loopback (`127.0.0.1` / `::1`). +- LAN clients still need the username/password. +- Toggle under WebUI **Settings → Local access & hooks**. +- **Local hook trust (on by default)** allows `POST /api/v1/hooks/local` from loopback without a webhook token. +- Change the default password before exposing the service more broadly. + +Build the React application, then run the Web/API and Worker processes against the same data directory: + +```powershell +pnpm --dir webui install +pnpm --dir webui build + +# Trusted LAN HTTP development only +python AutoAnimeWeb.py --data-dir C:\ProgramData\AutoAnime --insecure-http +python AutoAnimeWorker.py --data-dir C:\ProgramData\AutoAnime ``` -## Important Configuration -- `USEOPENAIAPI` / `OPENAI_PRIORITY_FIRST` / `OPENAI_IDENTIFY_ALL`: AI recognition flow -- `OPENAI_API_KEY_ENV` / `TMDB_BEARER_TOKEN_ENV`: credential environment variable names -- `USELINK` / `STRICT_MODE` / `LINKFAILSUSEMOVEFLAGS`: file handling strategy -- `NAMING_STYLE` / `OUTPUT_PATH` / `MAX_FILENAME_LENGTH`: naming and output behavior -- `DRY_RUN` / `OPERATION_LOG_ENABLE` / `OPERATION_LOG_DIR`: preview, audit, and rollback controls +Open `http://127.0.0.1:8765` for the console (passwordless on loopback by default). Use `http://server-ip:8765` with the default credentials from another machine. The console manages multiple source/library roots, per-profile link/copy/move policies, manual scans, job events, reviews, immutable plans, operation rollback, library-title corrections, versioned JSON rules, encrypted secret status, ordinary settings, and online backups. -## Public Repository Notes -- Keep `config.ini` local and never commit it -- Store real `API keys` / `tokens` in environment variables only -- Ignore `docs/plans/`, `.cache/`, `logs/`, local logs, and virtual environments -- Dependency setup is handled through `requirements.txt`; `get-pip.py` is no longer kept in the repository -- If you plan to publish the full Git history, review author emails and legacy repository traces first +For production, bind the Web process to loopback and use the example Caddy configuration in `deploy/windows/` for LAN HTTPS. The example also rejects remote bootstrap requests. Do not pass `--insecure-http` behind HTTPS. WinSW templates for the Web and Worker services are included in the same directory. ## Documentation -- Index: `docs/00_文档总目录.md` -- Architecture: `docs/01_项目架构与模块职责.md` -- Deployment: `docs/02_开发环境与构建部署.md` -- External APIs and dependencies: `docs/04_接口协议与外部依赖.md` -## Feedback -Please use the current repository's Issue or Pull Request workflow for bug reports and improvements. +- [Chinese README with full usage](./README.md) +- [v3 architecture and migration](./docs/12_v3_架构与迁移.md) +- [WebUI and data-layer plan](./docs/11_v3_WebUI与数据层规划.md) + +## Tests + +```powershell +python -m unittest discover -s tests -p "test_v3_*.py" -v +pnpm --dir webui test --run +pnpm --dir webui build +pnpm --dir webui e2e +pnpm --dir webui audit --prod --audit-level high +``` ## License -This project is released under [GPL-3.0](./LICENSE). \ No newline at end of file + +[GPL-3.0](./LICENSE) diff --git a/analyze_cache.py b/analyze_cache.py deleted file mode 100644 index 24dad2a..0000000 --- a/analyze_cache.py +++ /dev/null @@ -1,54 +0,0 @@ -import json -from collections import defaultdict - -with open('.cache/titles.json', 'r', encoding='utf-8') as f: - data = json.load(f) - -aliases = data.get('aliases', {}) -canonicals = data.get('canonicals', {}) - -# 反向映射:canonical_id -> 所有alias -reverse_map = defaultdict(list) -for alias, info in aliases.items(): - cid = info.get('canonical_id', '') - reverse_map[cid].append((alias, info.get('source', ''), info.get('trust_level', 0))) - -# 找出可疑的映射:同一个canonical_id关联了明显不同的番剧 -suspicious = [] -for cid, alias_list in reverse_map.items(): - if len(alias_list) >= 5: - # 检查这些alias是否来自完全不同的番剧 - # 通过长度和关键词来判断 - # 收集英文alias(通常来自原文件名) - en_aliases = [a for a, s, t in alias_list if all(ord(c) < 128 for c in a)] - if len(en_aliases) >= 3: - suspicious.append((cid, alias_list)) - -print(f"=== 发现 {len(suspicious)} 个可疑的canonical映射 ===\n") - -for cid, alias_list in suspicious: - print(f"Canonical: {cid}") - print(f" 共有 {len(alias_list)} 个别名:") - for alias, source, trust in sorted(alias_list, key=lambda x: x[2], reverse=True): - print(f" [{source}:{trust}] {alias}") - print() - -# 特别检查一些已知的错误映射 -known_bad_patterns = [ - ('mairimashita', '欢迎来到实力至上主义的教室'), - ('iruma', '欢迎来到实力至上主义的教室'), - ('wistoria', '钢之炼金术师'), - ('replica', '约会大作战'), - ('shunkashuutou', '猫娘咖啡馆'), - ('bang dream', '哆啦A梦'), -] - -print("=== 检查已知的错误模式 ===\n") -for keyword, wrong_canonical in known_bad_patterns: - for cid, alias_list in reverse_map.items(): - if wrong_canonical in cid: - matches = [a for a, s, t in alias_list if keyword.lower() in a.lower()] - if matches: - print(f"找到污染! keyword='{keyword}' 被映射到 '{cid}'") - print(f" 匹配别名: {matches}") - print() diff --git a/analyze_logs.py b/analyze_logs.py deleted file mode 100644 index 6d3577f..0000000 --- a/analyze_logs.py +++ /dev/null @@ -1,144 +0,0 @@ -import json -import os -import re -from collections import Counter, defaultdict - -log_dir = 'F:/下载/logs/' -files = [] -for f in os.listdir(log_dir): - if f.startswith('AutoAnime_operations_') and f.endswith('.json'): - files.append(os.path.join(log_dir, f)) - -files.sort() -files = files[-500:] # 最近500个 - -all_records = [] -for f in files: - try: - with open(f, 'r', encoding='utf-8') as fp: - data = json.load(fp) - if isinstance(data, dict) and 'records' in data: - all_records.extend(data['records']) - except Exception as e: - pass - -output = [] -output.append(f"分析 {len(files)} 个日志文件") -output.append(f"总记录数: {len(all_records)}") - -if not all_records: - output.append("无记录") - with open('analyze_result.txt', 'w', encoding='utf-8') as f: - f.write('\n'.join(output)) - exit() - -# 查看字段 -output.append(f"\n字段: {list(all_records[0].keys())}") - -# 分类统计 -status_counter = Counter() -action_counter = Counter() -message_counter = Counter() - -for r in all_records: - status_counter[r.get('status', 'unknown')] += 1 - action_counter[r.get('action', 'unknown')] += 1 - message_counter[r.get('message', 'unknown')] += 1 - -output.append("\n=== Status 分布 ===") -for s, c in status_counter.most_common(): - output.append(f" {s}: {c}") - -output.append("\n=== Action 分布 ===") -for a, c in action_counter.most_common(): - output.append(f" {a}: {c}") - -output.append("\n=== Message 分布 ===") -for m, c in message_counter.most_common(30): - output.append(f" {m}: {c}") - -# 提取所有成功整理的记录,分析原标题和识别后的标题 -output.append("\n=== 分析所有成功/失败记录中的标题识别问题 ===") - -# 收集所有不同的番剧文件夹名 -show_folders = Counter() -for r in all_records: - dst = r.get('dst', '') - if dst: - parts = dst.replace('\\', '/').split('/') - # 找到番剧文件夹 (Season的上级或dst中的某个文件夹) - for i, p in enumerate(parts): - if p.startswith('Season'): - if i > 0: - show_folders[parts[i-1]] += 1 - break - -output.append(f"\n共识别出 {len(show_folders)} 个不同的番剧名称") -output.append("\n最常出现的番剧名称 (前30):") -for name, c in show_folders.most_common(30): - output.append(f" {name}: {c}次") - -# 分析特定问题 -output.append("\n=== 详细案例分析 (前50条非skip记录) ===") -count = 0 -for r in all_records: - if r.get('action') != 'skip' and count < 50: - src = r.get('src', '') - dst = r.get('dst', '') - msg = r.get('message', '') - src_basename = os.path.basename(src) - output.append(f"\n--- 案例 {count+1} ---") - output.append(f"SRC: {src_basename}") - output.append(f"DST: {dst}") - output.append(f"MSG: {msg}") - count += 1 - -# 分析 RSS 订阅的原始文件名规律 -output.append("\n=== 分析原始文件名中的发布组/字幕组标记 ===") -group_tags = Counter() -for r in all_records: - src = os.path.basename(r.get('src', '')) - m = re.search(r'\[(.*?)\]', src) - if m: - group_tags[m.group(1)] += 1 - -output.append("\n发布组分布 (前20):") -for g, c in group_tags.most_common(20): - output.append(f" [{g}]: {c}次") - -# 分析可能的问题: 日文原名 vs 中文译名 -output.append("\n=== 可能的识别问题分析 ===") -# 找出dst中的标题和src中的标题差异较大的情况 -problem_cases = [] -for r in all_records: - src = os.path.basename(r.get('src', '')) - dst = r.get('dst', '') - if not dst: - continue - parts = dst.replace('\\', '/').split('/') - show_folder = None - for i, p in enumerate(parts): - if p.startswith('Season'): - if i > 0: - show_folder = parts[i-1] - break - if show_folder: - # 简单启发式: 如果src中有英文标题但dst是纯中文,或反之 - has_eng = bool(re.search(r'[a-zA-Z]{4,}', src)) - has_chi = any('\u4e00' <= ch <= '\u9fff' for ch in show_folder) - if has_eng and not has_chi: - # 英文原文件名但识别出中文 - 这是正常的翻译 - pass - # 记录一些特殊案例 - if len(show_folder) > 20: - problem_cases.append((src, show_folder, 'long_name')) - -output.append(f"\n发现 {len(problem_cases)} 个潜在问题案例") -for src, folder, reason in problem_cases[:20]: - output.append(f" [{reason}] {folder}") - output.append(f" SRC: {src}") - -with open('analyze_result.txt', 'w', encoding='utf-8') as f: - f.write('\n'.join(output)) - -print("分析完成,结果已保存到 analyze_result.txt") diff --git a/analyze_logs2.py b/analyze_logs2.py deleted file mode 100644 index 880a240..0000000 --- a/analyze_logs2.py +++ /dev/null @@ -1,199 +0,0 @@ -import json -import os -import re -from collections import Counter, defaultdict - -log_dir = 'F:/下载/logs/' -files = [] -for f in os.listdir(log_dir): - if f.startswith('AutoAnime_operations_') and f.endswith('.json'): - files.append(os.path.join(log_dir, f)) - -files.sort() -files = files[-500:] - -all_records = [] -for f in files: - try: - with open(f, 'r', encoding='utf-8') as fp: - data = json.load(fp) - if isinstance(data, dict) and 'records' in data: - all_records.extend(data['records']) - except Exception as e: - pass - -output = [] - -# 1. 分析同一原文件名的不同识别结果 -output.append("=== 1. 分析同一原文件名/相似文件名是否被识别为不同结果 ===") - -# 2. 重点找出明显的错误识别案例 -# 规则:如果src中的中文名/日文名/英文名和dst中的中文名完全对不上 -output.append("\n=== 2. 严重误识别案例分析 ===") - -mismatches = [] -for r in all_records: - src = os.path.basename(r.get('src', '')) - dst = r.get('dst', '') - if not dst: - continue - - # 提取dst中的番剧名 - parts = dst.replace('\\', '/').split('/') - show_folder = None - for i, p in enumerate(parts): - if p.startswith('Season'): - if i > 0: - show_folder = parts[i-1] - break - - if not show_folder: - continue - - # 从src中提取可能的标题 - # 去掉发布组标签 - src_clean = re.sub(r'^[\[【].*?[\]】]\s*', '', src) - # 去掉集数和后缀 - src_clean = re.sub(r'\s*-?\s*\d+\s*\[.*$', '', src_clean) - src_clean = re.sub(r'\.(mkv|mp4|avi)$', '', src_clean) - - # 简单判断:如果src中有明确的中文标题,但和dst完全不同 - # 或者src中有明确的英文/日文标题,但dst是毫不相关的名字 - - # 收集一些已知严重错误的模式 - known_wrong = { - '入间同学入魔了': ['欢迎来到实力至上主义的教室'], - 'Mairimashita': ['欢迎来到实力至上主义的教室'], - 'Replica datte': ['约会大作战'], - '複製品': ['他和她的故事'], - '左撇子': ['进击的巨人'], - 'Wistoria': ['钢之炼金术师'], - 'Shunkashuutou': ['猫娘咖啡馆'], - 'GANSO BanG Dream': ['哆啦A梦'], - '天使': ['关于我转生变成史莱姆这档事'], # 邻家天使 -> 史莱姆 - 'Niwatori Fighter': ['公鸡斗士'], # 这个是对的 - 'Kusuriya no Hitorigoto': ['药屋少女的呢喃'], # 这个是对的 - } - - is_wrong = False - for keyword, wrong_names in known_wrong.items(): - if keyword.lower() in src_clean.lower(): - if any(w in show_folder for w in wrong_names): - is_wrong = True - break - - # 额外判断:一些明显的跨番剧错误 - if not is_wrong: - # 如果src中有明确的中文名,但dst中完全没有相关字 - # 提取src中的中文字符 - src_chinese = ''.join(re.findall(r'[\u4e00-\u9fff]', src_clean)) - dst_chinese = ''.join(re.findall(r'[\u4e00-\u9fff]', show_folder)) - - # 如果src有中文标题(>=4个字),且dst也有中文标题,且两者完全不重叠 - if len(src_chinese) >= 4 and len(dst_chinese) >= 2: - # 计算重叠度 - overlap = set(src_chinese) & set(dst_chinese) - if len(overlap) == 0: - # 但有例外:有些翻译差异很大是正常的 - # 排除一些合理的翻译差异 - pass - - if is_wrong: - mismatches.append((src, show_folder, dst)) - -output.append(f"发现 {len(mismatches)} 个确认的误识别案例:") -for src, folder, dst in mismatches: - output.append(f"\nSRC: {src}") - output.append(f" → 识别为: {folder}") - -# 3. 分析同一dst番剧名的来源文件名多样性 -# 如果同一个番剧名来自完全不同的文件名,可能是缓存污染 -output.append("\n=== 3. 同一番剧名的来源多样性分析 (可能暗示缓存污染) ===") -show_to_sources = defaultdict(list) -for r in all_records: - src = os.path.basename(r.get('src', '')) - dst = r.get('dst', '') - if not dst: - continue - parts = dst.replace('\\', '/').split('/') - show_folder = None - for i, p in enumerate(parts): - if p.startswith('Season'): - if i > 0: - show_folder = parts[i-1] - break - if show_folder: - show_to_sources[show_folder].append(src) - -# 找出来源文件名差异很大的番剧 -for show, sources in show_to_sources.items(): - if len(sources) >= 3: - # 检查这些来源是否都包含相似的关键词 - # 简单检查:去掉发布组后的标题是否相似 - cleaned = [] - for s in sources: - c = re.sub(r'^[\[【].*?[\]】]\s*', '', s) - c = re.sub(r'\s*-?\s*\d+.*$', '', c) - cleaned.append(c.lower()) - - # 如果 cleaned 之间差异很大 - unique_cleaned = list(set(cleaned)) - if len(unique_cleaned) >= 3: - # 检查是否有明显的不同番剧被归为同一类 - output.append(f"\n番剧: {show}") - output.append(f" 来源多样性高 ({len(unique_cleaned)} 种不同文件名):") - for u in unique_cleaned[:5]: - output.append(f" - {u}") - -# 4. 分析 Season/Episode 提取错误 -output.append("\n=== 4. Season/Episode 异常案例分析 ===") -for r in all_records: - src = os.path.basename(r.get('src', '')) - dst = r.get('dst', '') - - # 从src提取集数 - src_ep_match = re.search(r'\s-\s*(\d+)|\[(\d+)\]|\s(\d+)\s*\[', src) - src_ep = None - if src_ep_match: - src_ep = int(next(g for g in src_ep_match.groups() if g is not None)) - - # 从dst提取集数 - dst_ep_match = re.search(r'S\d+E(\d+)', dst) - dst_ep = None - if dst_ep_match: - dst_ep = int(dst_ep_match.group(1)) - - if src_ep and dst_ep and src_ep != dst_ep: - output.append(f"\n集数不匹配!") - output.append(f" SRC: {src} (集数: {src_ep})") - output.append(f" DST: {dst} (集数: {dst_ep})") - -# 5. Season提取异常 -output.append("\n=== 5. Season 异常案例分析 ===") -for r in all_records: - src = os.path.basename(r.get('src', '')) - dst = r.get('dst', '') - - # 从src提取季数 - src_season = None - if '第一季' in src or '第1季' in src or 'S1' in src or 'Season 1' in src: - src_season = 1 - elif '第二季' in src or '第2季' in src or 'S2' in src or 'Season 2' in src: - src_season = 2 - elif '第三季' in src or '第3季' in src or 'S3' in src or 'Season 3' in src: - src_season = 3 - elif '第四季' in src or '第4季' in src or 'S4' in src or 'Season 4' in src: - src_season = 4 - - dst_season_match = re.search(r'S(\d+)E', dst) - dst_season = int(dst_season_match.group(1)) if dst_season_match else None - - if src_season and dst_season and src_season != dst_season: - output.append(f"\n季数不匹配!") - output.append(f" SRC: {src} (推断季: {src_season})") - output.append(f" DST: {dst} (识别季: {dst_season})") - -with open('analyze_result2.txt', 'w', encoding='utf-8') as f: - f.write('\n'.join(output)) - -print("分析完成") diff --git a/analyze_result.txt b/analyze_result.txt deleted file mode 100644 index 4e76fdc..0000000 --- a/analyze_result.txt +++ /dev/null @@ -1,367 +0,0 @@ -分析 500 个日志文件 -总记录数: 504 - -字段: ['timestamp', 'action', 'src', 'dst', 'status', 'message', 'backup'] - -=== Status 分布 === - success: 363 - skipped: 137 - recover: 4 - -=== Action 分布 === - link: 364 - skip: 140 - -=== Message 分布 === - : 363 - already_organized_show_cache: 136 - already_organized_show_cache_stale: 4 - existing_link_kept: 1 - -=== 分析所有成功/失败记录中的标题识别问题 === - -共识别出 122 个不同的番剧名称 - -最常出现的番剧名称 (前30): - 欢迎来到实力至上主义的教室: 21次 - 主播女孩重度依赖: 15次 - 邻家的天使同学: 14次 - 想结束这场“我爱你”的游戏: 12次 - 上伊那牡丹,酒醉身姿似百合花般: 11次 - 最强王者的第二人生: 10次 - 公鸡斗士: 9次 - 杀手青春: 9次 - 又被杀掉了呢,侦探大人: 8次 - 和班上第二可爱的女孩子成为了朋友: 8次 - 婚姻剧毒: 8次 - Re:从零开始的异世界生活: 8次 - 异兽魔都: 8次 - 加油!中村同学!!: 8次 - 淡岛百景: 8次 - 吞噬魔物的冒险者~只有我能通过吞噬魔物变强~: 8次 - 弱弱老师: 8次 - 黄泉的使者: 8次 - 春夏秋冬代行者 春之舞: 8次 - 勇者之屑: 8次 - 异世界悠闲农家: 7次 - 钢之炼金术师: 7次 - 身为悲剧始作俑者的最强邪恶BOSS女王为民竭心尽力。: 7次 - 女神“异世界转生想成为什么”我“勇者的肋骨”: 7次 - 木头风纪委员和迷你裙JK的故事: 7次 - 女骑士成为蛮族新娘: 7次 - 迦楠大人的白给是恶魔级: 7次 - 出租女友: 6次 - 溜掉的大鱼比不上自己钓到的鱼: 6次 - 关于我转生变成史莱姆这档事: 6次 - -=== 详细案例分析 (前50条非skip记录) === - ---- 案例 1 --- -SRC: [Nekomoe kissaten][Shunkashuutou Daikousha - Haru no Mai][05][1080p][JPSC].mp4 -DST: F:\动漫库\猫娘咖啡馆 春之舞\Season00\S00E00.猫娘咖啡馆 春之舞.mp4 -MSG: - ---- 案例 2 --- -SRC: [Studio GreenTea] Tsue to Tsurugi no Wistoria [15][WebRip][HEVC-10bit 1080p AAC][JPSC].mp4 -DST: F:\动漫库\钢之炼金术师\Season02\S02E15.钢之炼金术师.mp4 -MSG: - ---- 案例 3 --- -SRC: [ANi] 我和班上第二可愛的女生成為朋友 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\和班上第二可爱的女孩子成为了朋友\Season01\S01E04.和班上第二可爱的女孩子成为了朋友.mp4 -MSG: - ---- 案例 4 --- -SRC: [ANi] 複製品的我也會談戀愛 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\他和她的故事\Season01\S01E04.他和她的故事.mp4 -MSG: - ---- 案例 5 --- -SRC: [ANi] 婚姻劇毒 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\婚姻剧毒\Season01\S01E04.婚姻剧毒.mp4 -MSG: - ---- 案例 6 --- -SRC: [ANi] 想結束這場「我愛你」的遊戲 - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\想结束这场“我爱你”的游戏\Season01\S01E03.想结束这场“我爱你”的游戏.mp4 -MSG: - ---- 案例 7 --- -SRC: [ANi] 百鬼夜行抄 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\百鬼夜行抄\Season01\S01E04.百鬼夜行抄.mp4 -MSG: - ---- 案例 8 --- -SRC: [ANi] 成為悲劇元兇的最強異端,最後頭目女王為了人民犧牲奉獻 第二季 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\身为悲剧始作俑者的最强邪恶BOSS女王为民竭心尽力。\Season02\S02E04.身为悲剧始作俑者的最强邪恶BOSS女王为民竭心尽力。.mp4 -MSG: - ---- 案例 9 --- -SRC: [ANi] 左撇子艾倫 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\进击的巨人\Season02\S02E04.进击的巨人.mp4 -MSG: - ---- 案例 10 --- -SRC: [ANi] 我回來了,他又來打擾了! - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\我回来了,他又来打扰了!\Season01\S01E04.我回来了,他又来打扰了!.mp4 -MSG: - ---- 案例 11 --- -SRC: [ANi] 女神「異世界轉生想成為什麼」我「勇者的肋骨」 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\女神“异世界转生想成为什么”我“勇者的肋骨”\Season01\S01E04.女神“异世界转生想成为什么”我“勇者的肋骨”.mp4 -MSG: - ---- 案例 12 --- -SRC: [Studio GreenTea] Niwatori Fighter [04][WebRip][HEVC-10bit 1080p AAC][JPSC].mp4 -DST: F:\动漫库\公鸡斗士\Season01\S01E04.公鸡斗士.mp4 -MSG: - ---- 案例 13 --- -SRC: [ANi] 出租女友 第五季 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\出租女友\Season05\S05E04.出租女友.mp4 -MSG: - ---- 案例 14 --- -SRC: [ANi] 歡迎來到實力至上主義的教室 第四季 2年級篇 第一學期 - 08 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\欢迎来到实力至上主义的教室\Season04\S04E08.欢迎来到实力至上主义的教室.mp4 -MSG: - ---- 案例 15 --- -SRC: [ANi] 從前從前有隻貓!世界喵童話 - 29 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\会动的!从前从前有只猫 世界喵童话\Season01\S01E29.会动的!从前从前有只猫 世界喵童话.mp4 -MSG: - ---- 案例 16 --- -SRC: [ANi] Re:從零開始的異世界生活 第四季 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\Re:从零开始的异世界生活\Season04\S04E04.Re:从零开始的异世界生活.mp4 -MSG: - ---- 案例 17 --- -SRC: [ANi] 異獸魔都 第二季 - 19 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\异兽魔都\Season02\S02E19.异兽魔都.mp4 -MSG: - ---- 案例 18 --- -SRC: [ANi] 溜掉的大魚比不上自己釣到的魚 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\事与愿违的不死冒险者\Season01\S01E05.事与愿违的不死冒险者.mp4 -MSG: - ---- 案例 19 --- -SRC: [Sakurato] Mairimashita! Iruma-kun (2026) [04][AVC-8bit 1080P AAC][CHS].mp4 -DST: F:\动漫库\欢迎来到实力至上主义的教室\Season01\S01E04.欢迎来到实力至上主义的教室.mp4 -MSG: - ---- 案例 20 --- -SRC: [ANi] CANDY CARIES 蛀在糖糖裡 - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\Candy Caries 蛀在糖糖里\Season01\S01E03.Candy Caries 蛀在糖糖里.mp4 -MSG: - ---- 案例 21 --- -SRC: [ANi] 加油!中村同學!! - 06 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\加油!中村同学!!\Season01\S01E06.加油!中村同学!!.mp4 -MSG: - ---- 案例 22 --- -SRC: [ANi] 終末起點 第 2 季 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\最强王者的第二人生\Season02\S02E05.最强王者的第二人生.mp4 -MSG: - ---- 案例 23 --- -SRC: [LoliHouse] Replica datte, Koi wo Suru. - 04 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv -DST: F:\动漫库\约会大作战\Season01\S01E04.约会大作战.mkv -MSG: - ---- 案例 24 --- -SRC: [LoliHouse] Nigetsuri - 05 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv -DST: F:\动漫库\溜掉的大鱼比不上自己钓到的鱼\Season01\S01E05.溜掉的大鱼比不上自己钓到的鱼.mkv -MSG: - ---- 案例 25 --- -SRC: [LoliHouse] Otaku ni Yasashii Gal wa Inai - 04 [WebRip 1080p HEVC-10bit AAC].mkv -DST: F:\动漫库\哪里有温柔对待阿宅的辣妹!?\Season01\S01E04.哪里有温柔对待阿宅的辣妹!?.mkv -MSG: - ---- 案例 26 --- -SRC: [ANi] 想看她一臉嫌惡地露出褲褲 R - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\尼尔:自动人形\Season01\S01E01.尼尔:自动人形.mp4 -MSG: - ---- 案例 27 --- -SRC: [ANi] 女騎士成為蠻族新娘 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\女骑士成为蛮族新娘\Season01\S01E04.女骑士成为蛮族新娘.mp4 -MSG: - ---- 案例 28 --- -SRC: [ANi] 輪迴的花瓣 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\轮回的花瓣\Season01\S01E05.轮回的花瓣.mp4 -MSG: - ---- 案例 29 --- -SRC: [ANi] 霧尾粉絲後援會 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\雾尾粉丝后援会\Season01\S01E05.雾尾粉丝后援会.mp4 -MSG: - ---- 案例 30 --- -SRC: [ANi] Dr.STONE 新石紀 第四季 - 29 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\石纪元\Season04\S04E29.石纪元.mp4 -MSG: - ---- 案例 31 --- -SRC: [ANi] 庫吉馬唱歌的家 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\动物合唱团\Season01\S01E04.动物合唱团.mp4 -MSG: - ---- 案例 32 --- -SRC: [ANi] 淡島百景 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\淡岛百景\Season01\S01E04.淡岛百景.mp4 -MSG: - ---- 案例 33 --- -SRC: [ANi] 你又被殺了呢,偵探大人 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\又被杀掉了呢,侦探大人\Season01\S01E05.又被杀掉了呢,侦探大人.mp4 -MSG: - ---- 案例 34 --- -SRC: [ANi] 吞噬魔物的冒險者 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\吞噬魔物的冒险者~只有我能通过吞噬魔物变强~\Season01\S01E05.吞噬魔物的冒险者~只有我能通过吞噬魔物变强~.mp4 -MSG: - ---- 案例 35 --- -SRC: [LoliHouse] Dorohedoro S2 - 07 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv -DST: F:\动漫库\异兽魔都\Season02\S02E07.异兽魔都.mkv -MSG: - ---- 案例 36 --- -SRC: [LoliHouse] Mata Korosarete Shimatta no desu ne, Tantei-sama - 05 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv -DST: F:\动漫库\又被杀掉了呢,侦探大人\Season01\S01E05.又被杀掉了呢,侦探大人.mkv -MSG: - ---- 案例 37 --- -SRC: [Sakurato] Ookii Onnanoko wa Suki Desuka? [04][AVC-8bit 1080P AAC][CHS].mp4 -DST: F:\动漫库\这样高大的女孩子你喜欢吗?\Season01\S01E04.这样高大的女孩子你喜欢吗?.mp4 -MSG: - ---- 案例 38 --- -SRC: [hyakuhuyu&LoliHouse] GANSO BanG Dream Chan - 30 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv -DST: F:\动漫库\哆啦A梦:大雄的南海大冒险\Season01\S01E30.哆啦A梦:大雄的南海大冒险.mkv -MSG: - ---- 案例 39 --- -SRC: [ANi] 關於我在無意間被隔壁的天使變成廢柴這件事 2 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\关于我转生变成史莱姆这档事\Season02\S02E05.关于我转生变成史莱姆这档事.mp4 -MSG: - ---- 案例 40 --- -SRC: [ANi] 上伊那牡丹,醉姿如百合 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\上伊那牡丹,酒醉身姿似百合花般\Season01\S01E04.上伊那牡丹,酒醉身姿似百合花般.mp4 -MSG: - ---- 案例 41 --- -SRC: [Nekomoe kissaten][KILL BLUE][03][1080p][JPSC].mp4 -DST: F:\动漫库\杀手青春\Season01\S01E03.杀手青春.mp4 -MSG: - ---- 案例 42 --- -SRC: [ANi] 神之雫 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\神之水滴\Season01\S01E04.神之水滴.mp4 -MSG: - ---- 案例 43 --- -SRC: [ANi] 凍結地球 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\冻结地球\Season01\S01E05.冻结地球.mp4 -MSG: - ---- 案例 44 --- -SRC: [Nekomoe kissaten][Kamiina Botan, Yoeru Sugata wa Yuri no Hana][04][1080p][JPSC].mp4 -DST: F:\动漫库\猫娘咖啡馆\Season00\S00E00.猫娘咖啡馆.mp4 -MSG: - ---- 案例 45 --- -SRC: [Nekomoe kissaten&LoliHouse] LIAR GAME - 04 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv -DST: F:\动漫库\欺诈游戏\Season01\S01E04.欺诈游戏.mkv -MSG: - ---- 案例 46 --- -SRC: [ANi] 拉拉熊 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\轻松熊\Season01\S01E05.轻松熊.mp4 -MSG: - ---- 案例 47 --- -SRC: [LoliHouse] Hokuto no Ken FIST OF THE NORTH STAR - 06 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv -DST: F:\动漫库\北斗神拳\Season01\S01E06.北斗神拳.mkv -MSG: - ---- 案例 48 --- -SRC: [Studio GreenTea] Niwatori Fighter [05][WebRip][HEVC-10bit 1080p AAC][JPSC].mp4 -DST: F:\动漫库\公鸡斗士\Season01\S01E05.公鸡斗士.mp4 -MSG: - ---- 案例 49 --- -SRC: [ANi] 我的英雄學院 FINAL SEASON - 171 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\我的英雄学院\Season04\S04E171.我的英雄学院.mp4 -MSG: - ---- 案例 50 --- -SRC: [ANi] 入間同學入魔了!第四季 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 -DST: F:\动漫库\入间同学入魔了!\Season04\S04E05.入间同学入魔了!.mp4 -MSG: - -=== 分析原始文件名中的发布组/字幕组标记 === - -发布组分布 (前20): - [ANi]: 224次 - [LoliHouse]: 137次 - [Studio GreenTea]: 47次 - [Nekomoe kissaten]: 24次 - [Nekomoe kissaten&LoliHouse]: 23次 - [Sakurato]: 20次 - [hyakuhuyu&LoliHouse]: 8次 - [DMG&SumiSora&LoliHouse]: 6次 - [Prejudice-Studio]: 6次 - [Ends with Love&LoliHouse]: 4次 - [FreesiaSub&LoliHouse]: 3次 - [SweetSub&LoliHouse]: 2次 - -=== 可能的识别问题分析 === - -发现 30 个潜在问题案例 - [long_name] 身为悲剧始作俑者的最强邪恶BOSS女王为民竭心尽力。 - SRC: [ANi] 成為悲劇元兇的最強異端,最後頭目女王為了人民犧牲奉獻 第二季 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - [long_name] 女神“异世界转生想成为什么”我“勇者的肋骨” - SRC: [ANi] 女神「異世界轉生想成為什麼」我「勇者的肋骨」 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - [long_name] 女神“异世界转生想成为什么”我“勇者的肋骨” - SRC: [LoliHouse] Yuusha no Rokkotsu de - 04 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv - [long_name] 吞噬魔物的冒险者~只有我能通过吞噬魔物变强~ - SRC: [ANi] 吞噬魔物的冒險者 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - [long_name] 吞噬魔物的冒险者~只有我能通过吞噬魔物变强~ - SRC: [LoliHouse] Mamonogurai no Boukensha - 05 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv - [long_name] 最强的职业不是勇者也不是贤者好像是鉴定士(伪)的样子? - SRC: [LoliHouse] Kanteishi (Kari) - 06 [WebRip 1080p HEVC-10bit AAC].mkv - [long_name] 身为悲剧始作俑者的最强邪恶BOSS女王为民竭心尽力。 - SRC: [FreesiaSub&LoliHouse] LasTame S2 - 04 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv - [long_name] 身为悲剧始作俑者的最强邪恶BOSS女王为民竭心尽力。 - SRC: [ANi] 成為悲劇元兇的最強異端,最後頭目女王為了人民犧牲奉獻 第二季 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - [long_name] 女神“异世界转生想成为什么”我“勇者的肋骨” - SRC: [ANi] 女神「異世界轉生想成為什麼」我「勇者的肋骨」 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - [long_name] 吞噬魔物的冒险者~只有我能通过吞噬魔物变强~ - SRC: [ANi] 吞噬魔物的冒險者 - 06 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - [long_name] 吞噬魔物的冒险者~只有我能通过吞噬魔物变强~ - SRC: [LoliHouse] Mamonogurai no Boukensha - 06 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv - [long_name] 爱书的下克上:为了成为图书管理员不择手段! - SRC: [ANi] 小書痴的下剋上 為了成為圖書管理員不擇手段!領主的養女 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - [long_name] 最强的职业不是勇者也不是贤者好像是鉴定士(伪)的样子? - SRC: [LoliHouse] Kanteishi (Kari) - 07 [WebRip 1080p HEVC-10bit AAC].mkv - [long_name] 身为悲剧始作俑者的最强邪恶BOSS女王为民竭心尽力。 - SRC: [FreesiaSub&LoliHouse] LasTame S2 - 05 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv - [long_name] 猫枕咖啡店 17 1080p JPSC.mp4 - SRC: [Nekomoe kissaten][Tsue to Tsurugi no Wistoria][17][1080p][JPSC].mp4 - [long_name] 身为悲剧始作俑者的最强邪恶BOSS女王为民竭心尽力。 - SRC: [ANi] 成為悲劇元兇的最強異端,最後頭目女王為了人民犧牲奉獻 第二季 - 06 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - [long_name] 女神“异世界转生想成为什么”我“勇者的肋骨” - SRC: [ANi] 女神「異世界轉生想成為什麼」我「勇者的肋骨」 - 06 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - [long_name] 女神“异世界转生想成为什么”我“勇者的肋骨” - SRC: [LoliHouse] Yuusha no Rokkotsu de - 06 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv - [long_name] 吞噬魔物的冒险者~只有我能通过吞噬魔物变强~ - SRC: [ANi] 吞噬魔物的冒險者 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - [long_name] 吞噬魔物的冒险者~只有我能通过吞噬魔物变强~ - SRC: [LoliHouse] Mamonogurai no Boukensha - 07 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv \ No newline at end of file diff --git a/analyze_result2.txt b/analyze_result2.txt deleted file mode 100644 index 0969555..0000000 --- a/analyze_result2.txt +++ /dev/null @@ -1,190 +0,0 @@ -=== 1. 分析同一原文件名/相似文件名是否被识别为不同结果 === - -=== 2. 严重误识别案例分析 === -发现 37 个确认的误识别案例: - -SRC: [Nekomoe kissaten][Shunkashuutou Daikousha - Haru no Mai][05][1080p][JPSC].mp4 - → 识别为: 猫娘咖啡馆 春之舞 - -SRC: [Studio GreenTea] Tsue to Tsurugi no Wistoria [15][WebRip][HEVC-10bit 1080p AAC][JPSC].mp4 - → 识别为: 钢之炼金术师 - -SRC: [ANi] 複製品的我也會談戀愛 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - → 识别为: 他和她的故事 - -SRC: [ANi] 左撇子艾倫 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - → 识别为: 进击的巨人 - -SRC: [Studio GreenTea] Niwatori Fighter [04][WebRip][HEVC-10bit 1080p AAC][JPSC].mp4 - → 识别为: 公鸡斗士 - -SRC: [Sakurato] Mairimashita! Iruma-kun (2026) [04][AVC-8bit 1080P AAC][CHS].mp4 - → 识别为: 欢迎来到实力至上主义的教室 - -SRC: [LoliHouse] Replica datte, Koi wo Suru. - 04 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv - → 识别为: 约会大作战 - -SRC: [hyakuhuyu&LoliHouse] GANSO BanG Dream Chan - 30 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv - → 识别为: 哆啦A梦:大雄的南海大冒险 - -SRC: [ANi] 關於我在無意間被隔壁的天使變成廢柴這件事 2 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - → 识别为: 关于我转生变成史莱姆这档事 - -SRC: [Studio GreenTea] Niwatori Fighter [05][WebRip][HEVC-10bit 1080p AAC][JPSC].mp4 - → 识别为: 公鸡斗士 - -SRC: [LoliHouse] Mairimashita! Iruma-kun S4 - 05 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv - → 识别为: 欢迎来到实力至上主义的教室 - -SRC: [Studio GreenTea] Tsue to Tsurugi no Wistoria [16][WebRip][HEVC-10bit 1080p AAC][JPSC].mp4 - → 识别为: 钢之炼金术师 - -SRC: [ANi] 複製品的我也會談戀愛 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - → 识别为: 他和她的故事 - -SRC: [ANi] 左撇子艾倫 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - → 识别为: 进击的巨人 - -SRC: [LoliHouse] Replica datte, Koi wo Suru. - 05 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv - → 识别为: 约会大作战 - -SRC: [ANi] 關於我在無意間被隔壁的天使變成廢柴這件事 2 - 06 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - → 识别为: 关于我转生变成史莱姆这档事 - -SRC: [LoliHouse] Mairimashita! Iruma-kun S4 - 06 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv - → 识别为: 欢迎来到实力至上主义的教室 - -SRC: [Sakurato] Mairimashita! Iruma-kun (2026) [05][AVC-8bit 1080P AAC][CHS].mp4 - → 识别为: 欢迎来到实力至上主义的教室 - -SRC: [Nekomoe kissaten&LoliHouse] Tsue to Tsurugi no Wistoria - 16 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv - → 识别为: 钢之炼金术师 - -SRC: [Nekomoe kissaten&LoliHouse] Tsue to Tsurugi no Wistoria - 17 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv - → 识别为: 钢之炼金术师 - -SRC: [Nekomoe kissaten][Shunkashuutou Daikousha - Haru no Mai][06][1080p][JPSC].mp4 - → 识别为: 猫娘咖啡馆 春之舞 - -SRC: [ANi] 左撇子艾倫 - 06 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - → 识别为: 进击的巨人 - -SRC: [ANi] 複製品的我也會談戀愛 - 06 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - → 识别为: 他和她的故事 - -SRC: [Studio GreenTea] Tsue to Tsurugi no Wistoria [17][WebRip][HEVC-10bit 1080p AAC][JPSC].mp4 - → 识别为: 钢之炼金术师 - -SRC: [Sakurato] Mairimashita! Iruma-kun (2026) [06][AVC-8bit 1080P AAC][CHS].mp4 - → 识别为: 欢迎来到实力至上主义的教室 - -SRC: [ANi] 關於我在無意間被隔壁的天使變成廢柴這件事 2 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - → 识别为: 关于我转生变成史莱姆这档事 - -SRC: [LoliHouse] Mairimashita! Iruma-kun S4 - 07 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv - → 识别为: 欢迎来到实力至上主义的教室 - -SRC: [Nekomoe kissaten&LoliHouse] Tsue to Tsurugi no Wistoria - 18 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv - → 识别为: 钢之炼金术师 - -SRC: [LoliHouse] Replica datte, Koi wo Suru. - 06 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv - → 识别为: 约会大作战 - -SRC: [ANi] 複製品的我也會談戀愛 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4 - → 识别为: 他和她的故事 - -SRC: [Studio GreenTea] Tsue to Tsurugi no Wistoria [18][WebRip][HEVC-10bit 1080p AAC][JPSC].mp4 - → 识别为: 钢之炼金术师 - -SRC: [Studio GreenTea] Niwatori Fighter [06][WebRip][HEVC-10bit 1080p AAC][JPSC].mp4 - → 识别为: 公鸡斗士 - -SRC: [Studio GreenTea] Niwatori Fighter [07][WebRip][HEVC-10bit 1080p AAC][JPSC].mp4 - → 识别为: 公鸡斗士 - -SRC: [Nekomoe kissaten][Shunkashuutou Daikousha - Haru no Mai][08][1080p][JPSC].mp4 - → 识别为: 猫娘咖啡馆 春之舞 [08 - -SRC: [Studio GreenTea] Niwatori Fighter [08][WebRip][HEVC-10bit 1080p AAC][JPSC].mp4 - → 识别为: 公鸡斗士 - -SRC: [Studio GreenTea] Niwatori Fighter [09][WebRip][HEVC-10bit 1080p AAC][JPSC].mp4 - → 识别为: 公鸡斗士 - -SRC: [LoliHouse] Mairimashita! Iruma-kun S4 - 08 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv - → 识别为: 欢迎来到实力至上主义的教室 - -=== 3. 同一番剧名的来源多样性分析 (可能暗示缓存污染) === - -番剧: 又被杀掉了呢,侦探大人 - 来源多样性高 (3 种不同文件名): - - mata korosarete shimatta no desu ne, tantei-sama [ - - 你又被殺了呢,偵探大人 - - mata korosarete shimatta no desu ne, tantei-sama - -番剧: 想结束这场“我爱你”的游戏 - 来源多样性高 (3 种不同文件名): - - 想結束這場「我愛你」的遊戲 - - aishiteru game wo owarasetai [ - - aishiteru game wo owarasetai - -番剧: 欢迎来到实力至上主义的教室 - 来源多样性高 (4 种不同文件名): - - youkoso jitsuryoku shijou shugi no kyoushitsu e s - - mairimashita! iruma-kun s - - 歡迎來到實力至上主義的教室 第四季 - - mairimashita! iruma-kun ( - -番剧: 邻家的天使同学 - 来源多样性高 (4 种不同文件名): - - otonari no tenshi-sama s - - otonari no tenshi-sama ni itsunomanika dame ningen ni sareteita ken s - - otonari no tenshi-sama ni itsunomanika dame ningen ni sareteita ken - - 關於我在無意間被隔壁的天使變成廢柴這件事 - -番剧: 主播女孩重度依赖 - 来源多样性高 (3 种不同文件名): - - 主播女孩重度依賴 - - needy girl overdose - - needy girl overdose [ - -番剧: 上伊那牡丹,酒醉身姿似百合花般 - 来源多样性高 (4 种不同文件名): - - 上伊那牡丹,醉姿如百合 - - kamiina botan, yoeru sugata wa yuri no hana [ - - [kamiina botan, yoeru sugata wa yuri no hana][ - - kamiina botan, yoeru sugata wa yuri no hana - -番剧: 杀手青春 - 来源多样性高 (3 种不同文件名): - - 殺手青春 - - kill blue - - [kill blue][ - -番剧: 春夏秋冬代行者 春之舞 - 来源多样性高 (3 种不同文件名): - - 春夏秋冬代行者 春之舞 - - [shunkashuutou daikousha - haru no mai][ - - shunkashuutou daikousha - haru no mai - -番剧: 勇者之屑 - 来源多样性高 (3 种不同文件名): - - 勇者之渣 - - yuusha no rokkotsu de - - yuusha no kuzu - -=== 4. Season/Episode 异常案例分析 === - -集数不匹配! - SRC: [Sakurato] Otonari no Tenshi-sama ni Itsunomanika Dame Ningen ni Sareteita Ken 2 [05][AVC-8bit 1080P AAC][CHS].mp4 (集数: 2) - DST: F:\动漫库\邻家的天使同学\Season02\S02E05.邻家的天使同学.mkv (集数: 5) - -集数不匹配! - SRC: [Sakurato] Otonari no Tenshi-sama ni Itsunomanika Dame Ningen ni Sareteita Ken 2 [06][AVC-8bit 1080P AAC][CHS].mp4 (集数: 2) - DST: F:\动漫库\邻家的天使同学\Season01\S01E06.邻家的天使同学.mp4 (集数: 6) - -集数不匹配! - SRC: [Sakurato] Otonari no Tenshi-sama ni Itsunomanika Dame Ningen ni Sareteita Ken 2 [07][AVC-8bit 1080P AAC][CHS].mp4 (集数: 2) - DST: F:\动漫库\邻家的天使同学\Season01\S01E07.邻家的天使同学.mp4 (集数: 7) - -=== 5. Season 异常案例分析 === \ No newline at end of file diff --git a/autoanime/__init__.py b/autoanime/__init__.py deleted file mode 100644 index b2e7bb3..0000000 --- a/autoanime/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -AutoAnimeMv 模块化包骨架 - -- 为新入口 `AutoAnimeMv2.py` 提供模块化实现; -- 原入口 `AutoAnimeMv.py` 保持不变,与本包并存; -- 公共可变状态统一放在 `autoanime.state`,避免各模块散布 `global` 声明。 -""" - -__version__ = '3.(4.5).6' - -VERSION = __version__ - -PACKAGE_NAME = 'autoanime' diff --git a/autoanime/apis/__init__.py b/autoanime/apis/__init__.py deleted file mode 100644 index b057ab1..0000000 --- a/autoanime/apis/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -autoanime 外部 API 客户端包 - -- `http` : 通用 HTTP 工具 + 代理初始化 -- `openai_client` : OpenAI 兼容接口多槽位客户端 + 译名 -- `tmdb` : TMDB Api 查询(中/英/分季结构) -- `bangumi` : Bangumi/BGM Api 查询(两个 API 的封装) -- `bgm` : BGM 查询(当前实现复用 bangumi.bgm 源) -""" diff --git a/autoanime/apis/bangumi.py b/autoanime/apis/bangumi.py deleted file mode 100644 index 3a68ca3..0000000 --- a/autoanime/apis/bangumi.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -autoanime Bangumi/BGM 查询 - -对应原 `AutoAnimeMv.py::Auxiliary_QueryBangumiChineseTitle`。 -原代码中 "Bgm" 和 "Bangumi" 两个开关控制同一 bgm.tv 数据源; -本模块暴露 `Auxiliary_QueryBangumiChineseTitle`,用于剧名链 & 回退链路。 -""" - -from urllib.parse import quote - -from .. import state -from ..logging_utils import Auxiliary_Log -from ..text_utils import ( - Auxiliary_HasChineseText, - Auxiliary_NormalizeApiTitle, - Auxiliary_NormalizeDisplayTitle, -) -from .http import Auxiliary_Http - - -def Auxiliary_QueryBangumiChineseTitle(QueryName, CandidateEn='', CandidateRomaji='', AliasList=None): - '''仅通过 Bangumi 查询中文标题;未命中中文时返回 None''' - from ..cache.canonical import ( - Auxiliary_ResolveCanonicalTitleByAliases, - Auxiliary_UpsertCanonicalTitle, - ) - from ..cache.persistent import ( - Auxiliary_GetPersistentCache, - Auxiliary_SetPersistentCache, - ) - from ..identification.title_chain import Auxiliary_GetStandardTitleCacheCandidates - - QueryName = Auxiliary_NormalizeDisplayTitle(QueryName) - CandidateEn = Auxiliary_NormalizeDisplayTitle(CandidateEn) - CandidateRomaji = Auxiliary_NormalizeDisplayTitle(CandidateRomaji) - if QueryName in [None, ''] or state.USEBANGUMIAPI != True: - return None - - CanonicalZh, _, _ = Auxiliary_ResolveCanonicalTitleByAliases(QueryName, CandidateEn, CandidateRomaji) - if CanonicalZh not in [None, '']: - return CanonicalZh - - CandidateKeys = Auxiliary_GetStandardTitleCacheCandidates(QueryName) - if QueryName not in CandidateKeys: - CandidateKeys.insert(0, QueryName) - for CacheKey in CandidateKeys: - CacheValue = None - if type(state.BangumiAPIDataCache) == dict and CacheKey in state.BangumiAPIDataCache: - CacheValue = state.BangumiAPIDataCache.get(CacheKey) - Auxiliary_Log(f'{CacheValue} << Bangumi内存缓存查询结果') - else: - CacheValue = Auxiliary_GetPersistentCache('Bangumi', CacheKey) - if CacheValue not in [None, '']: - if type(state.BangumiAPIDataCache) != dict: - state.BangumiAPIDataCache = {} - state.BangumiAPIDataCache[CacheKey] = CacheValue - Auxiliary_Log(f'{CacheValue} << Bangumi持久化缓存查询结果') - CacheValue = Auxiliary_NormalizeApiTitle(CacheValue) - if CacheValue in [None, ''] or Auxiliary_HasChineseText(CacheValue) == False: - continue - return CacheValue - - BangumiApiData = Auxiliary_Http( - f"https://api.bgm.tv/search/subject/{quote(QueryName)}?type=2&responseGroup=medium&max_results=1", - ResponseType='json', - Timeout=20, - ) - if type(BangumiApiData) != dict: - Auxiliary_Log(f'BangumiApi查询失败: {QueryName}', 'WARNING') - return None - ResultList = BangumiApiData.get('list', []) - if type(ResultList) != list or ResultList == [] or type(ResultList[0]) != dict: - Auxiliary_Log(f'BangumiApi没有检索到关于 {QueryName} 内容', 'WARNING') - return None - - AnimeData = ResultList[0] - ApiTitle = Auxiliary_NormalizeApiTitle(AnimeData.get('name_cn') or AnimeData.get('name') or '') - if ApiTitle in [None, ''] or Auxiliary_HasChineseText(ApiTitle) == False: - Auxiliary_Log(f'BangumiApi未返回可用中文标题: {QueryName}', 'WARNING') - return None - - CandidateEnForUpsert = CandidateEn - if CandidateEnForUpsert in [None, ''] and Auxiliary_HasChineseText(QueryName) == False: - CandidateEnForUpsert = QueryName - CandidateAliases = [QueryName, CandidateEn, CandidateRomaji] - if type(AliasList) == list: - CandidateAliases.extend(AliasList) - CandidateAliases = [Auxiliary_NormalizeDisplayTitle(Item) for Item in CandidateAliases if Item not in [None, '']] - - _, CanonicalTitle = Auxiliary_UpsertCanonicalTitle( - ApiTitle, CandidateEnForUpsert, CandidateRomaji, 'Bangumi', CandidateAliases, - ) - if CanonicalTitle not in [None, ''] and Auxiliary_HasChineseText(CanonicalTitle): - ApiTitle = CanonicalTitle - for CacheKey in CandidateKeys: - state.BangumiAPIDataCache[CacheKey] = ApiTitle - Auxiliary_SetPersistentCache('Bangumi', CacheKey, ApiTitle) - Auxiliary_Log(f'{ApiTitle} << BangumiApi查询结果') - return ApiTitle diff --git a/autoanime/apis/bgm.py b/autoanime/apis/bgm.py deleted file mode 100644 index 9122c3d..0000000 --- a/autoanime/apis/bgm.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -autoanime BGM 查询模块 - -原 `AutoAnimeMv.py` 中 `USEBGMAPI` 开关与 `BgmAPIDataCache` 对应 bgm.tv 旧版接口。 -当前实现与 `bangumi.py` 共用相同 bgm.tv 端点,因此 `Auxiliary_QueryBgmChineseTitle` -直接代理到 `Auxiliary_QueryBangumiChineseTitle`,但仅在 `state.USEBGMAPI == True` -时生效,方便 CLI 按开关独立控制。 -""" - -from .. import state -from .bangumi import Auxiliary_QueryBangumiChineseTitle - - -def Auxiliary_QueryBgmChineseTitle(QueryName, CandidateEn='', CandidateRomaji='', AliasList=None): - '''BGM 中文标题查询。当前与 Bangumi 使用同一 bgm.tv 端点,此函数受 USEBGMAPI 控制。''' - if state.USEBGMAPI != True: - return None - return Auxiliary_QueryBangumiChineseTitle(QueryName, CandidateEn, CandidateRomaji, AliasList) diff --git a/autoanime/apis/http.py b/autoanime/apis/http.py deleted file mode 100644 index 04ace61..0000000 --- a/autoanime/apis/http.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -autoanime 通用网络请求工具 - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_Http` -- `Auxiliary_PROXY` -""" - -from os import environ -from urllib.request import getproxies - -from requests import exceptions, get, post - -from .. import state -from ..config_loader import Auxiliary_GetTMDBBearerToken, Auxiliary_ParseInt -from ..logging_utils import Auxiliary_Log - - -def Auxiliary_PROXY(): - '''代理''' - if state.USEPROXY == True: - Auxiliary_Log('代理功能开启') - if state.USESYSPROXY == True: - Auxiliary_Log('使用系统代理') - ProxyTuple = tuple(getproxies().values()) - if ProxyTuple != (): - state.HTTPPROXY, state.HTTPSPROXY, _ = ProxyTuple - else: - state.HTTPPROXY, state.HTTPSPROXY = '', '' - environ['http_proxy'] = state.HTTPPROXY - environ['https_proxy'] = state.HTTPSPROXY - environ['all_proxy'] = state.ALLPROXY - - -def Auxiliary_Http(Url, flag='GET', JsonData=None, ExtraHeaders=None, Timeout=30, ResponseType='text'): - '''网络请求,支持 JSON 解析与字段校验前置''' - - headers = {'User-Agent': f'AutoAnimeMv/{state.Versions}'} - if type(ExtraHeaders) == dict: - headers.update(ExtraHeaders) - if 'themoviedb' in Url: - TMDBToken = Auxiliary_GetTMDBBearerToken() - if TMDBToken not in [None, '']: - headers['Authorization'] = f'Bearer {TMDBToken}' - else: - Auxiliary_Log('TMDB token 未配置,TMDBApi 将不可用。请设置环境变量 TMDB_BEARER_TOKEN', 'WARNING') - - RetryTimes = Auxiliary_ParseInt(state.NETERRRECTRYTIMS, 1) - if RetryTimes < 0: - RetryTimes = 0 - for i in range(RetryTimes + 1): - try: - if str(flag).upper() != 'GET': - HttpData = post(Url, json=JsonData, headers=headers, timeout=Timeout) - else: - HttpData = get(Url, headers=headers, timeout=Timeout) - if HttpData.status_code == 200: - if ResponseType == 'json': - try: - return HttpData.json() - except ValueError: - Auxiliary_Log(f'接口返回不是合法 JSON: {Url}', 'WARNING') - return None - return HttpData.text.replace(r'\/', r'/') - Auxiliary_Log(f'HttpData Status Code = {HttpData.status_code}', 'WARNING') - except exceptions.ConnectionError: - Auxiliary_Log(f'访问 {Url} 失败,请检查代理与网络连通性', 'WARNING') - except exceptions.RequestException as err: - Auxiliary_Log(f'访问 {Url} 失败: {err}', 'WARNING') - except Exception as err: - Auxiliary_Log(f'访问 {Url} 失败,未能获取到内容: {err}', 'WARNING') - Auxiliary_Log(f'第{i+1}/{RetryTimes+1}次尝试失败', 'WARNING') - return None diff --git a/autoanime/apis/openai_client.py b/autoanime/apis/openai_client.py deleted file mode 100644 index d69ffa6..0000000 --- a/autoanime/apis/openai_client.py +++ /dev/null @@ -1,237 +0,0 @@ -""" -autoanime OpenAI 兼容接口客户端 - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_GetOpenAIRuntimeStatePath` -- `Auxiliary_LoadOpenAIRuntimeState` -- `Auxiliary_SaveOpenAIRuntimeState` -- `Auxiliary_GetOpenAIEndpointSlots` -- `Auxiliary_ParseOpenAIRotateStatusCodes` -- `Auxiliary_OpenAIHttpBodyIndicatesQuota` -- `Auxiliary_OpenAIChatCompletionsPost` -- `Auxiliary_OpenAITranslateForeignTitleToChinese` -""" - -import json - -from pathlib import Path as PathlibPath -from time import time - -from requests import exceptions, post - -from .. import state -from ..config_loader import ( - Auxiliary_GetCacheStorePath, - Auxiliary_GetOpenAIApiKey, - Auxiliary_ParseDelimitedConfigList, - Auxiliary_ParseInt, -) -from ..logging_utils import Auxiliary_Log -from ..text_utils import ( - Auxiliary_HasChineseText, - Auxiliary_NormalizeApiTitle, - Auxiliary_NormalizeDisplayTitle, - Auxiliary_ParseJsonFromAIContent, -) - - -def Auxiliary_GetOpenAIRuntimeStatePath() -> PathlibPath: - return Auxiliary_GetCacheStorePath().parent / 'openai_runtime_state.json' - - -def Auxiliary_LoadOpenAIRuntimeState(): - StatePath = Auxiliary_GetOpenAIRuntimeStatePath() - if StatePath.is_file() == False: - return {'active_slot_index': 0, 'updated_at': 0.0} - try: - with open(StatePath, 'r', encoding='UTF-8') as StateFile: - Data = json.load(StateFile) - if type(Data) != dict: - return {'active_slot_index': 0, 'updated_at': 0.0} - IndexValue = Auxiliary_ParseInt(Data.get('active_slot_index', 0), 0) - if IndexValue < 0: - IndexValue = 0 - return {'active_slot_index': IndexValue, 'updated_at': float(Data.get('updated_at', 0.0) or 0.0)} - except Exception: - return {'active_slot_index': 0, 'updated_at': 0.0} - - -def Auxiliary_SaveOpenAIRuntimeState(StateDict): - StatePath = Auxiliary_GetOpenAIRuntimeStatePath() - try: - StatePath.parent.mkdir(parents=True, exist_ok=True) - Payload = { - 'active_slot_index': int(StateDict.get('active_slot_index', 0)), - 'updated_at': time(), - } - with open(StatePath, 'w', encoding='UTF-8') as StateFile: - json.dump(Payload, StateFile, ensure_ascii=False, indent=2) - except Exception as err: - Auxiliary_Log(f'OpenAI 运行时状态写入失败: {err}', 'WARNING') - - -def Auxiliary_GetOpenAIEndpointSlots(): - UrlList = Auxiliary_ParseDelimitedConfigList(state.OPENAI_BASE_URLS) - if UrlList == []: - BaseFallback = state.OPENAI_BASE_URL if state.OPENAI_BASE_URL not in [None, ''] else '' - UrlList = [str(BaseFallback).strip()] if str(BaseFallback).strip() not in [None, ''] else [] - KeyList = Auxiliary_ParseDelimitedConfigList(state.OPENAI_API_KEYS) - if KeyList == []: - SingleKey = Auxiliary_GetOpenAIApiKey() - KeyList = [SingleKey] if SingleKey not in [None, ''] else [] - if UrlList == [] or KeyList == []: - return [] - SlotCount = max(len(UrlList), len(KeyList)) - Slots = [] - for SlotIndex in range(SlotCount): - UrlItem = UrlList[SlotIndex % len(UrlList)].rstrip('/') - KeyItem = KeyList[SlotIndex % len(KeyList)] - Slots.append((UrlItem, KeyItem)) - return Slots - - -def Auxiliary_ParseOpenAIRotateStatusCodes(): - RawText = str(state.OPENAI_KEY_ROTATE_ON_STATUS).strip() if state.OPENAI_KEY_ROTATE_ON_STATUS not in [None, ''] else '401,429' - Codes = set() - for Part in RawText.replace('|', ',').split(','): - Part = Part.strip() - if Part.isdigit(): - Codes.add(int(Part)) - if Codes == set(): - Codes = {401, 429} - return Codes - - -def Auxiliary_OpenAIHttpBodyIndicatesQuota(ResponseText): - if ResponseText in [None, '']: - return False - LowerText = str(ResponseText).lower() - return 'insufficient_quota' in LowerText or 'rate_limit' in LowerText or 'billing' in LowerText - - -def Auxiliary_OpenAIChatCompletionsPost(RequestJson): - Slots = Auxiliary_GetOpenAIEndpointSlots() - if Slots == []: - return None - StateSnapshot = Auxiliary_LoadOpenAIRuntimeState() - StartIndex = Auxiliary_ParseInt(StateSnapshot.get('active_slot_index', 0), 0) % len(Slots) - TimeoutSeconds = Auxiliary_ParseInt(state.OPENAI_TIMEOUT_SECONDS, 60) - if TimeoutSeconds <= 0: - TimeoutSeconds = 60 - RetryTimes = Auxiliary_ParseInt(state.NETERRRECTRYTIMS, 2) - if RetryTimes < 0: - RetryTimes = 0 - RotateCodes = Auxiliary_ParseOpenAIRotateStatusCodes() - MaxConsecutive = Auxiliary_ParseInt(state.OPENAI_KEY_MAX_CONSECUTIVE_FAILURES, 3) - if MaxConsecutive <= 0: - MaxConsecutive = 1 - - for SlotOffset in range(len(Slots)): - SlotIndex = (StartIndex + SlotOffset) % len(Slots) - BaseUrl, ApiKey = Slots[SlotIndex] - if ApiKey in [None, '']: - continue - ConsecutiveFailures = 0 - for RetryIndex in range(RetryTimes + 1): - HttpData = None - try: - HttpData = post( - f'{BaseUrl.rstrip("/")}/v1/chat/completions', - json=RequestJson, - headers={ - 'Authorization': f'Bearer {ApiKey}', - 'Content-Type': 'application/json', - 'User-Agent': f'AutoAnimeMv/{state.Versions}', - }, - timeout=TimeoutSeconds, - ) - except exceptions.RequestException as err: - ConsecutiveFailures += 1 - if RetryIndex < RetryTimes: - Auxiliary_Log(f'OpenAI 请求异常,槽位 {SlotIndex+1}/{len(Slots)} 第{RetryIndex+1}/{RetryTimes+1}次重试: {err}', 'WARNING') - continue - Auxiliary_Log(f'OpenAI 请求失败,槽位 {SlotIndex+1}/{len(Slots)}: {err}', 'WARNING') - break - if HttpData.status_code == 200: - StateSnapshot['active_slot_index'] = SlotIndex - Auxiliary_SaveOpenAIRuntimeState(StateSnapshot) - return HttpData - ResponseText = '' - try: - ResponseText = HttpData.text - except Exception: - ResponseText = '' - if HttpData.status_code in RotateCodes or Auxiliary_OpenAIHttpBodyIndicatesQuota(ResponseText): - Auxiliary_Log(f'OpenAI 槽位 {SlotIndex+1}/{len(Slots)} 返回 {HttpData.status_code},切换下一槽位', 'WARNING') - break - ConsecutiveFailures += 1 - if RetryIndex < RetryTimes: - Auxiliary_Log(f'OpenAI 槽位 {SlotIndex+1}/{len(Slots)} 状态码 {HttpData.status_code},重试 {RetryIndex+1}/{RetryTimes+1}', 'WARNING') - continue - Auxiliary_Log(f'OpenAI 槽位 {SlotIndex+1}/{len(Slots)} 状态码 {HttpData.status_code},放弃本槽位', 'WARNING') - break - if ConsecutiveFailures >= MaxConsecutive: - Auxiliary_Log(f'OpenAI 槽位 {SlotIndex+1}/{len(Slots)} 连续失败达 {MaxConsecutive},尝试下一槽位', 'WARNING') - return None - - -def Auxiliary_OpenAITranslateForeignTitleToChinese(ForeignTitle): - '''将外文剧名译为简体中文(剧名链最后一步)''' - ForeignTitle = Auxiliary_NormalizeDisplayTitle(ForeignTitle) - if ForeignTitle in [None, '']: - return None - if state.USEOPENAIAPI != True: - return None - ApiKey = Auxiliary_GetOpenAIApiKey() - if ApiKey in [None, '']: - Auxiliary_Log('OpenAI 译名需要密钥', 'WARNING') - return None - BaseUrl = state.OPENAI_BASE_URL if state.OPENAI_BASE_URL not in [None, ''] else 'https://api.longcat.chat/openai' - ModelName = state.OPENAI_MODEL if state.OPENAI_MODEL not in [None, ''] else 'LongCat-Flash-Chat' - TimeoutSeconds = Auxiliary_ParseInt(state.OPENAI_TIMEOUT_SECONDS, 60) - if TimeoutSeconds <= 0: - TimeoutSeconds = 60 - try: - HttpData = post( - f'{BaseUrl.rstrip("/")}/v1/chat/completions', - json={ - 'model': ModelName, - 'temperature': 0, - 'messages': [ - {'role': 'system', 'content': '你是番剧译名助手。输入为一部动画的日文/英文或罗马音标题,请只输出一个最常用的简体中文官方译名,不要季数、集数、引号或解释。无法确定则只输出空字符串。'}, - {'role': 'user', 'content': ForeignTitle}, - ], - }, - headers={ - 'Authorization': f'Bearer {ApiKey}', - 'Content-Type': 'application/json', - 'User-Agent': f'AutoAnimeMv/{state.Versions}', - }, - timeout=TimeoutSeconds, - ) - if HttpData.status_code != 200: - Auxiliary_Log(f'OpenAI 译名请求失败,状态码 {HttpData.status_code}', 'WARNING') - return None - OpenAIData = HttpData.json() - if type(OpenAIData) != dict: - return None - Choices = OpenAIData.get('choices', []) - if type(Choices) != list or Choices == []: - return None - Message = Choices[0].get('message', {}) - RawText = Message.get('content', '') if type(Message) == dict else '' - Parsed = Auxiliary_ParseJsonFromAIContent(RawText) - if type(Parsed) == dict: - ApiTitle = Auxiliary_NormalizeApiTitle( - Parsed.get('anime_name_zh') or Parsed.get('anime_name') or Parsed.get('title') or '' - ) - else: - ApiTitle = Auxiliary_NormalizeApiTitle(RawText) - if ApiTitle in ['', 'None', 'none', 'null', '未知', '无法识别', '无法判断', '不确定']: - return None - if Auxiliary_HasChineseText(ApiTitle) != True: - return None - return ApiTitle - except Exception as err: - Auxiliary_Log(f'OpenAI 译名失败: {err}', 'WARNING') - return None diff --git a/autoanime/apis/tmdb.py b/autoanime/apis/tmdb.py deleted file mode 100644 index 840f298..0000000 --- a/autoanime/apis/tmdb.py +++ /dev/null @@ -1,317 +0,0 @@ -""" -autoanime TMDB Api 查询 - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_QueryTMDBChineseTitle` -- `Auxiliary_QueryTMDBEnglishTitle` -- `Auxiliary_ParseTMDBTvDetailsSeasonLayout` -- `Auxiliary_MapAbsoluteEpisodeUsingTMDBSeasonLayout` -- `Auxiliary_GetTMDBTvSeasonLayoutBySeriesId` -- `Auxiliary_ResolveTMDBTvSeriesIdFromEnglishQuery` -- `Auxiliary_ResolveTMDBTvIdForJujutsuKaisen` -""" - -from urllib.parse import quote - -from .. import state -from ..config_loader import Auxiliary_GetTMDBBearerToken -from ..logging_utils import Auxiliary_Log -from ..text_utils import ( - Auxiliary_HasChineseText, - Auxiliary_NormalizeAliasKey, - Auxiliary_NormalizeApiTitle, - Auxiliary_NormalizeDisplayTitle, -) -from .http import Auxiliary_Http - - -def Auxiliary_QueryTMDBChineseTitle(QueryName, CandidateEn='', CandidateRomaji='', AliasList=None): - '''仅通过 TMDB 查询中文标题;未命中中文时返回 None''' - from ..cache.canonical import ( - Auxiliary_ResolveCanonicalTitleByAliases, - Auxiliary_UpsertCanonicalTitle, - ) - from ..cache.persistent import ( - Auxiliary_GetPersistentCache, - Auxiliary_SetPersistentCache, - ) - from ..identification.title_chain import Auxiliary_GetStandardTitleCacheCandidates - - QueryName = Auxiliary_NormalizeDisplayTitle(QueryName) - CandidateEn = Auxiliary_NormalizeDisplayTitle(CandidateEn) - CandidateRomaji = Auxiliary_NormalizeDisplayTitle(CandidateRomaji) - if QueryName in [None, ''] or state.USETMDBAPI != True: - return None - - CanonicalZh, _, _ = Auxiliary_ResolveCanonicalTitleByAliases(QueryName, CandidateEn, CandidateRomaji) - if CanonicalZh not in [None, '']: - return CanonicalZh - if Auxiliary_GetTMDBBearerToken() in [None, '']: - Auxiliary_Log('TMDBApi 已启用但未配置 token,跳过 TMDB 查询', 'WARNING') - return None - - CandidateKeys = Auxiliary_GetStandardTitleCacheCandidates(QueryName) - if QueryName not in CandidateKeys: - CandidateKeys.insert(0, QueryName) - for CacheKey in CandidateKeys: - CacheValue = None - if type(state.TMDBAPIDataCache) == dict and CacheKey in state.TMDBAPIDataCache: - CacheValue = state.TMDBAPIDataCache.get(CacheKey) - Auxiliary_Log(f'{CacheValue} << TMDB内存缓存查询结果') - else: - CacheValue = Auxiliary_GetPersistentCache('TMDB', CacheKey) - if CacheValue not in [None, '']: - if type(state.TMDBAPIDataCache) != dict: - state.TMDBAPIDataCache = {} - state.TMDBAPIDataCache[CacheKey] = CacheValue - Auxiliary_Log(f'{CacheValue} << TMDB持久化缓存查询结果') - CacheValue = Auxiliary_NormalizeApiTitle(CacheValue) - if CacheValue in [None, ''] or Auxiliary_HasChineseText(CacheValue) == False: - continue - return CacheValue - - TMDBApiData = Auxiliary_Http( - f'https://api.themoviedb.org/3/search/tv?query={quote(QueryName)}&include_adult=true&language=zh&page=1', - ResponseType='json', - Timeout=20, - ) - if type(TMDBApiData) != dict: - Auxiliary_Log(f'TMDBApi返回异常: {QueryName}', 'WARNING') - return None - ResultList = TMDBApiData.get('results', []) - if type(ResultList) != list or ResultList == []: - Auxiliary_Log(f'TMDBApi没有检索到关于 {QueryName} 内容', 'WARNING') - return None - - ApiTitle = '' - for ResultItem in ResultList: - if type(ResultItem) != dict: - continue - CandidateTitle = Auxiliary_NormalizeApiTitle(ResultItem.get('name') or ResultItem.get('original_name') or '') - if CandidateTitle not in [None, ''] and Auxiliary_HasChineseText(CandidateTitle): - ApiTitle = CandidateTitle - break - if ApiTitle in [None, '']: - Auxiliary_Log(f'TMDBApi命中结果但未返回中文标题: {QueryName}', 'WARNING') - return None - - CandidateEnForUpsert = CandidateEn - if CandidateEnForUpsert in [None, ''] and Auxiliary_HasChineseText(QueryName) == False: - CandidateEnForUpsert = QueryName - CandidateAliases = [QueryName, CandidateEn, CandidateRomaji] - if type(AliasList) == list: - CandidateAliases.extend(AliasList) - CandidateAliases = [Auxiliary_NormalizeDisplayTitle(Item) for Item in CandidateAliases if Item not in [None, '']] - - _, CanonicalTitle = Auxiliary_UpsertCanonicalTitle( - ApiTitle, CandidateEnForUpsert, CandidateRomaji, 'TMDB', CandidateAliases, - ) - if CanonicalTitle not in [None, ''] and Auxiliary_HasChineseText(CanonicalTitle): - ApiTitle = CanonicalTitle - for CacheKey in CandidateKeys: - state.TMDBAPIDataCache[CacheKey] = ApiTitle - Auxiliary_SetPersistentCache('TMDB', CacheKey, ApiTitle) - Auxiliary_Log(f'{ApiTitle} << TMDBApi查询结果') - return ApiTitle - - -def Auxiliary_QueryTMDBEnglishTitle(QueryName, CandidateEn='', CandidateRomaji='', AliasList=None): - '''TMDB en-US 检索,返回英文剧名(不要求中文)''' - from ..cache.canonical import Auxiliary_UpsertCanonicalTitle - from ..cache.persistent import Auxiliary_SetPersistentCache - from ..identification.title_chain import Auxiliary_GetStandardTitleCacheCandidates - - QueryName = Auxiliary_NormalizeDisplayTitle(QueryName) - CandidateEn = Auxiliary_NormalizeDisplayTitle(CandidateEn) - CandidateRomaji = Auxiliary_NormalizeDisplayTitle(CandidateRomaji) - if QueryName in [None, ''] or state.USETMDBAPI != True: - return None - if Auxiliary_GetTMDBBearerToken() in [None, '']: - Auxiliary_Log('TMDBApi 已启用但未配置 token,跳过 TMDB 英文查询', 'WARNING') - return None - CandidateKeys = Auxiliary_GetStandardTitleCacheCandidates(QueryName) - if QueryName not in CandidateKeys: - CandidateKeys.insert(0, QueryName) - for CacheKey in CandidateKeys: - RawVal = None - if type(state.TMDBAPIDataCache) == dict and f'en:{CacheKey}' in state.TMDBAPIDataCache: - RawVal = state.TMDBAPIDataCache.get(f'en:{CacheKey}') - else: - Group = state.PersistentApiCache.get('TMDB_EN', {}) if type(state.PersistentApiCache) == dict else {} - Rec = Group.get(CacheKey) if type(Group) == dict else None - if type(Rec) == dict and Rec.get('value') not in [None, '']: - RawVal = Rec.get('value') - if RawVal not in [None, '']: - return Auxiliary_NormalizeDisplayTitle(str(RawVal)) - TMDBApiData = Auxiliary_Http( - f'https://api.themoviedb.org/3/search/tv?query={quote(QueryName)}&include_adult=true&language=en-US&page=1', - ResponseType='json', - Timeout=20, - ) - if type(TMDBApiData) != dict: - Auxiliary_Log(f'TMDBApi(EN)返回异常: {QueryName}', 'WARNING') - return None - ResultList = TMDBApiData.get('results', []) - if type(ResultList) != list or ResultList == []: - Auxiliary_Log(f'TMDBApi(EN)没有检索到关于 {QueryName} 内容', 'WARNING') - return None - ApiTitle = '' - for ResultItem in ResultList: - if type(ResultItem) != dict: - continue - ApiTitle = Auxiliary_NormalizeDisplayTitle(ResultItem.get('name') or ResultItem.get('original_name') or '') - if ApiTitle not in [None, '']: - break - if ApiTitle in [None, '']: - return None - CandidateAliases = [QueryName, CandidateEn, CandidateRomaji] - if type(AliasList) == list: - CandidateAliases.extend(AliasList) - CandidateAliases = [Auxiliary_NormalizeDisplayTitle(Item) for Item in CandidateAliases if Item not in [None, '']] - EnForUpsert = CandidateEn if CandidateEn not in [None, ''] else ApiTitle - Auxiliary_UpsertCanonicalTitle( - '', EnForUpsert if EnForUpsert not in [None, ''] else ApiTitle, CandidateRomaji, 'TMDB', CandidateAliases + [ApiTitle], - ) - if type(state.TMDBAPIDataCache) != dict: - state.TMDBAPIDataCache = {} - for CacheKey in CandidateKeys: - state.TMDBAPIDataCache[f'en:{CacheKey}'] = ApiTitle - Auxiliary_SetPersistentCache('TMDB_EN', CacheKey, ApiTitle) - Auxiliary_Log(f'{ApiTitle} << TMDBApi(EN)查询结果') - return ApiTitle - - -def Auxiliary_ParseTMDBTvDetailsSeasonLayout(DetailsData): - '''从 TMDB tv/{id} 详情中解析正片分季集数列表,忽略第 0 季特典。''' - if type(DetailsData) != dict: - return [] - RawSeasons = DetailsData.get('seasons', []) - if type(RawSeasons) != list: - return [] - Pairs = [] - for Item in RawSeasons: - if type(Item) != dict: - continue - try: - Sn = int(Item.get('season_number', -1)) - Ec = int(Item.get('episode_count', 0)) - except (TypeError, ValueError): - continue - if Sn < 1 or Ec < 1: - continue - Pairs.append((Sn, Ec)) - Pairs.sort(key=lambda X: X[0]) - return Pairs - - -def Auxiliary_MapAbsoluteEpisodeUsingTMDBSeasonLayout(AbsEp, SeasonPairs): - ''' - 将「全剧累计集号」映射到 (季号, 该季内的集号)。 - 最后一季若累计集超出 TMDB 已登记的 episode_count,仍归入最后一季并顺延集号。 - ''' - if AbsEp < 1 or type(SeasonPairs) != list or SeasonPairs == []: - return None - Prefix = 0 - LastIndex = len(SeasonPairs) - 1 - for Idx, (SeasonNum, EpCount) in enumerate(SeasonPairs): - if Idx == LastIndex: - return SeasonNum, AbsEp - Prefix - if AbsEp <= Prefix + EpCount: - return SeasonNum, AbsEp - Prefix - Prefix += EpCount - return None - - -def Auxiliary_GetTMDBTvSeasonLayoutBySeriesId(tv_id): - from ..cache.persistent import Auxiliary_GetPersistentCache, Auxiliary_SetPersistentCache - - try: - TvIdInt = int(tv_id) - except (TypeError, ValueError): - return [] - if TvIdInt in state.TMDBTvSeasonLayoutMemoryCache: - return state.TMDBTvSeasonLayoutMemoryCache[TvIdInt] - CachedRaw = Auxiliary_GetPersistentCache('TMDBTvSeasons', f'id:{TvIdInt}') - if type(CachedRaw) == list and CachedRaw != []: - Pairs = [] - for Row in CachedRaw: - if type(Row) in (list, tuple) and len(Row) >= 2: - try: - Pairs.append((int(Row[0]), int(Row[1]))) - except (TypeError, ValueError): - continue - if Pairs != []: - state.TMDBTvSeasonLayoutMemoryCache[TvIdInt] = Pairs - return Pairs - if state.USETMDBAPI != True or Auxiliary_GetTMDBBearerToken() in [None, '']: - return [] - Details = Auxiliary_Http( - f'https://api.themoviedb.org/3/tv/{TvIdInt}', - ResponseType='json', - Timeout=25, - ) - Pairs = Auxiliary_ParseTMDBTvDetailsSeasonLayout(Details) - if Pairs != []: - state.TMDBTvSeasonLayoutMemoryCache[TvIdInt] = Pairs - Auxiliary_SetPersistentCache( - 'TMDBTvSeasons', - f'id:{TvIdInt}', - [[Sn, Ec] for Sn, Ec in Pairs], - ) - return Pairs - - -def Auxiliary_ResolveTMDBTvSeriesIdFromEnglishQuery(QueryName): - from ..cache.persistent import Auxiliary_GetPersistentCache, Auxiliary_SetPersistentCache - - QueryName = Auxiliary_NormalizeDisplayTitle(QueryName) - if QueryName in [None, '']: - return None - AliasKey = Auxiliary_NormalizeAliasKey(QueryName) - if AliasKey in [None, '']: - return None - if AliasKey in state.TMDBTvSeriesIdMemoryCache: - return state.TMDBTvSeriesIdMemoryCache[AliasKey] - CachedId = Auxiliary_GetPersistentCache('TMDBTvSeriesId', AliasKey) - try: - CachedId = int(CachedId) - except (TypeError, ValueError): - CachedId = 0 - if CachedId > 0: - state.TMDBTvSeriesIdMemoryCache[AliasKey] = CachedId - return CachedId - if state.USETMDBAPI != True or Auxiliary_GetTMDBBearerToken() in [None, '']: - return None - SearchData = Auxiliary_Http( - f'https://api.themoviedb.org/3/search/tv?query={quote(QueryName)}&include_adult=false&language=en-US&page=1', - ResponseType='json', - Timeout=20, - ) - if type(SearchData) != dict: - return None - ResultList = SearchData.get('results', []) - if type(ResultList) != list or ResultList == [] or type(ResultList[0]) != dict: - return None - Tid = ResultList[0].get('id') - try: - Tid = int(Tid) - except (TypeError, ValueError): - return None - if Tid < 1: - return None - state.TMDBTvSeriesIdMemoryCache[AliasKey] = Tid - Auxiliary_SetPersistentCache('TMDBTvSeriesId', AliasKey, Tid) - return Tid - - -def Auxiliary_ResolveTMDBTvIdForJujutsuKaisen(NameEN, NameRomaji): - QueryList = [] - for Q in (NameEN, NameRomaji, 'Jujutsu Kaisen'): - Qn = Auxiliary_NormalizeDisplayTitle(Q or '') - if Qn != '' and Qn not in QueryList: - QueryList.append(Qn) - for Qn in QueryList: - Tid = Auxiliary_ResolveTMDBTvSeriesIdFromEnglishQuery(Qn) - if Tid not in [None, ''] and int(Tid) > 0: - return int(Tid) - return None diff --git a/autoanime/cache/README.md b/autoanime/cache/README.md deleted file mode 100644 index 7660819..0000000 --- a/autoanime/cache/README.md +++ /dev/null @@ -1,358 +0,0 @@ -# `autoanime.cache` 使用说明 - -本包负责 **Schema v2 多文件持久化缓存**:整理进度、剧名/别名索引、API 响应分区,以及别名写入校验与审计。业务代码应优先通过下文「推荐入口」调用,避免直接读写 `.cache` 下的 JSON。 - -- **`cache_doctor` 重命名与剧名纠偏**(七子命令全说明、真实 PowerShell 与 `organization` 例):专题目录 [cache_doctor_重命名与剧名纠偏_使用说明.md](cache_doctor_重命名与剧名纠偏_使用说明.md)。 -- 更完整的设计说明见项目文档:[docs/10_缓存Schema_v2设计.md](../../docs/10_缓存Schema_v2设计.md)。 - ---- - -## 1. 磁盘布局(Schema v2) - -在 `config.ini` 的 `CACHE_DIR` 目录下(默认项目根目录的 `.cache/`): - -| 文件 | 作用 | -| --- | --- | -| `cache_meta.json` | `schema_version`、各子文件 sha256/条目统计、`legacy_archive`(若有) | -| `organization.json` | 每部番整理进度(`ShowOrganizationIndex`),永不过期 | -| `titles.json` | 中文主名表(`CanonicalTitleIndex`)+ 别名表(`TitleAliasIndex`,含 `trust_level`),永不过期 | -| `api_responses.json` | TMDB / Bangumi / 扩展组等 API 缓存,按条目 TTL 过期 | -| `pollution_audit.jsonl` | 别名写入成功/拒绝等审计行(仅追加) | -| `manual_title_whitelist.json` | 手工剧名白名单(与 v1 相同) | -| `backups/api_cache_legacy_.json` | 首次迁移时从旧 `api_cache.json` 归档而来(若存在) | - -**判定 v2 是否生效**:存在 `.cache/cache_meta.json` 且其中 `schema_version` 为 `2`。 - -**旧版单文件**:若仅有 `.cache/api_cache.json` 且无 `cache_meta.json`,则仍按旧版整块 JSON 读写(与 `AutoAnimeMv.py` 行为一致)。 - ---- - -## 2. 生命周期(何时加载/保存) - -- **加载**:`autoanime.cli.Start_PATH()` → `Auxiliary_LoadPersistentCache()` - 内部会先 `Auxiliary_MigrateCacheToV2IfNeeded()`(无 `cache_meta` 时归档旧 `api_cache.json` 并初始化空 v2 子文件),再读入内存到 `state.PersistentApiCache`。 -- **保存**:`main()` 的 `finally` 中 `Auxiliary_SavePersistentCache()` - v2 下只写入 **有改动的子文件**(`state.CacheSubfileDirty`),不会每次全量重写三个 JSON。 -- **定时刷盘**:`Auxiliary_MaybeFlushPersistentCache()`(受 `CACHE_FLUSH_INTERVAL_SECONDS` 控制)。 - ---- - -## 3. 推荐入口(业务侧) - -### 3.1 通用键值缓存(与旧代码签名一致) - -适合:TMDB/Bangumi 等 API 结果、Show 记录、Canonical 记录等。 - -```python -from autoanime.cache.persistent import ( - Auxiliary_LoadPersistentCache, - Auxiliary_SavePersistentCache, - Auxiliary_GetPersistentCache, - Auxiliary_SetPersistentCache, - Auxiliary_MaybeFlushPersistentCache, -) - -# 读取(过期 API 条目会自动删内存键并标记对应子文件 dirty) -value = Auxiliary_GetPersistentCache("TMDB", "Some English Query") - -# 写入(自动带 ts / ttl,并标记 api_responses 子文件 dirty) -Auxiliary_SetPersistentCache("TMDB", "Some English Query", "中文标题") -``` - -**`CacheGroup` 与落盘子文件对应关系**(实现见 `persistent.py`): - -| CacheGroup | 子文件 | TTL | -| --- | --- | --- | -| `ShowOrganizationIndex` | `organization.json` | 永不过期 | -| `CanonicalTitleIndex` | `titles.json`(`canonicals`) | 永不过期 | -| `TitleAliasIndex` | `titles.json`(`aliases`) | 永不过期 | -| `TMDB` | `api_responses.json` → `tmdb.titles` | 默认 86400 秒 | -| `TMDB_EN` | `api_responses.json` → `tmdb.titles_en` | 同上 | -| `TMDBTvSeriesId` | `api_responses.json` → `tmdb.tv_series` | 默认 604800 秒 | -| `TMDBTvSeasons` | `api_responses.json` → `tmdb.tv_seasons` | 同上 | -| `Bangumi` | `api_responses.json` → `bangumi.titles` | 默认 86400 秒 | -| `BGM` | `api_responses.json` → `ext.BGM` | 默认 86400 秒 | -| 其它未列组名 | `api_responses.json` → `ext.<组名>` | 默认 `CACHE_TTL_SECONDS` | - -`Auxiliary_GetPersistentCache` 的返回值始终是 **业务 `value`**(例如 TMDB 的中文标题字符串、Show 的一条 dict),不会把 `{"value","ts","ttl"}` 整包返回给调用方。 - -### 3.2 别名(带信任等级,必须走 canonical) - -**不要**对 `TitleAliasIndex` 直接 `Auxiliary_SetPersistentCache`,否则会绕过校验与审计。 - -```python -from autoanime.cache.canonical import Auxiliary_LinkAliasToCanonical - -# 由 SourceTag 推导默认 trust_level;也可显式传入 trust_level= -Auxiliary_LinkAliasToCanonical("Sousou no Frieren", "葬送的芙莉莲", SourceTag="TMDB") -``` - -内部会调用 `Auxiliary_ValidateAliasWrite`;失败则只写 `pollution_audit.jsonl`(`type=alias_rejected`),不落盘别名。 - -### 3.3 剧名主记录与解析 - -```python -from autoanime.cache.canonical import ( - Auxiliary_UpsertCanonicalTitle, - Auxiliary_GetCanonicalTitleRecord, - Auxiliary_GetAliasCanonicalID, - Auxiliary_ResolveCanonicalTitleByAliases, -) -``` - -### 3.4 整理进度(ShowOrganizationIndex) - -```python -from autoanime.cache import show_index - -show_index.Auxiliary_ShowHasOrganizedEpisode(canonical_id, se, ep) # -> (has_tag, expected_dst_path_or_none) -show_index.Auxiliary_ShowMarkOrganizedEpisode(..., DstPath=dst) # 成功落盘后写入 expected_dst -show_index.Auxiliary_ShowClearOrganizedEpisode(...) # 自愈:目标缺失时剔除 tag -``` - -### 3.5 手工白名单 - -```python -from autoanime.cache.manual_whitelist import Auxiliary_LoadManualWhitelist - -Auxiliary_LoadManualWhitelist(force=True) -``` - ---- - -## 4. 迁移(v1 单文件 → v2) - -```python -from autoanime.cache.migrate import Auxiliary_MigrateCacheToV2IfNeeded - -# 幂等:已有 cache_meta.json 则直接返回 None -archive_path = Auxiliary_MigrateCacheToV2IfNeeded() -# 若归档了旧文件,返回 str 路径;否则 None -``` - -首次迁移时:旧 `.cache/api_cache.json` 会移动到 `.cache/backups/api_cache_legacy_.json`,并生成空的 `organization.json` / `titles.json` / `api_responses.json`(**零数据冷启动**,由你之前在计划里选择的策略决定)。 - -**回滚到旧单文件**:删除 v2 的 `cache_meta.json` 与子 JSON,把 `backups/` 里备份移回 `.cache/api_cache.json`(仅在使用旧 `AutoAnimeMv.py` 且未改代码路径时有效)。 - ---- - -## 5. 信任等级与别名校验(`trust.py`) - -| 默认等级 | 典型 `SourceTag` / 条件 | -| --- | --- | -| 100 | `manual` / 白名单 | -| 90 | `BGM` | -| 80 | `Bangumi`、`TMDB` | -| 60 | `openai_identify`、`OpenAI` | -| 40 | 冲突降级、未知来源等 | - -`Auxiliary_ValidateAliasWrite` 会拒绝例如:**别名 key 超过 `trust.ALIAS_KEY_MAX_LEN`(当前 100)字符**、纯数字、连续 4 位以上数字噪声、canonical 尚无可用主名、`canonical.locked` 且 trust<100、已有更高 trust 的别名等。 - -手动校验: - -```python -from autoanime.cache.trust import Auxiliary_ValidateAliasWrite, Auxiliary_TrustLevelFromSource - -ok, reason = Auxiliary_ValidateAliasWrite("sousounofrieren", "葬送的芙莉莲", 80, new_source="TMDB") -tl = Auxiliary_TrustLevelFromSource("Bangumi") -``` - ---- - -## 6. 审计(`audit.py`) - -```python -from autoanime.cache.audit import Auxiliary_AppendPollutionAudit - -Auxiliary_AppendPollutionAudit("custom_event", {"note": "..."}) -``` - -业务上别名相关事件一般由 `canonical.Auxiliary_LinkAliasToCanonical` 自动写入 `alias_written` / `alias_rejected`。 - ---- - -## 7. 底层路径与原子写(`v2_data.py`) - -扩展脚本若需直接读子文件,可用: - -```python -from autoanime.cache.v2_data import Auxiliary_GetV2DataDir, Auxiliary_GetV2SubfilePath - -base = Auxiliary_GetV2DataDir() # 即 CACHE_DIR -org = Auxiliary_GetV2SubfilePath("organization") # organization.json -``` - -`Auxiliary_AtomicWriteJson` 用于先写 `.tmp` 再 `replace`,避免半截 JSON。 - ---- - -## 8. 命令行运维(`scripts/cache_doctor.py`,完整指令) - -**前置**:在**项目根目录**(含 `scripts/` 与 `autoanime/`)下执行;Python 3.8+。 - -**查看帮助**(列出所有参数): - -```powershell -cd C:\Users\17645\Desktop\AutoAnime -python scripts\cache_doctor.py --help -``` - -### 8.1 全局参数 - -| 参数 | 用途 | 示例 | -| --- | --- | --- | -| `--cache-dir <路径>` | 缓存根目录(默认项目下 `.cache`);相对路径相对**项目根** | `--cache-dir D:\AnimeData\.cache` | - -### 8.2 子命令(七选一,互斥) - -以下七个开关**必须且只能**选一个。 - -| 子命令 | 用途 | 额外必填参数 | 风险 | -| --- | --- | --- | --- | -| `--inspect` | 检查是否为 Schema v2:打印 `cache_meta.json`、`organization.json`、`titles.json`、`api_responses.json`、`pollution_audit.jsonl` 的大小、sha256 前缀、条目统计;`titles.json` 会统计「别名键长度大于 `ALIAS_KEY_MAX_LEN`」「trust 小于 50」等污染嫌疑 | 无 | 只读 | -| `--export-audit` | 从 `pollution_audit.jsonl` 导出 **时间戳 ≥ 指定日期 00:00** 的 JSON 行到标准输出 | **`--since YYYY-MM-DD`** | 只读 | -| `--revert` | 按某条审计记录**撤销一次别名写入**:仅从 `titles.json` 的 `aliases` 中删除对应键(仅支持 `type=alias_written`) | **`--audit-id `**(与审计行中 `audit_id` 一致) | **修改** `titles.json` 与 `cache_meta.json` 中 titles 统计 | -| `--rebuild-from-organization` | 用 `organization.json` 的 `records` **覆盖重写** `titles.json`:重建 `canonicals` + 从 zh/en/romaji 生成别名(归一后长度 ≤`ALIAS_KEY_MAX_LEN` 才写入) | 无 | **覆盖** `titles.json`;API 缓存不动 | -| `--set-whitelist` | 写入/合并 **`manual_title_whitelist.json`**(`--alias` + `--zh` 经归一化后的键值)。可选与 `--apply-rename` 联用 | **`--alias`**、**`--zh`**;若加 `--apply-rename` 还需 **`--canonical-id`** 或 **`--old-title-zh`** 以唯一定位 `organization` 一条 | 只写白名单,或**再改** `organization`/`titles` 与**移动**已整理媒体(见下) | -| `--set-title-zh` | 将 **`titles.json` → `canonicals[id].zh`** 与 **`organization` 对应 `title_zh`** 同步为 `--zh`;可选 `--apply-rename` | **`--canonical-id`**、**`--zh`** | **改** `titles`+`organization`;加 `--apply-rename` 时可能 **move 文件** | -| `--rename-episodes` | **仅**使用 `episode_last_dst` 做与 `Sorting_Mv` 一致的重命名**计划**或**执行**;**默认只打印、不改 JSON 不动盘**;加 `--apply-rename` 再 move 并回写主名。七子命令**完整**说明与例见 [cache_doctor_重命名与剧名纠偏_使用说明.md](cache_doctor_重命名与剧名纠偏_使用说明.md) | **`--zh`**,以及 **`--canonical-id`** 或 **`--old-title-zh`** | 加 `--apply-rename` 时**移动**媒体并改缓存 | - -### 8.2.1 共用重命名逻辑(`autoanime/episode_dst_rename.py`) - -当联用 **`--apply-rename`**(或 **`--set-title-zh` + `--apply-rename`**)时,工具按 `organization` 中该条 **`episode_last_dst`**(`SxxEyy` → 上次落盘绝对路径)计算目标路径,规则与主程序 **`Sorting_Mv`** 一致(`--naming-style` / `--no-use-title-to-ep` 对齐 `NAMING_STYLE` / `USETITLTOEP`)。**`--rename-episodes` 且未**加 `--apply-rename` 时:只打印计划,不修改 `organization` / `titles` / 磁盘。其余:`--set-title-zh` 不加 `--apply-rename` 时仅改 JSON;`--set-whitelist` 只写白名单(除非再配 `--apply-rename`)。 - -### 8.2.2 与 `--apply-rename` 相关参数 - -| 参数 | 作用 | -| --- | --- | -| `--apply-rename` | 对 `episode_last_dst` 中列出的文件执行 `shutil.move`,并回写同条 `organization` 内的路径与 `title_zh`(及 `titles` 中 `canonical.zh`) | -| `--naming-style default\|emby` | 与主程序 `NAMING_STYLE` 一致(默认 `default`) | -| `--no-use-title-to-ep` | 对应主程序 `USETITLTOEP=False`(默认不加此项,与 `USETITLTOEP=True` 一致,即 `SxxEyy.剧名` 风格) | -| `--old-title-zh` | 用**归一后**的 `title_zh` 在 `organization.records` 中**唯一条**;用于 `set-whitelist+apply` 或 **`rename-episodes`** | -| `--canonical-id` | 在 `organization.records` 中按键或 `record.canonical_id` 查找;**`--set-title-zh` 必填**;`rename-episodes` 与 `set-whitelist+apply` 时可与 `--old-title-zh` 二选一 | - -### 8.3 调用实例(PowerShell) - -**实例 1:默认目录快速体检** - -```powershell -cd C:\Users\17645\Desktop\AutoAnime -python scripts\cache_doctor.py --inspect -``` - -若输出「未找到有效的 cache_meta.json」,说明仍是**旧版单文件** `api_cache.json` 布局;需先跑一次新入口触发迁移,或仍用旧脚本维护单文件。 - -**实例 2:自定义缓存目录(例如库与项目分离)** - -```powershell -python scripts\cache_doctor.py --inspect --cache-dir "D:\Media\.cache" -``` - -**实例 3:导出 2026-04-01 以来的审计事件(每行一条 JSON,可重定向到文件)** - -```powershell -python scripts\cache_doctor.py --export-audit --since 2026-04-01 --cache-dir .\.cache > audit_export.jsonl -``` - -说明:匹配条件为事件内 `ts`(Unix 时间戳)≥ 该日 0 点;末尾 `# exported N events...` 在**标准错误**,不会进重定向文件。 - -**实例 4:撤销某次错误别名(先 export 找到 `audit_id`)** - -```powershell -# 1) 导出近期审计,人工找到 type=alias_written 且 alias_key 不对的那条,复制 audit_id -python scripts\cache_doctor.py --export-audit --since 2026-04-20 - -# 2) 撤销(将 UUID 换成真实 audit_id) -python scripts\cache_doctor.py --revert --audit-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -``` - -撤销后需**重启**正在运行的整理进程,或下次启动会重新从磁盘加载。 - -**实例 5:titles 全坏、organization 仍可信时,从整理进度重建 titles(先备份)** - -```powershell -Copy-Item .\.cache\titles.json .\.cache\titles.json.bak -python scripts\cache_doctor.py --rebuild-from-organization --cache-dir .\.cache -``` - -**实例 6:写入手工白名单(不移动磁盘)** - -```powershell -python scripts\cache_doctor.py --set-whitelist --alias "某RAW关键词" --zh "正确中文剧名" --cache-dir .\.cache -``` - -**实例 7:修正识别用中文名(只改 `titles.json` + `organization.json`)** - -```powershell -python scripts\cache_doctor.py --set-title-zh --canonical-id "摩绪" --zh "摩绪" --cache-dir .\.cache -``` - -**实例 8:按 `episode_last_dst` 在库中重命名/迁移,并回写缓存(显式写磁盘,请先备份;首次可加 `--naming-style` 等与主程序一致)** - -```powershell -python scripts\cache_doctor.py --set-title-zh --canonical-id "摩绪" --zh "新剧名" --apply-rename --cache-dir .\.cache -``` - -**实例 9:先写白名单,再对唯一匹配 `title_zh` 的一部番做迁移(需能唯一定位,否则改用 `--canonical-id`)** - -```powershell -python scripts\cache_doctor.py --set-whitelist --alias "x" --zh "正确名" --apply-rename --old-title-zh "旧库中展示名" --cache-dir .\.cache -``` - -**实例 10:仅重命名已整理集(只预览,不改 JSON、不 move)** - -```powershell -python scripts\cache_doctor.py --rename-episodes --canonical-id "摩绪" --zh "摩绪 MAO" --cache-dir .\.cache -``` - -**实例 11:仅重命名子命令,确认预览后再执行真迁移(迁移前务必备份)** - -```powershell -python scripts\cache_doctor.py --rename-episodes --canonical-id "摩绪" --zh "摩绪 MAO" --apply-rename --cache-dir .\.cache -``` - -更细步骤、**全部子命令**与 Emby/带引号剧名等例见 [cache_doctor_重命名与剧名纠偏_使用说明.md](cache_doctor_重命名与剧名纠偏_使用说明.md)。 - ---- - -## 9. `scripts/` 目录各文件用途 - -| 文件 | 用途 | 典型场景 | 调用示例 | 备注 | -| --- | --- | --- | --- | --- | -| [scripts/cache_doctor.py](../../scripts/cache_doctor.py) | Schema v2 缓存诊断;**`--rename-episodes`**、白名单/改主名/审计等共七子命令 | 排障、别名回滚、白名单、改主名、按已整理目标路径批量改名 | 见 §8 与 [cache_doctor_重命名与剧名纠偏_使用说明.md](cache_doctor_重命名与剧名纠偏_使用说明.md) | 依赖项目根在 `sys.path`;`--apply-rename` 会**移动**媒体 | -| [scripts/verify_refactor_with_real_data.py](../../scripts/verify_refactor_with_real_data.py) | **集成自测**:用固定日志样本 + 缓存样本(脚本内路径)验证 ShowIndex 自愈、CLI 单文件、OpenAI 回退 mock、流水线 dry-run;**克隆缓存到临时目录**,不污染工作区 | CI 或本地回归、改 `pipeline`/`cache` 后快速验收 | `python scripts\verify_refactor_with_real_data.py` | 默认读 `logs/AutoAnime_operations_20260421_204030.json` 与 `.cache/api_cache.json`;若你删了这些文件需改脚本常量 | -| [scripts/normalize_api_cache_cn_punct.py](../../scripts/normalize_api_cache_cn_punct.py) | **旧版单文件** `api_cache.json`:把含中文的标题/键中的半角标点批量换成中文全角(冒号、引号等),减少「同名不同标点」分叉 | 仍在使用 **v1 单文件**且需统一中文标点时 | 先编辑脚本内 `CACHE_PATH` 指向你的 `api_cache.json`,再 `python scripts\normalize_api_cache_cn_punct.py` | **原地覆盖**目标文件;路径当前写死在脚本里,使用前务必改对;**不适用于**已拆分的 v2 `titles.json`(需另写或手工处理) | - -**与主程序的关系**:日常整理用 `python AutoAnimeMv2.py ...`;`cache_doctor` 与 `verify_*` 为运维/测试工具,不参与正常整理链路。 - ---- - -## 10. 自动化测试 - -- `tests/test_cache_schema_v2.py`:路由、信任、原子写、迁移、兼容、`cache_doctor` 等。 -- `tests/test_episode_dst_rename.py`:`episode_last_dst` 重命名计划与路径计算。 - -```bash -python -m unittest tests.test_cache_schema_v2 tests.test_episode_dst_rename -v -``` - ---- - -## 11. 常见问题 - -**Q:`Auxiliary_GetPersistentCache('TitleAliasIndex', key)` 返回什么?** -A:返回 **canonical_id 字符串**(与 v1 行为一致)。磁盘上 `titles.json` 的 `aliases` 可能存的是带 `trust_level` 的对象,加载时会展开为内存中的 `value` 字段。 - -**Q:为何别名不再接受超长 key?** -A:防止把整段「文件名归一」写入永久别名表导致污染;长线索应走 OpenAI/季集识别,而不是 alias 表。 - -**Q:修改了缓存但没退出程序,数据会在磁盘上吗?** -A:依赖 `Auxiliary_MaybeFlushPersistentCache` 间隔或进程退出时的 `Auxiliary_SavePersistentCache`;调试时可显式调用 `Auxiliary_SavePersistentCache(force=True)`。 - ---- - -## 12. 变更记录 - -| 日期 | 说明 | -| --- | --- | -| 2026-04-23 | 专题目录合并为 [cache_doctor_重命名与剧名纠偏_使用说明.md](cache_doctor_重命名与剧名纠偏_使用说明.md)(`inspect` / 审计 / `revert` / `rebuild` / 白名单 / `set-title-zh` / `rename-episodes` 全表与实机例);原 `apply_rename_按已整理目标重命名.md` 删除。 | diff --git a/autoanime/cache/__init__.py b/autoanime/cache/__init__.py deleted file mode 100644 index 83ac2eb..0000000 --- a/autoanime/cache/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -autoanime 持久化缓存与索引子包(Schema v2:多子文件 + 路由 + 增量刷盘) - -使用说明与示例见同目录 [README.md](README.md);设计文档见 `docs/10_缓存Schema_v2设计.md`。 - -- `persistent` : `Auxiliary_Load/Save/Get/SetPersistentCache`、`MaybeFlush` -- `migrate` : `Auxiliary_MigrateCacheToV2IfNeeded` -- `v2_data` : v2 路径、空结构、原子写 JSON -- `trust` : 别名信任等级与 `Auxiliary_ValidateAliasWrite` -- `audit` : `pollution_audit.jsonl` 追加 -- `canonical` : 剧名主记录 + 别名链接(应走 `LinkAlias`,勿直接 Set 别名) -- `show_index` : ShowOrganizationIndex(已整理集 + episode_last_dst) -- `manual_whitelist` : 手工剧名白名单 -""" diff --git a/autoanime/cache/audit.py b/autoanime/cache/audit.py deleted file mode 100644 index 0e003d2..0000000 --- a/autoanime/cache/audit.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -JSONL 审计:alias / canonical 写入、拒绝 -""" - -import json -from time import time -from uuid import uuid4 - -from ..logging_utils import Auxiliary_Log - - -def Auxiliary_AppendPollutionAudit(event_type: str, detail: dict) -> None: - """ - 向 `.cache/pollution_audit.jsonl` 追加一行 JSON。 - detail 中可含 alias_key, canonical_id, reason, source 等;自动补充 ts、audit_id。 - """ - from .persistent import Auxiliary_GetCacheDir - - line = { - "audit_id": str(uuid4()), - "ts": time(), - "type": str(event_type), - } - if type(detail) is dict: - line.update(detail) - p = Auxiliary_GetCacheDir() / "pollution_audit.jsonl" - try: - p.parent.mkdir(parents=True, exist_ok=True) - with open(p, "a", encoding="utf-8") as f: - f.write(json.dumps(line, ensure_ascii=False) + "\n") - except Exception as err: - Auxiliary_Log(f"pollution_audit 写入失败: {err}", "WARNING") diff --git "a/autoanime/cache/cache_doctor_\351\207\215\345\221\275\345\220\215\344\270\216\345\211\247\345\220\215\347\272\240\345\201\217_\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/autoanime/cache/cache_doctor_\351\207\215\345\221\275\345\220\215\344\270\216\345\211\247\345\220\215\347\272\240\345\201\217_\344\275\277\347\224\250\350\257\264\346\230\216.md" deleted file mode 100644 index 19e7755..0000000 --- "a/autoanime/cache/cache_doctor_\351\207\215\345\221\275\345\220\215\344\270\216\345\211\247\345\220\215\347\272\240\345\201\217_\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ /dev/null @@ -1,292 +0,0 @@ -# `cache_doctor`:重命名、剧名纠偏与缓存运维(使用说明) - -> 本文是 **`scripts/cache_doctor.py`** 的**专题目录**,覆盖 **Schema v2** 下与「已整理资源路径」「别名校正」「剧名主名」相关的**全部子命令**与**共用参数**;与主程序**同名规则**重命名时,实现见 `autoanime/episode_dst_rename.py`(与 `Sorting_Mv` 一致)。**总览与 API 式入口**另见同目录 [README.md](README.md) §8;设计背景见 [docs/10_缓存Schema_v2设计.md](../../docs/10_缓存Schema_v2设计.md)。 -> **执行位置**:下例均在**项目根**(含 `scripts/` 与 `autoanime/`)的 PowerShell 中运行;`--cache-dir` 相对路径相对项目根。 -> **数据示例**:为贴近真实库,下文物有所涉路径来自**示例**;你本机以 `.cache/organization.json` 为准。下面 JSON 可与你仓库中结构对照。 - ---- - -## 1. 前置条件 - -| 项 | 说明 | -| --- | --- | -| Python | 3.8+,且可 `import autoanime`(项目根在解释器路径中,直接 `python scripts\cache_doctor.py` 时脚本会注入项目根) | -| Schema v2 | `.cache/cache_meta.json` 存在且 `schema_version` 为 `2`;否则 `inspect` 会提示单文件 `api_cache.json` 老布局,本文多数子命令针对 v2 | -| 备份 | 任何**写**操作(`--revert`、`--rebuild`、`--apply-rename`、白名单/改剧名等)前建议备份 `.cache/` 下相关 JSON 与媒体库 | - ---- - -## 2. 全局与共用参数 - -| 参数 | 适用 | 说明 | -| --- | --- | --- | -| `--cache-dir <路径>` | 全部 | 缓存根,默认**项目下** `.cache`;可写 `.\.cache` 或绝对路径。 | -| `--zh` | `set-whitelist` / `set-title-zh` / `rename-episodes` | 白名单**值**、或**新**中文主名(经与主程序相同归一化后落盘/计算路径)。 | -| `--apply-rename` | `set-whitelist`、`set-title-zh`、`rename-episodes` | **实际**对 `episode_last_dst` 中文件做 `shutil.move` 并回写 `organization`/`titles`;未加时行为见下表各子命令。 | -| `--naming-style default\|emby` | 含 `apply-rename` 的各子命令、`rename-episodes` | 与主程序 `NAMING_STYLE` 一致。 | -| `--no-use-title-to-ep` | 同上 | 对应主程序 `USETITLTOEP=False`(集文件名不拼剧名,如 `S01E01.mkv`)。 | -| `--canonical-id` | `set-title-zh`(必填);`set-whitelist`+`apply-rename`;`rename-episodes` 二选一 | 在 `organization.records` 中按键名或 `record.canonical_id` 命中一条。 | -| `--old-title-zh` | `set-whitelist`+`apply-rename` 或 `rename-episodes` 二选一 | 用**归一后**的 `title_zh` 在 `records` 中**唯一条**匹配。 | -| `--alias` | 仅 `set-whitelist` | 白名单 **key** 原始串,脚本内会 `Auxiliary_NormalizeAliasKey`。 | -| `--since` | 仅 `export-audit` | 必填,格式 `YYYY-MM-DD`(从该日 0 点起按事件 `ts` 过滤)。 | -| `--audit-id` | 仅 `revert` | 必填,`pollution_audit.jsonl` 中某行 `audit_id`(仅支持撤销 `type=alias_written`)。 | - ---- - -## 3. 七子命令总览 - -以下七个开关在一条命令中**互斥,必须选其一**。 - -| 子命令 | 作用摘要 | 是否改磁盘上媒体/JSON | 下文章节 | -| --- | --- | --- | --- | -| `--inspect` | 看各 v2 子文件大小、sha256、条数、别名嫌疑等 | 只读 | [§4](#4-inspect) | -| `--export-audit` | 按日期导出 `pollution_audit.jsonl` 行到 stdout | 只读 | [§5](#5-export-audit) | -| `--revert` | 按 `audit_id` 从 `titles.json` 的 `aliases` 删一条**已写入**的别名 | 改 `titles` + `cache_meta` | [§6](#6-revert) | -| `--rebuild-from-organization` | 用 `organization` **整表覆盖**重生成 `titles` | **覆盖** `titles.json` | [§7](#7-rebuild-from-organization) | -| `--set-whitelist` | 写/合并 `manual_title_whitelist.json` | 可只写白名单,或加 `--apply-rename` 再迁盘 | [§8](#8-set-whitelist) | -| `--set-title-zh` | 把 `titles.canonical[id].zh` 与 `organization` 的 `title_zh` 同步为 `--zh` | 默认**必写**两 JSON;加 `--apply-rename` 再 move | [§9](#9-set-title-zh) | -| `--rename-episodes` | **只**用 `episode_last_dst` 做与 `Sorting` 一致的迁盘**计划/执行** | 默认**只预览**;加 `--apply-rename` 才写盘与两 JSON | [§10](#10-rename-episodes) | - ---- - -## 4. `--inspect` - -**用途**:确认 v2 是否就绪、子文件是否齐全、快速扫 `titles` 中可疑别名规模。 - -**实际例**(默认 `.cache`): - -```powershell -cd C:\Users\17645\Desktop\AutoAnime -python scripts\cache_doctor.py --inspect -``` - -指定缓存目录例: - -```powershell -python scripts\cache_doctor.py --inspect --cache-dir "D:\Project\AutoAnime\.cache" -``` - -若输出首行提示**未找到有效的 cache_meta.json**,表示仍是旧单文件 `api_cache.json` 布局,需先走迁移或新入口;详见 [README](README.md)。 - ---- - -## 5. `--export-audit` - -**用途**:从 `pollution_audit.jsonl` 导出**时间戳 ≥ 指定日期 0 点**的 JSON 行,便于查 `alias_written` / `alias_rejected` 与 `audit_id`(为 `--revert` 准备)。 -**必填**:`--since YYYY-MM-DD`。 - -**实际例**(打到文件;统计行在 stderr 不进文件): - -```powershell -cd C:\Users\17645\Desktop\AutoAnime -python scripts\cache_doctor.py --export-audit --since 2026-04-20 --cache-dir .\.cache 2> audit_meta.txt | Out-File -Encoding utf8 audit_lines.jsonl -``` - -若只终端查看: - -```powershell -python scripts\cache_doctor.py --export-audit --since 2026-04-01 --cache-dir .\.cache -``` - -人工找到 `"type":"alias_written"` 且别名字段不对的那行,复制其中 `audit_id`(UUID)。 - ---- - -## 6. `--revert` - -**用途**:**仅**撤销一次**已成功写入**的别名:从 `titles.json` 的 `aliases` 中删除与审计记录 `alias_key` 对应项;不碰 `organization`、不移动媒体。 -**必填**:`--audit-id `。 - -**实际例**(把 UUID 换成上一步从审计里复制的 `audit_id`): - -```powershell -cd C:\Users\17645\Desktop\AutoAnime -python scripts\cache_doctor.py --revert --audit-id "3fa85f64-5717-4562-b3fc-2c963f66afa6" --cache-dir .\.cache -``` - -成功后需**重启**正在跑的整理进程,或下次启动以重新从磁盘加载缓存。 - ---- - -## 7. `--rebuild-from-organization` - -**用途**:`titles` 全坏、但 `organization` 仍可信时,**按** `organization.json` 的 `records` **覆盖重写** `titles.json` 的 `canonicals` 与由 zh/en/romaji 推的短 `aliases`(见脚本 `cmd_rebuild`)。**会覆盖**当前 `titles.json`,请先备份。 - -**实际例**: - -```powershell -cd C:\Users\17645\Desktop\AutoAnime -Copy-Item .\.cache\titles.json .\.cache\titles.json.bak -python scripts\cache_doctor.py --rebuild-from-organization --cache-dir .\.cache -``` - ---- - -## 8. `--set-whitelist` - -**用途**:在 **`.cache/manual_title_whitelist.json`** 中**合并**一条「归一化别名键 → 归一化中文主名」;识别链会**优先**用白名单值(与主程序 `Auxiliary_GetManualWhitelistedTitle` 一致)。 -**必填**:`--alias`、`--zh`。 -**可选** `--apply-rename`:在写白名单之后,对**同一条** `organization` 记录做与 `rename-episodes` 相同的 move + 主名回写;此时**另外必填** `--canonical-id` 或 `--old-title-zh`(唯一定位一条记录)。 - -**实际例 8.1 只改白名单、不动盘、不动 `organization`/`titles` 主名流程以外的逻辑**(适合:先加映射防再认错的场景): - -```powershell -cd C:\Users\17645\Desktop\AutoAnime -python scripts\cache_doctor.py --set-whitelist --alias "MAO" --zh "摩绪" --cache-dir .\.cache -``` - -**实际例 8.2** 写白名单后,按 `canonical_id` 对「摩绪」这一部做迁盘+缓存对齐(**先**确认 `episode_last_dst` 中路径在磁盘上仍存在;**执行前务必备份**): - -```powershell -python scripts\cache_doctor.py --set-whitelist --alias "某 RAW 名" --zh "摩绪" --apply-rename --canonical-id "摩绪" --cache-dir .\.cache -``` - -**实际例 8.3 不记 `canonical_id` 键,用当前 `title_zh` 唯一定位**(例如全库只有一条归一后等于 `出租女友` 的 `title_zh`): - -```powershell -python scripts\cache_doctor.py --set-whitelist --alias "rent" --zh "理想女友" --apply-rename --old-title-zh "出租女友" --cache-dir .\.cache -``` - -若存在两条以上归一后相同的 `title_zh`,会**无法**唯一定位,应改用 `--canonical-id` 指向 `records` 的键,例如 `出租女友`。 - ---- - -## 9. `--set-title-zh` - -**用途**:把**识别/展示用**中文主名写进 **`titles.json` → `canonicals[canonical_id].zh`** 与 **对应** `organization` 条目的 **`title_zh`**,保持二者一致。 -**必填**:`--canonical-id`、`--zh`。 -**可选** `--apply-rename`:在写出上述 JSON **之前**,若加该开关,会按 `episode_last_dst` 先做 move(与下节 `rename-episodes`+apply 相同逻辑),再写两 JSON。 -**不加** `--apply-rename` 时**仍会**更新两个 JSON 中的主名,**不**对媒体做 move(可能仅在「只修缓存、磁盘下次整理再对」场景使用)。 - -**实际例 9.1 只改缓存主名、不迁盘**(若某条键为 `无尾熊绘日记`): - -```powershell -cd C:\Users\17645\Desktop\AutoAnime -python scripts\cache_doctor.py --set-title-zh --canonical-id "无尾熊绘日记" --zh "无尾熊绘画日记" --cache-dir .\.cache -``` - -**实际例 9.2 先按 `episode_last_dst` 把文件迁到「新主名」目录,再写回主名**(**务必先 `--inspect` 与备份**): - -```powershell -python scripts\cache_doctor.py --set-title-zh --canonical-id "摩绪" --zh "摩绪 MAO" --apply-rename --cache-dir .\.cache -``` - -**实际例 9.3 Emby 用户迁盘+改主名**(与当时整理时 `NAMING_STYLE=emby` 一致时): - -```powershell -python scripts\cache_doctor.py --set-title-zh --canonical-id "无尾熊绘日记" --zh "新展示名" --apply-rename --naming-style emby --cache-dir .\.cache -``` - ---- - -## 10. `--rename-episodes` - -**用途**:**只**根据 `organization` 中一条的 `episode_last_dst` 计算目标路径,**不**经白名单;默认**只打印** `[SxxEyy] 源 -> 目`,**不**改 `organization`/`titles`、**不** move;**加** `--apply-rename` **才**执行与主程序相同规则的迁盘,并**再**回写 `episode_last_dst`、`title_zh`、`canonical.zh`。 - -**必填**:`--zh`(新主名),以及 `--canonical-id` 或 `--old-title-zh`(唯一定位一条)。 - -**实际例 10.1 仅预览**(`records` 键 `摩绪`,目标展示名加后缀供观察路径变化;**无**`--apply-rename`): - -```powershell -cd C:\Users\17645\Desktop\AutoAnime -python scripts\cache_doctor.py --rename-episodes --canonical-id "摩绪" --zh "摩绪 MAO" --cache-dir .\.cache -``` - -你本地若 `episode_last_dst` 为(摘自真实结构,路径以你机为准): - -```text -"F:\动漫库\摩绪\Season01\S01E03.摩绪.mp4" -``` - -则标准输出**类似**一行: - -```text -[S01E03] F:\动漫库\摩绪\Season01\S01E03.摩绪.mp4 -> F:\动漫库\摩绪 MAO\Season01\S01E03.摩绪 MAO.mp4 -``` - -行末另有说明当前为**预览**、未改缓存与磁盘。 - -**实际例 10.2 确认预览后真执行**(**危险**,先备份媒体与 `.cache`): - -```powershell -python scripts\cache_doctor.py --rename-episodes --canonical-id "摩绪" --zh "摩绪 MAO" --apply-rename --cache-dir .\.cache -``` - -**实际例 10.3** 用 `--old-title-zh` 而不用 `--canonical-id`(`title_zh` 归一后唯一,例如「出租女友」): - -```powershell -python scripts\cache_doctor.py --rename-episodes --old-title-zh "出租女友" --zh "理想女友" --cache-dir .\.cache -``` - -**实际例 10.4** 与主程序 `USETITLTOEP=False`、且 `NAMING_STYLE=emby` 时对齐的预览: - -```powershell -python scripts\cache_doctor.py --rename-episodes --canonical-id "无尾熊绘日记" --zh "新番名" --naming-style emby --no-use-title-to-ep --cache-dir .\.cache -``` - ---- - -## 11. 仓库内 `organization.json` 样例(便于对照键名与路径形态) - -下为与当前工作区**结构一致**的摘录(`episode_last_dst` 为**示例路径**;盘符/文件夹以你本机为准)。键名多等于 `canonical_id` / `title_zh`(经归一后可用于 `--canonical-id`)。 - -**「摩绪」**(`default`+ 常见 `S01E03.摩绪.mp4` 形式): - -```json -"摩绪": { - "canonical_id": "摩绪", - "episode_last_dst": { - "S01E03": "F:\\动漫库\\摩绪\\Season01\\S01E03.摩绪.mp4" - }, - "title_zh": "摩绪" -} -``` - -**「出租女友」**(`Season05` 与 `S05E01`): - -```json -"出租女友": { - "canonical_id": "出租女友", - "episode_last_dst": { - "S05E01": "F:\\动漫库\\出租女友\\Season05\\S05E01.出租女友.mp4" - }, - "title_zh": "出租女友" -} -``` - -**长剧名**与 **`.mkv` 集文件**(扩展名参与重命名目标 basename)可在本机用键 `哪里有温柔对待阿宅的辣妹` 与 `想结束这场我爱你的游戏` 对 `organization.json` 中记录跑 `--rename-episodes` 仅预览,核对输出是否指向你盘上的真实路径。 - ---- - -## 12. 与 `--apply-rename` 相关的决策(简表) - -| 你的目标 | 建议子命令与开关 | -| --- | --- | -| 只加/改一条手工**别名 → 主名** | `--set-whitelist`(**不要**加 `--apply-rename`) | -| 只改**缓存**里的识别用主名、**不**动媒体 | `--set-title-zh`(**不要**加 `--apply-rename`) | -| **只**看迁盘计划、**不**写 JSON | `--rename-episodes` + `--zh` + 定位参数(**不要**加 `--apply-rename`) | -| 动媒体且与 `Sorting` 一致,并同步主名到 `titles` + `organization` | `--rename-episodes` + `--apply-rename`,或 `--set-title-zh` + `--apply-rename`(后者**必然**会先把主名写进两 JSON,带 `--apply-rename` 时还会先 move) | -| 白名单 + 同一部**顺手**迁盘 | `--set-whitelist` + `--apply-rename` + `--canonical-id` 或 `--old-title-zh` | - ---- - -## 13. 常见错误与排障 - -| 现象 | 可能原因与处理 | -| --- | --- | -| `inspect` 报非 v2 | 无 `cache_meta.json` 或为旧单文件布局;先迁移/新入口见 [README](README.md)。 | -| `export-audit` 无输出 | 该日期后无事件,或 `pollution_audit.jsonl` 为空。 | -| `revert` 报 type 不可撤销 | 非 `alias_written`(如 `alias_rejected` 从未写入,无需撤)。 | -| `rename-episodes` 或带 `--apply-rename` 时报源文件不存在 | `episode_last_dst` 过旧、文件已挪走或手删;先修库或重整理。 | -| 报**不同剧集根目录** | 同一条 `episode_last_dst` 中路径不属同一剧文件夹下;分目录整理后分批跑。 | -| `old-title-zh` 匹配不到或匹配多条 | 归一后 `title_zh` 不唯一或字不一致;用 `--canonical-id` 指定 `records` 的键。 | -| 预览与记忆不符 | `--naming-style`、`--no-use-title-to-ep` 与当时整理**主程序**配置不一致;对齐后重试。 | - ---- - -## 14. 变更记录 - -| 日期 | 说明 | -| --- | --- | -| 2026-04-23 | 全量重写为七子命令 + 全局参数 + 决策表 + 样例/排障;文件定名为 `cache_doctor_重命名与剧名纠偏_使用说明.md`(原 `apply_rename_按已整理目标重命名.md` 已弃用并删除)。 | -| 2026-04-23 | 初版专题目录:独立 `--rename-episodes` 与摩绪/出租女友实例。 | diff --git a/autoanime/cache/canonical.py b/autoanime/cache/canonical.py deleted file mode 100644 index 8ad79ab..0000000 --- a/autoanime/cache/canonical.py +++ /dev/null @@ -1,294 +0,0 @@ -""" -autoanime 标准化剧名索引 - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_GetTitleSourcePriority` -- `Auxiliary_ShouldPreferChineseTitle` -- `Auxiliary_ShouldPreferShorterJujutsuMainTitle` -- `Auxiliary_IsJujutsuKaisenSeries` -- `Auxiliary_ContractJujutsuKaisenChineseTitle` -- `Auxiliary_GetAliasCanonicalID` -- `Auxiliary_GetCanonicalTitleRecord` -- `Auxiliary_LinkAliasToCanonical` -- `Auxiliary_ResolveCanonicalTitleByAliases` -- `Auxiliary_UpsertCanonicalTitle` -""" - -from time import localtime, strftime, time - -from .. import state -from ..config_loader import Auxiliary_ParseInt -from ..text_utils import ( - Auxiliary_HasChineseText, - Auxiliary_NormalizeAliasKey, - Auxiliary_NormalizeApiTitle, - Auxiliary_NormalizeDisplayTitle, -) -from .audit import Auxiliary_AppendPollutionAudit -from .persistent import ( - Auxiliary_GetPersistentCache, - Auxiliary_SetPersistentCache, - Auxiliary_SetPersistentCacheAliasWithMeta, -) -from .trust import Auxiliary_TrustLevelFromSource, Auxiliary_ValidateAliasWrite - - -def Auxiliary_GetTitleSourcePriority(SourceTag): - SourceTag = '' if SourceTag in [None, ''] else str(SourceTag) - PriorityMap = { - 'manual': 100, - 'Bangumi': 95, - 'BGM': 90, - 'TMDB': 80, - 'openai_identify': 75, - 'OpenAI': 70, - 'legacy': 50, - 'unknown': 40, - } - return PriorityMap.get(SourceTag, 45) - - -def Auxiliary_ShouldPreferShorterJujutsuMainTitle(OldTitle, NewTitle): - OldTitle = Auxiliary_NormalizeDisplayTitle(OldTitle) - NewTitle = Auxiliary_NormalizeDisplayTitle(NewTitle) - if NewTitle != '咒术回战': - return False - if Auxiliary_HasChineseText(OldTitle) == False or ('咒术' in OldTitle and '回战' in OldTitle) == False: - return False - if any(Fragment in OldTitle for Fragment in ('怀玉', '玉折', '涩谷', '渋谷', '死灭')): - return True - return False - - -def Auxiliary_ShouldPreferChineseTitle(OldTitle, NewTitle, OldSource='unknown', NewSource='unknown'): - NewTitle = Auxiliary_NormalizeDisplayTitle(NewTitle) - OldTitle = Auxiliary_NormalizeDisplayTitle(OldTitle) - if NewTitle == '': - return False - if NewTitle in ['未知', '无法识别', '无法判断', '不确定']: - return False - OldHasChinese = Auxiliary_HasChineseText(OldTitle) - NewHasChinese = Auxiliary_HasChineseText(NewTitle) - if NewHasChinese == False: - return False - if OldTitle == '': - return True - if NewHasChinese and OldHasChinese == False: - return True - if NewHasChinese == False and OldHasChinese: - return False - if OldTitle in ['未知', '无法识别', '无法判断', '不确定']: - return True - NewPriority = Auxiliary_GetTitleSourcePriority(NewSource) - OldPriority = Auxiliary_GetTitleSourcePriority(OldSource) - if NewPriority >= OldPriority + 5 and NewTitle != OldTitle: - return True - if NewHasChinese and OldHasChinese and len(NewTitle) >= len(OldTitle) + 3: - return True - if Auxiliary_ShouldPreferShorterJujutsuMainTitle(OldTitle, NewTitle): - return True - return False - - -def Auxiliary_IsJujutsuKaisenSeries(NameEN='', NameRomaji='', NameZH=''): - Key = Auxiliary_NormalizeAliasKey(NameEN or NameRomaji or '') - if Key == 'jujutsukaisen': - return True - Zh = NameZH or '' - if Auxiliary_HasChineseText(Zh) and '咒术' in Zh and '回战' in Zh: - return True - return False - - -def Auxiliary_ContractJujutsuKaisenChineseTitle(ChineseTitle): - ChineseTitle = Auxiliary_NormalizeApiTitle(ChineseTitle) - if ChineseTitle in [None, ''] or Auxiliary_HasChineseText(ChineseTitle) == False: - return ChineseTitle - if ('咒术' in ChineseTitle and '回战' in ChineseTitle) == False: - return ChineseTitle - if any(Fragment in ChineseTitle for Fragment in ('怀玉', '玉折', '涩谷', '渋谷', '死灭')): - return '咒术回战' - return ChineseTitle - - -def Auxiliary_GetAliasCanonicalID(AliasTitle): - AliasKey = Auxiliary_NormalizeAliasKey(AliasTitle) - if AliasKey == '': - return None - if AliasKey in state.TitleAliasIndexDataCache: - return state.TitleAliasIndexDataCache[AliasKey] - CanonicalID = Auxiliary_GetPersistentCache('TitleAliasIndex', AliasKey) - if CanonicalID not in [None, '']: - state.TitleAliasIndexDataCache[AliasKey] = CanonicalID - return CanonicalID - return None - - -def Auxiliary_GetCanonicalTitleRecord(CanonicalID): - CanonicalID = '' if CanonicalID in [None, ''] else str(CanonicalID) - if CanonicalID == '': - return None - Record = None - if CanonicalID in state.CanonicalTitleIndexDataCache: - Record = state.CanonicalTitleIndexDataCache.get(CanonicalID) - else: - Record = Auxiliary_GetPersistentCache('CanonicalTitleIndex', CanonicalID) - if Record not in [None, '']: - state.CanonicalTitleIndexDataCache[CanonicalID] = Record - if type(Record) != dict: - return None - FixedRecord = { - 'zh': Auxiliary_NormalizeDisplayTitle(Record.get('zh', '')), - 'en': Auxiliary_NormalizeDisplayTitle(Record.get('en', '')), - 'romaji': Auxiliary_NormalizeDisplayTitle(Record.get('romaji', '')), - 'source': str(Record.get('source', 'unknown')), - 'last_updated': str(Record.get('last_updated', '')), - 'confidence': Auxiliary_ParseInt(Record.get('confidence', 0), 0), - 'locked': bool(Record.get('locked', False)), - } - return FixedRecord - - -def Auxiliary_LinkAliasToCanonical( - AliasTitle, CanonicalID, SourceTag: str = 'unknown', trust_level: int = None, conflict: bool = False -): - AliasKey = Auxiliary_NormalizeAliasKey(AliasTitle) - CanonicalID = '' if CanonicalID in [None, ''] else str(CanonicalID) - if AliasKey == '' or CanonicalID == '': - return - if trust_level is None: - trust_level = Auxiliary_TrustLevelFromSource(str(SourceTag), conflict=bool(conflict)) - ok, reason = Auxiliary_ValidateAliasWrite(AliasKey, CanonicalID, int(trust_level), new_source=str(SourceTag or '')) - if ok is not True: - if reason == "same_canonical_higher_trust_noop": - return - Auxiliary_AppendPollutionAudit('alias_rejected', { - 'alias_key': AliasKey, - 'canonical_id': CanonicalID, - 'reason': reason, - 'source': str(SourceTag or ''), - 'trust_level': int(trust_level), - }) - return - if state.TitleAliasIndexDataCache.get(AliasKey) == CanonicalID: - # 仍可能需更新 trust,此处仅同 id 无变更则跳过 - return - state.TitleAliasIndexDataCache[AliasKey] = CanonicalID - ts = strftime("%Y-%m-%d %H:%M:%S", localtime(time())) - Auxiliary_SetPersistentCacheAliasWithMeta( - AliasKey, - CanonicalID, - trust_level=int(trust_level), - source=str(SourceTag or ''), - added_at=ts, - ) - Auxiliary_AppendPollutionAudit("alias_written", { - "alias_key": AliasKey, - "canonical_id": CanonicalID, - "source": str(SourceTag or ""), - "trust_level": int(trust_level), - }) - - -def Auxiliary_ResolveCanonicalTitleByAliases(*AliasTitleList): - CheckedAliasSet = set() - FallbackCanonicalID = None - FallbackCanonicalRecord = None - for AliasTitle in AliasTitleList: - AliasKey = Auxiliary_NormalizeAliasKey(AliasTitle) - if AliasKey == '' or AliasKey in CheckedAliasSet: - continue - CheckedAliasSet.add(AliasKey) - CanonicalID = Auxiliary_GetAliasCanonicalID(AliasTitle) - if CanonicalID in [None, '']: - continue - CanonicalRecord = Auxiliary_GetCanonicalTitleRecord(CanonicalID) - if type(CanonicalRecord) != dict: - continue - if FallbackCanonicalID in [None, '']: - FallbackCanonicalID = CanonicalID - FallbackCanonicalRecord = CanonicalRecord - CanonicalZh = Auxiliary_NormalizeApiTitle(CanonicalRecord.get('zh', '')) - if CanonicalZh not in [None, '']: - return CanonicalZh, CanonicalID, CanonicalRecord - if FallbackCanonicalID not in [None, '']: - return None, FallbackCanonicalID, FallbackCanonicalRecord - return None, None, None - - -def Auxiliary_UpsertCanonicalTitle(ChineseTitle='', EnglishTitle='', RomajiTitle='', SourceTag='unknown', AliasList=None): - ChineseTitle = Auxiliary_NormalizeApiTitle(ChineseTitle) - EnglishTitle = Auxiliary_NormalizeDisplayTitle(EnglishTitle) - RomajiTitle = Auxiliary_NormalizeDisplayTitle(RomajiTitle) - AllAliases = [ChineseTitle, EnglishTitle, RomajiTitle] - if type(AliasList) in [list, tuple]: - for OneAlias in AliasList: - if OneAlias not in [None, '']: - AllAliases.append(Auxiliary_NormalizeDisplayTitle(OneAlias)) - CandidateCanonicalIDs = [] - for AliasTitle in AllAliases: - if (MatchedCanonicalID := Auxiliary_GetAliasCanonicalID(AliasTitle)) not in [None, '']: - if MatchedCanonicalID not in CandidateCanonicalIDs: - CandidateCanonicalIDs.append(MatchedCanonicalID) - if CandidateCanonicalIDs == []: - SeedTitle = ChineseTitle if ChineseTitle not in [None, ''] else (EnglishTitle if EnglishTitle not in [None, ''] else RomajiTitle) - CanonicalID = Auxiliary_NormalizeAliasKey(SeedTitle) - if CanonicalID in [None, '']: - return None, ChineseTitle - else: - CanonicalID = CandidateCanonicalIDs[0] - BestRecord = Auxiliary_GetCanonicalTitleRecord(CanonicalID) - for OneCanonicalID in CandidateCanonicalIDs[1:]: - OneRecord = Auxiliary_GetCanonicalTitleRecord(OneCanonicalID) - if type(OneRecord) == dict and type(BestRecord) == dict: - if Auxiliary_HasChineseText(OneRecord.get('zh', '')) and Auxiliary_HasChineseText(BestRecord.get('zh', '')) == False: - CanonicalID = OneCanonicalID - BestRecord = OneRecord - ExistingRecord = Auxiliary_GetCanonicalTitleRecord(CanonicalID) - if type(ExistingRecord) != dict: - CanonicalRecord = { - 'zh': '', - 'en': '', - 'romaji': '', - 'source': 'unknown', - 'last_updated': '', - 'confidence': 0, - 'locked': False, - } - ChangedFlag = True - else: - CanonicalRecord = ExistingRecord.copy() - ChangedFlag = False - if Auxiliary_ShouldPreferChineseTitle(CanonicalRecord.get('zh', ''), ChineseTitle, CanonicalRecord.get('source', 'unknown'), SourceTag): - CanonicalRecord['zh'] = ChineseTitle - CanonicalRecord['source'] = SourceTag - ChangedFlag = True - elif CanonicalRecord.get('source', '') in [None, '']: - CanonicalRecord['source'] = SourceTag - ChangedFlag = True - if EnglishTitle not in [None, ''] and CanonicalRecord.get('en', '') in [None, '']: - CanonicalRecord['en'] = EnglishTitle - ChangedFlag = True - if RomajiTitle not in [None, ''] and CanonicalRecord.get('romaji', '') in [None, '']: - CanonicalRecord['romaji'] = RomajiTitle - ChangedFlag = True - NewConfidence = max( - Auxiliary_ParseInt(CanonicalRecord.get('confidence', 0), 0), - Auxiliary_GetTitleSourcePriority(SourceTag), - ) - if NewConfidence != Auxiliary_ParseInt(CanonicalRecord.get('confidence', 0), 0): - CanonicalRecord['confidence'] = NewConfidence - ChangedFlag = True - if ChangedFlag: - CanonicalRecord['last_updated'] = strftime("%Y-%m-%d %H:%M:%S", localtime(time())) - state.CanonicalTitleIndexDataCache[CanonicalID] = CanonicalRecord - if ChangedFlag: - Auxiliary_SetPersistentCache('CanonicalTitleIndex', CanonicalID, CanonicalRecord) - SeenAliasKeys = set() - for OneAlias in AllAliases + [CanonicalRecord.get('zh', ''), CanonicalRecord.get('en', ''), CanonicalRecord.get('romaji', '')]: - ak = Auxiliary_NormalizeAliasKey(OneAlias) - if ak == '' or ak in SeenAliasKeys: - continue - SeenAliasKeys.add(ak) - Auxiliary_LinkAliasToCanonical(OneAlias, CanonicalID, SourceTag=SourceTag) - return CanonicalID, CanonicalRecord.get('zh', '') diff --git a/autoanime/cache/manual_whitelist.py b/autoanime/cache/manual_whitelist.py deleted file mode 100644 index b5c1878..0000000 --- a/autoanime/cache/manual_whitelist.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -autoanime 手工剧名白名单 - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_GetManualWhitelistPath` -- `Auxiliary_LoadManualWhitelist` -- `Auxiliary_GetManualWhitelistedTitle` -""" - -import json - -from pathlib import Path as PathlibPath - -from .. import state -from ..config_loader import Auxiliary_GetCacheStorePath -from ..logging_utils import Auxiliary_Log -from ..text_utils import ( - Auxiliary_NormalizeAliasKey, - Auxiliary_NormalizeApiTitle, -) - - -def Auxiliary_GetManualWhitelistPath() -> PathlibPath: - CacheDirPath = Auxiliary_GetCacheStorePath().parent - return CacheDirPath / 'manual_title_whitelist.json' - - -def Auxiliary_LoadManualWhitelist(force=False): - DefaultWhitelist = { - 'mao': '摩绪', - } - WhitelistPath = Auxiliary_GetManualWhitelistPath() - if WhitelistPath.exists() == False: - try: - with open(WhitelistPath, 'w', encoding='UTF-8') as f: - json.dump(DefaultWhitelist, f, ensure_ascii=False, indent=2) - state.ManualTitleWhitelistDataCache = DefaultWhitelist.copy() - state.ManualTitleWhitelistMTime = float(WhitelistPath.stat().st_mtime) - Auxiliary_Log(f'已创建手工白名单文件: {WhitelistPath}', 'INFO') - return state.ManualTitleWhitelistDataCache - except Exception as err: - Auxiliary_Log(f'创建手工白名单文件失败,将使用内置默认值: {err}', 'WARNING') - state.ManualTitleWhitelistDataCache = DefaultWhitelist.copy() - state.ManualTitleWhitelistMTime = 0.0 - return state.ManualTitleWhitelistDataCache - - try: - FileMTime = float(WhitelistPath.stat().st_mtime) - except Exception: - FileMTime = 0.0 - if force != True and type(state.ManualTitleWhitelistDataCache) == dict and state.ManualTitleWhitelistDataCache != {} and state.ManualTitleWhitelistMTime == FileMTime: - return state.ManualTitleWhitelistDataCache - - try: - with open(WhitelistPath, 'r', encoding='UTF-8') as f: - RawData = json.load(f) - if type(RawData) != dict: - raise ValueError('手工白名单文件格式应为 JSON 对象') - LoadedWhitelist = {} - for RawAlias, RawTitle in RawData.items(): - AliasKey = Auxiliary_NormalizeAliasKey(RawAlias) - TitleValue = Auxiliary_NormalizeApiTitle(RawTitle) - if AliasKey in [None, ''] or TitleValue in [None, '']: - continue - LoadedWhitelist[AliasKey] = TitleValue - if LoadedWhitelist == {}: - LoadedWhitelist = DefaultWhitelist.copy() - state.ManualTitleWhitelistDataCache = LoadedWhitelist - state.ManualTitleWhitelistMTime = FileMTime - return state.ManualTitleWhitelistDataCache - except Exception as err: - Auxiliary_Log(f'读取手工白名单文件失败,将使用内置默认值: {err}', 'WARNING') - state.ManualTitleWhitelistDataCache = DefaultWhitelist.copy() - state.ManualTitleWhitelistMTime = 0.0 - return state.ManualTitleWhitelistDataCache - - -def Auxiliary_GetManualWhitelistedTitle(*AliasCandidates): - '''返回手工白名单中文标题(按别名归一匹配)''' - ManualWhitelist = Auxiliary_LoadManualWhitelist() - if type(ManualWhitelist) != dict or ManualWhitelist == {}: - return None - for Candidate in AliasCandidates: - AliasKey = Auxiliary_NormalizeAliasKey(Candidate) - if AliasKey in ManualWhitelist: - return ManualWhitelist.get(AliasKey) - return None diff --git a/autoanime/cache/migrate.py b/autoanime/cache/migrate.py deleted file mode 100644 index a3ce2a0..0000000 --- a/autoanime/cache/migrate.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -一次性格式迁移:将旧 `.cache/api_cache.json` 归档为 `backups/api_cache_legacy_.json`, -并初始化空 Schema v2 子文件与 `cache_meta.json`(零数据冷启动)。 -""" - -from typing import Optional - -from .. import state -from ..logging_utils import Auxiliary_Log -from .v2_data import ( - EMPTY_API_RESPONSES, - EMPTY_ORGANIZATION, - EMPTY_TITLES, - Auxiliary_AtomicWriteJson, - Auxiliary_GetV2DataDir, - Auxiliary_GetV2SubfilePath, - Auxiliary_Sha256File, - Auxiliary_WriteV2CacheMeta, -) - - -def Auxiliary_MigrateCacheToV2IfNeeded() -> Optional[str]: - """ - 若已有 `cache_meta.json` 则直接返回 None。 - 否则:若存在旧 `api_cache.json` 则移入 `backups/`,并写入空 v2 子文件 + meta。 - 返回 `legacy_archive` 全路径或 None(无归档)。 - """ - base = Auxiliary_GetV2DataDir() - base.mkdir(parents=True, exist_ok=True) - meta = base / "cache_meta.json" - if meta.is_file(): - return None - legacy = base / "api_cache.json" - archive: Optional[str] = None - if legacy.is_file(): - bdir = base / "backups" - bdir.mkdir(parents=True, exist_ok=True) - dest = bdir / f"api_cache_legacy_{state.CurrentRunID}.json" - try: - legacy.replace(dest) - archive = str(dest) - Auxiliary_Log( - f"旧 api_cache.json 已归档至 {dest},新 Schema v2 启用(零数据冷启动)", "INFO" - ) - except Exception as err: - Auxiliary_Log(f"归档旧 api_cache.json 失败: {err},将尝试在原地保留", "WARNING") - for name, data in [ - ("organization", EMPTY_ORGANIZATION), - ("titles", EMPTY_TITLES), - ("api", EMPTY_API_RESPONSES), - ]: - Auxiliary_AtomicWriteJson(Auxiliary_GetV2SubfilePath(name), data) - org = Auxiliary_GetV2SubfilePath("organization") - titles = Auxiliary_GetV2SubfilePath("titles") - api = Auxiliary_GetV2SubfilePath("api") - from datetime import datetime - - now = datetime.now().replace(microsecond=0).isoformat() - Auxiliary_WriteV2CacheMeta( - subfile_stats={ - "organization.json": { - "sha256": Auxiliary_Sha256File(org), - "records": 0, - "updated_at": now, - }, - "titles.json": { - "sha256": Auxiliary_Sha256File(titles), - "canonicals": 0, - "aliases": 0, - "updated_at": now, - }, - "api_responses.json": { - "sha256": Auxiliary_Sha256File(api), - "entries": 0, - "updated_at": now, - }, - }, - legacy_archive=archive, - ) - return archive diff --git a/autoanime/cache/persistent.py b/autoanime/cache/persistent.py deleted file mode 100644 index 6ed356a..0000000 --- a/autoanime/cache/persistent.py +++ /dev/null @@ -1,557 +0,0 @@ -""" -autoanime 持久化缓存读写(Schema v2:多子文件 + 路由 + 增量脏刷) - -对外保持: -- `Auxiliary_LoadPersistentCache` / `Auxiliary_SavePersistentCache` -- `Auxiliary_GetPersistentCache` / `Auxiliary_SetPersistentCache` -- `Auxiliary_MaybeFlushPersistentCache` -- `Auxiliary_RebuildCanonicalIndexesFromPersistentCache` -""" - -import json -from copy import deepcopy -from datetime import datetime -from time import time -from typing import Any, Dict, Tuple - -from .. import state -from ..config_loader import Auxiliary_GetCacheStorePath, Auxiliary_ParseInt -from ..logging_utils import Auxiliary_Log -from .v2_data import ( - EMPTY_API_RESPONSES, - EMPTY_ORGANIZATION, - EMPTY_TITLES, - Auxiliary_AtomicWriteJson, - Auxiliary_GetV2DataDir, - Auxiliary_GetV2SubfilePath, - Auxiliary_Sha256File, - Auxiliary_WriteV2CacheMeta, -) - - -def Auxiliary_GetCacheDir(): - return Auxiliary_GetV2DataDir() - - -def _now_iso() -> str: - return datetime.now().replace(microsecond=0).isoformat() - - -# 子文件 key -> organization | titles | api -def _mark_subfile_dirty(key: str) -> None: - if hasattr(state, "CacheSubfileDirty") and type(state.CacheSubfileDirty) is dict: - state.CacheSubfileDirty[key] = True - state.PersistentApiCacheDirty = True - - -def _default_ttl_for_group(CacheGroup: str) -> int: - g = str(CacheGroup) - if g in ("TMDB", "TMDB_EN", "Bangumi", "BGM"): - return 86400 - if g in ("TMDBTvSeriesId", "TMDBTvSeasons"): - return 604800 - return int(state.Runtime.config.cache_ttl_seconds) if state.Runtime and state.Runtime.config else 86400 - - -_NEVER_EXPIRE = {"TitleAliasIndex", "CanonicalTitleIndex", "ShowOrganizationIndex"} - -# CacheGroup -> (api 根下路径, 叶子 dict 名); 非 API 在 _subfile_for 处理 -_API_PATH: Dict[str, Tuple[str, ...]] = { - "TMDB": ("tmdb", "titles"), - "TMDB_EN": ("tmdb", "titles_en"), - "TMDBTvSeriesId": ("tmdb", "tv_series"), - "TMDBTvSeasons": ("tmdb", "tv_seasons"), - "Bangumi": ("bangumi", "titles"), - "BGM": ("ext", "BGM"), -} - - -def _subfile_key_for_group(CacheGroup: str) -> str: - g = str(CacheGroup) - if g == "ShowOrganizationIndex": - return "organization" - if g in ("CanonicalTitleIndex", "TitleAliasIndex"): - return "titles" - return "api_responses" - - -def _is_v2_layout() -> bool: - return (Auxiliary_GetV2DataDir() / "cache_meta.json").is_file() - - -def _deep_get(obj: Any, parts: Tuple[str, ...]) -> Any: - cur = obj - for p in parts: - if type(cur) is not dict or p not in cur: - return None - cur = cur[p] - return cur - - -def _deep_ensure(obj: Any, parts: Tuple[str, ...]) -> dict: - cur = obj - for p in parts: - if p not in cur or type(cur[p]) is not dict: - cur[p] = {} - cur = cur[p] - return cur - - -def _load_legacy_monolithic(CacheFilePath) -> None: - if CacheFilePath.is_file() is False: - return - try: - with open(CacheFilePath, "r", encoding="UTF-8") as CacheFile: - CacheData = json.load(CacheFile) - if type(CacheData) == dict: - state.PersistentApiCache = CacheData - Auxiliary_Log(f"已加载持久化缓存文件 {CacheFilePath}", "INFO") - except Exception as err: - Auxiliary_Log(f"缓存文件读取失败,将使用空缓存: {err}", "WARNING") - state.PersistentApiCache = {} - - -def _load_v2_into_memory() -> None: - orgp = Auxiliary_GetV2SubfilePath("organization") - tpath = Auxiliary_GetV2SubfilePath("titles") - apath = Auxiliary_GetV2SubfilePath("api") - odata: dict = {} - tdata: dict = {} - adata: dict = {} - try: - if orgp.is_file(): - with open(orgp, "r", encoding="utf-8") as f: - odata = json.load(f) - except Exception: - odata = {} - try: - if tpath.is_file(): - with open(tpath, "r", encoding="utf-8") as f: - tdata = json.load(f) - except Exception: - tdata = {} - try: - if apath.is_file(): - with open(apath, "r", encoding="utf-8") as f: - adata = json.load(f) - except Exception: - adata = {} - if type(odata) is not dict: - odata = {} - if type(tdata) is not dict: - tdata = {} - if type(adata) is not dict: - adata = {} - state.PersistentApiCache = {} - recs = odata.get("records", {}) - if type(recs) is dict: - state.PersistentApiCache["ShowOrganizationIndex"] = {} - for cid, rec in recs.items(): - if rec in [None, ""]: - continue - state.PersistentApiCache["ShowOrganizationIndex"][str(cid)] = {"value": rec, "ts": 0.0} - cans = tdata.get("canonicals", {}) - if type(cans) is dict: - state.PersistentApiCache["CanonicalTitleIndex"] = {} - for cid, crec in cans.items(): - state.PersistentApiCache["CanonicalTitleIndex"][str(cid)] = {"value": crec, "ts": 0.0} - als = tdata.get("aliases", {}) - if type(als) is dict: - state.PersistentApiCache["TitleAliasIndex"] = {} - for akey, arec in als.items(): - if type(arec) is str: - state.PersistentApiCache["TitleAliasIndex"][str(akey)] = { - "value": arec, - "ts": 0.0, - "trust_level": 50, - "source": "", - "added_at": "", - } - elif type(arec) is dict: - cid = arec.get("canonical_id", arec.get("value", "")) - state.PersistentApiCache["TitleAliasIndex"][str(akey)] = { - "value": cid, - "ts": 0.0, - "trust_level": int(arec.get("trust_level", 80) or 80), - "source": str(arec.get("source", "")), - "added_at": str(arec.get("added_at", "")), - } - for grp, path_parts in _API_PATH.items(): - bucket = _deep_get(adata, path_parts) - if type(bucket) is not dict: - continue - if grp not in state.PersistentApiCache: - state.PersistentApiCache[grp] = {} - for ck, crec in bucket.items(): - if type(crec) is not dict: - continue - state.PersistentApiCache[grp][str(ck)] = { - "value": crec.get("value"), - "ts": float(crec.get("ts", 0) or 0), - "ttl": int(crec.get("ttl", _default_ttl_for_group(grp) or 86400) or 86400), - } - # ext 下非 _API_PATH 表内键名的其它组 - ext = adata.get("ext", {}) - if type(ext) is dict: - for gname, bucket in ext.items(): - gn = str(gname) - if gn in _API_PATH: - continue - if type(bucket) is not dict: - continue - if gn not in state.PersistentApiCache: - state.PersistentApiCache[gn] = {} - for ck, crec in bucket.items(): - if type(crec) is not dict: - continue - state.PersistentApiCache[gn][str(ck)] = { - "value": crec.get("value"), - "ts": float(crec.get("ts", 0) or 0), - "ttl": int(crec.get("ttl", _default_ttl_for_group(gn) or 86400) or 86400), - } - Auxiliary_Log( - f"已加载 Schema v2 缓存 {Auxiliary_GetV2DataDir()} (organization / titles / api_responses)", "INFO" - ) - - -def _dump_organization_json() -> dict: - root = deepcopy(EMPTY_ORGANIZATION) - meta = root["__meta__"] - if type(meta) is dict: - meta["updated_at"] = _now_iso() - grp = state.PersistentApiCache.get("ShowOrganizationIndex", {}) - if type(grp) is not dict: - return root - out = root["records"] - for cid, ent in grp.items(): - if type(ent) is dict and "value" in ent: - out[str(cid)] = ent["value"] - return root - - -def _dump_titles_json() -> dict: - root = deepcopy(EMPTY_TITLES) - meta = root["__meta__"] - if type(meta) is dict: - meta["updated_at"] = _now_iso() - cg = state.PersistentApiCache.get("CanonicalTitleIndex", {}) - if type(cg) is dict: - for cid, ent in cg.items(): - if type(ent) is dict and "value" in ent and type(ent["value"]) is dict: - root["canonicals"][str(cid)] = ent["value"] - ag = state.PersistentApiCache.get("TitleAliasIndex", {}) - if type(ag) is dict: - for ak, ent in ag.items(): - if type(ent) is not dict or "value" not in ent: - continue - v = ent["value"] - root["aliases"][str(ak)] = { - "canonical_id": v, - "trust_level": int(ent.get("trust_level", 50) or 50), - "source": str(ent.get("source", "")), - "added_at": str(ent.get("added_at", "")), - } - return root - - -def _dump_api_json() -> dict: - base = deepcopy(EMPTY_API_RESPONSES) - for grp, path_parts in _API_PATH.items(): - mem = state.PersistentApiCache.get(grp, {}) - if type(mem) is not dict: - continue - bucket = _deep_ensure(base, path_parts) - for ck, ent in mem.items(): - if type(ent) is not dict: - continue - bucket[str(ck)] = { - "value": ent.get("value"), - "ts": float(ent.get("ts", 0) or 0), - "ttl": int( - ent.get("ttl", _default_ttl_for_group(grp) or 86400) or 86400 - ), - } - known_api = set(_API_PATH.keys()) - for gname, mem in (state.PersistentApiCache or {}).items(): - if gname in _NEVER_EXPIRE or gname in known_api: - continue - if type(mem) is not dict: - continue - ex = _deep_ensure(base, ("ext", str(gname))) - for ck, ent in mem.items(): - if type(ent) is not dict: - continue - ex[str(ck)] = { - "value": ent.get("value"), - "ts": float(ent.get("ts", 0) or 0), - "ttl": int( - ent.get("ttl", _default_ttl_for_group(str(gname)) or 86400) or 86400 - ), - } - return base - - -def _flush_one_subfile(name: str) -> None: - if name == "organization": - data = _dump_organization_json() - p = Auxiliary_GetV2SubfilePath("organization") - Auxiliary_AtomicWriteJson(p, data) - n = len((data.get("records") or {})) if type(data) is dict else 0 - Auxiliary_WriteV2CacheMeta( - { - "organization.json": { - "sha256": Auxiliary_Sha256File(p), - "records": n, - "updated_at": _now_iso(), - } - } - ) - elif name == "titles": - data = _dump_titles_json() - p = Auxiliary_GetV2SubfilePath("titles") - Auxiliary_AtomicWriteJson(p, data) - nc = len((data.get("canonicals") or {})) if type(data) is dict else 0 - na = len((data.get("aliases") or {})) if type(data) is dict else 0 - Auxiliary_WriteV2CacheMeta( - { - "titles.json": { - "sha256": Auxiliary_Sha256File(p), - "canonicals": nc, - "aliases": na, - "updated_at": _now_iso(), - } - } - ) - elif name == "api_responses": - data = _dump_api_json() - p = Auxiliary_GetV2SubfilePath("api") - Auxiliary_AtomicWriteJson(p, data) - ent_n = 0 - if type(data) is dict: - tmdb = data.get("tmdb", {}) - b = data.get("bangumi", {}) - ext = data.get("ext", {}) - for sec in (tmdb, b): - if type(sec) is dict: - for _k, bkt in sec.items(): - if type(bkt) is dict: - ent_n += len(bkt) - oa = (data.get("openai_identify") or {}).get("file_info", {}) - if type(oa) is dict: - ent_n += len(oa) - if type(ext) is dict: - for _gn, bkt in ext.items(): - if type(bkt) is dict: - ent_n += len(bkt) - Auxiliary_WriteV2CacheMeta( - { - "api_responses.json": { - "sha256": Auxiliary_Sha256File(p), - "entries": ent_n, - "updated_at": _now_iso(), - } - } - ) - - -def Auxiliary_LoadPersistentCache(): - from .migrate import Auxiliary_MigrateCacheToV2IfNeeded - - state.PersistentApiCache = {} - state.PersistentApiCacheDirty = False - if hasattr(state, "CacheSubfileDirty") and type(state.CacheSubfileDirty) is dict: - for k in list(state.CacheSubfileDirty.keys()): - state.CacheSubfileDirty[k] = False - else: - state.CacheSubfileDirty = { - "organization": False, - "titles": False, - "api_responses": False, - } - Auxiliary_MigrateCacheToV2IfNeeded() - if _is_v2_layout(): - _load_v2_into_memory() - else: - _load_legacy_monolithic(Auxiliary_GetCacheStorePath()) - Auxiliary_RebuildCanonicalIndexesFromPersistentCache() - - -def _needs_persistent_save(force: bool) -> bool: - if force is True: - return True - if state.PersistentApiCacheDirty is True: - return True - if _is_v2_layout() and type(getattr(state, "CacheSubfileDirty", None)) is dict: - return any(state.CacheSubfileDirty.values()) - return False - - -def Auxiliary_SavePersistentCache(force=False): - if _needs_persistent_save(force) is not True: - return - if not _is_v2_layout(): - P = Auxiliary_GetCacheStorePath() - try: - with open(P, "w", encoding="utf-8") as f: - json.dump(state.PersistentApiCache, f, ensure_ascii=False, indent=2, sort_keys=True) - state.PersistentApiCacheDirty = False - Auxiliary_Log(f"持久化缓存写入完成 {P}", "INFO") - except Exception as err: - Auxiliary_Log(f"持久化缓存写入失败: {err}", "WARNING") - return - dirty = state.CacheSubfileDirty if type(getattr(state, "CacheSubfileDirty", None)) is dict else {} - order = ("organization", "titles", "api_responses") - for k in order: - if force is True or dirty.get(k) is True: - try: - _flush_one_subfile(k) - except Exception as err: - Auxiliary_Log(f"v2 子文件写入失败 {k}: {err}", "WARNING") - for k in list((state.CacheSubfileDirty or {}).keys()): - state.CacheSubfileDirty[k] = False - state.PersistentApiCacheDirty = False - Auxiliary_Log( - f"Schema v2 持久化缓存已写入 {Auxiliary_GetV2DataDir()}", "INFO" - ) - - -def Auxiliary_GetPersistentCache(CacheGroup, CacheKey): - if CacheGroup not in state.PersistentApiCache: - return None - GroupCache = state.PersistentApiCache[CacheGroup] - if type(GroupCache) is not dict or CacheKey not in GroupCache: - return None - CacheRecord = GroupCache[CacheKey] - if type(CacheRecord) is not dict: - return None - CacheValue = CacheRecord.get("value") - CacheTimestamp = float(CacheRecord.get("ts", 0) or 0) - g = str(CacheGroup) - if g in _NEVER_EXPIRE: - TTLValue = 0 - else: - TTLValue = int(CacheRecord.get("ttl", 0) or 0) or _default_ttl_for_group(g) - if ( - g not in _NEVER_EXPIRE - and TTLValue > 0 - and (time() - CacheTimestamp) > float(TTLValue) - ): - try: - del GroupCache[CacheKey] - _mark_subfile_dirty(_subfile_key_for_group(g)) - except Exception: - pass - return None - return CacheValue - - -def Auxiliary_SetPersistentCache(CacheGroup, CacheKey, CacheValue): - g = str(CacheGroup) - if g not in state.PersistentApiCache or type(state.PersistentApiCache[g]) is not dict: - state.PersistentApiCache[g] = {} - rec = {"value": CacheValue, "ts": time()} - if g in _NEVER_EXPIRE: - pass - else: - rec["ttl"] = _default_ttl_for_group(g) - state.PersistentApiCache[g][CacheKey] = rec - _mark_subfile_dirty(_subfile_key_for_group(g)) - - -def Auxiliary_MaybeFlushPersistentCache(): - Interval = Auxiliary_ParseInt(state.CACHE_FLUSH_INTERVAL_SECONDS, 60) - if Interval <= 0: - return - if state.PersistentApiCacheDirty is not True: - return - NowTs = time() - if NowTs - float(state.LastPersistentCacheFlushTime or 0.0) < float(Interval): - return - Auxiliary_SavePersistentCache(force=True) - state.LastPersistentCacheFlushTime = NowTs - - -def Auxiliary_SetPersistentCacheAliasWithMeta( - CacheKey, canonical_id, *, trust_level, source, added_at: str -) -> None: - """ - 仅 `canonical.LinkAlias` 使用:在 TitleAliasIndex 中写入带 trust 的包装记录。 - """ - g = "TitleAliasIndex" - if g not in state.PersistentApiCache or type(state.PersistentApiCache[g]) is not dict: - state.PersistentApiCache[g] = {} - state.PersistentApiCache[g][str(CacheKey)] = { - "value": str(canonical_id), - "ts": 0.0, - "trust_level": int(trust_level), - "source": str(source or ""), - "added_at": str(added_at or ""), - } - _mark_subfile_dirty("titles") - - -def Auxiliary_RebuildCanonicalIndexesFromPersistentCache(): - from .canonical import Auxiliary_UpsertCanonicalTitle - from ..text_utils import ( - Auxiliary_HasChineseText, - Auxiliary_NormalizeApiTitle, - Auxiliary_NormalizeDisplayTitle, - ) - - if type(state.PersistentApiCache) is not dict: - return - - def IterateRawGroupValue(CacheGroup): - GroupData = state.PersistentApiCache.get(CacheGroup, {}) - if type(GroupData) is not dict: - return [] - ReturnList = [] - for CacheKey, CacheRecord in GroupData.items(): - if type(CacheRecord) is dict and "value" in CacheRecord: - ReturnList.append((CacheKey, CacheRecord.get("value"))) - return ReturnList - - ChangedFlag = False - for CacheGroup in ["Bangumi", "TMDB"]: - for QueryName, CacheValue in IterateRawGroupValue(CacheGroup): - if CacheValue in [None, ""]: - continue - CandidateZh = Auxiliary_NormalizeApiTitle(CacheValue) - CandidateEn = Auxiliary_NormalizeDisplayTitle( - QueryName if QueryName not in [None, ""] else "" - ) - if Auxiliary_HasChineseText(CandidateZh) is False: - if CandidateEn in [None, ""]: - CandidateEn = Auxiliary_NormalizeDisplayTitle(CacheValue) - CandidateZh = "" - CanonicalID, CanonicalZh = Auxiliary_UpsertCanonicalTitle( - CandidateZh, CandidateEn, "", CacheGroup, [QueryName, CacheValue], - ) - if CanonicalID not in [None, ""]: - if CanonicalZh not in [None, ""] and Auxiliary_HasChineseText(CanonicalZh): - if type(state.PersistentApiCache.get(CacheGroup, {}).get(QueryName)) is dict: - if state.PersistentApiCache[CacheGroup][QueryName].get("value") != CanonicalZh: - state.PersistentApiCache[CacheGroup][QueryName]["value"] = CanonicalZh - ChangedFlag = True - _mark_subfile_dirty("api_responses") - for QueryName, CacheValue in IterateRawGroupValue("TMDB_EN"): - if CacheValue in [None, ""]: - continue - EnTitle = Auxiliary_NormalizeDisplayTitle(str(CacheValue)) - if EnTitle in [None, ""]: - continue - Auxiliary_UpsertCanonicalTitle("", EnTitle, "", "TMDB", [QueryName, EnTitle]) - for CanonicalKey, CacheValue in IterateRawGroupValue("ShowOrganizationIndex"): - if type(CacheValue) is not dict: - continue - zh = Auxiliary_NormalizeApiTitle(CacheValue.get("title_zh", "")) - en = Auxiliary_NormalizeDisplayTitle(CacheValue.get("title_en", "")) - romaji = Auxiliary_NormalizeDisplayTitle(CacheValue.get("title_romaji", "")) - if ( - zh not in [None, ""] - or en not in [None, ""] - or romaji not in [None, ""] - ): - Auxiliary_UpsertCanonicalTitle(zh, en, romaji, "unknown", [CanonicalKey]) - if ChangedFlag is True: - state.PersistentApiCacheDirty = True diff --git a/autoanime/cache/schema_v2.py b/autoanime/cache/schema_v2.py deleted file mode 100644 index 73e9314..0000000 --- a/autoanime/cache/schema_v2.py +++ /dev/null @@ -1,230 +0,0 @@ -""" -Schema v2 缓存子文件:路径布局、dataclass、原子 JSON 读写、cache_meta 子文件 sha256 统计。 - -供 migrate / persistent 改造 / audit 等模块复用;不直接替代现有 api_cache.json 读写逻辑, -直至上层接入完成。 -""" - -from __future__ import annotations - -import hashlib -import json -import os -from dataclasses import dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, Optional - -from ..config_loader import Auxiliary_GetCacheStorePath - - -SCHEMA_VERSION_V2 = 2 - -FILENAME_CACHE_META = 'cache_meta.json' -FILENAME_ORGANIZATION = 'organization.json' -FILENAME_TITLES = 'titles.json' -FILENAME_API_RESPONSES = 'api_responses.json' -FILENAME_POLLUTION_AUDIT = 'pollution_audit.jsonl' -DIR_BACKUPS = 'backups' - - -def Auxiliary_SchemaV2NowIso() -> str: - return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S%z') - - -@dataclass(frozen=True) -class SchemaV2Layout: - """`.cache/` 下 Schema v2 各文件路径(与 `api_cache.json` 同目录)。""" - - cache_dir: Path - - @property - def cache_meta(self) -> Path: - return self.cache_dir / FILENAME_CACHE_META - - @property - def organization(self) -> Path: - return self.cache_dir / FILENAME_ORGANIZATION - - @property - def titles(self) -> Path: - return self.cache_dir / FILENAME_TITLES - - @property - def api_responses(self) -> Path: - return self.cache_dir / FILENAME_API_RESPONSES - - @property - def pollution_audit(self) -> Path: - return self.cache_dir / FILENAME_POLLUTION_AUDIT - - @property - def backups_dir(self) -> Path: - return self.cache_dir / DIR_BACKUPS - - -def Auxiliary_SchemaV2LayoutFromRuntime() -> SchemaV2Layout: - return SchemaV2Layout(cache_dir=Auxiliary_GetCacheStorePath().parent) - - -@dataclass -class SchemaV2SubfileDescriptor: - """单个子文件在 cache_meta.subfiles 中的统计描述(写入 meta 前填充)。""" - - sha256: str = '' - updated_at: str = '' - records: int = 0 - canonicals: int = 0 - aliases: int = 0 - entries: int = 0 - - def to_meta_dict(self) -> Dict[str, Any]: - Out: Dict[str, Any] = {'sha256': self.sha256, 'updated_at': self.updated_at} - if self.records > 0: - Out['records'] = self.records - if self.canonicals > 0: - Out['canonicals'] = self.canonicals - if self.aliases > 0: - Out['aliases'] = self.aliases - if self.entries > 0: - Out['entries'] = self.entries - return Out - - -@dataclass -class SchemaV2CacheMetaDocument: - """cache_meta.json 内存表示(与 JSON 字段对齐)。""" - - schema_version: int = SCHEMA_VERSION_V2 - created_at: str = '' - last_flush_at: str = '' - subfiles: Dict[str, Dict[str, Any]] = field(default_factory=dict) - legacy_archive: Optional[str] = None - - def to_json_dict(self) -> Dict[str, Any]: - D: Dict[str, Any] = { - 'schema_version': self.schema_version, - 'created_at': self.created_at, - 'last_flush_at': self.last_flush_at, - 'subfiles': dict(self.subfiles), - } - if self.legacy_archive not in [None, '']: - D['legacy_archive'] = self.legacy_archive - return D - - @classmethod - def from_json_dict(cls, data: Dict[str, Any]) -> SchemaV2CacheMetaDocument: - return cls( - schema_version=int(data.get('schema_version', SCHEMA_VERSION_V2)), - created_at=str(data.get('created_at', '')), - last_flush_at=str(data.get('last_flush_at', '')), - subfiles=dict(data.get('subfiles', {})) if type(data.get('subfiles')) == dict else {}, - legacy_archive=data.get('legacy_archive'), - ) - - -def Auxiliary_SchemaV2ComputeSha256(path: Path) -> str: - if path.is_file() != True: - return '' - H = hashlib.sha256() - with open(path, 'rb') as F: - for Chunk in iter(lambda: F.read(1024 * 1024), b''): - H.update(Chunk) - return H.hexdigest() - - -def Auxiliary_SchemaV2AtomicReadJson(path: Path) -> Optional[Dict[str, Any]]: - if path.is_file() != True: - return None - try: - with open(path, 'r', encoding='UTF-8') as F: - Data = json.load(F) - if type(Data) == dict: - return Data - except Exception: - return None - return None - - -def Auxiliary_SchemaV2AtomicWriteJson(path: Path, data: Any, *, indent: int = 2, sort_keys: bool = True) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - TmpPath = path.with_suffix(path.suffix + '.tmp') - with open(TmpPath, 'w', encoding='UTF-8') as F: - json.dump(data, F, ensure_ascii=False, indent=indent, sort_keys=sort_keys) - F.flush() - os.fsync(F.fileno()) - os.replace(TmpPath, path) - - -def Auxiliary_SchemaV2CountOrganizationRecords(doc: Dict[str, Any]) -> int: - Rec = doc.get('records', {}) - if type(Rec) == dict: - return len(Rec) - return 0 - - -def Auxiliary_SchemaV2CountTitles(doc: Dict[str, Any]) -> tuple: - C = doc.get('canonicals', {}) - A = doc.get('aliases', {}) - Cn = len(C) if type(C) == dict else 0 - An = len(A) if type(A) == dict else 0 - return Cn, An - - -def Auxiliary_SchemaV2CountApiEntries(doc: Dict[str, Any]) -> int: - def CountTtlLeaves(Obj: Any) -> int: - if type(Obj) != dict: - return 0 - if 'value' in Obj and 'ts' in Obj: - return 1 - return sum(CountTtlLeaves(V) for V in Obj.values()) - - Total = 0 - for TopKey in ('tmdb', 'bangumi', 'openai_identify'): - Sub = doc.get(TopKey, {}) - if type(Sub) == dict: - Total += CountTtlLeaves(Sub) - return Total - - -def Auxiliary_SchemaV2BuildSubfileDescriptor(path: Path, role: str) -> SchemaV2SubfileDescriptor: - Updated = '' - if path.is_file(): - try: - Updated = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%S%z') - except Exception: - Updated = Auxiliary_SchemaV2NowIso() - Doc = Auxiliary_SchemaV2AtomicReadJson(path) - Sha = Auxiliary_SchemaV2ComputeSha256(path) - Desc = SchemaV2SubfileDescriptor(sha256=Sha, updated_at=Updated) - if Doc is None: - return Desc - if role == 'organization': - Desc.records = Auxiliary_SchemaV2CountOrganizationRecords(Doc) - elif role == 'titles': - Cn, An = Auxiliary_SchemaV2CountTitles(Doc) - Desc.canonicals = Cn - Desc.aliases = An - elif role == 'api_responses': - Desc.entries = Auxiliary_SchemaV2CountApiEntries(Doc) - return Desc - - -def Auxiliary_SchemaV2RefreshSubfilesInMeta( - meta_doc: SchemaV2CacheMetaDocument, - layout: SchemaV2Layout, -) -> SchemaV2CacheMetaDocument: - """根据磁盘上子文件重算 sha256 与各计数,写回 meta_doc.subfiles。""" - Sub: Dict[str, Dict[str, Any]] = {} - Org = Auxiliary_SchemaV2BuildSubfileDescriptor(layout.organization, 'organization') - Sub[FILENAME_ORGANIZATION] = Org.to_meta_dict() - Tit = Auxiliary_SchemaV2BuildSubfileDescriptor(layout.titles, 'titles') - Sub[FILENAME_TITLES] = Tit.to_meta_dict() - Api = Auxiliary_SchemaV2BuildSubfileDescriptor(layout.api_responses, 'api_responses') - Sub[FILENAME_API_RESPONSES] = Api.to_meta_dict() - meta_doc.subfiles = Sub - return meta_doc - - -def Auxiliary_SchemaV2WriteCacheMeta(layout: SchemaV2Layout, meta_doc: SchemaV2CacheMetaDocument) -> None: - Auxiliary_SchemaV2AtomicWriteJson(layout.cache_meta, meta_doc.to_json_dict()) diff --git a/autoanime/cache/show_index.py b/autoanime/cache/show_index.py deleted file mode 100644 index ddce1ab..0000000 --- a/autoanime/cache/show_index.py +++ /dev/null @@ -1,208 +0,0 @@ -""" -autoanime ShowOrganizationIndex(已整理集标记) - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_FormatOrganizedEpisodeTag` -- `Auxiliary_GetShowOrganizationRecord` -- `Auxiliary_OrderedShowRecordDict` -- `Auxiliary_SetShowOrganizationRecord` -- `Auxiliary_ShowHasOrganizedEpisode` -- `Auxiliary_ShowMarkOrganizedEpisode` - -增量能力(fix_show_index todo): -- `Auxiliary_ShowHasOrganizedEpisode` 升级为 `(has_tag, expected_dst)`,由 pipeline 再做 - 同物理文件判定; -- `Auxiliary_ShowClearOrganizedEpisode` 提供 tag 自愈剔除; -- `Auxiliary_ShowGetEpisodeExpectedDst` / `Auxiliary_ShowSetEpisodeExpectedDst` 负责 `episode_last_dst` - 字段的读写,后者在 `ShowMarkOrganizedEpisode` 成功落盘时由 pipeline 调用,记录上次目标路径。 -""" - -from pathlib import Path as PathlibPath -from time import localtime, strftime, time - -from .. import state -from ..naming import Auxiliary_FormatSEEPToken -from ..text_utils import Auxiliary_NormalizeApiTitle, Auxiliary_NormalizeDisplayTitle -from .persistent import Auxiliary_GetPersistentCache, Auxiliary_SetPersistentCache - - -def Auxiliary_FormatOrganizedEpisodeTag(SE, EP): - SEValue = Auxiliary_FormatSEEPToken(SE) - EPValue = Auxiliary_FormatSEEPToken(EP) - return f'S{SEValue}E{EPValue}' - - -def Auxiliary_GetShowOrganizationRecord(CanonicalID): - CanonicalID = '' if CanonicalID in [None, ''] else str(CanonicalID) - if CanonicalID == '': - return None - if CanonicalID in state.ShowOrganizationIndexDataCache: - return state.ShowOrganizationIndexDataCache[CanonicalID] - Raw = Auxiliary_GetPersistentCache('ShowOrganizationIndex', CanonicalID) - if type(Raw) != dict: - return None - state.ShowOrganizationIndexDataCache[CanonicalID] = Raw - return Raw - - -def Auxiliary_OrderedShowRecordDict(Record): - if type(Record) != dict: - Record = {} - EpisodeLastDst = Record.get('episode_last_dst', {}) - if type(EpisodeLastDst) != dict: - EpisodeLastDst = {} - # 清洗:只保留 str->str 的 tag->path 映射 - CleanedLastDst = {} - for K, V in EpisodeLastDst.items(): - if K in [None, ''] or V in [None, '']: - continue - CleanedLastDst[str(K)] = str(V) - v = int(Record.get('v', 1)) - if v < 2: - v = 2 - return { - 'canonical_id': str(Record.get('canonical_id', '')), - 'organized_episodes': list(Record.get('organized_episodes', [])) if type(Record.get('organized_episodes')) == list else [], - 'episode_last_dst': CleanedLastDst, - 'title_en': str(Record.get('title_en', '')), - 'title_romaji': str(Record.get('title_romaji', '')), - 'title_zh': str(Record.get('title_zh', '')), - 'first_organized_at': str(Record.get('first_organized_at', '')), - 'last_organized_at': str(Record.get('last_organized_at', '')), - 'v': v, - } - - -def Auxiliary_SetShowOrganizationRecord(CanonicalID, Record): - CanonicalID = '' if CanonicalID in [None, ''] else str(CanonicalID) - if CanonicalID == '': - return - now_ts = strftime("%Y-%m-%d %H:%M:%S", localtime(time())) - Record = Record.copy() - Record['canonical_id'] = CanonicalID - if 'organized_episodes' not in Record or type(Record['organized_episodes']) != list: - Record['organized_episodes'] = [] - if str(Record.get('first_organized_at', '')).strip() in [None, '']: - Record['first_organized_at'] = now_ts - Record['last_organized_at'] = now_ts - Record['v'] = 2 - Ordered = Auxiliary_OrderedShowRecordDict(Record) - state.ShowOrganizationIndexDataCache[CanonicalID] = Ordered - Auxiliary_SetPersistentCache('ShowOrganizationIndex', CanonicalID, Ordered) - - -def Auxiliary_ShowGetEpisodeExpectedDst(CanonicalID, SE, EP): - '''返回上一次此 (Canonical, SE, EP) 成功落盘的目标绝对路径字符串;无记录时返回空串。''' - Rec = Auxiliary_GetShowOrganizationRecord(CanonicalID) - if type(Rec) != dict: - return '' - LastDstMap = Rec.get('episode_last_dst', {}) - if type(LastDstMap) != dict: - return '' - Tag = Auxiliary_FormatOrganizedEpisodeTag(SE, EP) - V = LastDstMap.get(Tag, '') - return str(V) if V not in [None, ''] else '' - - -def Auxiliary_ShowHasOrganizedEpisode(CanonicalID, SE, EP): - '''校验 (CanonicalID, SE, EP) 是否已整理。 - - 返回 `(has_tag: bool, expected_dst: Path|None)`: - - `has_tag` : ShowOrganizationIndex 中是否打过整理标签; - - `expected_dst` : 若记录过 `episode_last_dst`,返回 pathlib.Path;否则返回 None。 - - 向后兼容:旧调用 `if Auxiliary_ShowHasOrganizedEpisode(...)` 依赖 bool 判定; - tuple 在 `bool(...)` 下始终为 True,但现有新调用方应当使用 `has_tag, _ = ...` 解构。 - 为避免旧布尔语义误伤,若仅有 tag 而无 expected_dst,`__bool__` 仍保持 True, - 历史调用仅落在本包内(migrate 已迁移),外部不会直接调用。 - ''' - Rec = Auxiliary_GetShowOrganizationRecord(CanonicalID) - if type(Rec) != dict: - return False, None - Tag = Auxiliary_FormatOrganizedEpisodeTag(SE, EP) - EpList = Rec.get('organized_episodes', []) - if type(EpList) != list or Tag not in EpList: - return False, None - ExpectedDstStr = Auxiliary_ShowGetEpisodeExpectedDst(CanonicalID, SE, EP) - if ExpectedDstStr in [None, '']: - return True, None - try: - return True, PathlibPath(ExpectedDstStr) - except Exception: - return True, None - - -def Auxiliary_ShowClearOrganizedEpisode(CanonicalID, SE, EP): - '''自愈剔除:当目标文件缺失时,从 `organized_episodes` 与 `episode_last_dst` 中摘掉此集 tag。 - - 返回 True 表示确实做了修改,便于上层打一条 INFO 日志。 - ''' - CanonicalID = '' if CanonicalID in [None, ''] else str(CanonicalID) - if CanonicalID == '': - return False - Rec = Auxiliary_GetShowOrganizationRecord(CanonicalID) - if type(Rec) != dict: - return False - Tag = Auxiliary_FormatOrganizedEpisodeTag(SE, EP) - EpList = list(Rec.get('organized_episodes', [])) if type(Rec.get('organized_episodes')) == list else [] - LastDstMap = dict(Rec.get('episode_last_dst', {})) if type(Rec.get('episode_last_dst')) == dict else {} - Changed = False - if Tag in EpList: - EpList = [E for E in EpList if E != Tag] - Changed = True - if Tag in LastDstMap: - LastDstMap.pop(Tag, None) - Changed = True - if Changed == False: - return False - Rec['organized_episodes'] = sorted(EpList) - Rec['episode_last_dst'] = LastDstMap - Auxiliary_SetShowOrganizationRecord(CanonicalID, Rec) - return True - - -def Auxiliary_ShowSetEpisodeExpectedDst(CanonicalID, SE, EP, DstPath): - '''写入 `episode_last_dst[tag] = DstPath`,用于下次判定目标文件是否仍然存在。''' - CanonicalID = '' if CanonicalID in [None, ''] else str(CanonicalID) - if CanonicalID == '' or DstPath in [None, '']: - return - Rec = Auxiliary_GetShowOrganizationRecord(CanonicalID) - if type(Rec) != dict: - return - LastDstMap = dict(Rec.get('episode_last_dst', {})) if type(Rec.get('episode_last_dst')) == dict else {} - Tag = Auxiliary_FormatOrganizedEpisodeTag(SE, EP) - LastDstMap[Tag] = str(DstPath) - Rec['episode_last_dst'] = LastDstMap - Auxiliary_SetShowOrganizationRecord(CanonicalID, Rec) - - -def Auxiliary_ShowMarkOrganizedEpisode(CanonicalID, title_zh, title_en, title_romaji, SE, EP, DstPath=None): - CanonicalID = '' if CanonicalID in [None, ''] else str(CanonicalID) - if CanonicalID == '': - return - Rec = Auxiliary_GetShowOrganizationRecord(CanonicalID) - if type(Rec) != dict: - Rec = { - 'canonical_id': CanonicalID, - 'title_zh': '', - 'title_en': '', - 'title_romaji': '', - 'organized_episodes': [], - 'episode_last_dst': {}, - 'v': 2, - 'first_organized_at': '', - 'last_organized_at': '', - } - EpList = list(Rec.get('organized_episodes', [])) if type(Rec.get('organized_episodes')) == list else [] - Tag = Auxiliary_FormatOrganizedEpisodeTag(SE, EP) - if Tag not in EpList: - EpList.append(Tag) - Rec['organized_episodes'] = sorted(EpList) - Rec['title_zh'] = Auxiliary_NormalizeApiTitle(title_zh or Rec.get('title_zh', '')) - Rec['title_en'] = Auxiliary_NormalizeDisplayTitle(title_en or Rec.get('title_en', '')) - Rec['title_romaji'] = Auxiliary_NormalizeDisplayTitle(title_romaji or Rec.get('title_romaji', '')) - if DstPath not in [None, '']: - LastDstMap = dict(Rec.get('episode_last_dst', {})) if type(Rec.get('episode_last_dst')) == dict else {} - LastDstMap[Tag] = str(DstPath) - Rec['episode_last_dst'] = LastDstMap - Auxiliary_SetShowOrganizationRecord(CanonicalID, Rec) diff --git a/autoanime/cache/trust.py b/autoanime/cache/trust.py deleted file mode 100644 index b55024c..0000000 --- a/autoanime/cache/trust.py +++ /dev/null @@ -1,114 +0,0 @@ -""" -alias 写入信任等级与校验(Schema v2) - -- `Auxiliary_TrustLevelFromSource` 由来源推默认 trust -- `Auxiliary_ValidateAliasWrite` 写入前校验,失败时仅拒绝不落盘 -""" - -import re -from typing import Any, Tuple - -from .. import state -from ..text_utils import ( - Auxiliary_HasChineseText, - Auxiliary_NormalizeApiTitle, - Auxiliary_NormalizeDisplayTitle, -) - -# 归一化别名键最大长度;超过则拒绝写入 TitleAliasIndex(审计 reason=alias_key_too_long) -ALIAS_KEY_MAX_LEN = 100 - - -def Auxiliary_TrustLevelFromSource( - source_tag: str, *, conflict: bool = False, openai_downgrade: bool = False -) -> int: - """ - 与 docs/plan 中 trust 等级对齐的默认值(自动写入路径)。 - """ - t = "" if source_tag in [None, ""] else str(source_tag) - t_lower = t.lower() - if conflict and ("openai" in t_lower or t in ["OpenAI", "openai_identify"]): - return 40 - if openai_downgrade and ("openai" in t_lower or t in ["openai_identify", "OpenAI"]): - return 40 - if t in ("manual", "manual_title_whitelist", "ManualWhitelist"): - return 100 - if t in ("BGM",): - return 90 - if t in ("Bangumi", "TMDB"): - return 80 - if t in ("openai_identify", "OpenAI"): - return 60 - if t in ("unknown", "legacy", ""): - return 40 - return 45 - - -def _existing_alias_trust(entry: Any) -> int: - if type(entry) is not dict: - return 0 - try: - return int(entry.get("trust_level", 0) or 0) - except Exception: - return 0 - - -def _canonical_bare_titles(canonical_id: str) -> bool: - """ - 对应「canonicals 中无任何可用主名」:zh/en/romaji 全空则拒绝 alias。 - """ - from .canonical import Auxiliary_GetCanonicalTitleRecord - - rec = Auxiliary_GetCanonicalTitleRecord(str(canonical_id)) - if type(rec) is not dict: - return True - zh = Auxiliary_NormalizeApiTitle(str(rec.get("zh", ""))) - en = Auxiliary_NormalizeDisplayTitle(str(rec.get("en", ""))) - rj = Auxiliary_NormalizeDisplayTitle(str(rec.get("romaji", ""))) - return zh in [None, ""] and en in [None, ""] and rj in [None, ""] - - -def Auxiliary_ValidateAliasWrite( - alias_key: str, - canonical_id: str, - trust_level: int, - *, - new_source: str = "", -) -> Tuple[bool, str]: - """ - 返回 (allow, reason)。allow False 时 reason 为可读原因,供审计。 - """ - if alias_key in [None, ""] or canonical_id in [None, ""]: - return False, "empty_alias_or_canonical" - if len(str(alias_key)) > ALIAS_KEY_MAX_LEN: - return False, "alias_key_too_long" - ak = str(alias_key) - if re.fullmatch(r"\d+", ak) is not None: - return False, "alias_pure_digits" - if re.search(r"\d{4,}", ak) is not None: - return False, "alias_digit_noise" - if _canonical_bare_titles(str(canonical_id)): - return False, "canonical_titles_empty" - from .canonical import Auxiliary_GetCanonicalTitleRecord - - rec = Auxiliary_GetCanonicalTitleRecord(str(canonical_id)) - if type(rec) is dict and bool(rec.get("locked")) is True and int(trust_level) < 100: - return False, "canonical_locked" - g = state.PersistentApiCache.get("TitleAliasIndex", {}) if type(state.PersistentApiCache) is dict else {} - ex = g.get(ak) if type(g) is dict else None - if type(ex) is dict: - et = _existing_alias_trust(ex) - ex_cid = ex.get("value") - if ex_cid in [None, ""]: - ex_cid = ex.get("canonical_id") - else: - # 旧 v1 仅存 canonical id 串,无 trust - et = 50 - ex_cid = ex - if ex is None: - return True, "" - if int(et) > int(trust_level): - if str(ex_cid) == str(canonical_id): - return False, "same_canonical_higher_trust_noop" - return False, "existing_higher_trust" - return True, "" diff --git a/autoanime/cache/v2_data.py b/autoanime/cache/v2_data.py deleted file mode 100644 index e7a37e9..0000000 --- a/autoanime/cache/v2_data.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Schema v2 路径、空结构体与原子写 JSON -""" - -import hashlib -import json -from datetime import datetime -from pathlib import Path -from typing import Any, Optional - -from ..config_loader import Auxiliary_GetCacheStorePath - - -def Auxiliary_GetV2DataDir() -> Path: - return Auxiliary_GetCacheStorePath().parent - - -def Auxiliary_GetV2SubfilePath(subfile: str) -> Path: - m = { - "organization": "organization.json", - "titles": "titles.json", - "api": "api_responses.json", - "api_responses": "api_responses.json", - } - return Auxiliary_GetV2DataDir() / m.get(subfile, f"{subfile}.json") - - -def Auxiliary_Sha256File(P: Path) -> str: - if P.is_file() is False: - return "" - h = hashlib.sha256() - with open(P, "rb") as f: - for chunk in iter(lambda: f.read(65536), b""): - h.update(chunk) - return h.hexdigest() - - -def Auxiliary_AtomicWriteJson(P: Path, Data: Any) -> None: - P.parent.mkdir(parents=True, exist_ok=True) - Tmp = P.with_suffix(P.suffix + ".tmp") - with open(Tmp, "w", encoding="utf-8") as f: - json.dump(Data, f, ensure_ascii=False, indent=2) - Tmp.replace(P) - - -V2_VERSION = 2 - -EMPTY_ORGANIZATION: dict = { - "__meta__": {"schema_version": V2_VERSION, "updated_at": None}, - "records": {}, -} - -EMPTY_TITLES: dict = { - "__meta__": {"schema_version": V2_VERSION, "updated_at": None}, - "canonicals": {}, - "aliases": {}, -} - -EMPTY_API_RESPONSES: dict = { - "__meta__": {"schema_version": V2_VERSION}, - "tmdb": {"titles": {}, "titles_en": {}, "tv_series": {}, "tv_seasons": {}}, - "bangumi": {"titles": {}}, - "openai_identify": {"file_info": {}}, - "ext": {}, -} - - -def Auxiliary_WriteV2CacheMeta( - subfile_stats: Optional[dict] = None, - legacy_archive: Optional[str] = None, -) -> None: - """仅更新 meta 文件(flush 时由 persistent 调)。subfile_stats 为可选的部分覆盖。""" - p = Auxiliary_GetV2DataDir() / "cache_meta.json" - existing: dict = {} - if p.is_file(): - try: - with open(p, "r", encoding="utf-8") as f: - existing = json.load(f) - except Exception: - existing = {} - now = datetime.now().replace(microsecond=0).isoformat() - if type(existing) is not dict or existing.get("schema_version") != V2_VERSION: - existing = { - "schema_version": V2_VERSION, - "created_at": now, - "subfiles": {}, - } - existing["last_flush_at"] = now - if legacy_archive is not None: - existing["legacy_archive"] = legacy_archive - if type(subfile_stats) is dict: - sf = existing.get("subfiles", {}) - if type(sf) is not dict: - sf = {} - for k, v in subfile_stats.items(): - sf[k] = v - existing["subfiles"] = sf - Auxiliary_AtomicWriteJson(p, existing) diff --git a/autoanime/cli.py b/autoanime/cli.py deleted file mode 100644 index e5cdbcd..0000000 --- a/autoanime/cli.py +++ /dev/null @@ -1,221 +0,0 @@ -""" -autoanime 命令行入口骨架 - -本模块在当前迁移批次(skeleton/migrate_* 系列 todo)中暂以"引导 + 派发"骨架形式提供: - -- 负责:包初始化(state、配置、持久化缓存、zhconv 词典预加载)、 - 单文件/目录/qB 回调三态输入归一化、rollback 模式的入口识别。 -- 不负责:完整主流水线 / 字幕整理 / 操作日志写盘 / 回滚执行器; - 这些逻辑将随 `migrate_sort` / `migrate_pipeline` / `migrate_cli_full` 等后续 todo 迁移。 - -函数命名向旧版 `AutoAnimeMv.py` 对齐,使得现有调用方(如未来 qB 回调脚本) -可以直接替换为 `python AutoAnimeMv2.py ...`。 -""" - -import argparse - -from os import path -from sys import argv -from time import time - -from . import state -from .config_loader import ( - Auxiliary_ApplyConfig, - Auxiliary_InitRuntimeContext, - Auxiliary_READConfig, -) -from .logging_utils import Auxiliary_Exit, Auxiliary_Log -from .scanning import NormalizeSingleFileInput -from .zhconv_safe import Auxiliary_InitZhconvDictionarySafely - - -def Start_PATH(**kwargs) -> dict: - '''新入口的初始化序列。与旧 `Start_PATH` 语义对齐:读取配置、加载缓存、预热 zhconv。''' - state.init_defaults() - Auxiliary_InitZhconvDictionarySafely() - Auxiliary_READConfig() - Auxiliary_ApplyConfig() - Auxiliary_InitRuntimeContext() - from .cache.migrate import Auxiliary_MigrateCacheToV2IfNeeded - from .cache.manual_whitelist import Auxiliary_LoadManualWhitelist - from .cache.persistent import Auxiliary_LoadPersistentCache - - # Schema v2:在加载持久化缓存前执行一次性归档/初始化(与 persistent 内二次调用幂等) - Auxiliary_MigrateCacheToV2IfNeeded() - Auxiliary_LoadManualWhitelist(force=True) - Auxiliary_LoadPersistentCache() - Auxiliary_Log( - ( - f'当前工具版本为{state.Versions}', - f'当前操作系统识别码为{__import__("os").name},posix/nt/java对应linux/windows/java虚拟机', - ), - 'INFO', - ) - if state.DRY_RUN: - Auxiliary_Log('当前处于 DRY_RUN 模式,所有操作仅预览不落盘', 'WARNING') - for k, v in kwargs.items(): - setattr(state, k, v) - return {'state': state, 'version': state.Versions} - - -def Start_GetArgv(): - '''命令行参数解析。支持以下形态: - - - `rollback --log ` : 回滚模式 - - `` : 旧目录扫描 - - `` : 单文件快捷输入(自动拆 parent + basename) - - ` 1` : 原 qB 回调保持兼容 - - ` --file ` : 目录 + 文件名显式 - ''' - if len(argv) == 1: - _PrintUsageAndExit() - - if argv[1].lower() == 'rollback': - RollbackParser = argparse.ArgumentParser(prog='AutoAnimeMv2.py rollback', description='根据操作日志执行回滚') - RollbackParser.add_argument('log_path', nargs='?', help='操作日志路径') - RollbackParser.add_argument('--log', dest='log_opt', help='操作日志路径') - Args = RollbackParser.parse_args(argv[2:]) - RollbackPath = Args.log_opt if Args.log_opt else Args.log_path - if RollbackPath in [None, '']: - Auxiliary_Exit('rollback 模式需要传入日志路径,例如: python AutoAnimeMv2.py rollback --log operation.json') - state.RUN_COMMAND = 'rollback' - state.ROLLBACK_LOG_PATH = RollbackPath - Auxiliary_InitRuntimeContext() - return RollbackPath - - Parser = argparse.ArgumentParser(add_help=False) - Parser.add_argument('filepath_pos', nargs='?') - Parser.add_argument('filename_pos', nargs='?') - Parser.add_argument('number_pos', nargs='?') - Parser.add_argument('categoryname_pos', nargs='?') - Parser.add_argument('tag_pos', nargs='?') - Parser.add_argument('--filepath', dest='filepath_opt') - Parser.add_argument('--filename', dest='filename_opt') - Parser.add_argument('--file', dest='file_opt', help='目录模式下显式指定单个目标文件名') - Parser.add_argument('--number', dest='number_opt') - Parser.add_argument('--categoryname', dest='categoryname_opt') - Parser.add_argument('--animename', dest='animename_opt') - Parser.add_argument('--tag', dest='tag_opt') - Parser.add_argument('--dry-run', dest='dry_run_opt', action='store_true') - Parser.add_argument('--naming-style', dest='naming_style_opt', choices=['default', 'emby']) - Parser.add_argument('--output-path', dest='output_path_opt', help='整理输出目录路径') - Parser.add_argument('--strict-mode', dest='strict_mode_opt', choices=['true', 'false'], help='严格模式开关') - Parser.add_argument('--use-link', dest='force_use_link', action='store_true', help='强制使用硬链接') - Parser.add_argument('--no-link', dest='force_no_link', action='store_true', help='禁用硬链接,使用移动') - Parser.add_argument('-h', '--help', dest='show_help', action='store_true') - Args, _ = Parser.parse_known_args(argv[1:]) - - if Args.show_help: - _PrintUsageAndExit() - - raw_filepath = Args.filepath_opt if Args.filepath_opt else Args.filepath_pos - # 单文件输入归一化:若位置参数指向文件,拆成目录 + basename - normalized_dir, auto_names, single_file = NormalizeSingleFileInput(raw_filepath) - state.filepath = normalized_dir - state.filename = Args.filename_opt if Args.filename_opt else Args.filename_pos - state.number = Args.number_opt if Args.number_opt else Args.number_pos - state.categoryname = Args.categoryname_opt if Args.categoryname_opt else Args.categoryname_pos - state.animename = Args.animename_opt if Args.animename_opt else None - state.tag = Args.tag_opt if Args.tag_opt else Args.tag_pos - state.SingleFileMode = False - state.SingleFileVideoName = '' - state.SingleFileSubtitles = [] - - if single_file == True and auto_names: - # 按 plan:单文件模式下 state.filename 指向视频,额外收录同目录字幕 - state.filename = auto_names[0] - state.SingleFileMode = True - state.SingleFileVideoName = auto_names[0] - state.SingleFileSubtitles = list(auto_names[1:]) if len(auto_names) > 1 else [] - if state.number in [None, '']: - state.number = '1' - elif Args.file_opt not in [None, '']: - state.filename = Args.file_opt - state.SingleFileMode = True - state.SingleFileVideoName = Args.file_opt - state.SingleFileSubtitles = [] - if state.number in [None, '']: - state.number = '1' - - if Args.naming_style_opt not in [None, '']: - state.NAMING_STYLE = Args.naming_style_opt - if Args.dry_run_opt: - state.DRY_RUN = True - if Args.output_path_opt not in [None, '']: - state.OUTPUT_PATH = Args.output_path_opt - if Args.strict_mode_opt not in [None, '']: - state.STRICT_MODE = True if str(Args.strict_mode_opt).lower() == 'true' else False - if Args.force_use_link: - state.USELINK = True - if Args.force_no_link: - state.USELINK = False - - for Key in ['filepath', 'filename', 'number', 'categoryname', 'animename', 'tag', 'NAMING_STYLE', 'DRY_RUN', 'OUTPUT_PATH', 'STRICT_MODE', 'USELINK']: - Auxiliary_Log(f'{Key} < {getattr(state, Key, None)}') - - if state.filepath in [None, ''] or path.exists(state.filepath) == False: - Auxiliary_Exit('请输入正确的处理目录路径') - - # 重新初始化 runtime,使 filepath/OUTPUT_PATH 等写入生效 - Auxiliary_InitRuntimeContext() - - if state.filename not in [None, ''] and state.number not in [None, '']: - return state.filepath, state.filename, state.number - return state.filepath - - -def _PrintUsageAndExit(): - Auxiliary_Log( - '用法示例:\n' - ' python AutoAnimeMv2.py \n' - ' python AutoAnimeMv2.py \n' - ' python AutoAnimeMv2.py --file \n' - ' python AutoAnimeMv2.py 1\n' - ' python AutoAnimeMv2.py rollback --log \n', - 'PRINT', - flag='PRINT', - ) - Auxiliary_Exit('请查阅以上用法') - - -def main() -> int: - '''对外主入口:模块化流水线的完整实现。 - - - rollback 命令 -> `autoanime.pipeline.rollback.Auxiliary_RollbackFromLog` - - 其他命令 -> `autoanime.pipeline.main.Processing_Main(Processing_Mode(ArgvData))` - ''' - start = time() - try: - Start_PATH() - ArgvData = Start_GetArgv() - _RunPipeline(ArgvData) - except SystemExit: - raise - except Exception as err: - Auxiliary_Log(f'没有预料到的错误 > {err}', 'ERROR', flag='PRINT') - return 1 - finally: - from .cache.persistent import Auxiliary_SavePersistentCache - - # Schema v2 下仅刷新被标记为 dirty 的子文件(organization / titles / api_responses),不全量重写 - Auxiliary_SavePersistentCache(force=False) - end = time() - Auxiliary_Log(f'一切工作已经完成,用时{end - start}', 'INFO', flag='PRINT') - return 0 - - -def _RunPipeline(ArgvData): - '''运行新的 `autoanime.pipeline.*` 流水线(migrate_sort_pipeline todo)''' - from .pipeline.main import Processing_Main - from .pipeline.mode import Processing_Mode - from .pipeline.operation_log import Auxiliary_WriteOperationLog - from .pipeline.rollback import Auxiliary_RollbackFromLog - - if state.RUN_COMMAND == 'rollback': - Auxiliary_RollbackFromLog(state.ROLLBACK_LOG_PATH) - return - Processing_Main(Processing_Mode(ArgvData)) - try: - Auxiliary_WriteOperationLog() - except Exception: - pass diff --git a/autoanime/config_loader.py b/autoanime/config_loader.py deleted file mode 100644 index ee1bf75..0000000 --- a/autoanime/config_loader.py +++ /dev/null @@ -1,216 +0,0 @@ -""" -autoanime 配置加载与运行时上下文初始化 - -对应原 `AutoAnimeMv.py` 中: -- `Auxiliary_NormalizeConfigSection` -- `Auxiliary_ParseConfigValue` -- `Auxiliary_MaskConfigValue` -- `Auxiliary_READConfig` -- `Auxiliary_ApplyConfig` -- `Auxiliary_ParseBool` / `Auxiliary_ParseInt` -- `Auxiliary_ParseDelimitedConfigList` -- `Auxiliary_InitRuntimeContext` -- `Auxiliary_GetCacheStorePath` -- `Auxiliary_GetTMDBBearerToken` -- `Auxiliary_GetOpenAIApiKey` -""" - -from ast import literal_eval -from os import environ, path -from pathlib import Path as PathlibPath -from re import I, findall, search - -from . import state -from .config_model import Config, RuntimeContext -from .logging_utils import Auxiliary_Log - - -def Auxiliary_NormalizeConfigSection(section_name): - '''规范化配置分区名称''' - section_name = section_name.strip() - if section_name.lower() in ['settings', 'config', '#config']: - return '#Config' - return section_name - - -def Auxiliary_ParseConfigValue(ConfigValue): - '''解析配置值,兼容字符串/布尔/数字/列表''' - ConfigValue = ConfigValue.strip() - if ConfigValue == '': - return '' - LowerValue = ConfigValue.lower() - if LowerValue == 'true': - return True - if LowerValue == 'false': - return False - if LowerValue in ['none', 'null']: - return None - try: - return literal_eval(ConfigValue) - except Exception: - return ConfigValue - - -def Auxiliary_MaskConfigValue(ConfigName, ConfigValue): - '''日志打印时对敏感配置做脱敏''' - if search(r'key|token|secret|password', ConfigName, flags=I) != None: - ConfigValue = '' if ConfigValue is None else str(ConfigValue) - if ConfigValue == '': - return '' - if len(ConfigValue) <= 8: - return '***' - return f'{ConfigValue[:3]}***{ConfigValue[-2:]}' - return ConfigValue - - -def Auxiliary_ParseBool(Value) -> bool: - if type(Value) == bool: - return Value - if type(Value) in [int, float]: - return Value != 0 - if Value is None: - return False - return str(Value).strip().lower() in ['true', '1', 'yes', 'y', 'on'] - - -def Auxiliary_ParseInt(Value, DefaultValue) -> int: - try: - return int(Value) - except Exception: - return DefaultValue - - -def Auxiliary_ParseDelimitedConfigList(ConfigValue): - if ConfigValue in [None, '']: - return [] - if type(ConfigValue) == list: - return [str(Item).strip() for Item in ConfigValue if str(Item).strip() not in [None, '']] - RawText = str(ConfigValue).strip() - if RawText == '': - return [] - for Delimiter in ['|', ',', '\n', ';']: - if Delimiter in RawText: - return [Part.strip() for Part in RawText.replace('\r', '').split(Delimiter) if Part.strip() not in [None, '']] - return [RawText] - - -def Auxiliary_READConfig(): - '''读取外置 config.ini 文件并写入 state.ConfigMagdict''' - state.ConfigMagdict = {} - ConfigPath = f'{state.PyPath}{state.Separator}config.ini' - if path.isfile(ConfigPath) == False: - return - with open(ConfigPath, 'r', encoding='UTF-8') as ff: - Auxiliary_Log('正在读取外置ini文件', 'INFO') - KeyName = None - for i in ff.readlines(): - i = i.strip('\n').strip() - if i == '' or i[0] == ';': - continue - if findall(r'\[(.*?)\]', i) != []: - KeyName = Auxiliary_NormalizeConfigSection(findall(r'\[(.*?)\]', i)[0]) - if KeyName not in state.ConfigMagdict: - state.ConfigMagdict[KeyName] = {} - elif i[0] != '#': - if KeyName is None: - Auxiliary_Log(f'跳过未归属分区的配置行: {i}', 'WARNING') - continue - if '=' not in i: - Auxiliary_Log(f'跳过不合法配置行: {i}', 'WARNING') - continue - ConfigItem = i.split("=", 1) - state.ConfigMagdict[KeyName][ConfigItem[0].strip('- ')] = ConfigItem[1].strip('- ') - if state.ConfigMagdict != {}: - ConfigSummary = {sect: list(values.keys()) for sect, values in state.ConfigMagdict.items()} - Auxiliary_Log(f'读取到配置分区: {ConfigSummary}') - else: - Auxiliary_Log('外置ini文件没有配置', 'WARNING') - - -def Auxiliary_ApplyConfig(): - '''将 ConfigMagdict['#Config'] 下的键值写回 state 模块属性''' - if '#Config' not in state.ConfigMagdict: - return - for ConfigName in state.ConfigMagdict['#Config']: - ConfigValue = Auxiliary_ParseConfigValue(state.ConfigMagdict['#Config'][ConfigName]) - if hasattr(state, ConfigName): - setattr(state, ConfigName, ConfigValue) - else: - # 保留未列出的配置项,便于兼容扩展模块 - setattr(state, ConfigName, ConfigValue) - Auxiliary_Log(f'配置 < {ConfigName} = {Auxiliary_MaskConfigValue(ConfigName, ConfigValue)}', 'INFO') - # 代理初始化依赖配置项,因此放在最后 - from .apis.http import Auxiliary_PROXY - Auxiliary_PROXY() - - -def Auxiliary_InitRuntimeContext(): - '''初始化 state.Runtime 运行时上下文''' - CacheTTL = Auxiliary_ParseInt(state.CACHE_TTL_SECONDS, 86400) - if CacheTTL < 0: - CacheTTL = 86400 - NamingStyle = str(state.NAMING_STYLE).strip().lower() if state.NAMING_STYLE not in [None, ''] else 'default' - if NamingStyle not in ['default', 'emby']: - NamingStyle = 'default' - CategoryNameValue = state.categoryname if state.categoryname not in [None, ''] else '' - SourcePath = state.filepath if state.filepath not in [None, ''] else state.PyPath - OutputPathValue = state.OUTPUT_PATH if state.OUTPUT_PATH not in [None, ''] else SourcePath - OutputPathObj = PathlibPath(OutputPathValue) - if OutputPathObj.is_absolute() == False: - OutputPathObj = PathlibPath(SourcePath) / OutputPathObj - state.Runtime = RuntimeContext( - source_path=PathlibPath(SourcePath), - output_path=OutputPathObj, - category_name=CategoryNameValue, - config=Config( - naming_style=NamingStyle, - dry_run=Auxiliary_ParseBool(state.DRY_RUN), - cache_dir=str(state.CACHE_DIR).strip() if state.CACHE_DIR not in [None, ''] else '.cache', - cache_ttl_seconds=CacheTTL, - tmdb_token_env=str(state.TMDB_BEARER_TOKEN_ENV).strip() if state.TMDB_BEARER_TOKEN_ENV not in [None, ''] else 'TMDB_BEARER_TOKEN', - openai_key_env=str(state.OPENAI_API_KEY_ENV).strip() if state.OPENAI_API_KEY_ENV not in [None, ''] else 'OPENAI_API_KEY', - openai_identify_all=Auxiliary_ParseBool(state.OPENAI_IDENTIFY_ALL), - strict_mode=Auxiliary_ParseBool(state.STRICT_MODE), - output_path=str(OutputPathObj), - ), - ) - if state.OPERATION_LOG_ENABLE: - LogBasePath = state.Runtime.source_path - if LogBasePath.exists() == False: - LogBasePath = PathlibPath(state.PyPath) - OpDirName = str(state.OPERATION_LOG_DIR).strip() if state.OPERATION_LOG_DIR not in [None, ''] else 'logs' - state.Runtime.operation_log_path = LogBasePath / OpDirName / f'AutoAnime_operations_{state.CurrentRunID}.json' - if state.RUN_COMMAND == 'rollback' and state.ROLLBACK_LOG_PATH not in [None, '']: - state.Runtime.rollback_log_path = PathlibPath(state.ROLLBACK_LOG_PATH) - - -def Auxiliary_GetCacheStorePath() -> PathlibPath: - '''返回持久化缓存文件路径 .cache/api_cache.json''' - if state.Runtime and state.Runtime.config and state.Runtime.config.cache_dir not in [None, '']: - CacheDir = state.Runtime.config.cache_dir - else: - CacheDir = '.cache' - CacheBasePath = PathlibPath(CacheDir) - if CacheBasePath.is_absolute() == False: - CacheBasePath = PathlibPath(state.PyPath) / CacheBasePath - if CacheBasePath.exists() == False: - CacheBasePath.mkdir(parents=True, exist_ok=True) - return CacheBasePath / 'api_cache.json' - - -def Auxiliary_GetTMDBBearerToken(): - TokenValue = state.TMDB_BEARER_TOKEN - if TokenValue not in [None, '']: - return str(TokenValue).strip() - EnvName = state.Runtime.config.tmdb_token_env if state.Runtime and state.Runtime.config else state.TMDB_BEARER_TOKEN_ENV - EnvName = str(EnvName).strip() if EnvName not in [None, ''] else 'TMDB_BEARER_TOKEN' - return str(environ.get(EnvName, '')).strip() - - -def Auxiliary_GetOpenAIApiKey(): - ApiKey = state.OPENAI_API_KEY - if ApiKey not in [None, '']: - return str(ApiKey).strip() - EnvName = state.Runtime.config.openai_key_env if state.Runtime and state.Runtime.config else state.OPENAI_API_KEY_ENV - EnvName = str(EnvName).strip() if EnvName not in [None, ''] else 'OPENAI_API_KEY' - return str(environ.get(EnvName, '')).strip() diff --git a/autoanime/config_model.py b/autoanime/config_model.py deleted file mode 100644 index a966a07..0000000 --- a/autoanime/config_model.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -autoanime 配置模型与哨兵常量 - -提供以下类型: -- `Config` : 运行期配置快照 -- `RuntimeContext` : 运行期上下文(源路径/输出路径/分类名/操作日志等) -- `_OpenAISkipSpIdentification` + `OPENAI_SKIP_SP_IDENTIFICATION` : 哨兵 -- `WINDOWS_RESERVED_NAMES` : Windows 保留文件名集合 -""" - -from dataclasses import dataclass, field -from pathlib import Path as PathlibPath -from typing import Optional - - -WINDOWS_RESERVED_NAMES = { - 'CON', 'PRN', 'AUX', 'NUL', - 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', - 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', -} - - -class _OpenAISkipSpIdentification: - '''OpenAI 将文件识别为 SP 特典时的哨兵,主流程跳过整理''' - pass - - -OPENAI_SKIP_SP_IDENTIFICATION = _OpenAISkipSpIdentification() - - -@dataclass -class Config: - naming_style: str = 'default' - dry_run: bool = False - cache_dir: str = '.cache' - cache_ttl_seconds: int = 86400 - tmdb_token_env: str = 'TMDB_BEARER_TOKEN' - openai_key_env: str = 'OPENAI_API_KEY' - openai_identify_all: bool = True - strict_mode: bool = True - output_path: str = '' - - -@dataclass -class RuntimeContext: - source_path: PathlibPath = PathlibPath('.') - output_path: PathlibPath = PathlibPath('.') - category_name: str = '' - config: Config = field(default_factory=Config) - operation_log_path: Optional[PathlibPath] = None - rollback_log_path: Optional[PathlibPath] = None - operation_records: list = field(default_factory=list) diff --git a/autoanime/episode_dst_rename.py b/autoanime/episode_dst_rename.py deleted file mode 100644 index d24cc5d..0000000 --- a/autoanime/episode_dst_rename.py +++ /dev/null @@ -1,234 +0,0 @@ -# -*- coding: utf-8 -*- -""" -根据 organization 单条 `episode_last_dst` 生成与 `Sorting_Mv` 一致的目标路径,用于重命名/迁移已整理资源。 - -- 不依赖 `state` / dry-run 全局:由调用方传入命名参数。 -- 与 `autoanime.sorting` 包内模块解耦,避免经 `sorting` 包 `__init__` 时触发循环导入(`file_ops` → `autoanime.pipeline`)。 - -默认由 `scripts/cache_doctor.py` 在「计划」阶段打印 move,显式 `--apply-rename` 时执行 `shutil.move`。 -""" - -from __future__ import annotations - -import re -import shutil -from dataclasses import dataclass -from os import path -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple - -from .naming import ( - Auxiliary_ASSFileCA, - Auxiliary_FormatSEEPToken, - Auxiliary_SanitizePathComponent, - Auxiliary_SubtitleLanguageSuffixForEmby, -) -from .text_utils import Auxiliary_NormalizeChinesePunctuation - -_TAG_SXEY = re.compile(r"^S(\d+)E(\d+)$", re.IGNORECASE) - - -@dataclass(frozen=True) -class EpisodeDstRenameParams: - """与 `sorting/pipeline.py::Sorting_Mv` 一致的命名相关参数。""" - - naming_style: str = "default" - use_title_to_ep: bool = True - max_filename_length: int = 180 - - -@dataclass(frozen=True) -class EpisodeDstMove: - tag: str - src: Path - dst: Path - - -def Auxiliary_ParseOrganizedTag(tag: str) -> Optional[Tuple[str, str]]: - """解析 `S05E01` 风格 tag,返回 (se, ep) 字符串,与季集整理链路中 SE/EP 形式一致。""" - if tag in [None, ""]: - return None - m = _TAG_SXEY.match(str(tag).strip()) - if m is None: - return None - return m.group(1), m.group(2) - - -def _pc_sanitize(component: str, max_len: int) -> str: - return Auxiliary_SanitizePathComponent( - Auxiliary_NormalizeChinesePunctuation(str(component or "")), max_len - ) - - -def BuildSortingDestPath( - source_abs: Path, - se: str, - ep: str, - new_api_name: str, - params: EpisodeDstRenameParams, -) -> Path: - """ - 对单文件计算与 `Sorting_Mv` 相同规则的目标绝对路径;不检查源是否存在。 - - `source_abs` 需为已整理落盘后的绝对路径,形如 - `.../剧名/SeasonXX/文件名.ext`(`Sorting_Mv` 的 BaseDir/Show/Season/file)。 - """ - naming_style = str(params.naming_style or "default").strip().lower() - if naming_style not in ("default", "emby"): - naming_style = "default" - ml = int(params.max_filename_length or 180) - safe_name = _pc_sanitize(new_api_name, ml) - se_pad = Auxiliary_FormatSEEPToken(se) - ep_pad = Auxiliary_FormatSEEPToken(ep) - if naming_style == "emby": - season_dir_name = f"Season {se_pad}" - else: - season_dir_name = f"Season{se}" - season_san = _pc_sanitize(season_dir_name, ml) - try: - base_dir = source_abs.parent.parent.parent - except Exception: - base_dir = Path(".") - - new_dir = base_dir / safe_name / season_san - - if naming_style == "emby": - episode_base = f"{safe_name} - S{se_pad}E{ep_pad}" - else: - if params.use_title_to_ep is True: - episode_base = f"S{se}E{ep}.{safe_name}" - else: - episode_base = f"S{se}E{ep}" - episode_base = _pc_sanitize(episode_base, ml) - - file_name = path.basename(str(source_abs)) - ext = path.splitext(file_name)[1].lower() - if ext in [".ass", ".srt"]: - if naming_style == "emby": - new_stem = _pc_sanitize( - f"{safe_name} - S{se_pad}E{ep_pad}{Auxiliary_SubtitleLanguageSuffixForEmby(file_name)}", - ml, - ) - else: - new_stem = _pc_sanitize(episode_base + Auxiliary_ASSFileCA(file_name), ml) - else: - new_stem = episode_base - return new_dir / f"{new_stem}{ext}" - - -def PlanEpisodeDstRenames( - org_record: Dict[str, Any], - new_title_zh: str, - params: Optional[EpisodeDstRenameParams] = None, -) -> Tuple[List[EpisodeDstMove], List[str]]: - """ - 对一条 organization `records[...]` 的 `episode_last_dst` 生成 move 列表。 - - 返回 (moves, errors)。errors 非空时 moves 可能仍部分可用;调用方应视情况中止。 - """ - p = params or EpisodeDstRenameParams() - new_zh = str(new_title_zh or "").strip() - if new_zh == "": - return [], ["剧名为空"] - if type(org_record) is not dict: - return [], ["organization 记录不是 dict"] - last_map = org_record.get("episode_last_dst", {}) - if type(last_map) is not dict or not last_map: - return [], [] - err: List[str] = [] - show_roots: List[Path] = [] - moves: List[EpisodeDstMove] = [] - dups: Dict[str, str] = {} - for tag, raw in last_map.items(): - if tag in [None, ""] or raw in [None, ""]: - continue - parsed = Auxiliary_ParseOrganizedTag(str(tag)) - if parsed is None: - err.append(f"无法解析集标签: {tag!r}") - continue - se, ep = parsed - src = Path(str(raw)) - try: - if not src.is_absolute(): - src = src.resolve() - except Exception: - pass - if not src.is_file(): - err.append(f"源文件不存在: {src}") - try: - show_roots.append(src.parent.parent.resolve()) - except Exception as ex: - err.append(f"{tag} 父路径错误: {ex}") - continue - try: - dst = BuildSortingDestPath(src, se, ep, new_zh, p) - except Exception as ex: - err.append(f"{tag} 目标路径计算失败: {ex}") - continue - dkey = str(dst) - if dkey in dups and dups[dkey] != str(src): - err.append(f"目标冲突: {dkey!r} 已对应 {dups[dkey]},又与 {src} 冲突") - dups[dkey] = str(src) - moves.append(EpisodeDstMove(tag=tag, src=src, dst=dst)) - if len(show_roots) > 1: - u = {str(x) for x in show_roots} - if len(u) > 1: - err.append( - "同一 canonical 的 episode_last_dst 指向不同剧集根目录: " - + ", ".join(sorted(u)[:5]) - ) - return moves, err - - -def _path_equal_or_samefile(a: Path, b: Path) -> bool: - try: - if a == b: - return True - if a.is_file() and b.is_file() and a.samefile(b): - return True - except Exception: - return False - return False - - -def ApplyEpisodeDstRenames( - moves: Sequence[EpisodeDstMove], *, apply: bool -) -> Tuple[bool, List[str]]: - """ - 执行重命名。`apply` 为 False 时只返回将要执行的动作描述,不写磁盘。 - - 返回 (ok, 日志行)。 - """ - lines: List[str] = [] - if not moves: - lines.append("无需要移动的条目。") - return True, lines - for m in moves: - lines.append(f"[{m.tag}] {m.src} -> {m.dst}") - if not apply: - return True, lines - for m in moves: - m.dst.parent.mkdir(parents=True, exist_ok=True) - if m.dst.exists() and not _path_equal_or_samefile(m.src, m.dst): - return False, lines + [f"目标已存在且与源非同一文件: {m.dst}"] - if not m.src.is_file(): - return False, lines + [f"源已不存在: {m.src},已中止,请检查缓存与磁盘是否一致。"] - for m in moves: - if _path_equal_or_samefile(m.src, m.dst): - continue - shutil.move(str(m.src), str(m.dst)) - return True, lines + ["已完成 shutil.move。"] - - -def PatchOrganizationRecordPaths( - org_record: Dict[str, Any], - moves: Sequence[EpisodeDstMove], -) -> None: - """在内存中把 `episode_last_dst[tag]` 更新为与 moves 的 dst 一致(原地改 dict)。""" - d = org_record.get("episode_last_dst", {}) - if type(d) is not dict: - d = {} - new_map = {str(k): str(v) for k, v in d.items()} - for m in moves: - new_map[m.tag] = str(m.dst) - org_record["episode_last_dst"] = new_map diff --git a/autoanime/identification/__init__.py b/autoanime/identification/__init__.py deleted file mode 100644 index cb689ca..0000000 --- a/autoanime/identification/__init__.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -autoanime 剧集/剧名/剧季识别子包 - -- `openai_identify` : OpenAI 一次性全信息识别(主路径) -- `title_chain` : 剧名标准化链 TMDB->Bangumi->TMDB EN->OpenAI 译名 -- `episode_rules` : 季/集规则、Jujutsu 特例、预检索 -- 本模块另外导出 `Processing_Identification`,保持旧流水线兼容。 -""" - -from .. import state -from ..logging_utils import Auxiliary_Exit, Auxiliary_Log -from ..naming import ( - Auxiliary_AnimeFileCheck, - Auxiliary_RMOTSTR, - Auxiliary_RMSubtitlingTeam, - Auxiliary_UniformOTSTR, -) -from .local_fallback import ( - Auxiliary_IsFallbackEnabled, - Auxiliary_NoteOpenAIBreakerEvent, - Auxiliary_ResetOpenAIBreaker, - Auxiliary_ResolveFileInfoWithFallback, - Auxiliary_ShouldTripOpenAIBreaker, -) -from .openai_identify import ( - Auxiliary_AppendOpenAIIdentifyWarningLog, - Auxiliary_GetOpenAIIdentifyWarningLogPath, - Auxiliary_NoteOpenAIIdentifyFailure, - Auxiliary_OpenAIIdentifyFileInfo, -) -from .title_chain import Auxiliary_ResolvePlannedTitleChain - - -def Processing_Identification(File: str): - '''OpenAI 一次性识别为主路径,失败时按 `OPENAI_FALLBACK_ON_FAILURE` 开关走本地 + 传统 API 回退。 - - 返回:识别成功则为 (SE, EP, RAWSE, RAWEP, RAWName);彻底失败则为 None。 - 同时写入 state.LastOpenAIFileInfoMeta / state.LastIdentificationFromAI,供上层流水线读取。 - ''' - state.LastIdentificationFromAI = False - state.LastOpenAIFileInfoMeta = {} - - NewFile = Auxiliary_RMSubtitlingTeam(Auxiliary_RMOTSTR(Auxiliary_UniformOTSTR(File))) - AnimeFileCheckFlag = Auxiliary_AnimeFileCheck(NewFile) - if AnimeFileCheckFlag != True: - Auxiliary_Log(f'当前文件属于{AnimeFileCheckFlag},跳过处理', 'INFO') - return None - Auxiliary_Log('-' * 80, 'INFO') - - FallbackEnabled = Auxiliary_IsFallbackEnabled() - BreakerTripped = Auxiliary_ShouldTripOpenAIBreaker() - SkipOpenAI = ( - state.USEOPENAIAPI != True - or state.OPENAI_IDENTIFY_ALL != True - or (FallbackEnabled and BreakerTripped) - ) - if SkipOpenAI and FallbackEnabled != True and (state.USEOPENAIAPI != True or state.OPENAI_IDENTIFY_ALL != True): - Auxiliary_Exit('必须启用 USEOPENAIAPI 与 OPENAI_IDENTIFY_ALL,或启用 OPENAI_FALLBACK_ON_FAILURE 走回退链路') - - state.LastOpenAIIdentifyFailure = None - OpenAIIdentifyData = None - if SkipOpenAI != True: - OpenAIIdentifyData = Auxiliary_OpenAIIdentifyFileInfo(File) - - if OpenAIIdentifyData is not None: - state.LastIdentificationFromAI = True - return OpenAIIdentifyData - - if SkipOpenAI != True and type(state.LastOpenAIIdentifyFailure) == dict: - Auxiliary_NoteOpenAIBreakerEvent(state.LastOpenAIIdentifyFailure) - - if FallbackEnabled != True: - BaseRow = { - 'input_basename': File, - 'stage': 'Processing_Identification', - } - if type(state.LastOpenAIIdentifyFailure) == dict: - BaseRow.update(state.LastOpenAIIdentifyFailure) - else: - BaseRow['reason'] = 'openai_identify_returned_none' - BaseRow['detail'] = 'Auxiliary_OpenAIIdentifyFileInfo 返回 None(可能为 mock 或未记录原因)' - Auxiliary_AppendOpenAIIdentifyWarningLog(BaseRow) - Auxiliary_Log( - f'OpenAI 全信息识别失败,已跳过文件: {File}(明细已追加至 {Auxiliary_GetOpenAIIdentifyWarningLogPath().name})', - 'ERROR', - ) - return None - - # 走回退链路 - Info5, Meta = Auxiliary_ResolveFileInfoWithFallback(File) - if Info5 is None: - BaseRow = { - 'input_basename': File, - 'stage': 'Processing_Identification', - 'fallback': 'exhausted', - } - if type(state.LastOpenAIIdentifyFailure) == dict: - BaseRow.update(state.LastOpenAIIdentifyFailure) - Auxiliary_AppendOpenAIIdentifyWarningLog(BaseRow) - Auxiliary_Log( - f'OpenAI 识别 + 本地回退 + 传统 API 全部失败,已跳过文件: {File}', - 'ERROR', - ) - return None - - if type(Meta) == dict: - state.LastOpenAIFileInfoMeta = { - 'NameEN': Meta.get('NameEN', ''), - 'NameRomaji': Meta.get('NameRomaji', ''), - 'CanonicalID': Meta.get('CanonicalID', ''), - 'CanonicalZh': Meta.get('CanonicalZh', ''), - } - state.LastIdentificationFromAI = False - if BreakerTripped: - Auxiliary_Log( - f'OpenAI 熔断已触发(连续 401/403/429/missing_api_key >= 阈值),当前文件直接走回退链路: {File}', - 'INFO', - ) - Auxiliary_Log( - f'openai_failed_fallback_success << File={File}, Source={Meta.get("Source") if type(Meta) == dict else ""}', - 'INFO', - ) - return Info5 - - -__all__ = [ - 'Processing_Identification', - 'Auxiliary_OpenAIIdentifyFileInfo', - 'Auxiliary_ResolvePlannedTitleChain', - 'Auxiliary_NoteOpenAIIdentifyFailure', - 'Auxiliary_AppendOpenAIIdentifyWarningLog', - 'Auxiliary_GetOpenAIIdentifyWarningLogPath', - 'Auxiliary_IsFallbackEnabled', - 'Auxiliary_ResolveFileInfoWithFallback', - 'Auxiliary_NoteOpenAIBreakerEvent', - 'Auxiliary_ShouldTripOpenAIBreaker', - 'Auxiliary_ResetOpenAIBreaker', -] diff --git a/autoanime/identification/episode_rules.py b/autoanime/identification/episode_rules.py deleted file mode 100644 index 7d35dca..0000000 --- a/autoanime/identification/episode_rules.py +++ /dev/null @@ -1,253 +0,0 @@ -""" -autoanime 剧集/剧季规则 - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_IDE_ParseSeasonTokensFromFile` -- `Auxiliary_NormalizeEpisodeToken` -- `Auxiliary_CoalesceEpisodeFromParsed` / `Auxiliary_CoalesceSeasonFromParsed` -- `Auxiliary_RemappedJujutsuKaisenSeasonEpisode` -- `Auxiliary_RemoveEpisodeSuffixFromTitle` -- `Auxiliary_PreDetectEpisodeHint` -- `Auxiliary_BuildEpisodeDecisionKey` -- `Auxiliary_GetAbsoluteSourcePath` / `Auxiliary_GetSourceFileMTime` -""" - -from os import path -from pathlib import Path as PathlibPath -from re import I, findall, match, search, sub - -from .. import state -from ..naming import ( - Auxiliary_AnimeFileCheck, - Auxiliary_FormatSEEPToken, - Auxiliary_IDEEP, - Auxiliary_RMOTSTR, - Auxiliary_RMSubtitlingTeam, - Auxiliary_UniformOTSTR, -) -from ..text_utils import ( - Auxiliary_HasChineseText, - Auxiliary_NormalizeAliasKey, - Auxiliary_NormalizeApiTitle, - Auxiliary_NormalizeDisplayTitle, -) - - -def Auxiliary_IDE_ParseSeasonTokensFromFile(File): - '''仅从文件名解析季号,不截断剧名。返回 (SE, RAWSE, RomanSeasonToken)''' - SeasonMatchData = r'(季(.*?)第)|(([0-9]{0,1}[0-9]{1})S)|(([0-9]{0,1}[0-9]{1})nosaeS)|(([0-9]{0,1}[0-9]{1}) nosaeS)|(([0-9]{0,1}[0-9]{1})-nosaeS)|(nosaeS-dn([0-9]{1}))|(nosaeS-dr([0-9]{1}))' - SE = None - RAWSE = '' - RomanToken = '' - if (X := findall(SeasonMatchData, File[::-1], flags=I)) != []: - SEData = X - SEList = [] - for sedata in SEData: - for se in sedata: - if se != '' and se.isnumeric() == False: - RomanToken = se[::-1] - elif se.isnumeric() == True: - SEList.append(se) - for i in range(len(SEList)): - if SEList[i].isdecimal() == True: - SE = SEList[i][::-1] - elif '\u0e00' <= SEList[i] <= '\u9fa5': - digit = {'一': '01', '二': '02', '三': '03', '四': '04', '五': '05', '六': '06', '七': '07', '八': '08', '九': '09', - '壹': '01', '贰': '02', '叁': '03', '肆': '04', '伍': '05', '陆': '06', '柒': '07', '捌': '08', '玖': '09'} - SE = digit.get(SEList[i], '01') - if SE is not None: - RAWSE = str(SE).lstrip('0') or str(SE) - SE = str(SE) - return SE, RAWSE, RomanToken - elif (X := findall(r'[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫ]', File[::-1], flags=I)) != []: - A = {'Ⅰ': '01', 'Ⅱ': '02', 'Ⅲ': '03', 'Ⅳ': '04', 'Ⅴ': '05', 'Ⅵ': '06', 'Ⅶ': '07', 'Ⅷ': '08', 'Ⅸ': '09', 'Ⅹ': '10', 'Ⅺ': '11', 'Ⅻ': '12'} - SE = A[X[0]] - return SE, str(int(SE)), X[0] - return '01', '1', '' - - -def Auxiliary_NormalizeEpisodeToken(RawEpisode, FileName=''): - RawEpisode = '' if RawEpisode in [None, ''] else str(RawEpisode).strip() - if RawEpisode == '': - return '', True - RawEpisode = RawEpisode.replace('.', '.') - DecimalMatch = match(r'^([0-9]{1,4})\.([0-9]{1,2})$', RawEpisode) - if DecimalMatch is not None: - IntPart = DecimalMatch.group(1) - DecimalPart = DecimalMatch.group(2) - if DecimalPart.strip('0') == '': - RawEpisode = str(int(IntPart)) - elif DecimalPart == '5' and search(r'(?i)v[2-9]', str(FileName)) is not None: - RawEpisode = str(int(IntPart)) - IsSpecial = (RawEpisode in ['0', '00']) or ('.' in RawEpisode) - return RawEpisode, IsSpecial - - -def Auxiliary_CoalesceEpisodeFromParsed(ParsedData): - '''从模型 JSON 取剧集字段;不能用 `or` 链(episode 为整数 0 时会被当成假值丢弃)''' - if type(ParsedData) != dict: - return '' - for Key in ('episode', 'ep'): - if Key not in ParsedData: - continue - Val = ParsedData[Key] - if Val is None: - continue - Raw = str(Val).strip() - if Raw != '': - return Raw - return '' - - -def Auxiliary_CoalesceSeasonFromParsed(ParsedData, DefaultSeason='1'): - if type(ParsedData) != dict: - return DefaultSeason - for Key in ('season', 'se'): - if Key not in ParsedData: - continue - Val = ParsedData[Key] - if Val is None: - continue - Raw = str(Val).strip() - if Raw != '': - return Raw - return DefaultSeason - - -def Auxiliary_RemoveEpisodeSuffixFromTitle(Title, RawEpisode): - Title = Auxiliary_NormalizeDisplayTitle(Title) - EpisodeValue, _ = Auxiliary_NormalizeEpisodeToken(RawEpisode) - if Title == '' or EpisodeValue == '' or EpisodeValue.isdigit() == False: - return Auxiliary_NormalizeApiTitle(Title) - EpisodeInt = str(int(EpisodeValue)) - CandidateTitle = Title - CandidateTitle = sub(rf'[\s\-_]+0*{EpisodeInt}$', '', CandidateTitle, flags=I).strip(' -_') - CandidateTitle = sub(rf'第\s*0*{EpisodeInt}\s*[话話集]$', '', CandidateTitle, flags=I).strip(' -_') - CandidateTitle = sub(rf'[\(\[(【]\s*0*{EpisodeInt}\s*[\)\])】]$', '', CandidateTitle, flags=I).strip(' -_') - if CandidateTitle not in [None, '']: - return Auxiliary_NormalizeApiTitle(CandidateTitle) - return Auxiliary_NormalizeApiTitle(Title) - - -def Auxiliary_RemappedJujutsuKaisenSeasonEpisode(RAWSE, RAWEP, SE, EP, NameEN, NameRomaji, NameZH): - from ..apis.tmdb import ( - Auxiliary_GetTMDBTvSeasonLayoutBySeriesId, - Auxiliary_MapAbsoluteEpisodeUsingTMDBSeasonLayout, - Auxiliary_ResolveTMDBTvIdForJujutsuKaisen, - ) - from ..cache.canonical import Auxiliary_IsJujutsuKaisenSeries - - if Auxiliary_IsJujutsuKaisenSeries(NameEN, NameRomaji, NameZH) == False: - return None - RAWEP = str(RAWEP or '').strip() - if RAWEP == '' or RAWEP.split('.')[0].isdigit() == False: - return None - AbsEp = int(RAWEP.split('.')[0]) - SeasonPairs = [] - TvId = Auxiliary_ResolveTMDBTvIdForJujutsuKaisen(NameEN, NameRomaji) - if TvId not in [None, '']: - SeasonPairs = Auxiliary_GetTMDBTvSeasonLayoutBySeriesId(TvId) - FirstSeasonCap = SeasonPairs[0][1] if SeasonPairs else 24 - if AbsEp <= FirstSeasonCap: - return None - Mapped = Auxiliary_MapAbsoluteEpisodeUsingTMDBSeasonLayout(AbsEp, SeasonPairs) if SeasonPairs else None - if Mapped is None: - if AbsEp <= 47: - NewRAWSE = '2' - NewRAWEP = str(AbsEp - 24) - else: - NewRAWSE = '3' - NewRAWEP = str(AbsEp - 47) - else: - NewSeasonNum, NewEpInSeason = Mapped - NewRAWSE = str(int(NewSeasonNum)) - NewRAWEP = str(int(NewEpInSeason)) - NewSE = NewRAWSE.zfill(2) if state.SEEPSINGLECHARACTER == False else NewRAWSE.lstrip('0') - if NewSE in [None, '']: - NewSE = '1' if state.SEEPSINGLECHARACTER == True else '01' - NewEP = '0' + NewRAWEP if (len(NewRAWEP) < 2 or ('.' in NewRAWEP and NewRAWEP[0] != '0')) and (state.SEEPSINGLECHARACTER == False) else NewRAWEP - if state.SEEPSINGLECHARACTER == True: - NewSE = NewSE.lstrip('0') - NewEP = NewEP.lstrip('0') - NewSE = NewSE if NewSE not in [None, ''] else '0' - NewEP = NewEP if NewEP not in [None, ''] else '0' - return NewRAWSE, NewRAWEP, NewSE, NewEP - - -def Auxiliary_GetAbsoluteSourcePath(SourceFilePath): - SourceFilePath = '' if SourceFilePath in [None, ''] else str(SourceFilePath) - SourcePathObj = PathlibPath(SourceFilePath) - if SourcePathObj.is_absolute(): - return SourcePathObj - BasePath = PathlibPath(state.Path) if state.Path not in [None, ''] else ( - state.Runtime.source_path if state.Runtime else PathlibPath('.') - ) - return BasePath / SourcePathObj - - -def Auxiliary_GetSourceFileMTime(SourceFilePath): - SourcePathObj = Auxiliary_GetAbsoluteSourcePath(SourceFilePath) - try: - return float(SourcePathObj.stat().st_mtime) - except Exception: - return 0.0 - - -def Auxiliary_BuildEpisodeDecisionKey(CanonicalTitle, SE, EP, FileName): - CanonicalAliasKey = Auxiliary_NormalizeAliasKey(CanonicalTitle) - if CanonicalAliasKey == '': - return None - SEValue = Auxiliary_FormatSEEPToken(SE) - EPValue = Auxiliary_FormatSEEPToken(EP) - FileExt = str(path.splitext(path.basename(str(FileName)))[1]).lower() - if FileExt in ['.mp4', '.mkv']: - ExtBucket = 'video' - elif FileExt in ['.ass', '.srt']: - ExtBucket = 'subtitle' - else: - ExtBucket = FileExt if FileExt not in [None, ''] else 'unknown' - return f'{CanonicalAliasKey}|{SEValue}|{EPValue}|{ExtBucket}' - - -def Auxiliary_PreDetectEpisodeHint(FileName): - from ..cache.canonical import Auxiliary_ResolveCanonicalTitleByAliases - - QueryName = path.basename(str(FileName)) - NewFile = Auxiliary_RMSubtitlingTeam(Auxiliary_RMOTSTR(Auxiliary_UniformOTSTR(QueryName))) - if Auxiliary_AnimeFileCheck(NewFile) != True: - return None - try: - RAWEP = Auxiliary_IDEEP(NewFile) - except Exception: - return None - RAWEP, EpisodeSpecialFlag = Auxiliary_NormalizeEpisodeToken(RAWEP, QueryName) - if RAWEP in [None, '']: - return None - BaseTitle = path.splitext(NewFile)[0] - RAWName = Auxiliary_NormalizeApiTitle(BaseTitle) - EP = '0' + RAWEP if (len(RAWEP) < 2 or ('.' in RAWEP and RAWEP[0] != '0')) and (state.SEEPSINGLECHARACTER == False) else RAWEP - if EpisodeSpecialFlag: - SE = '00' if state.SEEPSINGLECHARACTER == False else '0' - RAWSE = '' - else: - SERaw, RSE, _ = Auxiliary_IDE_ParseSeasonTokensFromFile(NewFile) - SE = '0' + str(SERaw) if len(str(SERaw)) == 1 and state.SEEPSINGLECHARACTER == False else str(SERaw) - RAWSE = RSE - if state.SEEPSINGLECHARACTER == True: - SE = SE.lstrip('0') - EP = EP.lstrip('0') - SE = SE if SE not in [None, ''] else '0' - EP = EP if EP not in [None, ''] else '0' - CanonicalZh, CanonicalID, _ = Auxiliary_ResolveCanonicalTitleByAliases(RAWName) - CanonicalTitle = CanonicalZh if CanonicalZh not in [None, ''] else RAWName - EpisodeKey = Auxiliary_BuildEpisodeDecisionKey(CanonicalTitle, SE, EP, QueryName) - if EpisodeKey in [None, '']: - return None - return { - 'EpisodeKey': EpisodeKey, - 'SE': str(SE), - 'EP': str(EP), - 'RAWName': RAWName, - 'ApiName': CanonicalTitle, - 'CanonicalID': CanonicalID if CanonicalID not in [None, ''] else '', - } diff --git a/autoanime/identification/local_fallback.py b/autoanime/identification/local_fallback.py deleted file mode 100644 index 97dbadf..0000000 --- a/autoanime/identification/local_fallback.py +++ /dev/null @@ -1,244 +0,0 @@ -""" -autoanime 本地识别 + 传统 API 回退链路(fix_ai_fallback todo) - -当 OpenAI 主识别路径失败时,本模块提供"本地规则 -> BGM -> Bangumi -> TMDB"三路 -回退,确保不会因 missing_api_key / 429 / 网络抖动等暂时性故障而整盘跳过。 - -| 函数 | 作用 | 备注 | -| --- | --- | --- | -| `Auxiliary_FallbackLocalRules` | 仅用本地 IDESE/IDEEP/IDEVDName 规则抽取 (SE, EP, RAWSE, RAWEP, RAWName) | 不联网 | -| `Auxiliary_FallbackTraditionalApis` | 在本地规则基础上依次查 BGM -> Bangumi -> TMDB 中文标题 | 命中立即返回 | -| `Auxiliary_ResolveFileInfoWithFallback` | 对外主入口:AI 失败后的回退编排 | 返回与 `Auxiliary_OpenAIIdentifyFileInfo` 同结构元组 | - -熔断:`Auxiliary_ShouldTripOpenAIBreaker` / `Auxiliary_NoteOpenAIBreakerEvent` 记录 401/403/429 -连续次数,一旦超过 `OPENAI_FALLBACK_BREAKER_THRESHOLD`(默认 5)后续文件直接走回退链路。 -""" - -from os import path - -from .. import state -from ..logging_utils import Auxiliary_Log -from ..naming import ( - Auxiliary_AnimeFileCheck, - Auxiliary_IDEEP, - Auxiliary_IDEVDName, - Auxiliary_RMOTSTR, - Auxiliary_RMSubtitlingTeam, - Auxiliary_UniformOTSTR, -) -from ..text_utils import ( - Auxiliary_HasChineseText, - Auxiliary_NormalizeApiTitle, - Auxiliary_NormalizeDisplayTitle, -) -from .episode_rules import ( - Auxiliary_IDE_ParseSeasonTokensFromFile, - Auxiliary_NormalizeEpisodeToken, -) - - -_BREAKER_STATUS_CODES = {'401', '403', '429'} -_BREAKER_DEFAULT_THRESHOLD = 5 - - -def Auxiliary_IsFallbackEnabled() -> bool: - '''是否启用 AI 失败回退链路。默认 True;可通过 `config.ini` 的 `OPENAI_FALLBACK_ON_FAILURE` 关闭。''' - val = getattr(state, 'OPENAI_FALLBACK_ON_FAILURE', True) - if type(val) == bool: - return val - return str(val).strip().lower() not in ['false', '0', 'no', 'n', 'off'] - - -def _GetBreakerThreshold() -> int: - try: - val = int(getattr(state, 'OPENAI_FALLBACK_BREAKER_THRESHOLD', _BREAKER_DEFAULT_THRESHOLD)) - except Exception: - val = _BREAKER_DEFAULT_THRESHOLD - return val if val > 0 else _BREAKER_DEFAULT_THRESHOLD - - -def Auxiliary_NoteOpenAIBreakerEvent(failure: dict) -> None: - '''登记一次 AI 识别失败;当失败原因是认证/限流类 401/403/429 时增加熔断计数。''' - if type(failure) != dict: - return - reason = str(failure.get('reason', '')) - detail = str(failure.get('detail', '')) - triggered = False - if reason == 'http_status': - for code in _BREAKER_STATUS_CODES: - if f'status={code}' in detail: - triggered = True - break - elif reason == 'missing_api_key': - triggered = True - if triggered == False: - cur = int(getattr(state, 'OpenAIFallbackBreakerStreak', 0) or 0) - if cur > 0: - # 只要出现一次非熔断类失败就保留计数(不重置,避免被间歇性非熔断 err 清零) - pass - return - state.OpenAIFallbackBreakerStreak = int(getattr(state, 'OpenAIFallbackBreakerStreak', 0) or 0) + 1 - - -def Auxiliary_ResetOpenAIBreaker() -> None: - state.OpenAIFallbackBreakerStreak = 0 - - -def Auxiliary_ShouldTripOpenAIBreaker() -> bool: - '''返回 True 表示已连续累计 N 次熔断类失败,建议本轮后续文件直接跳过 AI、直走回退链路。''' - cur = int(getattr(state, 'OpenAIFallbackBreakerStreak', 0) or 0) - return cur >= _GetBreakerThreshold() - - -# ========================================================================= -# 本地规则回退 -# ========================================================================= -def Auxiliary_FallbackLocalRules(File: str): - '''只用本地正则规则提取 (SE, EP, RAWSE, RAWEP, RAWName)。 - - 返回 `(SE, EP, RAWSE, RAWEP, RAWName)` 或 None(剧集抽不出来时)。 - ''' - QueryFileName = path.basename(str(File)) - NewFile = Auxiliary_RMSubtitlingTeam(Auxiliary_RMOTSTR(Auxiliary_UniformOTSTR(QueryFileName))) - if Auxiliary_AnimeFileCheck(NewFile) != True: - return None - try: - RAWEP = Auxiliary_IDEEP(NewFile) - except Exception: - Auxiliary_Log(f'本地回退识别失败:无法抽取剧集 {QueryFileName}', 'WARNING') - return None - RAWEP, SpecialFlag = Auxiliary_NormalizeEpisodeToken(RAWEP, QueryFileName) - if RAWEP in [None, '']: - return None - - if SpecialFlag: - SE = '00' if state.SEEPSINGLECHARACTER == False else '0' - RAWSE = '' - else: - SERaw, RSE, _ = Auxiliary_IDE_ParseSeasonTokensFromFile(NewFile) - SE = '0' + str(SERaw) if len(str(SERaw)) == 1 and state.SEEPSINGLECHARACTER == False else str(SERaw) - RAWSE = RSE - EP = '0' + RAWEP if (len(RAWEP) < 2 or ('.' in RAWEP and RAWEP[0] != '0')) and (state.SEEPSINGLECHARACTER == False) else RAWEP - if state.SEEPSINGLECHARACTER == True: - SE = SE.lstrip('0') or '0' - EP = EP.lstrip('0') or '0' - - try: - RAWName = Auxiliary_IDEVDName(NewFile, RAWEP) - except Exception: - RAWName = path.splitext(QueryFileName)[0] - RAWName = Auxiliary_NormalizeApiTitle(RAWName) if RAWName not in [None, ''] else '' - if RAWName in [None, '']: - RAWName = path.splitext(QueryFileName)[0] - return SE, EP, RAWSE, RAWEP, RAWName - - -# ========================================================================= -# 本地规则 + 传统 API 三路回退 -# ========================================================================= -def Auxiliary_FallbackTraditionalApis(LocalBase): - '''在本地规则基础上查 BGM -> Bangumi -> TMDB 三路中文标题,取最先命中的。 - - 入参 `LocalBase` 必须是 `Auxiliary_FallbackLocalRules` 的返回元组。 - 返回 `(SE, EP, RAWSE, RAWEP, RAWName, NameEN, NameRomaji, CanonicalID)` 或 None。 - ''' - if LocalBase is None: - return None - SE, EP, RAWSE, RAWEP, RAWNameLocal = LocalBase - RAWNameLocal = Auxiliary_NormalizeDisplayTitle(RAWNameLocal) - - ChineseTitle = '' - SourceTag = '' - NameEN = '' - NameRomaji = '' - CanonicalID = '' - - from ..apis.bgm import Auxiliary_QueryBgmChineseTitle - from ..apis.bangumi import Auxiliary_QueryBangumiChineseTitle - from ..apis.tmdb import Auxiliary_QueryTMDBChineseTitle, Auxiliary_QueryTMDBEnglishTitle - from ..cache.canonical import ( - Auxiliary_GetCanonicalTitleRecord, - Auxiliary_ResolveCanonicalTitleByAliases, - Auxiliary_UpsertCanonicalTitle, - ) - - # 先从 Canonical 索引取英文/罗马音线索供各 API 使用 - CachedZh, CachedID, _ = Auxiliary_ResolveCanonicalTitleByAliases(RAWNameLocal) - if CachedID not in [None, '']: - CanonicalID = str(CachedID) - Record = Auxiliary_GetCanonicalTitleRecord(CanonicalID) - if type(Record) == dict: - NameEN = Auxiliary_NormalizeDisplayTitle(Record.get('en', '')) - NameRomaji = Auxiliary_NormalizeDisplayTitle(Record.get('romaji', '')) - if CachedZh not in [None, ''] and Auxiliary_HasChineseText(CachedZh): - ChineseTitle = CachedZh - SourceTag = 'canonical_cache' - - if ChineseTitle in [None, '']: - TryChain = [ - ('BGM', lambda: Auxiliary_QueryBgmChineseTitle(RAWNameLocal, NameEN, NameRomaji)), - ('Bangumi', lambda: Auxiliary_QueryBangumiChineseTitle(RAWNameLocal, NameEN, NameRomaji)), - ('TMDB', lambda: Auxiliary_QueryTMDBChineseTitle(RAWNameLocal, NameEN, NameRomaji)), - ] - for Tag, Fn in TryChain: - try: - Result = Fn() - except Exception as err: - Auxiliary_Log(f'回退链路 {Tag} 查询异常(已忽略):{err}', 'WARNING') - continue - Result = Auxiliary_NormalizeApiTitle(Result) if Result not in [None, ''] else '' - if Result not in [None, ''] and Auxiliary_HasChineseText(Result): - ChineseTitle = Result - SourceTag = Tag - break - - if NameEN in [None, '']: - try: - NameEN = Auxiliary_QueryTMDBEnglishTitle(RAWNameLocal) or '' - except Exception: - NameEN = '' - NameEN = Auxiliary_NormalizeDisplayTitle(NameEN) - - if ChineseTitle in [None, ''] and NameEN in [None, '']: - return None - - FinalName = ChineseTitle if ChineseTitle not in [None, ''] else RAWNameLocal - CanonicalFromUpsert, CanonicalZh = Auxiliary_UpsertCanonicalTitle( - ChineseTitle, - NameEN, - NameRomaji, - SourceTag if SourceTag not in [None, ''] else 'local_fallback', - [RAWNameLocal], - ) - if CanonicalFromUpsert not in [None, '']: - CanonicalID = CanonicalFromUpsert - if CanonicalZh not in [None, ''] and Auxiliary_HasChineseText(CanonicalZh): - FinalName = CanonicalZh - return SE, EP, RAWSE, RAWEP, FinalName, NameEN, NameRomaji, CanonicalID - - -def Auxiliary_ResolveFileInfoWithFallback(File: str): - '''对外主入口:AI 失败后的回退识别编排。 - - 返回 `(Info5, Meta)`: - - `Info5` : 与 `Auxiliary_OpenAIIdentifyFileInfo` 一致的 5 元组;失败时 None - - `Meta` : dict {NameEN, NameRomaji, CanonicalID, CanonicalZh, Source} - ''' - Local = Auxiliary_FallbackLocalRules(File) - if Local is None: - return None, None - Chain = Auxiliary_FallbackTraditionalApis(Local) - if Chain is None: - # 传统三路也全失败时,至少用本地规则结果(含无中文名的 RAWName)尝试整理 - SE, EP, RAWSE, RAWEP, RAWName = Local - Meta = {'NameEN': '', 'NameRomaji': '', 'CanonicalID': '', 'CanonicalZh': RAWName, 'Source': 'local_rules_only'} - return (SE, EP, RAWSE, RAWEP, RAWName), Meta - SE, EP, RAWSE, RAWEP, RAWName, NameEN, NameRomaji, CanonicalID = Chain - Meta = { - 'NameEN': NameEN, - 'NameRomaji': NameRomaji, - 'CanonicalID': CanonicalID, - 'CanonicalZh': RAWName, - 'Source': 'local_rules+traditional_api', - } - return (SE, EP, RAWSE, RAWEP, RAWName), Meta diff --git a/autoanime/identification/openai_identify.py b/autoanime/identification/openai_identify.py deleted file mode 100644 index 3ecf40c..0000000 --- a/autoanime/identification/openai_identify.py +++ /dev/null @@ -1,320 +0,0 @@ -""" -autoanime OpenAI 一次性全信息识别 - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_NoteOpenAIIdentifyFailure` -- `Auxiliary_GetOpenAIIdentifyWarningLogPath` -- `Auxiliary_AppendOpenAIIdentifyWarningLog` -- `Auxiliary_OpenAIIdentifyFileInfo` - -本模块直接调用 `apis.openai_client.Auxiliary_OpenAIChatCompletionsPost` 获得 -多槽位轮换能力;剧名二次标准化使用 `identification.title_chain.Auxiliary_ResolvePlannedTitleChain`。 -""" - -import json - -from os import path -from pathlib import Path as PathlibPath -from re import sub -from time import localtime, strftime, time - -from requests import exceptions, post - -from .. import state -from ..config_loader import ( - Auxiliary_GetOpenAIApiKey, - Auxiliary_ParseBool, - Auxiliary_ParseInt, -) -from ..logging_utils import Auxiliary_Log -from ..naming import Auxiliary_StripLeadingBracketReleaseTags -from .local_fallback import Auxiliary_IsFallbackEnabled - - -def _OpenAIFailLogLevel() -> str: - '''在启用回退链路时,OpenAI 主路径的预失败信息降级为 INFO,避免与后续回退成功叠成“假 WARN”。''' - return 'INFO' if Auxiliary_IsFallbackEnabled() else 'WARNING' -from ..text_utils import ( - Auxiliary_HasChineseText, - Auxiliary_NormalizeApiTitle, - Auxiliary_NormalizeDisplayTitle, - Auxiliary_ParseJsonFromAIContent, -) - - -def Auxiliary_NoteOpenAIIdentifyFailure(reason, detail='', **extra): - Pack = {'reason': str(reason), 'detail': str(detail)} - for Key, Val in extra.items(): - Pack[Key] = Val - state.LastOpenAIIdentifyFailure = Pack - - -def Auxiliary_GetOpenAIIdentifyWarningLogPath(): - if state.Runtime and getattr(state.Runtime, 'source_path', None): - LogBasePath = state.Runtime.source_path - if LogBasePath.exists() == False: - LogBasePath = PathlibPath(state.PyPath) - else: - LogBasePath = PathlibPath(state.PyPath) - OpDirName = str(state.OPERATION_LOG_DIR).strip() if state.OPERATION_LOG_DIR not in [None, ''] else 'logs' - return LogBasePath / OpDirName / 'AutoAnime_openai_identify_warnings.json' - - -def Auxiliary_AppendOpenAIIdentifyWarningLog(entry: dict): - '''追加 OpenAI 全信息识别失败记录到 logs/AutoAnime_openai_identify_warnings.json''' - LogPath = Auxiliary_GetOpenAIIdentifyWarningLogPath() - try: - LogPath.parent.mkdir(parents=True, exist_ok=True) - Records = [] - if LogPath.is_file(): - with open(LogPath, 'r', encoding='UTF-8') as LogFile: - try: - OldPayload = json.load(LogFile) - if type(OldPayload) == dict and type(OldPayload.get('records')) == list: - Records = OldPayload['records'] - except Exception: - Records = [] - Row = dict(entry) if type(entry) == dict else {'detail': str(entry)} - if 'timestamp' not in Row: - Row['timestamp'] = strftime('%Y-%m-%d %H:%M:%S', localtime(time())) - if 'run_id' not in Row: - Row['run_id'] = state.CurrentRunID - Records.append(Row) - Records.sort(key=lambda r: (str(r.get('timestamp', '')), str(r.get('run_id', '')), str(r.get('input_basename', '')))) - with open(LogPath, 'w', encoding='UTF-8') as LogFile: - json.dump({'records': Records}, LogFile, ensure_ascii=False, indent=2) - except Exception as err: - Auxiliary_Log(f'OpenAI 识别告警日志写入失败: {err}', 'WARNING') - - -def Auxiliary_OpenAIIdentifyFileInfo(FileName): - '''通过 OpenAI 一次性识别剧名/剧季/剧集;剧名经 TMDB 中文→Bangumi→TMDB 英文→OpenAI 译中文''' - from .episode_rules import ( - Auxiliary_CoalesceEpisodeFromParsed, - Auxiliary_CoalesceSeasonFromParsed, - Auxiliary_NormalizeEpisodeToken, - Auxiliary_PreDetectEpisodeHint, - ) - from .title_chain import ( - Auxiliary_ApplyStandardTitleCacheToFileInfoRecord, - Auxiliary_ResolvePlannedTitleChain, - ) - from ..cache.canonical import Auxiliary_GetCanonicalTitleRecord - - state.LastOpenAIFileInfoMeta = {} - state.LastOpenAIIdentifyFailure = None - if state.USEOPENAIAPI != True or state.OPENAI_IDENTIFY_ALL != True: - return None - QueryFileName = path.basename(FileName) - PromptBaseName = Auxiliary_StripLeadingBracketReleaseTags(QueryFileName) - InvalidNameSet = {'', 'None', 'none', 'null', '未知', '无法识别', '无法判断', '不确定'} - - def BuildMetaFromRecord(CacheRecord): - return { - 'NameEN': CacheRecord.get('NameEN', ''), - 'NameRomaji': CacheRecord.get('NameRomaji', ''), - 'CanonicalID': CacheRecord.get('CanonicalID', ''), - 'CanonicalZh': CacheRecord.get('RAWName', ''), - } - - if QueryFileName in state.OpenAIIdentifyFileMemoryCache: - CacheRecord = state.OpenAIIdentifyFileMemoryCache[QueryFileName] - if type(CacheRecord) == dict and all([Key in CacheRecord for Key in ['SE', 'EP', 'RAWSE', 'RAWEP', 'RAWName']]): - FixedRecord, Updated = Auxiliary_ApplyStandardTitleCacheToFileInfoRecord(CacheRecord) - if Updated == True: - state.OpenAIIdentifyFileMemoryCache[QueryFileName] = FixedRecord - CacheRecord = FixedRecord - if CacheRecord.get('RAWName') in [None, '']: - state.OpenAIIdentifyFileMemoryCache.pop(QueryFileName, None) - CacheRecord = None - if CacheRecord is not None: - Auxiliary_Log(f'OpenAI文件识别内存缓存命中 << {CacheRecord}', 'INFO') - state.LastOpenAIFileInfoMeta = BuildMetaFromRecord(CacheRecord) - return CacheRecord['SE'], CacheRecord['EP'], CacheRecord['RAWSE'], CacheRecord['RAWEP'], CacheRecord['RAWName'] - - ApiKey = Auxiliary_GetOpenAIApiKey() - if ApiKey in [None, '']: - Auxiliary_Log('OpenAI文件识别需要 OPENAI_API_KEY', _OpenAIFailLogLevel()) - Auxiliary_NoteOpenAIIdentifyFailure('missing_api_key', '未配置 OPENAI_API_KEY', input_basename=QueryFileName) - return None - - BaseUrl = state.OPENAI_BASE_URL if state.OPENAI_BASE_URL not in [None, ''] else 'https://api.longcat.chat/openai' - ModelName = state.OPENAI_MODEL if state.OPENAI_MODEL not in [None, ''] else 'LongCat-Flash-Chat' - TimeoutSeconds = Auxiliary_ParseInt(state.OPENAI_TIMEOUT_SECONDS, 60) - if TimeoutSeconds <= 0: - TimeoutSeconds = 60 - RetryTimes = Auxiliary_ParseInt(state.NETERRRECTRYTIMS, 2) - if RetryTimes < 0: - RetryTimes = 0 - HttpData = None - try: - for RetryIndex in range(RetryTimes + 1): - try: - HttpData = post( - f'{BaseUrl.rstrip("/")}/v1/chat/completions', - json={ - 'model': ModelName, - 'temperature': 0, - 'messages': [ - { - 'role': 'system', - 'content': '你是番剧文件识别助手。请根据用户提供的单个文件名,识别并仅输出 JSON:{"anime_name_zh":"简体中文番剧名","anime_name_en":"英文名或常见英文写法","anime_name_romaji":"罗马音","season":"季数字(未知填1)","episode":"集数字或小数","special":false}。anime_name_zh 必须尽量返回简体中文标准名称;若当前无法确定中文,请保持 anime_name_zh 为空字符串,同时尽可能给出 anime_name_en 或 anime_name_romaji。anime_name_zh、anime_name_en、anime_name_romaji 只允许填写番剧主标题,禁止包含季信息(如 S2、Season 2、2nd Season、第二季等)。不要输出解释文本。' - '文件名最前面的半角方括号 […] 与全角书名号式标签 【…】 中多为字幕组/发行方标记,不是番剧标题;anime_name_zh、anime_name_en、anime_name_romaji 只填作品主标题。' - }, - {'role': 'user', 'content': PromptBaseName}, - ], - }, - headers={ - 'Authorization': f'Bearer {ApiKey}', - 'Content-Type': 'application/json', - 'User-Agent': f'AutoAnimeMv/{state.Versions}', - }, - timeout=TimeoutSeconds, - ) - except exceptions.RequestException as err: - Lvl = _OpenAIFailLogLevel() - if RetryIndex < RetryTimes: - Auxiliary_Log(f'OpenAI文件识别请求超时/失败,第{RetryIndex+1}/{RetryTimes+1}次重试: {err}', Lvl) - continue - Auxiliary_Log(f'OpenAI文件识别请求失败: {err}', Lvl) - Auxiliary_NoteOpenAIIdentifyFailure('http_request_failed', str(err), input_basename=QueryFileName) - return None - if HttpData.status_code == 200: - break - Lvl = _OpenAIFailLogLevel() - if RetryIndex < RetryTimes: - Auxiliary_Log(f'OpenAI文件识别请求失败,状态码 {HttpData.status_code},第{RetryIndex+1}/{RetryTimes+1}次重试', Lvl) - continue - Auxiliary_Log(f'OpenAI文件识别请求失败,状态码 {HttpData.status_code}', Lvl) - Auxiliary_NoteOpenAIIdentifyFailure('http_status', f'status={HttpData.status_code}', input_basename=QueryFileName) - return None - if HttpData in [None, '']: - Auxiliary_Log('OpenAI文件识别请求失败,未获得有效响应', _OpenAIFailLogLevel()) - Auxiliary_NoteOpenAIIdentifyFailure('no_http_response', '', input_basename=QueryFileName) - return None - OpenAIData = HttpData.json() - if type(OpenAIData) != dict: - Auxiliary_Log('OpenAI文件识别返回数据结构异常', _OpenAIFailLogLevel()) - Auxiliary_NoteOpenAIIdentifyFailure('response_not_dict', '', input_basename=QueryFileName) - return None - Choices = OpenAIData.get('choices', []) - if type(Choices) != list or Choices == []: - Auxiliary_Log('OpenAI文件识别返回格式异常: 缺少 choices', _OpenAIFailLogLevel()) - Auxiliary_NoteOpenAIIdentifyFailure('no_choices', '', input_basename=QueryFileName) - return None - Message = Choices[0].get('message', {}) - ParsedData = Auxiliary_ParseJsonFromAIContent(Message.get('content', '') if type(Message) == dict else '') - if type(ParsedData) != dict: - Auxiliary_Log('OpenAI文件识别返回内容不是有效 JSON', _OpenAIFailLogLevel()) - RawPreview = Message.get('content', '') if type(Message) == dict else '' - if type(RawPreview) == str and len(RawPreview) > 800: - RawPreview = RawPreview[:800] + '…' - Auxiliary_NoteOpenAIIdentifyFailure('content_not_json', 'choices[0].message.content 无法解析为对象', input_basename=QueryFileName, raw_content_preview=RawPreview) - return None - - NameZH = Auxiliary_NormalizeApiTitle( - ParsedData.get('anime_name_zh') - or ParsedData.get('anime_name') - or ParsedData.get('title') - or ParsedData.get('name') - or '' - ) - NameEN = Auxiliary_NormalizeDisplayTitle( - ParsedData.get('anime_name_en') - or ParsedData.get('english_title') - or ParsedData.get('title_en') - or ParsedData.get('name_en') - or '' - ) - NameRomaji = Auxiliary_NormalizeDisplayTitle( - ParsedData.get('anime_name_romaji') - or ParsedData.get('romaji_title') - or ParsedData.get('title_romaji') - or ParsedData.get('name_romaji') - or '' - ) - if NameZH in InvalidNameSet: - NameZH = '' - if NameEN in InvalidNameSet: - NameEN = '' - if NameRomaji in InvalidNameSet: - NameRomaji = '' - if NameZH not in [None, ''] and Auxiliary_HasChineseText(NameZH) == False: - NameZH = '' - AINameZH = NameZH - - RAWEP = Auxiliary_CoalesceEpisodeFromParsed(ParsedData) - RAWEP, EpisodeSpecialFlag = Auxiliary_NormalizeEpisodeToken(RAWEP, QueryFileName) - if RAWEP in [None, '']: - Auxiliary_Log(f'OpenAI文件识别未返回可用剧集: {QueryFileName}', _OpenAIFailLogLevel()) - Snap = {} - for Key in ('anime_name_zh', 'anime_name_en', 'anime_name_romaji', 'season', 'episode', 'ep', 'se', 'special'): - if Key in ParsedData: - Snap[Key] = ParsedData.get(Key) - Auxiliary_NoteOpenAIIdentifyFailure( - 'episode_missing', - 'episode/ep 缺失、为空或归一后不可用(注意:整数 0 是合法第 0 集)', - input_basename=QueryFileName, - openai_parsed_snapshot=Snap, - ) - return None - - NameZH_out, CanonicalID, NameEN, NameRomaji = Auxiliary_ResolvePlannedTitleChain(AINameZH, NameEN, NameRomaji, FileName) - RAWName = NameZH_out - HintInfo = Auxiliary_PreDetectEpisodeHint(QueryFileName) - if type(HintInfo) == dict: - HintCanonicalID = str(HintInfo.get('CanonicalID') or '') - if HintCanonicalID != '': - HintRecord = Auxiliary_GetCanonicalTitleRecord(HintCanonicalID) - if type(HintRecord) == dict: - HintZh = Auxiliary_NormalizeApiTitle(HintRecord.get('zh', '')) - if HintZh not in [None, '']: - RAWName = HintZh - CanonicalID = HintCanonicalID - - SpecialFlag = Auxiliary_ParseBool(ParsedData.get('special', False)) - if SpecialFlag != True: - SpecialFlag = EpisodeSpecialFlag - if SpecialFlag == True: - SE = '00' if state.SEEPSINGLECHARACTER == False else '0' - RAWSE = '' - else: - SeasonValue = Auxiliary_CoalesceSeasonFromParsed(ParsedData, '1') - SeasonValue = sub(r'[^0-9]', '', str(SeasonValue).strip()) if SeasonValue not in [None, ''] else '1' - SeasonValue = '1' if SeasonValue in [None, '', '0'] else SeasonValue - RAWSE = SeasonValue - SE = SeasonValue.zfill(2) if state.SEEPSINGLECHARACTER == False else SeasonValue.lstrip('0') - if SE in [None, '']: - SE = '1' if state.SEEPSINGLECHARACTER == True else '01' - - EP = '0' + RAWEP if (len(RAWEP) < 2 or ('.' in RAWEP and RAWEP[0] != '0')) and (state.SEEPSINGLECHARACTER == False) else RAWEP - if state.SEEPSINGLECHARACTER == True: - SE = SE.lstrip('0') - EP = EP.lstrip('0') - SE = SE if SE not in [None, ''] else '0' - EP = EP if EP not in [None, ''] else '0' - - CacheRecord = { - 'SE': SE, - 'EP': EP, - 'RAWSE': RAWSE, - 'RAWEP': RAWEP, - 'RAWName': RAWName, - 'NameEN': NameEN, - 'NameRomaji': NameRomaji, - 'CanonicalID': CanonicalID if CanonicalID not in [None, ''] else '', - } - CacheRecord, _ = Auxiliary_ApplyStandardTitleCacheToFileInfoRecord(CacheRecord) - SE = CacheRecord.get('SE', SE) - EP = CacheRecord.get('EP', EP) - RAWSE = CacheRecord.get('RAWSE', RAWSE) - RAWEP = CacheRecord.get('RAWEP', RAWEP) - RAWName = CacheRecord.get('RAWName', RAWName) - state.OpenAIIdentifyFileMemoryCache[QueryFileName] = CacheRecord - state.LastOpenAIFileInfoMeta = BuildMetaFromRecord(CacheRecord) - Auxiliary_Log(f'OpenAI文件识别成功 => 剧名:{RAWName} 季:{SE} 集:{EP}', 'INFO') - return SE, EP, RAWSE, RAWEP, RAWName - except Exception as err: - Auxiliary_Log(f'OpenAI文件识别处理失败: {err}', _OpenAIFailLogLevel()) - Auxiliary_NoteOpenAIIdentifyFailure('exception', str(err), input_basename=path.basename(FileName)) - return None diff --git a/autoanime/identification/title_chain.py b/autoanime/identification/title_chain.py deleted file mode 100644 index b0d4944..0000000 --- a/autoanime/identification/title_chain.py +++ /dev/null @@ -1,264 +0,0 @@ -""" -autoanime 剧名标准化链 - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_ResolvePlannedTitleChain` -- `Auxiliary_GetStandardTitleCacheCandidates` -- `Auxiliary_GetStandardTitleFromCache` -- `Auxiliary_ApplyStandardTitleCacheToFileInfoRecord` -- `Auxiliary_ShouldCacheResolvedFileInfo` -""" - -from os import path -from re import sub - -from .. import state -from ..logging_utils import Auxiliary_Exit -from ..text_utils import ( - Auxiliary_HasChineseText, - Auxiliary_NormalizeApiTitle, - Auxiliary_NormalizeDisplayTitle, -) - - -def Auxiliary_GetStandardTitleCacheCandidates(QueryName): - QueryName = Auxiliary_NormalizeDisplayTitle(QueryName) - if QueryName == '': - return [] - - CandidateList = [] - - def AddCandidate(Value): - Value = Auxiliary_NormalizeDisplayTitle(Value) - if Value not in [None, ''] and Value not in CandidateList: - CandidateList.append(Value) - - CompactName = sub(r'\s+', ' ', QueryName).strip() - AddCandidate(QueryName) - AddCandidate(CompactName) - AddCandidate(CompactName.replace(' ', '-')) - AddCandidate(CompactName.replace('-', ' ')) - AddCandidate(CompactName.replace(' ', '')) - AddCandidate(CompactName.replace('-', '')) - return CandidateList - - -def Auxiliary_GetStandardTitleFromCache(QueryName): - from ..cache.canonical import ( - Auxiliary_ResolveCanonicalTitleByAliases, - Auxiliary_UpsertCanonicalTitle, - ) - from ..cache.persistent import Auxiliary_GetPersistentCache - - QueryName = Auxiliary_NormalizeDisplayTitle(QueryName) - if QueryName == '': - return None - CanonicalZh, _, _ = Auxiliary_ResolveCanonicalTitleByAliases(QueryName) - if CanonicalZh not in [None, '']: - return CanonicalZh - CacheGroupList = [ - ('Bangumi', state.BangumiAPIDataCache), - ('TMDB', state.TMDBAPIDataCache), - ] - for CacheKey in Auxiliary_GetStandardTitleCacheCandidates(QueryName): - for CacheGroup, InMemoryCache in CacheGroupList: - if type(InMemoryCache) == dict and CacheKey in InMemoryCache: - CacheValue = InMemoryCache[CacheKey] - else: - CacheValue = Auxiliary_GetPersistentCache(CacheGroup, CacheKey) - if CacheValue not in [None, ''] and type(InMemoryCache) == dict: - InMemoryCache[CacheKey] = CacheValue - CacheValue = Auxiliary_NormalizeDisplayTitle(CacheValue) - if CacheValue in [None, '']: - continue - CanonicalZh, _, _ = Auxiliary_ResolveCanonicalTitleByAliases(QueryName, CacheValue, CacheKey) - if CanonicalZh not in [None, '']: - return CanonicalZh - CandidateZh = Auxiliary_NormalizeApiTitle(CacheValue) - CandidateEn = CacheKey if Auxiliary_HasChineseText(CacheKey) == False else '' - if Auxiliary_HasChineseText(CandidateZh) == False: - CandidateZh = '' - if CandidateEn in [None, '']: - CandidateEn = CacheValue - _, CanonicalZh = Auxiliary_UpsertCanonicalTitle( - CandidateZh, CandidateEn, '', CacheGroup, [QueryName, CacheKey, CacheValue], - ) - if CanonicalZh not in [None, '']: - return CanonicalZh - if CandidateZh not in [None, '']: - return CandidateZh - return None - - -def Auxiliary_ResolvePlannedTitleChain(AINameZH, NameEN, NameRomaji, QueryFileName): - ''' - 剧名:TMDB 中文 → Bangumi 中文 → TMDB 英文 → OpenAI 译中文。 - 返回 (中文主名, CanonicalID, NameEN, NameRomaji);失败则 Auxiliary_Exit。 - ''' - from ..apis.bangumi import Auxiliary_QueryBangumiChineseTitle - from ..apis.openai_client import Auxiliary_OpenAITranslateForeignTitleToChinese - from ..apis.tmdb import ( - Auxiliary_QueryTMDBChineseTitle, - Auxiliary_QueryTMDBEnglishTitle, - ) - from ..cache.canonical import Auxiliary_UpsertCanonicalTitle - from ..cache.manual_whitelist import Auxiliary_GetManualWhitelistedTitle - - AINameZH = Auxiliary_NormalizeApiTitle(AINameZH or '') - NameEN = Auxiliary_NormalizeDisplayTitle(NameEN or '') - NameRomaji = Auxiliary_NormalizeDisplayTitle(NameRomaji or '') - BaseName = path.basename(str(QueryFileName)) - queries = [] - for q in [AINameZH, NameRomaji, NameEN, BaseName]: - qn = Auxiliary_NormalizeDisplayTitle(q or '') - if qn not in [None, ''] and qn not in queries: - queries.append(qn) - AliasBundle = queries.copy() - - if (ManualWhitelistedTitle := Auxiliary_GetManualWhitelistedTitle(*queries)) not in [None, '']: - cid, zh = Auxiliary_UpsertCanonicalTitle( - ManualWhitelistedTitle, NameEN, NameRomaji, 'manual', AliasBundle, - ) - return (zh if zh not in [None, ''] else ManualWhitelistedTitle), (cid or ''), NameEN, NameRomaji - - for q in queries: - if state.USETMDBAPI == True: - zh = Auxiliary_QueryTMDBChineseTitle(q, CandidateEn=NameEN or q, CandidateRomaji=NameRomaji, AliasList=AliasBundle) - if zh not in [None, ''] and Auxiliary_HasChineseText(zh): - cid, final = Auxiliary_UpsertCanonicalTitle(zh, NameEN, NameRomaji, 'TMDB', AliasBundle) - return (final if final not in [None, ''] else zh), (cid or ''), NameEN, NameRomaji - for q in queries: - if state.USEBANGUMIAPI == True: - zh = Auxiliary_QueryBangumiChineseTitle(q, CandidateEn=NameEN or q, CandidateRomaji=NameRomaji, AliasList=AliasBundle) - if zh not in [None, ''] and Auxiliary_HasChineseText(zh): - cid, final = Auxiliary_UpsertCanonicalTitle(zh, NameEN, NameRomaji, 'Bangumi', AliasBundle) - return (final if final not in [None, ''] else zh), (cid or ''), NameEN, NameRomaji - - EnTitle = None - for q in queries: - if state.USETMDBAPI == True: - EnTitle = Auxiliary_QueryTMDBEnglishTitle(q, CandidateEn=NameEN or q, CandidateRomaji=NameRomaji, AliasList=AliasBundle) - if EnTitle not in [None, '']: - if NameEN in [None, '']: - NameEN = EnTitle - break - ForeignForTranslate = EnTitle or NameEN or NameRomaji or '' - if ForeignForTranslate in [None, ''] and queries: - ForeignForTranslate = queries[0] - if state.USEOPENAIAPI == True: - Translated = Auxiliary_OpenAITranslateForeignTitleToChinese(ForeignForTranslate) - if Translated not in [None, '']: - cid, final = Auxiliary_UpsertCanonicalTitle(Translated, NameEN or ForeignForTranslate, NameRomaji, 'OpenAI', AliasBundle) - return (final if final not in [None, ''] else Translated), (cid or ''), NameEN, NameRomaji - if AINameZH not in [None, ''] and Auxiliary_HasChineseText(AINameZH): - cid, final = Auxiliary_UpsertCanonicalTitle(AINameZH, NameEN, NameRomaji, 'OpenAI', AliasBundle) - return (final if final not in [None, ''] else AINameZH), (cid or ''), NameEN, NameRomaji - Auxiliary_Exit('剧名解析链失败:TMDB 中文、Bangumi、TMDB 英文与 OpenAI 译中文均未得到可用简体中文剧名,已中止整理') - - -def Auxiliary_ApplyStandardTitleCacheToFileInfoRecord(CacheRecord): - from ..cache.canonical import ( - Auxiliary_ContractJujutsuKaisenChineseTitle, - Auxiliary_ResolveCanonicalTitleByAliases, - Auxiliary_UpsertCanonicalTitle, - ) - from .episode_rules import ( - Auxiliary_NormalizeEpisodeToken, - Auxiliary_RemappedJujutsuKaisenSeasonEpisode, - ) - - if type(CacheRecord) != dict or all([Key in CacheRecord for Key in ['SE', 'EP', 'RAWSE', 'RAWEP', 'RAWName']]) != True: - return CacheRecord, False - FixedRecord = CacheRecord.copy() - FixedRecord['RAWEP'] = str(FixedRecord.get('RAWEP', '')) - FixedRecord['RAWEP'], _ = Auxiliary_NormalizeEpisodeToken(FixedRecord['RAWEP']) - FixedRecord['RAWName'] = Auxiliary_NormalizeApiTitle(FixedRecord.get('RAWName')) - FixedRecord['NameEN'] = Auxiliary_NormalizeDisplayTitle(FixedRecord.get('NameEN') or FixedRecord.get('RAWNameEN') or '') - FixedRecord['NameRomaji'] = Auxiliary_NormalizeDisplayTitle(FixedRecord.get('NameRomaji') or FixedRecord.get('RAWNameRomaji') or '') - FixedRecord['CanonicalID'] = str(FixedRecord.get('CanonicalID') or '') - ChangedFlag = False - - CanonicalZh, CanonicalID, _ = Auxiliary_ResolveCanonicalTitleByAliases( - FixedRecord.get('RAWName'), - FixedRecord.get('NameEN'), - FixedRecord.get('NameRomaji'), - ) - if CanonicalZh in [None, '']: - CachedTitle = Auxiliary_GetStandardTitleFromCache( - FixedRecord.get('RAWName') or FixedRecord.get('NameEN') or FixedRecord.get('NameRomaji') - ) - if CachedTitle not in [None, '']: - CanonicalZh = CachedTitle - if CanonicalZh not in [None, ''] and CanonicalZh != FixedRecord.get('RAWName'): - FixedRecord['RAWName'] = CanonicalZh - ChangedFlag = True - if CanonicalID not in [None, ''] and CanonicalID != FixedRecord.get('CanonicalID'): - FixedRecord['CanonicalID'] = CanonicalID - ChangedFlag = True - UpsertCanonicalID, UpsertCanonicalZh = Auxiliary_UpsertCanonicalTitle( - FixedRecord.get('RAWName', ''), - FixedRecord.get('NameEN', ''), - FixedRecord.get('NameRomaji', ''), - 'openai_identify', - [FixedRecord.get('RAWName'), FixedRecord.get('NameEN'), FixedRecord.get('NameRomaji')], - ) - if UpsertCanonicalID not in [None, ''] and FixedRecord.get('CanonicalID') != UpsertCanonicalID: - FixedRecord['CanonicalID'] = UpsertCanonicalID - ChangedFlag = True - if UpsertCanonicalZh not in [None, ''] and FixedRecord.get('RAWName') != UpsertCanonicalZh: - FixedRecord['RAWName'] = UpsertCanonicalZh - ChangedFlag = True - ContractedZh = Auxiliary_ContractJujutsuKaisenChineseTitle(FixedRecord.get('RAWName', '')) - if ContractedZh not in [None, ''] and ContractedZh != FixedRecord.get('RAWName'): - FixedRecord['RAWName'] = ContractedZh - ChangedFlag = True - ReUpsertID, ReUpsertZh = Auxiliary_UpsertCanonicalTitle( - ContractedZh, - FixedRecord.get('NameEN', ''), - FixedRecord.get('NameRomaji', ''), - 'openai_identify', - [ContractedZh, FixedRecord.get('NameEN', ''), FixedRecord.get('NameRomaji', '')], - ) - if ReUpsertID not in [None, '']: - FixedRecord['CanonicalID'] = ReUpsertID - ChangedFlag = True - if ReUpsertZh not in [None, ''] and ReUpsertZh != FixedRecord.get('RAWName'): - FixedRecord['RAWName'] = ReUpsertZh - ChangedFlag = True - RemapTuple = Auxiliary_RemappedJujutsuKaisenSeasonEpisode( - FixedRecord.get('RAWSE'), - FixedRecord.get('RAWEP'), - FixedRecord.get('SE'), - FixedRecord.get('EP'), - FixedRecord.get('NameEN', ''), - FixedRecord.get('NameRomaji', ''), - FixedRecord.get('RAWName', ''), - ) - if RemapTuple is not None: - NewRAWSE, NewRAWEP, NewSE, NewEP = RemapTuple - if ( - NewRAWSE != str(FixedRecord.get('RAWSE', '')) - or NewRAWEP != str(FixedRecord.get('RAWEP', '')) - or NewSE != str(FixedRecord.get('SE', '')) - or NewEP != str(FixedRecord.get('EP', '')) - ): - FixedRecord['RAWSE'] = NewRAWSE - FixedRecord['RAWEP'] = NewRAWEP - FixedRecord['SE'] = NewSE - FixedRecord['EP'] = NewEP - ChangedFlag = True - return FixedRecord, ChangedFlag - - -def Auxiliary_ShouldCacheResolvedFileInfo(OperationResult): - if type(OperationResult) != dict: - return False - Status = OperationResult.get('status') - Message = OperationResult.get('message') - if Status == 'success': - return True - if Status == 'dry-run': - return True - if Status == 'skipped' and Message in ['same_file', 'existing_link_kept', 'target_exists', 'newer_duplicate_kept_oldest']: - return True - return False diff --git a/autoanime/logging_utils.py b/autoanime/logging_utils.py deleted file mode 100644 index b05b041..0000000 --- a/autoanime/logging_utils.py +++ /dev/null @@ -1,151 +0,0 @@ -""" -autoanime 日志工具 - -对应原 `AutoAnimeMv.py` 中下列函数: -- `Auxiliary_Log` -- `Auxiliary_ShouldPrintConsoleLog` -- `Auxiliary_WriteLog` -- `Auxiliary_Exit` -- `Auxiliary_FormatListPreview` -- `Auxiliary_DeleteLogs` - -所有函数名与原单文件保持一致,方便直接 `from autoanime.logging_utils import Auxiliary_Log`。 -""" - -from datetime import datetime -from os import makedirs, path, remove -from re import I, match -from time import localtime, strftime, time - -from . import state - - -def Auxiliary_ShouldPrintConsoleLog(OneMsg, MsgFlag='INFO', flag=None): - '''控制终端输出,只保留与番剧整理直接相关的信息''' - - if flag == 'PRINT': - return True - if MsgFlag != 'INFO': - return True - OneMsg = str(OneMsg).strip() - if OneMsg == '': - return False - if set(OneMsg) == {'-'}: - return False - - SilentPrefixes = ( - '正在读取外置ini文件', - '读取到配置分区:', - '配置 < ', - '当前工具版本为', - '当前操作系统识别码为', - 'filepath < ', - 'filename < ', - 'number < ', - 'categoryname < ', - 'animename < ', - 'tag < ', - 'NAMING_STYLE < ', - 'DRY_RUN < ', - 'OUTPUT_PATH < ', - 'STRICT_MODE < ', - 'USELINK < ', - '当前分类 >> ', - '排除模块:', - '模块 << ', - '无扩展', - '不存在扩展文件夹 ./Ext', - '已加载持久化缓存文件 ', - '持久化缓存写入完成 ', - 'OpenAI文件识别缓存标题已按标准化缓存修正:', - 'OpenAI文件识别缓存命中 << ', - 'OpenAI文件识别持久化缓存标题已按标准化缓存修正:', - 'OpenAI文件识别持久化缓存命中 << ', - '没有使用OpenAIApi进行检索', - '没有使用BgmApi进行检索', - '没有使用TMDBApi进行检索', - '没有使用BangumiApi进行检索', - '代理功能开启', - '使用系统代理', - ) - SilentSubstrings = ( - '秒延时中', - '个可加载模块', - '内存缓存查询结果', - '持久化缓存查询结果', - 'OpenAIApi查询结果', - 'BgmApi查询结果', - 'TMDBApi查询结果', - 'BangumiApi查询结果', - 'API获取到结果', - ) - if any(OneMsg.startswith(Prefix) for Prefix in SilentPrefixes): - return False - if any(Keyword in OneMsg for Keyword in SilentSubstrings): - return False - return True - - -def Auxiliary_Log(Msg: str, MsgFlag='INFO', flag=None, end='\n'): - '''日志''' - - Msg = Msg if type(Msg) == tuple else (Msg,) - for OneMsg in Msg: - FormattedMsg = f'[{strftime("%Y-%m-%d %H:%M:%S", localtime(time()))}] {MsgFlag}: {OneMsg}' - if (state.PRINTLOGFLAG == True or flag == 'PRINT') and Auxiliary_ShouldPrintConsoleLog(OneMsg, MsgFlag, flag): - print(FormattedMsg, end=end) - state.LogData = state.LogData + '\n' + FormattedMsg if state.LogData not in [None, ''] else FormattedMsg - - -def Auxiliary_FormatListPreview(FileList, preview_count=12): - '''列表日志预览,避免一次性输出过长内容拖慢终端''' - - if type(FileList) != list: - return str(FileList) - TotalCount = len(FileList) - if TotalCount <= preview_count: - return str(FileList) - PreviewList = FileList[:preview_count] - return f'{PreviewList} ... 省略{TotalCount - preview_count}项' - - -def Auxiliary_DeleteLogs(): - '''日志清理''' - - RmLogsList = [] - if state.RMLOGSFLAG != False and state.LogsFileList != []: - ToDay = datetime.strptime(datetime.now().strftime('%Y-%m-%d'), "%Y-%m-%d").date() - for Logs in state.LogsFileList: - LogFileName = path.basename(Logs) - if match(r'^\d{4}-\d{2}-\d{2}\.log$', LogFileName, flags=I) == None: - continue - LogDate = datetime.strptime(LogFileName.replace('.log', ''), "%Y-%m-%d").date() - if (ToDay - LogDate).days >= int(state.RMLOGSFLAG): - remove(f'{state.Path}{state.Separator}{Logs}') - RmLogsList.append(Logs) - if RmLogsList != []: - Auxiliary_Log(f'清理了保存时间达到和超过{state.RMLOGSFLAG}天的日志文件 << {RmLogsList}') - - -def Auxiliary_WriteLog(): - '''写log文件''' - - LogPath = state.filepath if state.filepath not in [None, ''] and path.exists(state.filepath) == True else state.PyPath - if LogPath in [None, '']: - from pathlib import Path as _P - LogPath = str(_P('.').resolve()) - if path.exists(LogPath) == False: - makedirs(LogPath, exist_ok=True) - if LogPath == state.PyPath: - Out = str(getattr(state, 'OUTPUT_PATH', '') or '').strip() - if Out == '': - Auxiliary_Log('Log文件保存在工具目录下', 'WARNING') - with open(f'{LogPath}{state.Separator}{strftime("%Y-%m-%d", localtime(time()))}.log', 'a+', encoding='UTF-8') as LogFile: - LogFile.write(state.LogData) - - -def Auxiliary_Exit(LogMsg): - '''因可预见错误离场''' - - Auxiliary_Log(LogMsg, 'EXIT', flag='PRINT') - raise SystemExit(0) diff --git a/autoanime/naming.py b/autoanime/naming.py deleted file mode 100644 index 6a0a8d2..0000000 --- a/autoanime/naming.py +++ /dev/null @@ -1,263 +0,0 @@ -""" -autoanime 命名/识别辅助(纯文本规则,不涉及网络) - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_SanitizePathComponent` -- `Auxiliary_FormatSEEPToken` -- `Auxiliary_UniformOTSTR` -- `Auxiliary_RMOTSTR` -- `Auxiliary_RMSubtitlingTeam` -- `Auxiliary_IDESE` -- `Auxiliary_IDEEP` -- `Auxiliary_IDEVDName` -- `Auxiliary_IDEASS` -- `Auxiliary_AnimeFileCheck` -- `Auxiliary_FileType` -- `Auxiliary_ASSFileCA` -- `Auxiliary_SubtitleLanguageSuffixForEmby` -- `Auxiliary_StripLeadingBracketReleaseTags` -""" - -from os import path -from re import I, compile, findall, match, search, sub - -from zhconv.zhconv import convert - -from . import state -from .config_loader import Auxiliary_ParseInt -from .config_model import WINDOWS_RESERVED_NAMES -from .logging_utils import Auxiliary_Exit, Auxiliary_Log, Auxiliary_FormatListPreview -from .text_utils import Auxiliary_NormalizeDisplayTitle - - -def Auxiliary_SanitizePathComponent(Name, MaxLen=None): - '''清洗文件名/目录名,避免 Windows 非法字符与保留名''' - if Name in [None, '']: - Name = 'Unknown' - Name = Auxiliary_NormalizeDisplayTitle(Name).replace('\n', ' ').replace('\r', ' ') - Name = sub(r'[<>:"/\\|?*\x00-\x1f]', '_', Name) - Name = sub(r'\s+', ' ', Name).strip(' .') - if Name == '': - Name = 'Unknown' - if Name.upper() in WINDOWS_RESERVED_NAMES: - Name = f'{Name}_' - Limit = Auxiliary_ParseInt(MaxLen, 180) if MaxLen not in [None, ''] else 180 - if Limit < 16: - Limit = 16 - if len(Name) > Limit: - Name = Name[:Limit].rstrip(' .') - Name = sub(r'[\s_\-–—]+$', '', Name).strip(' .') - return Name if Name != '' else 'Unknown' - - -def Auxiliary_FormatSEEPToken(Token): - Token = str(Token) - if Token.isdigit(): - return Token.zfill(2) - return Token - - -def Auxiliary_UniformOTSTR(File): - '''统一意外字符''' - - NewFile = convert(File, 'zh-hans') - NewUSTRFile = sub(r',|,| ', '-', NewFile, flags=I) - # 保留 ~ 字符(包括全角和半角),不替换成 = - NewUSTRFile = sub(r'[^a-z0-9\s&/::.\-\(\)()《》\u4e00-\u9fa5\u3040-\u309F\u30A0-\u30FF\u31F0-\u31FF°ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫ~~]', '=', NewUSTRFile, flags=I) - # 异种剧集统一 - OtEpisodesMatchData = [r'第(\d{1,4})集', r'(\d{1,4})集', r'第(\d{1,4})话', r'(\d{1,4})END', r'(\d{1,4}) END', r'(\d{1,4})E'] - for i in OtEpisodesMatchData: - i = f'[^0-9a-z]{i}[^0-9a-z]' - if search(i, NewUSTRFile, flags=I) != None: - a = search(i, NewUSTRFile, flags=I) - NewUSTRFile = NewUSTRFile.replace(a.group(), '=' + a.group(1).strip('\u4e00-\u9fa5') + '=') - return NewUSTRFile - - -def Auxiliary_RMOTSTR(File): - '''剔除意外字符''' - - NewPSTRFile = File - FuzzyMatchData = [r'(.*?|=)月新番(.*?|=)', r'\d{4}.\d{2}.\d{2}', r'20\d{2}', r'v[2-9]', r'\d{4}年\d{1,2}月番'] - PreciseMatchData = [r'仅限港澳台地区', r'年龄限制版', r'国漫', r'x264', r'1080p', r'720p', r'4k', r'(-)'] - # 同步到 state,避免测试/扩展访问旧属性时报错 - state.FuzzyMatchData = FuzzyMatchData - state.PreciseMatchData = PreciseMatchData - for i in PreciseMatchData: - NewPSTRFile = sub(r'%s' % i, '=', NewPSTRFile, flags=I) - for i in FuzzyMatchData: - NewPSTRFile = sub(i, '=', NewPSTRFile, flags=I) - return NewPSTRFile - - -def Auxiliary_RMSubtitlingTeam(File): - '''剔除字幕组信息''' - - if File[0] == '《': - File = sub(r'《|》', '', File, flags=I) - else: - File = sub(r'^=.*?=', '', File, flags=I) - return File - - -_STRIP_LEADING_BRACKET_RELEASE_TAGS = compile(r'^(?:(?:\s*\[[^\]]+\]|\s*【[^】]+】))+\s*') - - -def Auxiliary_StripLeadingBracketReleaseTags(basename): - """仅用于展示/LLM 输入:去掉基名首部的连续半角 […] 或全角 【…】 发行/字幕组标签。剥空则回退为原串。""" - - if basename is None: - return None - if basename == '': - return '' - s = str(basename) - t = _STRIP_LEADING_BRACKET_RELEASE_TAGS.sub('', s, count=1) - t = t.lstrip() if t else t - if t == '': - return s - return t - - -def Auxiliary_IDESE(File): - '''识别剧季并截断Name''' - - SeasonMatchData = r'(季(.*?)第)|(([0-9]{0,1}[0-9]{1})S)|(([0-9]{0,1}[0-9]{1})nosaeS)|(([0-9]{0,1}[0-9]{1}) nosaeS)|(([0-9]{0,1}[0-9]{1})-nosaeS)|(nosaeS-dn([0-9]{1}))|(nosaeS-dr([0-9]{1}))' - if (X := findall(SeasonMatchData, File[::-1], flags=I)) != []: - SEData = X - SENamelist = [] - SEList = [] - for sedata in SEData: - for se in sedata: - if se != '' and se.isnumeric() == False: - SENamelist.append(se[::-1]) - elif se.isnumeric() == True: - SEList.append(se) - for i in SENamelist: - File = sub(r'%s.*' % i, '', File, flags=I).strip('=') - for i in range(len(SEList)): - if SEList[i].isdecimal() == True: - SE = SEList[i][::-1] - elif '\u0e00' <= SEList[i] <= '\u9fa5': - digit = {'一': '01', '二': '02', '三': '03', '四': '04', '五': '05', '六': '06', '七': '07', '八': '08', '九': '09', - '壹': '01', '贰': '02', '叁': '03', '肆': '04', '伍': '05', '陆': '06', '柒': '07', '捌': '08', '玖': '09'} - SE = digit[SEList[i]] - if SE is not None: - return SE, File, SENamelist[0] - elif (X := findall(r'[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫ]', File[::-1], flags=I)) != []: - A = {'Ⅰ': '01', 'Ⅱ': '02', 'Ⅲ': '03', 'Ⅳ': '04', 'Ⅴ': '05', 'Ⅵ': '06', 'Ⅶ': '07', 'Ⅷ': '08', 'Ⅸ': '09', 'Ⅹ': '10', 'Ⅺ': '11', 'Ⅻ': '12'} - return A[X[0]], File, X[0] - else: - return '01', File, '' - - -def Auxiliary_IDEEP(File, *, _quiet_subtitle: bool = False): - '''识别剧集。`_quiet_subtitle=True` 时不在失败分支打日志,供 `Auxiliary_IDEASS` 末尾批量汇总。''' - - try: - if findall(r'[^0-9.\u4e00-\u9fa5\u0800-\u4e00]([0-9.]{1,4}-[0-9.]{1,4})[^0-9.\u4e00-\u9fa5\u0800-\u4e00]', File[::-1], flags=I) != []: - if _quiet_subtitle != True: - Auxiliary_Log('剧集包不予处理', 'WARNING') - raise Exception() - elif (X := findall(r'[^0-9a-z.\u4e00-\u9fa5\u0800-\u4e00]([0-9.]{1,5})[^0-9a-uw-z.\u4e00-\u9fa5\u0800-\u4e00]', File[::-1], flags=I)) != []: - Episodes = X[0][::-1].strip(" =-_eEv") - else: - Episodes = findall(r'[^0-9a-z.\u4e00-\u9fa5\u0800-\u4e00]([0-9]{1,4})[^0-9a-uw-z.\u4e00-\u9fa5\u0800-\u4e00]', File[::-1], flags=I)[0][::-1].strip(" =-_eEv") - except IndexError: - if _quiet_subtitle != True: - Auxiliary_Log('未匹配出剧集,请检查(程序目前不支持电影动漫)', 'WARNING') - raise Exception() - except Exception: - raise Exception() - else: - return Episodes - - -def Auxiliary_IDEVDName(File, RAWEP): - '''识别剧名''' - - try: - match_result = search(r'[=|-]%s[=|-](.*)' % RAWEP[::-1], File[::-1], flags=I) - if match_result: - VDName = match_result.group(1).strip('=-=-=-')[::-1] - else: - VDName = sub(r'.*%s' % RAWEP[::-1], '', File[::-1], count=0, flags=I).strip('=-=-=-')[::-1] - if not VDName or VDName == File: - VDName = path.splitext(File)[0] - Auxiliary_Log(f'通过剧集截断文件名 ==> {VDName}', 'INFO') - return VDName - except Exception as e: - Auxiliary_Log(f'剧名识别失败,使用原始文件名: {e}', 'WARNING') - return path.splitext(File)[0] - - -def Auxiliary_IDEASS(File, SE, EP, ASSList): - '''识别当前番剧视频的所属字幕文件''' - - ASSFileList = [] - SkippedNoEp = [] - for ASSFile in ASSList: - ASSName = Auxiliary_UniformOTSTR(path.basename(ASSFile)) - try: - ASSEP = Auxiliary_IDEEP(ASSName, _quiet_subtitle=True) - except Exception: - SkippedNoEp.append(ASSFile) - continue - if File in ASSName and EP == ASSEP and SE in ASSName: - ASSFileList.append(ASSFile) - if SkippedNoEp: - Auxiliary_Log( - f'字幕文件无法提取剧集,跳过 {len(SkippedNoEp)} 个: {Auxiliary_FormatListPreview(SkippedNoEp)}', - 'WARNING', - ) - ASSFileList = None if ASSFileList == [] else ASSFileList - return ASSFileList - - -def Auxiliary_FileType(FileName): - '''识别文件类型''' - - SuffixList = {'.ass': 'ASS', '.srt': 'ASS', '.mp4': 'MP4', '.mkv': 'MP4', '.log': 'LOG'} - for FileType in SuffixList: - if match(FileType[::-1], FileName[::-1], flags=I) != None: - try: - return SuffixList[FileType.lower()] - except Exception: - Auxiliary_Exit('文件类型不正确') - - -def Auxiliary_AnimeFileCheck(File): - '''检查是否为 OP/CM/SP/PV 等非正片''' - - Checklist = ['OP', 'CM', 'SP', 'PV'] - for i in Checklist: - if search(f'[-=]{i}[-=]', File, flags=I) != None: - return i - return True - - -def Auxiliary_ASSFileCA(ASSFileName): - '''字幕文件的语言分类''' - - ASSFileName = path.basename(ASSFileName) - SubtitleList = [['简', '簡', '簡體', 'sc', 'chs', 'GB'], ['繁', 'tc', 'cht', 'BIG5'], ['日', 'jp']] - for i in range(len(SubtitleList)): - for ii in SubtitleList[i]: - if search(f'[^0-9a-z]{ii[::-1]}[^0-9a-z]', ASSFileName[::-1], flags=I) != None: - if i == 0: - return '.chs' if state.JELLYFINFORMAT == False else '.简体中文.chi' - elif i == 1: - return '.cht' if state.JELLYFINFORMAT == False else '.繁体中文.chi' - elif i == 2: - return '.jp' - return '.other' - - -def Auxiliary_SubtitleLanguageSuffixForEmby(ASSFileName): - RawSuffix = Auxiliary_ASSFileCA(ASSFileName) - Mapping = { - '.chs': '.zh-CN', - '.cht': '.zh-TW', - '.jp': '.ja', - '.other': '.und', - } - return Mapping.get(RawSuffix, '.und') diff --git a/autoanime/pipeline/__init__.py b/autoanime/pipeline/__init__.py deleted file mode 100644 index 48cdfc4..0000000 --- a/autoanime/pipeline/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -autoanime 主流水线包 - -| 模块 | 作用 | -| --- | --- | -| `mode` | `Processing_Mode`:目录扫描/单文件短路 | -| `main` | `Processing_Main`:遍历识别 + 调度整理 | -| `operation_log` | `Auxiliary_RecordOperation` / `Auxiliary_WriteOperationLog` | -| `rollback` | `Auxiliary_RollbackFromLog` | -""" - -from .main import Processing_Main -from .mode import Processing_Mode -from .operation_log import Auxiliary_RecordOperation, Auxiliary_WriteOperationLog -from .rollback import Auxiliary_RollbackFromLog - -__all__ = [ - 'Processing_Mode', - 'Processing_Main', - 'Auxiliary_RecordOperation', - 'Auxiliary_WriteOperationLog', - 'Auxiliary_RollbackFromLog', -] diff --git a/autoanime/pipeline/main.py b/autoanime/pipeline/main.py deleted file mode 100644 index a21e877..0000000 --- a/autoanime/pipeline/main.py +++ /dev/null @@ -1,190 +0,0 @@ -""" -autoanime Processing_Main(主流水线) - -对应原 `AutoAnimeMv.py::Processing_Main`,关键增强(fix_show_index todo): -- `ShowOrganizationIndex` 跳过前需校验 `expected_dst` 是否仍然存在: - - 目标存在 + 与源同物理文件 => 照旧跳过(记 `already_organized_show_cache`); - - 目标缺失 => 自愈剔除 tag + 正常整理(记 `already_organized_show_cache_stale`); - - 有 tag 但缺少 `expected_dst`(老数据)=> 退化为旧行为跳过。 -""" - -from os import path - -from .. import state -from ..cache.canonical import Auxiliary_UpsertCanonicalTitle -from ..cache.show_index import ( - Auxiliary_ShowClearOrganizedEpisode, - Auxiliary_ShowHasOrganizedEpisode, - Auxiliary_ShowMarkOrganizedEpisode, - Auxiliary_FormatOrganizedEpisodeTag, -) -from ..identification import Processing_Identification -from ..identification.episode_rules import ( - Auxiliary_BuildEpisodeDecisionKey, - Auxiliary_GetAbsoluteSourcePath, - Auxiliary_GetSourceFileMTime, - Auxiliary_PreDetectEpisodeHint, -) -from ..identification.title_chain import Auxiliary_ShouldCacheResolvedFileInfo -from ..logging_utils import Auxiliary_Log -from ..naming import Auxiliary_FileType -from ..scanning import Auxiliary_IsIncompleteDownloadFile -from ..sorting import Sorting_Mv -from ..sorting.file_ops import Auxiliary_IsSamePhysicalFile -from ..sorting.subtitles import Auxiliary_IDEASS -from ..text_utils import Auxiliary_HasChineseText -from .operation_log import Auxiliary_RecordOperation - - -def Processing_Main(LorT): - '''遍历识别 + 调度整理。''' - SubtitleFiles = [] - if type(LorT) == tuple: - VideoFiles = LorT[0] - SubtitleFiles = LorT[1] - else: - VideoFiles = LorT - - if type(VideoFiles) != list: - return - - VideoFiles = sorted(VideoFiles, key=lambda X: Auxiliary_GetSourceFileMTime(X)) - for SourceFile in VideoFiles: - File = path.basename(SourceFile) - SourceAbsPath = Auxiliary_GetAbsoluteSourcePath(SourceFile) - SourceMTime = Auxiliary_GetSourceFileMTime(SourceFile) - state.LastOpenAIFileInfoMeta = {} - if Auxiliary_IsIncompleteDownloadFile(File): - Auxiliary_Log(f'跳过未完成下载文件: {SourceFile}', 'INFO') - continue - if Auxiliary_FileType(File) == 'ASS': - Auxiliary_Log(f'跳过仅字幕文件主处理: {SourceFile}', 'INFO') - continue - - PreDetectHint = Auxiliary_PreDetectEpisodeHint(File) - if type(PreDetectHint) == dict and PreDetectHint.get('EpisodeKey') in state.EpisodeDecisionDataCache: - ExistingDecision = state.EpisodeDecisionDataCache[PreDetectHint.get('EpisodeKey')] - ExistingMTime = float(ExistingDecision.get('source_mtime', 0.0)) - if SourceMTime >= ExistingMTime: - ExistingDst = ExistingDecision.get('dst', '') - Auxiliary_Log(f'同集已保留更早文件,跳过较新重复资源: {SourceFile}', 'INFO') - Auxiliary_RecordOperation('skip', SourceAbsPath, ExistingDst, 'skipped', 'newer_duplicate_kept_oldest') - continue - - flag = Processing_Identification(File) - if flag is None: - continue - SE, EP, RAWSE, RAWEP, RAWName = flag - NameEN = state.LastOpenAIFileInfoMeta.get('NameEN', '') - NameRomaji = state.LastOpenAIFileInfoMeta.get('NameRomaji', '') - CanonicalID = state.LastOpenAIFileInfoMeta.get('CanonicalID', '') - HintCanonicalID = PreDetectHint.get('CanonicalID', '') if type(PreDetectHint) == dict else '' - HintApiName = PreDetectHint.get('ApiName', '') if type(PreDetectHint) == dict else '' - - if state.animename not in ['', None]: - ApiName = state.animename - Auxiliary_Log('当前文件已由 OpenAI 识别季集,剧名使用手动指定 animename', 'INFO') - else: - ApiName = state.LastOpenAIFileInfoMeta.get('CanonicalZh') or RAWName - if HintCanonicalID not in [None, ''] and CanonicalID not in [None, ''] and HintCanonicalID != CanonicalID: - Auxiliary_Log(f'检测到单集剧名漂移,采用历史别名映射纠偏: {File}', 'WARNING') - CanonicalID = HintCanonicalID - if HintApiName not in [None, '']: - ApiName = HintApiName - RAWName = HintApiName - elif HintCanonicalID not in [None, ''] and CanonicalID in [None, '']: - CanonicalID = HintCanonicalID - if HintApiName not in [None, ''] and Auxiliary_HasChineseText(str(ApiName)) == False: - ApiName = HintApiName - if Auxiliary_HasChineseText(str(ApiName)) == False: - Auxiliary_Log('剧名未收敛到中文', 'WARNING') - else: - Auxiliary_Log('OpenAI 识别与剧名链已完成', 'INFO') - if NameEN in [None, ''] and Auxiliary_HasChineseText(RAWName) == False: - NameEN = RAWName - CanonicalSourceTag = 'openai_identify' if state.LastIdentificationFromAI else 'local_fallback' - CanonicalFromMainID, CanonicalFromMainZh = Auxiliary_UpsertCanonicalTitle( - ApiName, NameEN, NameRomaji, CanonicalSourceTag, [RAWName, ApiName, File] - ) - if CanonicalFromMainZh not in [None, '']: - ApiName = CanonicalFromMainZh - if CanonicalID in [None, ''] and CanonicalFromMainID not in [None, '']: - CanonicalID = CanonicalFromMainID - - # fix_show_index:ShowOrganizationIndex 盲跳自愈 - if CanonicalID not in [None, '']: - HasTag, ExpectedDst = Auxiliary_ShowHasOrganizedEpisode(CanonicalID, SE, EP) - if HasTag == True: - TagLabel = Auxiliary_FormatOrganizedEpisodeTag(SE, EP) - ShouldSkip = False - StaleReason = '' - if ExpectedDst is not None and ExpectedDst.exists(): - # 目标仍在:若与源是同一物理文件,跳过整理;否则仍跳过但降级日志 - ShouldSkip = True - if Auxiliary_IsSamePhysicalFile(SourceAbsPath, ExpectedDst): - StaleReason = 'already_organized_show_cache' - else: - StaleReason = 'already_organized_show_cache' - elif ExpectedDst is None: - # 老数据(未记录 expected_dst):维持旧行为,跳过 - ShouldSkip = True - StaleReason = 'already_organized_show_cache' - else: - # 有 tag 但目标缺失:自愈,剔除 tag 后照常整理 - ShouldSkip = False - StaleReason = 'already_organized_show_cache_stale' - Changed = Auxiliary_ShowClearOrganizedEpisode(CanonicalID, SE, EP) - if Changed: - Auxiliary_Log( - f'ShowIndex 自愈:目标缺失已剔除 tag {TagLabel} << {ApiName}', - 'INFO', - ) - if ShouldSkip: - Auxiliary_Log( - f'跳过已整理剧集(ShowOrganizationIndex): {TagLabel} << {ApiName}', - 'INFO', - ) - Auxiliary_RecordOperation('skip', SourceAbsPath, str(ExpectedDst or ''), 'skipped', StaleReason) - continue - else: - Auxiliary_RecordOperation( - 'skip', SourceAbsPath, '', 'recover', StaleReason, - ) - - EpisodeKey = Auxiliary_BuildEpisodeDecisionKey(ApiName, SE, EP, File) - if EpisodeKey not in [None, ''] and EpisodeKey in state.EpisodeDecisionDataCache: - ExistingDecision = state.EpisodeDecisionDataCache[EpisodeKey] - ExistingMTime = float(ExistingDecision.get('source_mtime', 0.0)) - if SourceMTime >= ExistingMTime: - ExistingDst = ExistingDecision.get('dst', '') - Auxiliary_Log(f'同集已保留更早文件,跳过较新重复资源: {SourceFile}', 'INFO') - Auxiliary_RecordOperation('skip', SourceAbsPath, ExistingDst, 'skipped', 'newer_duplicate_kept_oldest') - continue - - ASSList = Auxiliary_IDEASS(RAWName, RAWSE, RAWEP, SubtitleFiles) if SubtitleFiles != [] else None - MainOperationResult = Sorting_Mv(File, RAWName, SE, EP, ASSList, ApiName, SourceFilePath=SourceFile) - DstPath = MainOperationResult.get('dst', '') if type(MainOperationResult) == dict else '' - - if Auxiliary_ShouldCacheResolvedFileInfo(MainOperationResult) and CanonicalID not in [None, '']: - Auxiliary_ShowMarkOrganizedEpisode(CanonicalID, ApiName, NameEN, NameRomaji, SE, EP, DstPath=DstPath) - - if EpisodeKey not in [None, '']: - ExistingDecision = state.EpisodeDecisionDataCache.get(EpisodeKey, {}) - ExistingMTime = float(ExistingDecision.get('source_mtime', 0.0)) if type(ExistingDecision) == dict else 0.0 - if type(ExistingDecision) != dict or ExistingDecision == {} or SourceMTime <= ExistingMTime: - state.EpisodeDecisionDataCache[EpisodeKey] = { - 'source_mtime': SourceMTime, - 'src': str(SourceAbsPath), - 'dst': DstPath, - 'resolved': { - 'SE': str(SE), - 'EP': str(EP), - 'RAWSE': str(RAWSE), - 'RAWEP': str(RAWEP), - 'RAWName': str(RAWName), - 'ApiName': str(ApiName), - 'NameEN': str(NameEN) if NameEN not in [None, ''] else '', - 'NameRomaji': str(NameRomaji) if NameRomaji not in [None, ''] else '', - 'CanonicalID': str(CanonicalID) if CanonicalID not in [None, ''] else '', - }, - } diff --git a/autoanime/pipeline/mode.py b/autoanime/pipeline/mode.py deleted file mode 100644 index e510aaf..0000000 --- a/autoanime/pipeline/mode.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -autoanime Processing_Mode - -对应原 `AutoAnimeMv.py::Processing_Mode`,并新增: -- 单文件模式短路(state.SingleFileMode == True): - 直接返回 `[video_filename]` 或 `(视频列表, 字幕列表)`,避免 `rglob` 扫描误伤同目录其它剧集。 -- 字幕附属匹配:同目录下以视频基名开头的 `.ass/.srt` 被视为附属字幕(由 CLI 的 - `NormalizeSingleFileInput` 预先收入 `state.SingleFileSubtitles`)。 -""" - -from os import path - -from .. import state -from ..config_loader import Auxiliary_InitRuntimeContext -from ..logging_utils import Auxiliary_Exit, Auxiliary_Log -from ..scanning import Auxiliary_IsIncompleteDownloadFile, Auxiliary_ScanDIR - - -def _LogDeleteLogsIfAvailable(): - '''调用旧的日志清理函数(若存在)。当前模块化下本函数保留占位。''' - # 旧 Auxiliary_DeleteLogs 依赖旧模块 globals;本包暂未迁移清理逻辑, - # 兼容期内由上层旧入口负责;此处留空即可。 - return - - -def Processing_Mode(ArgvData): - '''模式选择:返回需要处理的文件列表(或 (videos, subtitles) 二元组)。 - - - 单文件模式(`state.SingleFileMode == True`):短路返回,仅此一文件 + 同目录字幕附属; - - qB 回调模式(ArgvData 为 `(dir, file, "1", ...)`):仅整理该文件; - - 其它情形:走 `Auxiliary_ScanDIR` 递归扫描。 - ''' - ArgvNumber = len(ArgvData) if type(ArgvData) in [list, tuple] else 1 - state.Path = state.filepath - state.CategoryName = state.categoryname - Auxiliary_InitRuntimeContext() - - if path.exists(state.Path) != True: - Auxiliary_Exit(f'不存在 {state.Path} 目录') - - if state.CategoryName: - Auxiliary_Log(f'当前分类 >> {state.CategoryName}') - - # 单文件模式优先级最高 - if state.SingleFileMode == True and state.SingleFileVideoName not in [None, '']: - VideoName = state.SingleFileVideoName - VideoAbs = path.join(state.Path, VideoName) - if path.isfile(VideoAbs) == False: - Auxiliary_Exit(f'单文件模式下指定的文件不存在: {VideoAbs}') - if Auxiliary_IsIncompleteDownloadFile(VideoName): - Auxiliary_Log(f'单文件输入为未完成下载文件,跳过: {VideoName}', 'WARNING') - return [] - SubtitleNames = [S for S in (state.SingleFileSubtitles or []) if path.isfile(path.join(state.Path, S))] - Auxiliary_Log(f'单文件模式:视频={VideoName} 字幕={SubtitleNames}', 'INFO') - VideoExt = path.splitext(VideoName)[1].lower() - if VideoExt in ('.ass', '.srt'): - # 单个字幕文件独立整理 - return [VideoName] - if SubtitleNames: - return [VideoName], SubtitleNames - return [VideoName] - - # qB 回调模式:位置参数 2 == '1' 时单文件整理 - if type(ArgvData) in [list, tuple] and ArgvNumber >= 3 and str(ArgvData[2]) == '1' and ArgvData[1] not in [None, '']: - FileListTuporList = [ArgvData[1]] - else: - FileListTuporList = Auxiliary_ScanDIR(state.Path) - - _LogDeleteLogsIfAvailable() - - if type(FileListTuporList) == tuple: - return FileListTuporList - - valid_files = [] - skipped_incomplete_files = [] - for i in FileListTuporList: - AbsPath = path.join(state.Path, i) - if path.isfile(AbsPath): - if Auxiliary_IsIncompleteDownloadFile(i): - skipped_incomplete_files.append(i) - Auxiliary_Log(f'跳过未完成下载文件: {i}', 'INFO') - continue - valid_files.append(i) - else: - Auxiliary_Log(f'{AbsPath} 不存在的文件', 'WARNING') - if valid_files: - return valid_files - if skipped_incomplete_files: - Auxiliary_Log('本次仅检测到未完成下载文件,已全部跳过', 'INFO') - return [] - Auxiliary_Exit('没有有效的番剧文件') diff --git a/autoanime/pipeline/operation_log.py b/autoanime/pipeline/operation_log.py deleted file mode 100644 index 7ba8cd8..0000000 --- a/autoanime/pipeline/operation_log.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -autoanime 操作日志 - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_RecordOperation` -- `Auxiliary_WriteOperationLog` -""" - -import json - -from time import localtime, strftime, time - -from .. import state -from ..logging_utils import Auxiliary_Log - - -def Auxiliary_RecordOperation(Action, SrcPath, DstPath, Status, Message='', BackupPath=''): - if state.Runtime is None: - return - state.Runtime.operation_records.append({ - 'timestamp': strftime('%Y-%m-%d %H:%M:%S', localtime(time())), - 'action': Action, - 'src': str(SrcPath), - 'dst': str(DstPath), - 'status': Status, - 'message': Message, - 'backup': str(BackupPath) if BackupPath not in [None, ''] else '', - }) - - -def Auxiliary_WriteOperationLog(): - if state.OPERATION_LOG_ENABLE != True or state.Runtime is None: - return - if state.RUN_COMMAND == 'rollback': - return - if state.Runtime.operation_log_path in [None, '']: - return - try: - state.Runtime.operation_log_path.parent.mkdir(parents=True, exist_ok=True) - Payload = { - 'run_id': state.CurrentRunID, - 'dry_run': state.Runtime.config.dry_run, - 'naming_style': state.Runtime.config.naming_style, - 'records': state.Runtime.operation_records, - } - with open(state.Runtime.operation_log_path, 'w', encoding='UTF-8') as LogFile: - json.dump(Payload, LogFile, ensure_ascii=False, indent=2) - Auxiliary_Log(f'操作日志已写入 {state.Runtime.operation_log_path}', 'INFO') - except Exception as err: - Auxiliary_Log(f'操作日志写入失败: {err}', 'WARNING') diff --git a/autoanime/pipeline/rollback.py b/autoanime/pipeline/rollback.py deleted file mode 100644 index 8114c52..0000000 --- a/autoanime/pipeline/rollback.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -autoanime 回滚 - -对应原 `AutoAnimeMv.py::Auxiliary_RollbackFromLog`。 -""" - -import json - -from os import remove -from pathlib import Path as PathlibPath -from shutil import move - -from ..logging_utils import Auxiliary_Exit, Auxiliary_Log - - -def Auxiliary_RollbackFromLog(LogPath): - '''根据操作日志回滚文件。''' - RollbackFile = PathlibPath(LogPath) - if RollbackFile.is_file() == False: - Auxiliary_Exit(f'回滚日志不存在: {RollbackFile}') - try: - with open(RollbackFile, 'r', encoding='UTF-8') as ff: - Data = json.load(ff) - except json.JSONDecodeError: - with open(RollbackFile, 'r', encoding='UTF-8-sig') as ff: - Data = json.load(ff) - Records = Data.get('records', []) - if type(Records) != list or Records == []: - Auxiliary_Exit(f'回滚日志内无可用记录: {RollbackFile}') - for Record in Records[::-1]: - if type(Record) != dict: - continue - if Record.get('status') not in ['success']: - continue - Action = Record.get('action') - SrcPath = PathlibPath(Record.get('src', '')) - DstPath = PathlibPath(Record.get('dst', '')) - BackupPath = PathlibPath(Record.get('backup')) if Record.get('backup') not in [None, ''] else None - try: - if Action == 'move': - if DstPath.exists(): - DstPath.parent.mkdir(parents=True, exist_ok=True) - move(str(DstPath), str(SrcPath)) - if BackupPath and BackupPath.exists(): - move(str(BackupPath), str(DstPath)) - elif Action == 'link': - if DstPath.exists(): - remove(str(DstPath)) - if BackupPath and BackupPath.exists(): - move(str(BackupPath), str(DstPath)) - elif Action == 'remove': - if BackupPath and BackupPath.exists(): - move(str(BackupPath), str(DstPath)) - Auxiliary_Log(f'回滚成功: {Action} {DstPath} -> {SrcPath}', 'INFO') - except Exception as err: - Auxiliary_Log(f'回滚失败: {Action} {DstPath} -> {SrcPath}, {err}', 'WARNING') diff --git a/autoanime/scanning.py b/autoanime/scanning.py deleted file mode 100644 index 29b76d0..0000000 --- a/autoanime/scanning.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -autoanime 扫描工具 - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_ScanDIR` -- `Auxiliary_IsIncompleteDownloadFile` -- `Auxiliary_ScanEntryShouldSkip` -- `Auxiliary_DefaultScanSkipPathMarkers` -- `Auxiliary_DefaultScanSkipNameRegexStrings` -- `Auxiliary_GetScanSkipPathMarkers` -- `Auxiliary_GetScanSkipNameRegexList` - -额外提供: -- `NormalizeSingleFileInput`: 配合 CLI 的单文件输入(目录/完整文件路径/目录+文件名)归一化。 -""" - -from os import path -from pathlib import Path as PathlibPath -from re import I, compile as re_compile, search - -from . import state -from .logging_utils import Auxiliary_Exit, Auxiliary_Log, Auxiliary_FormatListPreview - - -def Auxiliary_DefaultScanSkipPathMarkers(): - return [ - 'SP', 'SPs', 'OP', 'ED', 'PV', 'PVs', 'NCOP', 'NCED', 'NCOPs', 'NCEDs', - 'Special', 'Specials', 'Extra', 'Extras', 'Bonus', 'Menus', 'Menu', - 'Creditless', 'Clean', 'CM', 'Preview', 'Previews', 'Trailer', 'Teasers', - 'Scans', 'Scan', 'Making', 'Interview', 'Tokuten', 'Drama', - ] - - -def Auxiliary_DefaultScanSkipNameRegexStrings(): - return [ - r'(?i)\bNCOP\d*\b', - r'(?i)\bNCED\d*\b', - r'(?i)Non-?Credit', - r'(?i)\bMenu\d+\b', - r'(?i)\b(PV|Preview|CM)\d*\b', - ] - - -def Auxiliary_GetScanSkipPathMarkers(): - Markers = state.SCAN_SKIP_PATH_MARKERS - if type(Markers) != list or Markers == []: - return Auxiliary_DefaultScanSkipPathMarkers() - return [str(M).strip() for M in Markers if str(M).strip() not in [None, '']] - - -def Auxiliary_GetScanSkipNameRegexList(): - Patterns = state.SCAN_SKIP_NAME_REGEX - if type(Patterns) != list or Patterns == []: - PatternStrings = Auxiliary_DefaultScanSkipNameRegexStrings() - else: - PatternStrings = [str(P).strip() for P in Patterns if str(P).strip() not in [None, '']] - CompiledList = [] - for PatternStr in PatternStrings: - try: - CompiledList.append(re_compile(PatternStr)) - except Exception: - continue - return CompiledList - - -def Auxiliary_ScanEntryShouldSkip(RelativeFileNormalized, BaseName): - PathLower = RelativeFileNormalized.lower() - Segments = [Seg for Seg in PathLower.split('/') if Seg not in [None, '']] - MarkerList = [M.lower() for M in Auxiliary_GetScanSkipPathMarkers()] - for Segment in Segments[:-1]: - for Marker in MarkerList: - if Marker in [None, '']: - continue - if Segment == Marker or Segment.startswith(Marker + '.') or Segment.startswith(Marker + '_'): - return True - for RegexObj in Auxiliary_GetScanSkipNameRegexList(): - try: - if RegexObj.search(BaseName) != None: - return True - except Exception: - continue - return False - - -def Auxiliary_IsIncompleteDownloadFile(FileName) -> bool: - '''判断是否为未完成下载文件''' - BaseName = path.basename(str(FileName)).lower() - IncompleteSuffixes = ('.!qb', '.part', '.partial', '.aria2', '.crdownload') - return BaseName.endswith(IncompleteSuffixes) - - -def Auxiliary_ScanDIR(Dir, Flag=0) -> list: - '''扫描文件目录,返回文件列表''' - - def Scan(RelativeFile): - FileSuffix = path.splitext(RelativeFile)[1].lower() - if FileSuffix == '.ass' or FileSuffix == '.srt': - AssFileList.append(RelativeFile) - elif FileSuffix == '.log': - LogsFileList.append(RelativeFile) - elif FileSuffix == '.mp4' or FileSuffix == '.mkv': - VDFileList.append(RelativeFile) - - SuffixList = ['.ass', '.srt', '.mp4', '.mkv', '.log'] - AssFileList = [] - VDFileList = [] - LogsFileList = [] - RootPath = PathlibPath(Dir) - OutputRelativePrefix = None - if state.Runtime and getattr(state.Runtime, 'output_path', None): - try: - OutputRelativePrefix = str(PathlibPath(state.Runtime.output_path).resolve().relative_to(RootPath.resolve())).replace('\\', '/') - if OutputRelativePrefix in ['', '.']: - OutputRelativePrefix = None - except Exception: - OutputRelativePrefix = None - for Entry in RootPath.rglob('*'): - if Entry.is_file() == False: - continue - RelativeFile = str(Entry.relative_to(RootPath)) - RelativeFileNormalized = RelativeFile.replace('\\', '/') - if OutputRelativePrefix not in [None, ''] and ( - RelativeFileNormalized == OutputRelativePrefix or RelativeFileNormalized.startswith(f'{OutputRelativePrefix}/') - ): - continue - BaseName = path.basename(RelativeFile) - if path.splitext(BaseName)[1].lower() not in SuffixList: - continue - if Auxiliary_ScanEntryShouldSkip(RelativeFileNormalized, BaseName): - continue - if Flag == 0 and search(r'S\d{1,2}E\d{1,4}', BaseName, flags=I) == None: - Scan(RelativeFile) - elif Flag == 1 and search(r'S\d{1,2}E\d{1,4}', BaseName, flags=I) != None: - Scan(RelativeFile) - - # 同步日志清理所需的文件列表 - state.LogsFileList = LogsFileList - - if VDFileList != []: - if AssFileList != []: - Auxiliary_Log( - ( - f'发现{len(AssFileList)}个字幕文件 ==> {Auxiliary_FormatListPreview(AssFileList)}', - f'发现{len(VDFileList)}个视频文件 ==> {Auxiliary_FormatListPreview(VDFileList)}', - ), - 'INFO', - ) - return VDFileList, AssFileList - Auxiliary_Log( - f'发现{len(VDFileList)}个视频文件,没有发现字幕文件 ==> {Auxiliary_FormatListPreview(VDFileList)}', - 'INFO', - ) - return VDFileList - elif AssFileList != []: - Auxiliary_Log( - ( - f'没有发现任何番剧视频文件,但发现{len(AssFileList)}个字幕文件 ==> {Auxiliary_FormatListPreview(AssFileList)}', - '只有字幕文件需要处理', - ), - 'INFO', - ) - return AssFileList - else: - Auxiliary_Exit('没有任何番剧文件') - - -def NormalizeSingleFileInput(InputPath: str): - ''' - 配合 CLI 的新单文件输入做归一化。 - - 返回:`(effective_dir, filenames_or_None, single_file_mode)`; - - 若 `InputPath` 是目录:`(dir, None, False)`; - - 若 `InputPath` 是文件:`(dir=parent, [basename], True)`;该文件若为视频,同目录下同基名开头的 `.ass/.srt` 一并收录。 - - 若都不是:`(InputPath, None, False)`(留给后续逻辑/Exit)。 - ''' - if InputPath in [None, '']: - return InputPath, None, False - P = PathlibPath(InputPath) - if P.is_file(): - Parent = str(P.parent) - SubtitleList = [] - Base = P.stem - Video = P.suffix.lower() in ('.mp4', '.mkv') - if Video: - for Sib in P.parent.iterdir(): - if Sib.is_file() == False: - continue - if Sib.suffix.lower() in ('.ass', '.srt') and Sib.stem.startswith(Base): - SubtitleList.append(Sib.name) - FileNames = [P.name] + SubtitleList - return Parent, FileNames, True - if P.is_dir(): - return InputPath, None, False - return InputPath, None, False diff --git a/autoanime/sorting/__init__.py b/autoanime/sorting/__init__.py deleted file mode 100644 index 75c0471..0000000 --- a/autoanime/sorting/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -autoanime 整理链路(sorting)包 - -| 模块 | 作用 | -| --- | --- | -| `file_ops` | `Auxiliary_ExecuteFileOperation` / `Auxiliary_MakeOperationResult` / `Auxiliary_IsSamePhysicalFile` | -| `subtitles` | 字幕 IDEASS / ASSFileCA / Emby 语言后缀(再导出 naming.py 中已有实现,保持子包内聚) | -| `pipeline` | `Sorting_Mv`:主文件 + 字幕一并落盘 | -""" - -from .file_ops import ( - Auxiliary_ExecuteFileOperation, - Auxiliary_IsSamePhysicalFile, - Auxiliary_MakeOperationResult, -) -from .pipeline import Sorting_Mv -from .subtitles import Auxiliary_IDEASS - -__all__ = [ - 'Auxiliary_ExecuteFileOperation', - 'Auxiliary_MakeOperationResult', - 'Auxiliary_IsSamePhysicalFile', - 'Auxiliary_IDEASS', - 'Sorting_Mv', -] diff --git a/autoanime/sorting/file_ops.py b/autoanime/sorting/file_ops.py deleted file mode 100644 index dba8d47..0000000 --- a/autoanime/sorting/file_ops.py +++ /dev/null @@ -1,122 +0,0 @@ -""" -autoanime 文件操作执行器(move/link/dry-run + 覆盖备份) - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_IsSamePhysicalFile` -- `Auxiliary_MakeOperationResult` -- `Auxiliary_ExecuteFileOperation` - -`Auxiliary_RecordOperation` 迁至 `autoanime.pipeline.operation_log`。 -""" - -from os import link -from pathlib import Path as PathlibPath -from shutil import move - -from .. import state -from ..config_loader import Auxiliary_ParseBool -from ..logging_utils import Auxiliary_Log -from ..pipeline.operation_log import Auxiliary_RecordOperation - - -def Auxiliary_IsSamePhysicalFile(LeftPath, RightPath) -> bool: - '''判断两个路径是否指向同一个物理文件(含硬链接)''' - LeftPath = PathlibPath(LeftPath) - RightPath = PathlibPath(RightPath) - try: - if LeftPath.exists() and RightPath.exists(): - return LeftPath.samefile(RightPath) - except Exception: - return False - return False - - -def Auxiliary_MakeOperationResult(Action, SrcPath, DstPath, Status, Message='', BackupPath=''): - return { - 'action': Action, - 'src': str(SrcPath), - 'dst': str(DstPath), - 'status': Status, - 'message': Message, - 'backup': str(BackupPath) if BackupPath not in [None, ''] else '', - } - - -def Auxiliary_ExecuteFileOperation(SrcPath, DstPath): - '''执行 move/link,支持 dry-run 与覆盖备份。''' - SrcPath = PathlibPath(SrcPath) - DstPath = PathlibPath(DstPath) - BackupPath = '' - ActionName = 'link' if state.USELINK == True else 'move' - DryRunMode = state.Runtime.config.dry_run if state.Runtime and state.Runtime.config else Auxiliary_ParseBool(state.DRY_RUN) - StrictMode = state.Runtime.config.strict_mode if state.Runtime and state.Runtime.config else Auxiliary_ParseBool(state.STRICT_MODE) - - if SrcPath.is_file() == False: - Auxiliary_Log(f'源文件不存在,跳过: {SrcPath}', 'WARNING') - Auxiliary_RecordOperation(ActionName, SrcPath, DstPath, 'skipped', 'src_not_found') - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'skipped', 'src_not_found') - - if DstPath.exists(): - if Auxiliary_IsSamePhysicalFile(SrcPath, DstPath): - Auxiliary_Log(f'目标文件已与源文件一致,跳过重复整理: {DstPath}', 'INFO') - Auxiliary_RecordOperation(ActionName, SrcPath, DstPath, 'skipped', 'same_file') - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'skipped', 'same_file') - if state.USELINK == True: - Auxiliary_Log(f'目标文件已存在,保留原有硬链接,跳过替换: {DstPath}', 'INFO') - Auxiliary_RecordOperation(ActionName, SrcPath, DstPath, 'skipped', 'existing_link_kept') - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'skipped', 'existing_link_kept') - if state.MANDATORYCOVER != True: - Auxiliary_Log(f'{DstPath}已存在,故跳过', 'WARNING') - Auxiliary_RecordOperation(ActionName, SrcPath, DstPath, 'skipped', 'target_exists') - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'skipped', 'target_exists') - BackupPath = DstPath.with_name(f'{DstPath.name}.aam.bak.{state.CurrentRunID}') - if DryRunMode == True: - Auxiliary_Log(f'DRY_RUN: 预览覆盖备份 {DstPath} -> {BackupPath}', 'INFO') - else: - DstPath.parent.mkdir(parents=True, exist_ok=True) - move(str(DstPath), str(BackupPath)) - Auxiliary_Log(f'覆盖前备份: {DstPath} -> {BackupPath}', 'INFO') - - if DryRunMode == True: - Auxiliary_Log(f'DRY_RUN: 预览{ActionName.upper()} {SrcPath} -> {DstPath}', 'INFO') - Auxiliary_RecordOperation(ActionName, SrcPath, DstPath, 'dry-run', 'preview', BackupPath) - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'dry-run', 'preview', BackupPath) - - try: - DstPath.parent.mkdir(parents=True, exist_ok=True) - if state.USELINK == True: - try: - link(str(SrcPath), str(DstPath)) - except OSError as err: - if '[WinError 1]' in str(err): - if StrictMode == True: - Auxiliary_Log('严格模式开启:硬链接失败后不会降级移动,已跳过当前文件', 'ERROR') - Auxiliary_RecordOperation(ActionName, SrcPath, DstPath, 'failed', 'strict_mode_link_failed', BackupPath) - if BackupPath not in ['', None] and PathlibPath(BackupPath).exists(): - try: - move(str(BackupPath), str(DstPath)) - except Exception: - pass - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'failed', 'strict_mode_link_failed', BackupPath) - if state.LINKFAILSUSEMOVEFLAGS == True: - Auxiliary_Log('当前文件系统不支持硬链接,自动回退到 move', 'WARNING') - move(str(SrcPath), str(DstPath)) - ActionName = 'move' - else: - raise err - else: - raise err - else: - move(str(SrcPath), str(DstPath)) - Auxiliary_Log(f'{ActionName.upper()}-{DstPath} << {SrcPath}', 'INFO') - Auxiliary_RecordOperation(ActionName, SrcPath, DstPath, 'success', '', BackupPath) - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'success', '', BackupPath) - except Exception as err: - if BackupPath not in ['', None] and PathlibPath(BackupPath).exists() and DstPath.exists() == False: - try: - move(str(BackupPath), str(DstPath)) - except Exception: - pass - Auxiliary_Log(f'文件操作失败 {SrcPath} -> {DstPath}: {err}', 'ERROR') - Auxiliary_RecordOperation(ActionName, SrcPath, DstPath, 'failed', str(err), BackupPath) - return Auxiliary_MakeOperationResult(ActionName, SrcPath, DstPath, 'failed', str(err), BackupPath) diff --git a/autoanime/sorting/pipeline.py b/autoanime/sorting/pipeline.py deleted file mode 100644 index b70e542..0000000 --- a/autoanime/sorting/pipeline.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -autoanime Sorting_Mv(整理主函数) - -对应原 `AutoAnimeMv.py::Sorting_Mv`。 -""" - -from os import path -from pathlib import Path as PathlibPath - -from .. import state -from ..config_loader import Auxiliary_ParseBool -from ..logging_utils import Auxiliary_Log -from ..naming import ( - Auxiliary_ASSFileCA, - Auxiliary_FormatSEEPToken, - Auxiliary_SanitizePathComponent, - Auxiliary_SubtitleLanguageSuffixForEmby, -) -from ..text_utils import Auxiliary_NormalizeChinesePunctuation -from .file_ops import Auxiliary_ExecuteFileOperation - - -def Sorting_Mv(FileName, RAWName, SE, EP, ASSList, ApiName, SourceFilePath=None): - '''整理单个文件(含同集字幕)。 - - 返回主视频(或单字幕)的 `Auxiliary_MakeOperationResult` dict。 - ''' - SourceFilePath = FileName if SourceFilePath in [None, ''] else SourceFilePath - CategoryName = state.categoryname if state.categoryname not in [None, ''] else '' - ApiName = ApiName if ApiName else RAWName - NamingStyle = state.Runtime.config.naming_style if state.Runtime and state.Runtime.config else str(state.NAMING_STYLE).strip().lower() - NamingStyle = NamingStyle if NamingStyle in ['default', 'emby'] else 'default' - DryRunMode = state.Runtime.config.dry_run if state.Runtime and state.Runtime.config else Auxiliary_ParseBool(state.DRY_RUN) - - def PcSanitize(Component): - return Auxiliary_SanitizePathComponent(Auxiliary_NormalizeChinesePunctuation(Component), state.MAX_FILENAME_LENGTH) - - SafeCategory = PcSanitize(CategoryName) if CategoryName != '' else '' - SafeApiName = PcSanitize(ApiName) - SEPad = Auxiliary_FormatSEEPToken(SE) - EPPad = Auxiliary_FormatSEEPToken(EP) - - BaseDir = state.Runtime.output_path if state.Runtime and state.Runtime.output_path else PathlibPath(state.filepath or state.Path or '.') - if SafeCategory != '': - BaseDir = BaseDir / SafeCategory - - SeasonDirName = f'Season {SEPad}' if NamingStyle == 'emby' else f'Season{SE}' - NewDir = BaseDir / SafeApiName / PcSanitize(SeasonDirName) - if DryRunMode != True: - NewDir.mkdir(parents=True, exist_ok=True) - elif NewDir.exists(): - Auxiliary_Log(f'{NewDir}已存在', 'INFO') - - if NamingStyle == 'emby': - EpisodeBaseName = f'{SafeApiName} - S{SEPad}E{EPPad}' - else: - EpisodeBaseName = f'S{SE}E{EP}' if state.USETITLTOEP != True else f'S{SE}E{EP}.{SafeApiName}' - EpisodeBaseName = PcSanitize(EpisodeBaseName) - - SourceDir = PathlibPath(state.filepath) if state.filepath not in [None, ''] else ( - PathlibPath(state.Path) if state.Path not in [None, ''] else PathlibPath('.') - ) - - if ASSList is not None: - for ASSFile in ASSList: - FileType = path.splitext(ASSFile)[1].lower() - ASSBaseName = path.basename(ASSFile) - if NamingStyle == 'emby': - NewASSName = PcSanitize(f'{SafeApiName} - S{SEPad}E{EPPad}{Auxiliary_SubtitleLanguageSuffixForEmby(ASSBaseName)}') - else: - NewASSName = PcSanitize(EpisodeBaseName + Auxiliary_ASSFileCA(ASSBaseName)) - DstPath = NewDir / f'{NewASSName}{FileType}' - SrcPath = SourceDir / ASSFile - Auxiliary_ExecuteFileOperation(SrcPath, DstPath) - - FileType = path.splitext(FileName)[1].lower() - if FileType in ['.ass', '.srt']: - if NamingStyle == 'emby': - NewName = PcSanitize(f'{SafeApiName} - S{SEPad}E{EPPad}{Auxiliary_SubtitleLanguageSuffixForEmby(FileName)}') - else: - NewName = PcSanitize(EpisodeBaseName + Auxiliary_ASSFileCA(FileName)) - else: - NewName = EpisodeBaseName - DstPath = NewDir / f'{NewName}{FileType}' - SrcPath = SourceDir / SourceFilePath - return Auxiliary_ExecuteFileOperation(SrcPath, DstPath) diff --git a/autoanime/sorting/subtitles.py b/autoanime/sorting/subtitles.py deleted file mode 100644 index 85443b6..0000000 --- a/autoanime/sorting/subtitles.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -autoanime 字幕相关辅助 - -对外再导出 `naming.py` 中已落地的: -- `Auxiliary_IDEASS` -- `Auxiliary_ASSFileCA` -- `Auxiliary_SubtitleLanguageSuffixForEmby` - -本模块不重复实现,只在 `autoanime.sorting` 子包内提供统一的字幕工具入口。 -""" - -from ..naming import ( - Auxiliary_ASSFileCA, - Auxiliary_IDEASS, - Auxiliary_SubtitleLanguageSuffixForEmby, -) - -__all__ = [ - 'Auxiliary_IDEASS', - 'Auxiliary_ASSFileCA', - 'Auxiliary_SubtitleLanguageSuffixForEmby', -] diff --git a/autoanime/state.py b/autoanime/state.py deleted file mode 100644 index d4e61fa..0000000 --- a/autoanime/state.py +++ /dev/null @@ -1,189 +0,0 @@ -""" -autoanime 全局可变状态容器 - -- 汇集原 `AutoAnimeMv.py` 中由 `Start_PATH` 初始化的所有 `global` 变量; -- 任一子模块需要读写原 `global XXX` 时,改为 `from . import state` + `state.XXX`; -- 默认值由 `init_defaults()` 还原,`AutoAnimeMv2.py` 启动时调用; -- 若仅在 `tests/` 中按需重置,也可重复调用 `init_defaults()`。 -""" - -from os import name as os_name -from pathlib import Path as PathlibPath -from time import localtime, strftime, time - -from .config_model import Config, RuntimeContext - - -# ========================================================================= -# 版本/进程常量 -# ========================================================================= -Versions = '3.(4.5).6' -Separator = '\\' if os_name == 'nt' else '/' -PyPath = str(PathlibPath(__file__).resolve().parent.parent) -CurrentRunID = strftime('%Y%m%d_%H%M%S', localtime(time())) - - -# ========================================================================= -# 内存缓存/运行态 -# ========================================================================= -AimeListCache = None -BgmAPIDataCache: dict = {} -TMDBAPIDataCache: dict = {} -BangumiAPIDataCache: dict = {} -OpenAIAPIDataCache: dict = {} -OpenAIIdentifyFileMemoryCache: dict = {} -ShowOrganizationIndexDataCache: dict = {} -TitleAliasIndexDataCache: dict = {} -CanonicalTitleIndexDataCache: dict = {} -EpisodeDecisionDataCache: dict = {} -LastOpenAIFileInfoMeta: dict = {} -LastIdentificationFromAI: bool = False -LastOpenAIIdentifyFailure = None -LastIdentificationIsMovie: bool = False -PersistentApiCache: dict = {} -PersistentApiCacheDirty: bool = False -CacheSubfileDirty: dict = { - "organization": False, - "titles": False, - "api_responses": False, -} -ManualTitleWhitelistDataCache: dict = {} -ManualTitleWhitelistMTime: float = 0.0 -TMDBTvSeasonLayoutMemoryCache: dict = {} -TMDBTvSeriesIdMemoryCache: dict = {} -LastPersistentCacheFlushTime: float = 0.0 -LogData: str = '' -TgBotMsgData: str = '' -Runtime: RuntimeContext = RuntimeContext() -ConfigMagdict: dict = {} -HelpMessages: str = '' -LogsFileList: list = [] -# AI 失败 -> 回退链路相关(fix_ai_fallback todo) -OpenAIFallbackBreakerStreak: int = 0 - - -# ========================================================================= -# 入口参数(原 Start_GetArgv 负责写入) -# ========================================================================= -filepath = None -filename = None -number = None -categoryname = None -animename = None -tag = None -# 大写版(原 Processing_Mode 中又写一份大写同名) -Path = None -CategoryName = '' - - -# ========================================================================= -# 配置项(默认值保持与原 AutoAnimeMv.py Start_PATH 一致) -# ========================================================================= -USEMODULE = None -USEPROXY = True -USESYSPROXY = True -HTTPPROXY = 'http://127.0.0.1:7890' -HTTPSPROXY = 'http://127.0.0.1:7890' -ALLPROXY = '' -USEBGMAPI = True -USETMDBAPI = True -USEBANGUMIAPI = True -USEOPENAIAPI = True -OPENAI_BASE_URL = 'https://api.longcat.chat/openai' -OPENAI_BASE_URLS = '' -OPENAI_API_KEY = '' -OPENAI_API_KEYS = '' -OPENAI_API_KEY_ENV = 'OPENAI_API_KEY' -OPENAI_MODEL = 'LongCat-Flash-Chat' -OPENAI_TIMEOUT_SECONDS = 60 -OPENAI_PRIORITY_FIRST = True -OPENAI_IDENTIFY_ALL = True -OPENAI_KEY_ROTATE_ON_STATUS = '401,429' -OPENAI_KEY_MAX_CONSECUTIVE_FAILURES = 3 -TMDB_BEARER_TOKEN = '' -TMDB_BEARER_TOKEN_ENV = 'TMDB_BEARER_TOKEN' -USELINK = True -STRICT_MODE = True -JELLYFINFORMAT = False -USETITLTOEP = True -LINKFAILSUSEMOVEFLAGS = False -PRINTLOGFLAG = True -RMLOGSFLAG = 7 -USEBOTFLAG = False -TIMELAPSE = 0 -SEEPSINGLECHARACTER = False -NOTLOADEXTLIST: list = [] -MANDATORYCOVER = True -NETERRRECTRYTIMS = 2 -APIREQUESTSONLYUSECH = False -USEANIMETAG = False -NAMING_STYLE = 'default' -CACHE_DIR = '.cache' -CACHE_TTL_SECONDS = 86400 -CACHE_FLUSH_INTERVAL_SECONDS = 60 -SCAN_SKIP_PATH_MARKERS: list = [] -SCAN_SKIP_NAME_REGEX: list = [] -DRY_RUN = False -MAX_FILENAME_LENGTH = 180 -OPERATION_LOG_DIR = 'logs' -OPERATION_LOG_ENABLE = True -OUTPUT_PATH = '' -RUN_COMMAND = 'process' -ROLLBACK_LOG_PATH = '' -# AI 失败回退开关(fix_ai_fallback todo):默认开启,保证 missing_api_key 时不整盘跳过 -OPENAI_FALLBACK_ON_FAILURE = True -OPENAI_FALLBACK_BREAKER_THRESHOLD = 5 -# 单文件模式相关(cli_single_file todo) -SingleFileMode: bool = False -SingleFileVideoName: str = '' -SingleFileSubtitles: list = [] - - -# ========================================================================= -# 旧 AutoAnimeMv 中出现过的额外动态/临时全局 -# ========================================================================= -FuzzyMatchData: list = [] -PreciseMatchData: list = [] -Proxy = None - - -def init_defaults() -> None: - '''恢复所有内存缓存/运行态到默认值,供新入口启动或单元测试重复调用。''' - from . import state as self_mod - self_mod.AimeListCache = None - self_mod.BgmAPIDataCache = {} - self_mod.TMDBAPIDataCache = {} - self_mod.BangumiAPIDataCache = {} - self_mod.OpenAIAPIDataCache = {} - self_mod.OpenAIIdentifyFileMemoryCache = {} - self_mod.ShowOrganizationIndexDataCache = {} - self_mod.TitleAliasIndexDataCache = {} - self_mod.CanonicalTitleIndexDataCache = {} - self_mod.EpisodeDecisionDataCache = {} - self_mod.LastOpenAIFileInfoMeta = {} - self_mod.LastIdentificationFromAI = False - self_mod.LastOpenAIIdentifyFailure = None - self_mod.LastIdentificationIsMovie = False - self_mod.PersistentApiCache = {} - self_mod.PersistentApiCacheDirty = False - self_mod.CacheSubfileDirty = { - "organization": False, - "titles": False, - "api_responses": False, - } - self_mod.ManualTitleWhitelistDataCache = {} - self_mod.ManualTitleWhitelistMTime = 0.0 - self_mod.TMDBTvSeasonLayoutMemoryCache = {} - self_mod.TMDBTvSeriesIdMemoryCache = {} - self_mod.LastPersistentCacheFlushTime = 0.0 - self_mod.LogData = f'\n\n[{strftime("%Y-%m-%d %H:%M:%S", localtime(time()))}] INFO: Running....' - self_mod.TgBotMsgData = '' - self_mod.Runtime = RuntimeContext() - self_mod.ConfigMagdict = {} - self_mod.HelpMessages = '' - self_mod.LogsFileList = [] - self_mod.CurrentRunID = strftime('%Y%m%d_%H%M%S', localtime(time())) - self_mod.OpenAIFallbackBreakerStreak = 0 - self_mod.SingleFileMode = False - self_mod.SingleFileVideoName = '' - self_mod.SingleFileSubtitles = [] diff --git a/autoanime/text_utils.py b/autoanime/text_utils.py deleted file mode 100644 index a689cf5..0000000 --- a/autoanime/text_utils.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -autoanime 文本/标题/标点归一工具 - -对应原 `AutoAnimeMv.py`: -- `Auxiliary_HasChineseText` -- `Auxiliary_AsciiDoubleQuotesToCjk` / `Auxiliary_AsciiSingleQuotesToCjk` -- `Auxiliary_ConvertAsciiPunctuationToFullwidthCn` -- `Auxiliary_NormalizeDisplayTitle` -- `Auxiliary_NormalizeChinesePunctuation` -- `Auxiliary_NormalizeAliasKey` -- `Auxiliary_NormalizeApiTitle` -- `Auxiliary_ParseJsonFromAIContent` -""" - -import json - -from re import I, findall, match, search, sub - -from zhconv.zhconv import convert - - -def Auxiliary_HasChineseText(TextValue): - TextValue = '' if TextValue in [None, ''] else str(TextValue) - return search(r'[\u4e00-\u9fff]', TextValue) != None - - -def Auxiliary_AsciiDoubleQuotesToCjk(Title): - if '"' not in Title: - return Title - Parts = Title.split('"') - Out = [Parts[0]] - for Idx in range(1, len(Parts)): - Q = '\u201c' if (Idx % 2 == 1) else '\u201d' - Out.append(Q + Parts[Idx]) - return ''.join(Out) - - -def Auxiliary_AsciiSingleQuotesToCjk(Title): - if "'" not in Title: - return Title - Parts = Title.split("'") - Out = [Parts[0]] - for Idx in range(1, len(Parts)): - Q = '\u2018' if (Idx % 2 == 1) else '\u2019' - Out.append(Q + Parts[Idx]) - return ''.join(Out) - - -def Auxiliary_ConvertAsciiPunctuationToFullwidthCn(Title): - '''含汉字的标题中将常见英文标点转为中文全角标点(英文纯拉丁标题不改动)。''' - Title = '' if Title in [None, ''] else str(Title) - if Title == '' or Auxiliary_HasChineseText(Title) != True: - return Title - TStrip = Title.strip() - if match(r'^https?://', TStrip, I) != None: - return Title - T = Title - T = sub(r'(?<=[\u4e00-\u9fff\u3000-\u303f\uff01-\uff60]):(?=[\u4e00-\u9fff])', ':', T) - T = sub(r'(?<=[\u4e00-\u9fff]),(?=[\u4e00-\u9fff])', ',', T) - T = sub(r'(?<=[\u4e00-\u9fff]),(\s+)(?=[\u4e00-\u9fff])', r',\1', T) - T = sub(r'(?<=[\u4e00-\u9fff]);(?=[\u4e00-\u9fff])', ';', T) - T = sub(r'(?<=[\u4e00-\u9fff])!', '!', T) - T = sub(r'!(?=[\u4e00-\u9fff])', '!', T) - T = sub(r'(?<=[\u4e00-\u9fff])\?', '?', T) - T = sub(r'\?(?=[\u4e00-\u9fff])', '?', T) - T = sub(r'(?<=[\u4e00-\u9fff])/(?=[\u4e00-\u9fff])', '/', T) - T = sub(r'(?<=[\u4e00-\u9fff])\(', '\uff08', T) - T = sub(r'\((?=[\u4e00-\u9fff])', '\uff08', T) - T = sub(r'(?<=[\u4e00-\u9fff])\)', '\uff09', T) - T = sub(r'\)(?=[\u4e00-\u9fff])', '\uff09', T) - T = Auxiliary_AsciiDoubleQuotesToCjk(T) - T = Auxiliary_AsciiSingleQuotesToCjk(T) - T = sub(r'(?<=[\u4e00-\u9fff])\.(?=\s*$)', '。', T) - return T - - -def Auxiliary_NormalizeDisplayTitle(Title): - Title = '' if Title in [None, ''] else str(Title) - if Title == '': - return '' - Title = convert(Title, 'zh-hans') - Title = Title.replace('\u3000', ' ') - Title = Auxiliary_ConvertAsciiPunctuationToFullwidthCn(Title) - Title = Title.strip().split('\n')[0].strip('`"\' ') - Title = sub(r'\s+', ' ', Title).strip() - Title = Title.replace('?', '?') - return Title - - -def Auxiliary_NormalizeChinesePunctuation(Text): - '''路径与展示名中文标点统一入口(移动/建目录前对路径分量调用)''' - Text = '' if Text in [None, ''] else str(Text) - if Text == '': - return '' - Text = convert(Text, 'zh-hans') - Text = Text.replace('\u3000', ' ') - Text = Auxiliary_ConvertAsciiPunctuationToFullwidthCn(Text) - Text = sub(r'\s+', ' ', Text).strip() - Text = Text.replace('?', '?') - return Text - - -def Auxiliary_NormalizeAliasKey(Title): - Title = Auxiliary_NormalizeDisplayTitle(Title).lower() - if Title == '': - return '' - Title = sub(r'第\s*[0-9]{1,3}\s*季', '', Title, flags=I) - Title = sub(r'[0-9]{1,3}(st|nd|rd|th)\s*season', '', Title, flags=I) - Title = sub(r'season\s*[0-9]{1,3}', '', Title, flags=I) - Title = sub(r'(^|[^a-z0-9])s\s*[0-9]{1,3}([^a-z0-9]|$)', ' ', Title, flags=I) - Title = sub(r'[\[\]【】\(\)\uff08\uff09]', ' ', Title) - Title = sub(r'[\-_/\\::\.,,。!!??~~]+', ' ', Title) - Title = sub(r'\s+', '', Title) - Title = sub(r'[^0-9a-z\u4e00-\u9fff]+', '', Title) - return Title - - -def Auxiliary_NormalizeApiTitle(ApiTitle): - ApiTitle = Auxiliary_NormalizeDisplayTitle(ApiTitle) - if ApiTitle == '': - return '' - ApiTitle = sub(r'第.*?季|Season\s*[0-9]+|S[0-9]{1,2}$', '', ApiTitle, flags=I).strip('- []【】 ') - return ApiTitle - - -def Auxiliary_ParseJsonFromAIContent(Text): - Text = '' if Text in [None, ''] else str(Text).strip() - if Text == '': - return None - Text = sub(r'^```[a-zA-Z0-9_-]*\s*', '', Text) - Text = sub(r'\s*```$', '', Text) - try: - return json.loads(Text) - except Exception: - pass - if (X := findall(r'\{[\s\S]*\}', Text)) != []: - try: - return json.loads(X[0]) - except Exception: - return None - return None diff --git a/autoanime/zhconv_safe.py b/autoanime/zhconv_safe.py deleted file mode 100644 index a3c9f66..0000000 --- a/autoanime/zhconv_safe.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -zhconv 词典的安全预加载 - -对应原 `AutoAnimeMv.py::Auxiliary_InitZhconvDictionarySafely`。 -原版使用 `zhconv.get_module_res` 打开资源流并人工关闭; -本模块按计划改用 `importlib.resources.files(...).open('rb')` 的 with 上下文, -确保即便出错也会释放句柄,消除 `ResourceWarning`。 -""" - -import json - -import zhconv.zhconv as zhconv_module - - -def Auxiliary_InitZhconvDictionarySafely(): - '''安全预加载 zhconv 词典,避免第三方资源句柄泄漏警告''' - try: - if getattr(zhconv_module, 'zhcdicts', None) is not None: - return - DictFile = getattr(zhconv_module, 'DICTIONARY', 'zhcdict.json') - DefaultDictFile = getattr(zhconv_module, '_DEFAULT_DICT', 'zhcdict.json') - RawBytes = b'' - - if DictFile == DefaultDictFile: - Loaded = False - try: - # 优先 importlib.resources,with 上下文负责关闭句柄 - from importlib.resources import files as resource_files - Resource = resource_files('zhconv').joinpath(DictFile) - with Resource.open('rb') as f: - RawBytes = f.read() - Loaded = True - except Exception: - Loaded = False - if Loaded == False and hasattr(zhconv_module, 'get_module_res'): - ResourceStream = zhconv_module.get_module_res(DictFile) - if ResourceStream not in [None, '']: - try: - RawBytes = ResourceStream.read() - finally: - if hasattr(ResourceStream, 'close'): - try: - ResourceStream.close() - except Exception: - pass - else: - with open(DictFile, 'rb') as f: - RawBytes = f.read() - - if RawBytes in [None, b'']: - return - DictData = json.loads(RawBytes.decode('utf-8')) - DictData['SIMPONLY'] = frozenset(DictData.get('SIMPONLY', [])) - DictData['TRADONLY'] = frozenset(DictData.get('TRADONLY', [])) - zhconv_module.zhcdicts = DictData - except Exception: - return diff --git a/autoanime_v3/__init__.py b/autoanime_v3/__init__.py new file mode 100644 index 0000000..aad5043 --- /dev/null +++ b/autoanime_v3/__init__.py @@ -0,0 +1,4 @@ +"""AutoAnime v3:安全、可解释的番剧整理核心。""" + +__version__ = "3.1.1" +PARSER_VERSION = "3.1.1" diff --git a/autoanime_v3/api/__init__.py b/autoanime_v3/api/__init__.py new file mode 100644 index 0000000..0439268 --- /dev/null +++ b/autoanime_v3/api/__init__.py @@ -0,0 +1,2 @@ +"""FastAPI application factory for the LAN Web console.""" + diff --git a/autoanime_v3/api/app.py b/autoanime_v3/api/app.py new file mode 100644 index 0000000..689565d --- /dev/null +++ b/autoanime_v3/api/app.py @@ -0,0 +1,658 @@ +"""FastAPI app factory and v1 HTTP contract.""" + +import json +import secrets +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, Optional + +from fastapi import Depends, FastAPI, Header, Request, Response +from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +from starlette.exceptions import HTTPException as StarletteHTTPException + +from autoanime_v3.api.dependencies import CSRF_HEADER, SESSION_COOKIE, csrf_from_request, session_from_request +from autoanime_v3.api.errors import status_for_error +from autoanime_v3.db.engine import connect_sqlite +from autoanime_v3.db.migrations import SCHEMA_VERSION, run_migrations +from autoanime_v3.domain.entities import CreateProfile +from autoanime_v3.domain.errors import BootstrapLocalOnlyError, DomainError, LocalOnlyError +from autoanime_v3.jobs.queue import JobQueue +from autoanime_v3.security.network import is_loopback_host +from autoanime_v3.security.secrets import DpapiSecretStore, EncryptedFileSecretStore +from autoanime_v3.services.auth import ( + AUTH_LOCAL_BYPASS_KEY, + LOCAL_HOOK_TRUST_KEY, + AuthService, + SecretService, +) +from autoanime_v3.services.automation import ScheduleService, WebhookSourceService +from autoanime_v3.services.backups import BackupService +from autoanime_v3.services.changes import ChangeService +from autoanime_v3.services.jobs import JobService +from autoanime_v3.services.operations import OperationService +from autoanime_v3.services.plans import PlanService +from autoanime_v3.services.profiles import ProfileService +from autoanime_v3.services.reviews import ReviewService +from autoanime_v3.services.roots import RootService +from autoanime_v3.services.rules import RuleService +from autoanime_v3.services.settings import SettingsService + + +class SPAStaticFiles(StaticFiles): + """Serve Vite assets while falling back to index.html for client routes.""" + + async def get_response(self, path, scope): + try: + return await super().get_response(path, scope) + except StarletteHTTPException as error: + if error.status_code != 404 or path.startswith(("api/", "health/")): + raise + return await super().get_response("index.html", scope) + + +@dataclass(frozen=True) +class ServerSettings: + database_path: Path + data_directory: Path + host: str = "0.0.0.0" + port: int = 8765 + secure_cookies: bool = True + frontend_directory: Optional[Path] = None + secret_provider: str = "dpapi" + + +@dataclass +class ServiceContainer: + auth: AuthService + secrets: SecretService + roots: RootService + profiles: ProfileService + queue: JobQueue + jobs: JobService + reviews: ReviewService + plans: PlanService + operations: OperationService + rules: RuleService + changes: ChangeService + settings: SettingsService + backups: BackupService + schedules: ScheduleService + webhooks: WebhookSourceService + + @classmethod + def build(cls, settings): + settings.data_directory.mkdir(parents=True, exist_ok=True) + secret_store = ( + DpapiSecretStore() + if settings.secret_provider == "dpapi" + else EncryptedFileSecretStore(settings.data_directory / "secret-store") + ) + queue = JobQueue(settings.database_path) + auth = AuthService(settings.database_path) + auth.ensure_default_admin() + app_settings = SettingsService(settings.database_path) + return cls( + auth=auth, + secrets=SecretService(settings.database_path, secret_store), + roots=RootService(settings.database_path), + profiles=ProfileService(settings.database_path), + queue=queue, + jobs=JobService(queue), + reviews=ReviewService(settings.database_path), + plans=PlanService(settings.database_path), + operations=OperationService(settings.database_path, settings.data_directory / "operations"), + rules=RuleService(settings.database_path), + changes=ChangeService(settings.database_path), + settings=app_settings, + backups=BackupService(settings.database_path, settings.data_directory / "backups"), + schedules=ScheduleService(settings.database_path), + webhooks=WebhookSourceService(settings.database_path), + ) + + +class LoginBody(BaseModel): + username: str + password: str + + +class RootBody(BaseModel): + kind: str + path: str + + +class ProfileBody(BaseModel): + name: str + source_root_id: int + library_root_id: int + mode: str = "link" + execution_policy: str = "review_all" + min_confidence: int = 86 + stability_seconds: int = 30 + watch_enabled: bool = False + enabled: bool = True + + +class PatchBody(BaseModel): + revision: int + patch: Dict[str, Any] + + +class RootPatchBody(BaseModel): + patch: Dict[str, Any] + + +class SecretBody(BaseModel): + value: str + + +class ScanBody(BaseModel): + profile_id: int + paths: list = [] + + +class ReviewBody(BaseModel): + resolution: Any + + +class SettingBody(BaseModel): + key: str + value: Any + revision: int + + +class RuleSetBody(BaseModel): + name: str + + +class RuleRevisionBody(BaseModel): + rule_set_id: int + document: Dict[str, Any] + + +class LibraryChangeBody(BaseModel): + show_id: int + base_revision: int + patch: Dict[str, Any] + reason: str + + +class ScheduleBody(BaseModel): + profile_id: int + kind: str + schedule: Dict[str, Any] + timezone: str = "UTC" + enabled: bool = True + + +class WebhookSourceBody(BaseModel): + name: str + downloader: str + profile_id: int + enabled: bool = True + + +class DownloaderHookBody(BaseModel): + path: Optional[str] = None + paths: list[str] = [] + + +class DeleteBody(BaseModel): + revision: int + + +def serialize(value): + if hasattr(value, "__dataclass_fields__"): + return asdict(value) + return value + + +def client_host(request: Request): + return request.client.host if request.client else "" + + +def set_session_cookie(response: Response, session_token: str, secure_cookies: bool): + response.set_cookie( + SESSION_COOKIE, + session_token, + httponly=True, + secure=secure_cookies, + samesite="strict", + path="/", + max_age=43200, + ) + + +def credentials_payload(credentials): + return { + "user": serialize(credentials.user), + "csrf_token": credentials.csrf_token, + "expires_at": credentials.expires_at, + } + + +def create_app(settings, services=None): + services = services or ServiceContainer.build(settings) + # Ensure default security settings exist as soon as the app starts. + services.settings.ensure_defaults() + app = FastAPI(title="AutoAnime Web Console", version="3.0") + app.state.settings = settings + app.state.services = services + + @app.middleware("http") + async def trace_middleware(request, call_next): + request.state.trace_id = request.headers.get("X-Trace-ID") or secrets.token_hex(16) + response = await call_next(request) + response.headers["X-Trace-ID"] = request.state.trace_id + return response + + @app.exception_handler(DomainError) + async def domain_error_handler(request, error): + return JSONResponse( + status_code=status_for_error(error), + content={ + "code": error.code, + "message": error.message, + "details": error.details, + "trace_id": request.state.trace_id, + }, + ) + + def current_user(request: Request): + return services.auth.authenticate(session_from_request(request)) + + def changing_user(request: Request): + return services.auth.require_csrf( + session_from_request(request), csrf_from_request(request) + ) + + def rows(sql, params=()): + connection = connect_sqlite(settings.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + return [dict(row) for row in connection.execute(sql, params).fetchall()] + finally: + connection.close() + + @app.get("/health/live") + def live(): + return {"status": "live"} + + @app.get("/health/ready") + def ready(): + run_migrations(settings.database_path) + return {"status": "ready", "schema_version": SCHEMA_VERSION} + + @app.get("/api/v1/auth/bootstrap-status") + def bootstrap_status(request: Request): + host = client_host(request) + loopback = is_loopback_host(host) + configured = bool(rows("SELECT 1 FROM users LIMIT 1")) + local_bypass = services.auth.local_bypass_enabled() + return { + "configured": configured, + "local_bypass": local_bypass, + "local_client": loopback, + "can_local_login": bool(loopback and local_bypass and configured), + } + + @app.post("/api/v1/auth/bootstrap", status_code=201) + def bootstrap(body: LoginBody, request: Request): + host = client_host(request) + if not is_loopback_host(host): + raise BootstrapLocalOnlyError( + "The first administrator must be created from the local machine" + ) + return serialize(services.auth.bootstrap_admin(body.username, body.password)) + + @app.post("/api/v1/auth/login") + def login(body: LoginBody, request: Request, response: Response): + credentials = services.auth.login( + body.username, + body.password, + client_host(request), + request.headers.get("User-Agent"), + ) + set_session_cookie(response, credentials.session_token, settings.secure_cookies) + return credentials_payload(credentials) + + @app.post("/api/v1/auth/local-session") + def local_session(request: Request, response: Response): + host = client_host(request) + credentials = services.auth.local_session( + client_ip=host, + user_agent=request.headers.get("User-Agent"), + is_loopback=is_loopback_host(host), + ) + set_session_cookie(response, credentials.session_token, settings.secure_cookies) + return credentials_payload(credentials) + + @app.get("/api/v1/auth/me") + def me(user=Depends(current_user)): + return serialize(user) + + @app.post("/api/v1/auth/logout", status_code=204) + def logout(request: Request, response: Response, user=Depends(changing_user)): + services.auth.logout(session_from_request(request)) + response.delete_cookie(SESSION_COOKIE, path="/") + response.status_code = 204 + return response + + @app.get("/api/v1/dashboard") + def dashboard(user=Depends(current_user)): + counts = rows( + """ + SELECT + (SELECT COUNT(*) FROM jobs WHERE status IN ('queued','leased','running')) AS active_jobs, + (SELECT COUNT(*) FROM review_items WHERE status = 'open') AS open_reviews, + (SELECT COUNT(*) FROM plan_items WHERE execution_status = 'conflict') AS conflicts, + (SELECT COUNT(*) FROM jobs WHERE status = 'failed') AS failed_jobs + """ + )[0] + counts["roots"] = rows("SELECT id, kind, path, health_status, enabled FROM storage_roots ORDER BY id") + counts["recent_jobs"] = rows( + "SELECT id, job_type, status, current_stage, progress_current, progress_total, created_at FROM jobs ORDER BY id DESC LIMIT 8" + ) + return counts + + @app.get("/api/v1/roots") + def list_roots(user=Depends(current_user)): + return {"items": rows("SELECT * FROM storage_roots ORDER BY id"), "next_cursor": None} + + @app.post("/api/v1/roots", status_code=201) + def create_root(body: RootBody, user=Depends(changing_user)): + return serialize(services.roots.create_root(body.kind, Path(body.path))) + + @app.patch("/api/v1/roots/{root_id}") + def patch_root(root_id: int, body: RootPatchBody, user=Depends(changing_user)): + return serialize(services.roots.update_root(root_id, body.patch)) + + @app.post("/api/v1/roots/{root_id}/validate") + def validate_root(root_id: int, user=Depends(changing_user)): + return serialize(services.roots.validate_root(root_id)) + + @app.get("/api/v1/profiles") + def list_profiles(user=Depends(current_user)): + return {"items": rows("SELECT * FROM scan_profiles ORDER BY id"), "next_cursor": None} + + @app.post("/api/v1/profiles", status_code=201) + def create_profile(body: ProfileBody, user=Depends(changing_user)): + return serialize(services.profiles.create_profile(CreateProfile(**body.model_dump()))) + + @app.patch("/api/v1/profiles/{profile_id}") + def patch_profile(profile_id: int, body: PatchBody, user=Depends(changing_user)): + return serialize(services.profiles.update_profile(profile_id, body.revision, body.patch)) + + @app.get("/api/v1/schedules") + def list_schedules(user=Depends(current_user)): + return {"items": [serialize(item) for item in services.schedules.list()], "next_cursor": None} + + @app.post("/api/v1/schedules", status_code=201) + def create_schedule(body: ScheduleBody, user=Depends(changing_user)): + return serialize( + services.schedules.create( + body.profile_id, body.kind, body.schedule, body.timezone, body.enabled + ) + ) + + @app.patch("/api/v1/schedules/{schedule_id}") + def patch_schedule(schedule_id: int, body: PatchBody, user=Depends(changing_user)): + return serialize(services.schedules.update(schedule_id, body.revision, body.patch)) + + @app.delete("/api/v1/schedules/{schedule_id}", status_code=204) + def delete_schedule(schedule_id: int, body: DeleteBody, response: Response, user=Depends(changing_user)): + services.schedules.delete(schedule_id, body.revision) + response.status_code = 204 + return response + + @app.get("/api/v1/webhook-sources") + def list_webhook_sources(user=Depends(current_user)): + return {"items": [serialize(item) for item in services.webhooks.list()], "next_cursor": None} + + @app.post("/api/v1/webhook-sources", status_code=201) + def create_webhook_source(body: WebhookSourceBody, user=Depends(changing_user)): + return serialize( + services.webhooks.create( + body.name, body.downloader, body.profile_id, body.enabled + ) + ) + + @app.patch("/api/v1/webhook-sources/{source_id}") + def patch_webhook_source(source_id: int, body: PatchBody, user=Depends(changing_user)): + return serialize(services.webhooks.update(source_id, body.revision, body.patch)) + + @app.delete("/api/v1/webhook-sources/{source_id}", status_code=204) + def delete_webhook_source(source_id: int, body: DeleteBody, response: Response, user=Depends(changing_user)): + services.webhooks.delete(source_id, body.revision) + response.status_code = 204 + return response + + @app.post("/api/v1/hooks/downloaders/{token}", status_code=202) + def downloader_hook(token: str, body: DownloaderHookBody): + paths = list(body.paths) + if body.path: + paths.append(body.path) + return serialize(services.webhooks.submit_token(token, paths)) + + @app.post("/api/v1/hooks/local", status_code=202) + def local_downloader_hook(body: DownloaderHookBody, request: Request): + host = client_host(request) + if not is_loopback_host(host): + raise LocalOnlyError("Local hook endpoint is only available on loopback") + if not services.auth.local_hook_trust_enabled(): + raise LocalOnlyError("Local trusted hooks are disabled") + paths = list(body.paths) + if body.path: + paths.append(body.path) + profile_id = None + if paths: + # Prefer the first enabled profile whose source root contains the path. + for profile in rows( + """ + SELECT p.id AS profile_id, r.path AS source_path + FROM scan_profiles p + JOIN storage_roots r ON r.id = p.source_root_id + WHERE p.enabled = 1 AND r.enabled = 1 + ORDER BY p.id + """ + ): + from autoanime_v3.services.roots import path_is_within + + if any(path_is_within(Path(path).expanduser(), profile["source_path"]) for path in paths): + profile_id = int(profile["profile_id"]) + break + if profile_id is None: + enabled = rows("SELECT id FROM scan_profiles WHERE enabled = 1 ORDER BY id LIMIT 1") + if not enabled: + from autoanime_v3.domain.errors import NotFoundError + + raise NotFoundError("No enabled scan profile is available for local hook") + profile_id = int(enabled[0]["id"]) + return serialize( + services.jobs.submit_scan(profile_id, paths, f"local-hook:{profile_id}:{secrets.token_hex(8)}") + ) + + @app.post("/api/v1/jobs/scans", status_code=202) + def submit_scan( + body: ScanBody, + user=Depends(changing_user), + idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"), + ): + return serialize(services.jobs.submit_scan(body.profile_id, body.paths, idempotency_key)) + + @app.get("/api/v1/jobs") + def list_jobs(user=Depends(current_user)): + return {"items": rows("SELECT * FROM jobs ORDER BY id DESC LIMIT 100"), "next_cursor": None} + + @app.get("/api/v1/jobs/{job_id}") + def get_job(job_id: int, user=Depends(current_user)): + return serialize(services.jobs.get(job_id)) + + @app.post("/api/v1/jobs/{job_id}/cancel") + def cancel_job(job_id: int, user=Depends(changing_user)): + return serialize(services.jobs.cancel(job_id)) + + @app.get("/api/v1/jobs/{job_id}/events") + def job_events(job_id: int, request: Request, user=Depends(current_user)): + last = int(request.headers.get("Last-Event-ID", "0") or 0) + events = services.jobs.events(job_id, last) + + def stream(): + for event in events: + yield "id: %s\nevent: %s\ndata: %s\n\n" % ( + event.sequence, + event.event_type, + json.dumps(serialize(event), ensure_ascii=False), + ) + + return StreamingResponse(stream(), media_type="text/event-stream") + + @app.get("/api/v1/reviews") + def list_reviews(user=Depends(current_user)): + return {"items": [serialize(item) for item in services.reviews.list_open()], "next_cursor": None} + + @app.post("/api/v1/reviews/{review_id}/resolve") + def resolve_review(review_id: int, body: ReviewBody, user=Depends(changing_user)): + return serialize(services.reviews.resolve(review_id, body.resolution, user.id)) + + @app.get("/api/v1/plans") + def list_plans(user=Depends(current_user)): + return {"items": rows("SELECT * FROM plans ORDER BY id DESC LIMIT 100"), "next_cursor": None} + + @app.get("/api/v1/plans/{plan_id}") + def get_plan(plan_id: int, user=Depends(current_user)): + return serialize(services.plans.get(plan_id)) + + @app.post("/api/v1/plans/{plan_id}/approve") + def approve_plan(plan_id: int, user=Depends(changing_user)): + plan, job = services.plans.approve_and_enqueue(plan_id, user.id) + return {"plan": serialize(plan), "job": serialize(job)} + + @app.get("/api/v1/operations") + def list_operations(user=Depends(current_user)): + return {"items": rows("SELECT * FROM operation_batches ORDER BY id DESC LIMIT 100"), "next_cursor": None} + + @app.get("/api/v1/operations/{batch_id}") + def get_operation(batch_id: int, user=Depends(current_user)): + return serialize(services.operations.get(batch_id)) + + @app.post("/api/v1/operations/{batch_id}/rollback", status_code=202) + def rollback_operation(batch_id: int, user=Depends(changing_user)): + services.operations.validate_rollback(batch_id) + return serialize( + services.queue.enqueue( + "rollback_operation", + {"batch_id": batch_id, "requested_by": user.id}, + "rollback-operation:%s" % batch_id, + ) + ) + + @app.get("/api/v1/library/shows") + def library_shows(user=Depends(current_user)): + return {"items": rows("SELECT * FROM shows ORDER BY canonical_title"), "next_cursor": None} + + @app.get("/api/v1/library/shows/{show_id}") + def library_show(show_id: int, user=Depends(current_user)): + show = rows("SELECT * FROM shows WHERE id = ?", (show_id,)) + seasons = rows("SELECT * FROM seasons WHERE show_id = ? ORDER BY season_number", (show_id,)) + metadata = rows("SELECT * FROM metadata_records WHERE show_id = ? ORDER BY fetched_at DESC", (show_id,)) + return {"show": show[0] if show else None, "seasons": seasons, "metadata": metadata} + + @app.get("/api/v1/library/files/{media_id}") + def library_file(media_id: int, user=Depends(current_user)): + media = rows("SELECT * FROM media_files WHERE id = ?", (media_id,)) + locations = rows("SELECT * FROM file_locations WHERE media_file_id = ?", (media_id,)) + return {"media": media[0] if media else None, "locations": locations} + + @app.post("/api/v1/library/changes/preview", status_code=201) + def preview_library_change(body: LibraryChangeBody, user=Depends(changing_user)): + return serialize( + services.changes.preview_show_change( + body.show_id, body.base_revision, body.patch, body.reason + ) + ) + + @app.post("/api/v1/library/changes/{request_id}/approve") + def approve_library_change(request_id: int, user=Depends(changing_user)): + return serialize(services.changes.apply(request_id)) + + @app.get("/api/v1/rules") + def rules(user=Depends(current_user)): + items = rows("SELECT * FROM rule_sets ORDER BY id") + for item in items: + revisions = rows( + "SELECT * FROM rule_revisions WHERE rule_set_id = ? ORDER BY revision DESC", + (item["id"],), + ) + for revision in revisions: + revision["document"] = json.loads(revision.pop("document_json")) + revision["validation_errors"] = json.loads( + revision.pop("validation_errors_json") or "[]" + ) + item["revisions"] = revisions + return {"items": items, "next_cursor": None} + + @app.post("/api/v1/rules", status_code=201) + def create_rule_set(body: RuleSetBody, user=Depends(changing_user)): + return serialize(services.rules.create_set(body.name)) + + @app.post("/api/v1/rules/revisions", status_code=201) + def create_rule_revision(body: RuleRevisionBody, user=Depends(changing_user)): + return serialize(services.rules.create_revision(body.rule_set_id, body.document)) + + @app.post("/api/v1/rules/revisions/{revision_id}/validate") + def validate_rule_revision(revision_id: int, user=Depends(changing_user)): + return serialize(services.rules.validate(revision_id)) + + @app.post("/api/v1/rules/revisions/{revision_id}/activate") + def activate_rule_revision(revision_id: int, user=Depends(changing_user)): + return serialize(services.rules.activate(revision_id)) + + @app.post("/api/v1/rules/{rule_set_id}/revisions/{revision_id}/rollback") + def rollback_rule_revision(rule_set_id: int, revision_id: int, user=Depends(changing_user)): + return serialize(services.rules.rollback(rule_set_id, revision_id)) + + @app.get("/api/v1/settings") + def settings_view(user=Depends(current_user)): + items = services.settings.list() + by_key = {item["key"]: item for item in items} + return { + "items": items, + "security": { + "local_bypass": bool( + by_key.get(AUTH_LOCAL_BYPASS_KEY, {}).get("value", True) + ), + "local_bypass_revision": int( + by_key.get(AUTH_LOCAL_BYPASS_KEY, {}).get("revision", 0) + ), + "local_hook_trust": bool( + by_key.get(LOCAL_HOOK_TRUST_KEY, {}).get("value", True) + ), + "local_hook_trust_revision": int( + by_key.get(LOCAL_HOOK_TRUST_KEY, {}).get("revision", 0) + ), + }, + "secrets": rows("SELECT key, provider, updated_at, 1 AS configured FROM secret_settings ORDER BY key"), + } + + @app.patch("/api/v1/settings") + def update_setting(body: SettingBody, user=Depends(changing_user)): + return services.settings.update(body.key, body.value, body.revision) + + @app.put("/api/v1/settings/secrets/{key}") + def update_secret(key: str, body: SecretBody, user=Depends(changing_user)): + return serialize(services.secrets.set_secret(key, body.value)) + + @app.post("/api/v1/backups", status_code=201) + def create_backup(user=Depends(changing_user)): + return serialize(services.backups.create()) + + @app.get("/api/v1/backups") + def list_backups(user=Depends(current_user)): + return {"items": rows("SELECT * FROM backup_records ORDER BY id DESC"), "next_cursor": None} + + if settings.frontend_directory and Path(settings.frontend_directory).is_dir(): + app.mount("/", SPAStaticFiles(directory=str(settings.frontend_directory), html=True), name="webui") + + return app diff --git a/autoanime_v3/api/dependencies.py b/autoanime_v3/api/dependencies.py new file mode 100644 index 0000000..f0a7d30 --- /dev/null +++ b/autoanime_v3/api/dependencies.py @@ -0,0 +1,22 @@ +"""Dependency helpers kept free of global application state.""" + +from autoanime_v3.domain.errors import AuthenticationError, CsrfValidationError + + +SESSION_COOKIE = "autoanime_session" +CSRF_HEADER = "X-CSRF-Token" + + +def session_from_request(request): + token = request.cookies.get(SESSION_COOKIE) + if not token: + raise AuthenticationError("Authentication is required") + return token + + +def csrf_from_request(request): + token = request.headers.get(CSRF_HEADER) + if not token: + raise CsrfValidationError("CSRF token is required") + return token + diff --git a/autoanime_v3/api/errors.py b/autoanime_v3/api/errors.py new file mode 100644 index 0000000..b53c5c3 --- /dev/null +++ b/autoanime_v3/api/errors.py @@ -0,0 +1,25 @@ +"""HTTP status mapping for stable domain errors.""" + +from autoanime_v3.domain.errors import ( + AuthenticationError, + BootstrapLocalOnlyError, + CsrfValidationError, + LocalOnlyError, + LoginThrottledError, + NotFoundError, + ValidationError, +) + + +def status_for_error(error): + if isinstance(error, AuthenticationError): + return 401 + if isinstance(error, (CsrfValidationError, BootstrapLocalOnlyError, LocalOnlyError)): + return 403 + if isinstance(error, NotFoundError): + return 404 + if isinstance(error, LoginThrottledError): + return 429 + if isinstance(error, ValidationError): + return 422 + return 409 diff --git a/autoanime_v3/cache.py b/autoanime_v3/cache.py new file mode 100644 index 0000000..563bd23 --- /dev/null +++ b/autoanime_v3/cache.py @@ -0,0 +1,455 @@ +from __future__ import annotations + +import hashlib +import json +import os +import sqlite3 +from pathlib import Path +from typing import Optional, Union + +from . import PARSER_VERSION +from .models import Evidence, MediaFile, Resolution +from .normalize import alias_key + + +SCHEMA_VERSION = 2 + + +def _source_key(path: Union[Path, str]) -> str: + """Return a stable physical-source key using the host platform's path rules.""" + return os.path.normcase(os.path.abspath(os.path.normpath(os.fspath(path)))) + + +def fingerprint(media: MediaFile, decision_version: str = "") -> str: + value = "\0".join( + [ + PARSER_VERSION, + decision_version, + str(media.path).casefold(), + media.relative_path.casefold(), + media.path.name, + media.context_name, + str(media.size), + str(media.mtime_ns), + ] + ) + return hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest() + + +class ResolutionCache: + def __init__(self, path: Path) -> None: + self.path = path + self.connection: Optional[sqlite3.Connection] = None + + def __enter__(self) -> "ResolutionCache": + self.path.parent.mkdir(parents=True, exist_ok=True) + self.connection = sqlite3.connect(str(self.path)) + self.connection.row_factory = sqlite3.Row + self.connection.execute("PRAGMA foreign_keys=ON") + self.connection.execute("PRAGMA journal_mode=WAL") + self.connection.executescript( + """ + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS shows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + canonical_title TEXT NOT NULL UNIQUE, + normalized_title TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'active', + revision INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS seasons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + show_id INTEGER NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + season_number INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT '', + expected_episodes INTEGER, + UNIQUE(show_id, season_number) + ); + CREATE TABLE IF NOT EXISTS episodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + season_id INTEGER NOT NULL REFERENCES seasons(id) ON DELETE CASCADE, + episode_number INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT '', + UNIQUE(season_id, episode_number) + ); + CREATE TABLE IF NOT EXISTS media_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fingerprint TEXT NOT NULL UNIQUE, + source_key TEXT NOT NULL UNIQUE, + episode_id INTEGER REFERENCES episodes(id) ON DELETE SET NULL, + original_path TEXT NOT NULL, + current_path TEXT NOT NULL, + size INTEGER NOT NULL, + mtime_ns INTEGER NOT NULL, + release_tag TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'identified', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS resolutions ( + fingerprint TEXT PRIMARY KEY, + parser_version TEXT NOT NULL, + show_id INTEGER REFERENCES shows(id) ON DELETE SET NULL, + episode_id INTEGER REFERENCES episodes(id) ON DELETE SET NULL, + source_name TEXT NOT NULL, + context_name TEXT NOT NULL, + canonical_title TEXT NOT NULL, + season INTEGER, + episode INTEGER, + is_movie INTEGER NOT NULL, + confidence REAL NOT NULL, + release_tag TEXT NOT NULL, + evidence_json TEXT NOT NULL, + warnings_json TEXT NOT NULL, + accepted INTEGER NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + action TEXT NOT NULL, + source TEXT NOT NULL, + destination TEXT NOT NULL, + status TEXT NOT NULL, + error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS corrections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, + entity_id INTEGER NOT NULL, + field_name TEXT NOT NULL, + old_value TEXT NOT NULL, + new_value TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'draft', + migration_plan_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + applied_at TEXT + ); + CREATE VIEW IF NOT EXISTS show_progress AS + SELECT + sh.id AS show_id, + sh.canonical_title, + se.season_number, + COUNT(DISTINCT ep.id) AS identified_episodes, + COUNT(DISTINCT CASE WHEN mf.status='organized' THEN ep.id END) AS organized_episodes, + COUNT(DISTINCT mf.id) AS media_files + FROM media_files mf + JOIN episodes ep ON ep.id=mf.episode_id + JOIN seasons se ON se.id=ep.season_id + JOIN shows sh ON sh.id=se.show_id + GROUP BY sh.id, sh.canonical_title, se.season_number; + """ + ) + self._migrate_schema() + self.connection.execute("INSERT OR REPLACE INTO meta(key, value) VALUES('schema_version', ?)", (str(SCHEMA_VERSION),)) + self.connection.commit() + return self + + def _migrate_schema(self) -> None: + assert self.connection is not None + version_row = self.connection.execute( + "SELECT value FROM meta WHERE key='schema_version'" + ).fetchone() + if version_row is not None and int(version_row[0]) > SCHEMA_VERSION: + raise RuntimeError( + "cache schema version %s is newer than supported version %s" + % (version_row[0], SCHEMA_VERSION) + ) + + with self.connection: + columns = { + str(row["name"]) + for row in self.connection.execute("PRAGMA table_info(media_files)") + } + if "source_key" not in columns: + self.connection.execute("ALTER TABLE media_files ADD COLUMN source_key TEXT") + + rows = self.connection.execute( + "SELECT id, original_path FROM media_files ORDER BY id" + ).fetchall() + for row in rows: + self.connection.execute( + "UPDATE media_files SET source_key=? WHERE id=?", + (_source_key(row["original_path"]), int(row["id"])), + ) + + duplicate_keys = self.connection.execute( + """ + SELECT source_key + FROM media_files + GROUP BY source_key + HAVING COUNT(*) > 1 + """ + ).fetchall() + for duplicate in duplicate_keys: + duplicate_rows = self.connection.execute( + """ + SELECT id, current_path, status + FROM media_files + WHERE source_key=? + ORDER BY updated_at DESC, id DESC + """, + (duplicate["source_key"],), + ).fetchall() + current_id = int(duplicate_rows[0]["id"]) + organized_row = next( + (row for row in duplicate_rows if row["status"] == "organized"), + None, + ) + if organized_row is not None: + self.connection.execute( + "UPDATE media_files SET current_path=?, status='organized' WHERE id=?", + (organized_row["current_path"], current_id), + ) + superseded_ids = [int(row["id"]) for row in duplicate_rows[1:]] + self.connection.executemany( + "DELETE FROM media_files WHERE id=?", + [(row_id,) for row_id in superseded_ids], + ) + self.connection.execute( + "UPDATE media_files SET source_key=? WHERE id=?", + (duplicate["source_key"], current_id), + ) + + self.connection.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_media_files_source_key ON media_files(source_key)" + ) + self.connection.execute("DROP VIEW IF EXISTS show_progress") + self.connection.execute( + """ + CREATE VIEW show_progress AS + SELECT + sh.id AS show_id, + sh.canonical_title, + se.season_number, + COUNT(DISTINCT ep.id) AS identified_episodes, + COUNT(DISTINCT CASE WHEN mf.status='organized' THEN ep.id END) AS organized_episodes, + COUNT(DISTINCT mf.id) AS media_files + FROM media_files mf + JOIN episodes ep ON ep.id=mf.episode_id + JOIN seasons se ON se.id=ep.season_id + JOIN shows sh ON sh.id=se.show_id + GROUP BY sh.id, sh.canonical_title, se.season_number + """ + ) + + def __exit__(self, exc_type, exc, tb) -> None: + if self.connection is not None: + if exc_type is None: + self.connection.commit() + else: + self.connection.rollback() + self.connection.close() + self.connection = None + + def flush(self) -> None: + assert self.connection is not None + self.connection.commit() + + def get(self, media: MediaFile, decision_version: str = "") -> Optional[Resolution]: + assert self.connection is not None + key = fingerprint(media, decision_version) + row = self.connection.execute( + "SELECT * FROM resolutions WHERE fingerprint=? AND parser_version=? AND accepted=1", + (key, PARSER_VERSION), + ).fetchone() + if row is None: + return None + evidence_raw = json.loads(row["evidence_json"]) + return Resolution( + media=media, + canonical_title=row["canonical_title"], + season=row["season"], + episode=row["episode"], + is_movie=bool(row["is_movie"]), + confidence=float(row["confidence"]), + accepted=True, + release_tag=row["release_tag"], + evidence=[Evidence(**item) for item in evidence_raw], + warnings=list(json.loads(row["warnings_json"])), + fingerprint=key, + ) + + def put(self, resolution: Resolution) -> None: + assert self.connection is not None + if not resolution.accepted: + return + key = resolution.fingerprint or fingerprint(resolution.media) + show_id, episode_id = self._upsert_library_entities(resolution) + self.connection.execute( + """ + INSERT OR REPLACE INTO resolutions( + fingerprint, parser_version, show_id, episode_id, source_name, context_name, canonical_title, + season, episode, is_movie, confidence, release_tag, evidence_json, + warnings_json, accepted, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,CURRENT_TIMESTAMP) + """, + ( + key, PARSER_VERSION, show_id, episode_id, resolution.media.path.name, resolution.media.context_name, + resolution.canonical_title, resolution.season, resolution.episode, + int(resolution.is_movie), resolution.confidence, resolution.release_tag, + json.dumps([item.__dict__ for item in resolution.evidence], ensure_ascii=False), + json.dumps(resolution.warnings, ensure_ascii=False), + ), + ) + + def _upsert_library_entities(self, resolution: Resolution): + assert self.connection is not None + normalized = alias_key(resolution.canonical_title) + self.connection.execute( + "INSERT OR IGNORE INTO shows(canonical_title, normalized_title) VALUES(?,?)", + (resolution.canonical_title, normalized), + ) + show_row = self.connection.execute( + "SELECT id FROM shows WHERE canonical_title=? OR normalized_title=?", + (resolution.canonical_title, normalized), + ).fetchone() + show_id = int(show_row[0]) + self.connection.execute( + "INSERT OR IGNORE INTO seasons(show_id, season_number) VALUES(?,?)", + (show_id, int(1 if resolution.season is None else resolution.season)), + ) + season_id = int( + self.connection.execute( + "SELECT id FROM seasons WHERE show_id=? AND season_number=?", + (show_id, int(1 if resolution.season is None else resolution.season)), + ).fetchone()[0] + ) + self.connection.execute( + "INSERT OR IGNORE INTO episodes(season_id, episode_number) VALUES(?,?)", + (season_id, int(resolution.episode or 1)), + ) + episode_id = int( + self.connection.execute( + "SELECT id FROM episodes WHERE season_id=? AND episode_number=?", + (season_id, int(resolution.episode or 1)), + ).fetchone()[0] + ) + key = resolution.fingerprint or fingerprint(resolution.media) + source_key = _source_key(resolution.media.path) + self.connection.execute( + """ + INSERT INTO media_files( + fingerprint, source_key, episode_id, original_path, current_path, size, mtime_ns, + release_tag, status, updated_at + ) VALUES(?,?,?,?,?,?,?,?,'identified',CURRENT_TIMESTAMP) + ON CONFLICT(source_key) DO UPDATE SET + fingerprint=excluded.fingerprint, + episode_id=excluded.episode_id, + original_path=excluded.original_path, + current_path=CASE + WHEN media_files.size=excluded.size AND media_files.mtime_ns=excluded.mtime_ns + THEN media_files.current_path + ELSE excluded.original_path + END, + status=CASE + WHEN media_files.size=excluded.size AND media_files.mtime_ns=excluded.mtime_ns + THEN media_files.status + ELSE 'identified' + END, + size=excluded.size, + mtime_ns=excluded.mtime_ns, + release_tag=excluded.release_tag, + updated_at=CURRENT_TIMESTAMP + """, + ( + key, source_key, episode_id, str(resolution.media.path), str(resolution.media.path), + resolution.media.size, resolution.media.mtime_ns, resolution.release_tag, + ), + ) + return show_id, episode_id + + def mark_organized(self, resolution: Resolution, destination: Path) -> None: + assert self.connection is not None + self.connection.execute( + "UPDATE media_files SET current_path=?, status='organized', updated_at=CURRENT_TIMESTAMP WHERE source_key=?", + (str(destination), _source_key(resolution.media.path)), + ) + self.connection.commit() + + def mark_reverted(self, resolution: Resolution) -> None: + self.mark_reverted_path(resolution.media.path) + + def mark_reverted_path(self, source: Path) -> None: + assert self.connection is not None + self.connection.execute( + "UPDATE media_files SET current_path=original_path, status='identified', updated_at=CURRENT_TIMESTAMP WHERE source_key=?", + (_source_key(source),), + ) + self.connection.commit() + + def mark_reverted_fingerprint(self, key: str) -> None: + assert self.connection is not None + self.connection.execute( + "UPDATE media_files SET current_path=original_path, status='identified', updated_at=CURRENT_TIMESTAMP WHERE fingerprint=?", + (key,), + ) + self.connection.commit() + + def list_show_progress(self): + assert self.connection is not None + rows = self.connection.execute( + "SELECT * FROM show_progress ORDER BY canonical_title, season_number" + ).fetchall() + return [dict(row) for row in rows] + + def show_detail(self, show_id: int): + assert self.connection is not None + show = self.connection.execute("SELECT * FROM shows WHERE id=?", (show_id,)).fetchone() + if show is None: + return None + files = self.connection.execute( + """ + SELECT se.season_number, ep.episode_number, mf.*, + COALESCE(r.is_movie, 0) AS is_movie + FROM media_files mf + JOIN episodes ep ON ep.id=mf.episode_id + JOIN seasons se ON se.id=ep.season_id + LEFT JOIN resolutions r ON r.fingerprint=mf.fingerprint + WHERE se.show_id=? + ORDER BY se.season_number, ep.episode_number, mf.id + """, + (show_id,), + ).fetchall() + return {"show": dict(show), "episodes": [dict(row) for row in files]} + + def create_correction(self, entity_type: str, entity_id: int, field_name: str, old_value: str, new_value: str, reason: str, migration_plan) -> int: + assert self.connection is not None + cursor = self.connection.execute( + """ + INSERT INTO corrections(entity_type, entity_id, field_name, old_value, new_value, reason, migration_plan_json) + VALUES(?,?,?,?,?,?,?) + """, + (entity_type, entity_id, field_name, old_value, new_value, reason, json.dumps(migration_plan, ensure_ascii=False)), + ) + self.connection.commit() + return int(cursor.lastrowid) + + def record_operation(self, run_id: str, action: str, source: Path, destination: Path, status: str, error: str = "") -> None: + assert self.connection is not None + self.connection.execute( + "INSERT INTO operations(run_id, action, source, destination, status, error) VALUES(?,?,?,?,?,?)", + (run_id, action, str(source), str(destination), status, error), + ) + self.connection.commit() + + def reset(self) -> None: + assert self.connection is not None + self.connection.executescript( + """ + DELETE FROM corrections; + DELETE FROM operations; + DELETE FROM resolutions; + DELETE FROM media_files; + DELETE FROM episodes; + DELETE FROM seasons; + DELETE FROM shows; + """ + ) + self.connection.commit() diff --git a/autoanime_v3/catalog.py b/autoanime_v3/catalog.py new file mode 100644 index 0000000..77d5ff3 --- /dev/null +++ b/autoanime_v3/catalog.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import json +import re +import hashlib +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + +from .normalize import alias_key, display_title, strip_season_markers + + +class TitleCatalog: + def __init__(self, aliases: Dict[str, str], season_layouts: Dict[str, List[int]], episode_defaults=None, season_defaults=None) -> None: + self.aliases = aliases + self.season_layouts = season_layouts + self.episode_defaults = episode_defaults or {} + self.season_defaults = season_defaults or {} + serialized = json.dumps( + { + "aliases": self.aliases, + "season_layouts": self.season_layouts, + "episode_defaults": self.episode_defaults, + "season_defaults": self.season_defaults, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + self.version = hashlib.sha256(serialized.encode("utf-8")).hexdigest()[:16] + + @classmethod + def load( + cls, + path: Path, + user_path: Optional[Path] = None, + overlay: Optional[dict] = None, + ) -> "TitleCatalog": + aliases: Dict[str, str] = {} + layouts: Dict[str, List[int]] = {} + defaults: Dict[str, Tuple[int, int]] = {} + season_defaults: Dict[str, int] = {} + documents = [] + for current in [path, user_path]: + if not current or not current.is_file(): + continue + with current.open("r", encoding="utf-8") as handle: + documents.append(json.load(handle)) + if overlay is not None: + documents.append(overlay) + for document in documents: + raw_aliases = document.get("aliases", document) if isinstance(document, dict) else {} + if isinstance(raw_aliases, dict): + for raw_alias, raw_title in raw_aliases.items(): + if raw_alias == "season_layouts": + continue + key = alias_key(str(raw_alias)) + title = display_title(str(raw_title)) + if key and title: + aliases[key] = title + aliases.setdefault(alias_key(title), title) + raw_layouts = document.get("season_layouts", {}) if isinstance(document, dict) else {} + if isinstance(raw_layouts, dict): + for title, counts in raw_layouts.items(): + if isinstance(counts, list) and all(isinstance(value, int) and value > 0 for value in counts): + layouts[display_title(title)] = list(counts) + raw_defaults = document.get("episode_defaults", {}) if isinstance(document, dict) else {} + if isinstance(raw_defaults, dict): + for raw_alias, value in raw_defaults.items(): + if isinstance(value, list) and len(value) == 2: + try: + season, episode = int(value[0]), int(value[1]) + except (TypeError, ValueError): + continue + if season >= 0 and episode > 0: + defaults[alias_key(raw_alias)] = (season, episode) + raw_season_defaults = document.get("season_defaults", {}) if isinstance(document, dict) else {} + if isinstance(raw_season_defaults, dict): + for raw_alias, value in raw_season_defaults.items(): + try: + season = int(value) + except (TypeError, ValueError): + continue + if season > 0: + season_defaults[alias_key(raw_alias)] = season + return cls(aliases, layouts, defaults, season_defaults) + + def resolve(self, candidates: Iterable[str]) -> Optional[Tuple[str, str]]: + for candidate in candidates: + variants = [candidate] + stripped = re.sub( + r"(?:\s+(?:S(?:eason)?\s*)?\d+|\s+\d+(?:st|nd|rd|th)\s+Season|\s+II)$", + "", + candidate, + flags=re.I, + ).strip() + if stripped and stripped != candidate: + variants.append(stripped) + season_stripped = strip_season_markers(candidate) + if season_stripped and season_stripped not in variants: + variants.append(season_stripped) + parenthetical = re.sub(r"\s*[((][^))]*$", "", candidate).strip() + if parenthetical and parenthetical not in variants: + variants.append(parenthetical) + for variant in variants: + key = alias_key(variant) + if key in self.aliases: + return self.aliases[key], candidate + return None + + def default_episode(self, candidates: Iterable[str]) -> Optional[Tuple[int, int]]: + for candidate in candidates: + key = alias_key(candidate) + if key in self.episode_defaults: + return self.episode_defaults[key] + return None + + def default_season(self, candidates: Iterable[str]) -> Optional[int]: + for candidate in candidates: + key = alias_key(candidate) + if key in self.season_defaults: + return self.season_defaults[key] + return None + + def remap_absolute_episode(self, title: str, season: int, episode: int, explicit_season: bool) -> Tuple[int, int, bool]: + counts = self.season_layouts.get(title) + if not counts: + return season, episode, False + if explicit_season and season > 1 and season <= len(counts): + current_count = counts[season - 1] + previous_count = sum(counts[: season - 1]) + if episode > current_count and episode > previous_count: + local_episode = episode - previous_count + if 0 < local_episode <= current_count: + return season, local_episode, True + return season, episode, False + if explicit_season or season != 1 or episode <= counts[0]: + return season, episode, False + remaining = episode + for index, count in enumerate(counts, start=1): + if remaining <= count: + return index, remaining, True + remaining -= count + return season, episode, False diff --git a/autoanime_v3/cli.py b/autoanime_v3/cli.py new file mode 100644 index 0000000..4cdf375 --- /dev/null +++ b/autoanime_v3/cli.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import argparse +import json +import sys +from collections import Counter +from pathlib import Path +from typing import List, Optional + +from .repository import LibraryRepository +from .catalog import TitleCatalog +from .config import load_config +from .executor import execute_plan, rollback +from .planner import build_plan +from .resolver import Resolver +from .scanner import scan_media + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="AutoAnime v3 安全番剧整理器") + parser.add_argument("source", nargs="?", help="季度文件夹、下载目录或单个视频文件") + parser.add_argument("--output", help="媒体库输出目录") + parser.add_argument("--config", help="v3 配置文件") + parser.add_argument("--aliases", help="用户别名 JSON(覆盖内置目录)") + parser.add_argument("--mode", choices=["link", "copy", "move"], help="整理方式") + parser.add_argument("--apply", action="store_true", help="实际执行;不加时永远只预览") + parser.add_argument("--no-cache", action="store_true", help="忽略已有识别结果") + parser.add_argument("--report-json", help="输出完整计划 JSON") + parser.add_argument("--rollback", metavar="LOG", help="按操作日志回滚后退出") + parser.add_argument("--database-reset", "--cache-reset", action="store_true", help="清空 v3 资料库后退出") + return parser + + +def _print_summary(plan, log_path: Path, apply: bool) -> None: + counts = Counter(entry.action for entry in plan) + print("扫描结果:%d 项计划" % len(plan)) + for action in ("organize", "review", "conflict", "skip"): + print(" %-8s %d" % (action, counts.get(action, 0))) + print("模式:%s" % ("已执行" if apply else "仅预览(未改动任何媒体文件)")) + print("操作日志:%s" % log_path) + reviews = [entry for entry in plan if entry.action in {"review", "conflict"}] + if reviews: + print("需要人工确认的前 30 项:") + for entry in reviews[:30]: + print(" [%s] %s :: %s" % (entry.action, entry.source.name, entry.reason)) + + +def main(argv: Optional[List[str]] = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + parser = _parser() + args = parser.parse_args(argv) + project_root = Path(__file__).resolve().parent.parent + config = load_config(Path(args.config).resolve() if args.config else None, project_root) + if args.rollback: + with LibraryRepository(config.database_path) as repository: + restored = rollback(Path(args.rollback).resolve(), repository) + print("已回滚 %d 个文件;v3 资料库状态已同步。" % restored) + return 0 + with LibraryRepository(config.database_path) as cache: + if args.database_reset: + cache.reset() + print("v3 SQLite 资料库已清空;媒体文件未修改。") + return 0 + if not args.source: + parser.error("缺少 source") + source = Path(args.source).resolve() + output = Path(args.output).resolve() if args.output else config.output_root + if output is None: + output = source.parent / "AutoAnimeLibrary" if source.is_file() else source.parent / (source.name + "_Library") + output = output.resolve() + if output == source or (source.is_dir() and source in output.parents): + parser.error("输出目录不能等于输入目录或位于输入目录内部") + catalog = TitleCatalog.load(config.alias_file, Path(args.aliases).resolve() if args.aliases else None) + resolver = Resolver(catalog, config, cache) + media = scan_media(source, output) + resolutions = [resolver.resolve(item, use_cache=not args.no_cache) for item in media] + cache.flush() + plan = build_plan(resolutions, output) + if args.report_json: + report_path = Path(args.report_json).resolve() + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text( + json.dumps([entry.to_dict() for entry in plan], ensure_ascii=False, indent=2), encoding="utf-8" + ) + mode = args.mode or config.mode + log_path = execute_plan(plan, mode, bool(args.apply), cache, config.operation_dir) + _print_summary(plan, log_path, bool(args.apply)) + return 2 if any(entry.action in {"review", "conflict"} for entry in plan) else 0 diff --git a/autoanime_v3/config.py b/autoanime_v3/config.py new file mode 100644 index 0000000..8cbf42a --- /dev/null +++ b/autoanime_v3/config.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import configparser +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass(frozen=True) +class AppConfig: + database_path: Path + alias_file: Path + min_confidence: float = 0.86 + output_root: Optional[Path] = None + operation_dir: Optional[Path] = None + mode: str = "link" + openai_enabled: bool = False + openai_base_url: str = "https://api.openai.com" + openai_model: str = "gpt-4.1-mini" + openai_api_key: str = "" + openai_timeout: int = 30 + + @property + def cache_path(self) -> Path: + """兼容早期 v3 测试代码;新代码统一使用 database_path。""" + return self.database_path + + +def _get_bool(parser: configparser.ConfigParser, key: str, default: bool) -> bool: + try: + return parser.getboolean("autoanime", key) + except (ValueError, configparser.Error): + return default + + +def load_config(config_path: Optional[Path], project_root: Path) -> AppConfig: + parser = configparser.ConfigParser() + if config_path and config_path.is_file(): + parser.read(str(config_path), encoding="utf-8") + section = parser["autoanime"] if parser.has_section("autoanime") else {} + path_base = config_path.parent if config_path else project_root + + def value(name: str, default: str = "") -> str: + return str(section.get(name, default)).strip() + + def local_path(raw: str) -> Path: + candidate = Path(raw).expanduser() + return candidate if candidate.is_absolute() else (path_base / candidate).resolve() + + state_dir = project_root / ".autoanime-v3" + database_raw = value("database_path", value("cache_path", str(state_dir / "library.sqlite3"))) + aliases_raw = value("alias_file", str(project_root / "autoanime_v3" / "data" / "aliases.json")) + output_raw = value("output_root", "") + operation_raw = value("operation_dir", str(state_dir / "operations")) + api_env = value("openai_api_key_env", "OPENAI_API_KEY") + try: + confidence = float(value("min_confidence", "0.86")) + except ValueError: + confidence = 0.86 + try: + timeout = int(value("openai_timeout", "30")) + except ValueError: + timeout = 30 + return AppConfig( + database_path=local_path(database_raw), + alias_file=local_path(aliases_raw), + min_confidence=max(0.0, min(1.0, confidence)), + output_root=local_path(output_raw) if output_raw else None, + operation_dir=local_path(operation_raw) if operation_raw else None, + mode=value("mode", "link").lower(), + openai_enabled=_get_bool(parser, "openai_enabled", False), + openai_base_url=value("openai_base_url", "https://api.openai.com"), + openai_model=value("openai_model", "gpt-4.1-mini"), + openai_api_key=os.environ.get(api_env, "") or value("openai_api_key", ""), + openai_timeout=max(5, timeout), + ) diff --git a/autoanime_v3/data/aliases.json b/autoanime_v3/data/aliases.json new file mode 100644 index 0000000..b5dfd59 --- /dev/null +++ b/autoanime_v3/data/aliases.json @@ -0,0 +1,238 @@ +{ + "aliases": { + "MAO": "摩绪", + "Fate strange Fake": "命运:奇异赝品", + "GANSO BanG Dream Chan": "BanG Dream! 元祖小剧场", + "Medalist": "金牌得主", + "Ganbare Nakamura-kun": "加油吧!中村君!!", + "Yuusha no Kuzu": "勇者之屑", + "Mato Seihei no Slave": "魔都精兵的奴隶", + "Koori no Jouheki": "冰之城墙", + "Digimon Beatbreak": "数码宝贝 觉醒节拍", + "Isekai Nonbiri Nouka": "异世界悠闲农家", + "Maid-san wa Taberu Dake": "女仆小姐的贪吃日常", + "Otonari no Tenshi-sama ni Itsunomanika Dame Ningen ni Sareteita Ken": "关于邻家的天使大人不知不觉把我惯成了废人这档子事", + "Aishiteru Game wo Owarasetai": "想结束这场“我爱你”的游戏", + "Ookii Onnanoko wa Suki Desuka": "你喜欢高大的女孩子吗?", + "Ikoku Nikki": "异国日记", + "Ichijouma Mankitsu Gurashi": "一叠间漫画咖啡厅日常", + "Arne no Jikenbo": "阿涅斯事件簿", + "Akane Banashi": "落语朱音", + "Chitose-kun wa Ramune Bin no Naka": "千岁君在波子汽水瓶中", + "Saikyou no Ousama Nidome no Jinsei wa Nani wo Suru": "最强王者的第二人生", + "Otaku ni Yasashii Gal wa Inai": "哪里有温柔对待阿宅的辣妹!?", + "没有辣妹会对阿宅温柔": "哪里有温柔对待阿宅的辣妹!?", + "Virgin Punk": "处女朋克", + "Odayaka Kizoku no Kyuuka no Susume": "优雅贵族的休假指南", + "Kuranika": "和班上第二可爱的女孩子成了朋友", + "Kirei ni Shite Moraemasu ka": "能帮我弄干净吗", + "Champignon no Majo": "蘑菇魔女", + "Bungou Stray Dogs Wan": "文豪野犬 汪!", + "Otome Kaijuu Carameliser": "少女怪兽焦糖恋心", + "Needy Girl Overdose": "主播女孩重度依赖", + "Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e": "欢迎来到实力至上主义的教室", + "Tongari Boushi no Atelier": "尖帽子的魔法工坊", + "Ace of Diamond act II": "钻石王牌 act2", + "Kuroneko to Majo no Kyoushitsu": "黑猫与魔女的教室", + "Tsue to Tsurugi no Wistoria": "杖与剑的魔剑谭", + "Yozakura-san Chi no Daisakusen": "夜樱家的大作战", + "Dorohedoro": "异兽魔都", + "Re Zero kara Hajimeru Isekai Seikatsu": "Re:从零开始的异世界生活", + "Sousou no Frieren": "葬送的芙莉莲", + "Jujutsu Kaisen": "咒术回战", + "Tensei Shitara Slime Datta Ken": "关于我转生变成史莱姆这档事", + "One Piece": "海贼王", + "Flaming Dodgeball Girl Danko": "斗球女弹子", + "Dodge Danko": "斗球女弹子", + "Skeleton Knight in Another World": "骸骨骑士大人异世界冒险中", + "Gaikotsu Kishi sama Tadaima Isekai e Odekake chuu": "骸骨骑士大人异世界冒险中", + "Liar Game": "欺诈游戏", + "The Forsaken Saintess and Her Foodie Road Trip in Another World": "无用圣女的异世界美食之旅 凭借隐藏技能召唤露营车", + "The 100 Girlfriends Who Really Really Really Really REALLY Love You": "超超超超超喜欢你的100个女朋友", + "Victoria of Many Faces": "奇招百出的维多利亚", + "Young Ladies Dont Play Fighting Games": "感谢对战。~大小姐才不玩格斗游戏~", + "Haibara-kun no Tsuyokute Seishun New Game": "灰原同学重返过去,开启所向无敌的第二轮青春游戏", + "BanG Dream YUMEMITA": "BanG Dream! YUMEMITA", + "Mairimashita Iruma-kun": "入间同学入魔了", + "Mata Korosarete Shimatta no desu ne Tantei-sama": "又被杀掉了呢,侦探大人", + "Honzuki no Gekokujou": "小书痴的下克上", + "Star Detective Precure": "名侦探光之美少女!", + "Onegai Aipri": "拜托了偶像公主", + "Yomi no Tsugai": "黄泉使者", + "Kamiina Botan Yoeru Sugata wa Yuri no Hana": "上伊那牡丹,酒醉身姿似百合花般", + "MARRIAGETOXIN": "婚姻剧毒", + "Rooster Fighter": "公鸡斗士", + "LasTame": "身为悲剧始作俑者的最强邪恶BOSS女王为民竭心尽力。", + "Replica datte Koi wo Suru": "复制品的我也会谈恋爱。", + "Mamonogurai no Boukensha": "吞噬魔物的冒险者", + "Himekishi wa Barbaroi no Yome": "女骑士成为蛮族新娘", + "KILL BLUE": "杀手青春", + "Kanan-sama wa Akumade Choroi": "迦楠大人的白给是恶魔级", + "Ingoku Danchi": "淫狱团地", + "Magical Sisters LuluttoLilly": "魔法姐妹露露特莉莉", + "Nigetsuri": "关于虽然逃走的鱼很大、但钓上来的鱼却太大了这件事", + "Yuusha no Rokkotsu de": "女神“异世界转生想成为什么”我“勇者的肋骨”", + "Awajima Hyakkei": "淡岛百景", + "Ponkotsu Fuuki Iin to Skirt-take ga Futekisetsu na JK no Hanashi": "木头风纪委员和迷你裙JK的故事", + "Yowayowa Sensei": "弱弱老师", + "Class de 2-banme ni Kawaii Onnanoko to Tomodachi ni Natta": "和班上第二可爱的女孩子成了朋友", + "Nippon Sangoku": "日本三国", + "Jishou Akuyaku Reijou na Konyakusha no Kansatsu Kiroku": "自称恶役大小姐的婚约者观察记录。", + "GHOST CONCERT missing Songs": "幽灵音乐会", + "Kanteishi Kari": "最强的职业不是勇者也不是贤者好像是鉴定士(伪)的样子?", + "Shunkashuutou Daikousha Haru no Mai": "春夏秋冬代行者 春之舞", + "Jingai Kyoushitsu no Ningengirai Kyoushi": "非人学生与厌世教师", + "Marika-chan no Koukando wa Bukkowareteiru": "茉莉花同学的好感度坏得很彻底", + "Niwatori Fighter": "公鸡斗士", + "The World Is Dancing": "世界在起舞", + "Seihantai na Kimi to Boku": "相反的你和我", + "Neko to Ryuu": "猫与龙", + "Super no Ura de Yani Suu Futari": "在超市后门吸烟的二人", + "Rakudai Kenja no Gakuin Musou": "落第贤者的学院无双~第二次转生的S级开外挂魔术师冒险录~", + "Kanojo Okarishimasu": "租借女友", + "Mushoku Tensei": "无职转生~到了异世界就拿出真本事~", + "Grand Blue Dreaming": "碧蓝之海", + "Grand Blue": "碧蓝之海", + "Hell Mode": "地狱模式~喜欢挑战特殊成就的玩家在废设定的异世界成为无双~", + "Sekai Saikyou no Kouei": "世界最强的后卫 ~迷宫国的新人探索者~", + "Saved by the Ice Cold Princes Embrace": "拯救替身千金的是冷酷无情冰之王子的爱", + "Saijo no Osewa": "才女的侍从 在满是高岭之花的贵族学校暗中照顾(毫无生活自理能力的)学院第一大小姐", + "Ryoumin 0-nin Start no Henkyou Ryoushu-sama": "从0位居民开始的边境领主大人", + "Tenmaku no Jaadugar": "穹庐下的魔女", + "Classroom of the Elite": "欢迎来到实力至上主义的教室", + "Oshi no Ko": "我推的孩子", + "Toumei na Yoru ni Kakeru Kimi to Me ni Mienai Koi wo Shita": "与奔驰于透明之夜的你,谈一场看不见的恋爱。", + "Mayonaka Heart Tune": "午夜的倾心旋律", + "Magical Girl Lyrical Nanoha EXCEEDS Gun Blaze Vengeance": "魔法少女奈叶 EXCEEDS Gun Blaze Vengeance", + "Hime-sama Goumon no Jikan desu": "公主殿下,“拷问”的时间到了", + "公主大人“拷问”的时间到了": "公主殿下,“拷问”的时间到了", + "Yuusha kei ni Shosu Choubatsu Yuusha 9004 Tai Keimu Kiroku": "判处勇者刑 惩罚勇者9004队刑务纪录", + "Kabushikigaisha Magi Lumiere": "魔法光源股份有限公司", + "Kore Kaite Shine": "画完这个再去死", + "Yani Neko": "尼古喵喵", + "Tefuda ga Oome no Victoria": "奇招百出的维多利亚", + "Elegy for the Henchmen Fist of the North Star": "北斗神拳 拳王军杂兵们的挽歌", + "Dara-san of Reiwa": "令和妖神斑小姐", + "Oni no Hanayome": "鬼的新娘", + "Ibitte Konai Gibo to Gishi": "不虐待我的继母与继姐", + "Youjo Senki": "幼女战记", + "Toukutsu Ou": "盗墓王", + "I Want to Love You Till Your Dying Day": "与你相恋到生命尽头", + "Fumetsu no Anata e": "致不灭的你", + "Futsutsuka na Akujo dewa Gozaimasu ga": "我是不才恶女", + "Tsuihou sareta Tensei Juukishi wa Game Chishiki de Musou suru": "遭到流放的转生重骑士凭借游戏知识大开无双", + "Mato Seihei no Slave 2": "魔都精兵的奴隶", + "Hanaori-san wa Tensei shitemo Kenka ga Shitai": "花织即使是转生也想打架", + "Tamon-kun Ima Docchi": "现在多闻君是哪一面!?", + "Witch Hat Atelier": "尖帽子的魔法工坊", + "Android wa Keiken Ninzuu ni Hairimasu ka": "和机器人啪啪啪能算在经验次数里吗??", + "Though I Am an Inept Villainess": "我是不才恶女", + "Trapped in a Dating Sim The World of Otome Games Is Tough for Mobs": "女性向游戏世界对路人角色很不友好", + "Ushiro no Shoumen Kamui-san": "从后面来的神威先生", + "Dr Stone Science Future": "石纪元 科学与未来", + "Kamen Rider ZEZTZ": "假面骑士ZEZTZ", + "Kaya-chan wa Kowakunai": "神八小妹不可怕", + "Kimi ga Shinu made Koi wo Shitai": "与你相恋到生命尽头", + "Heroine Seijo Iie All Works Maid Desu": "女主角?圣女?不,我是全能女仆", + "Hana-Kimi": "花样少年少女", + "Frieren Beyond Journeys End": "葬送的芙莉莲", + "Otonari no Tenshi sama": "关于邻家的天使大人不知不觉把我惯成了废人这档子事", + "Ichijyoma Mankitsu Gurashi": "一叠间漫画咖啡厅日常", + "Saikyou no Ousama Nidome no Jinsei wa Nani o Suru": "最强王者的第二人生", + "Hokuto no Ken FIST OF THE NORTH STAR": "北斗神拳", + "Hokuto no Ken Kenougun Zako tachi no Banka": "北斗神拳 拳王军杂兵们的挽歌", + "Lv999 no Murabito": "LV999的村民", + "Hyakkano": "超超超超超喜欢你的100个女朋友", + "Clevatess": "克雷瓦提斯-魔兽之王与婴儿与尸之勇者-", + "Jigokuraku": "地狱乐", + "20 Seiki Denki Mokuroku": "二十世纪电气目录", + "The Ghost in the Shell": "攻壳机动队", + "Saga of Tanya the Evil": "幼女战记", + "Sayonara Lara": "再见菈菈", + "Perfect Addiction": "澈底对你成瘾", + "One Hundred Thousand Years of Qi Refining": "炼气十万年", + "My Stepmother and Stepsisters Arent Wicked": "不虐待我的继母与继姐", + "Ushiro no Shoumen Kamui san Mini Anime Gekijou": "从后面来的神威先生 迷你动画剧场", + "Enen no Shouboutai": "炎炎消防队", + "Eris no Seihai": "厄里斯的圣杯", + "Bungo Stray Dogs Wan": "文豪野犬 汪!", + "Otome Game Sekai wa Mob ni Kibishii Sekai desu": "女性向游戏世界对路人角色很不友好", + "Heroine Seijo Iie All Works Maid Desu Hokori": "女主角?圣女?不,我是全能女仆", + "Kabushikigaisha Magi Lumière": "魔法光源股份有限公司", + "Buchigire Reijou wa Houfuku wo Chikaimashita": "暴怒千金发誓复仇,凭借魔导书之力打垮祖国", + "Kokoore": "“你们先走我断后”,于是10年后我成为了传说", + "Tenbin": "转学后班上的清纯可爱美少女,竟是小时候玩在一起的哥儿们", + "Saikyou Degarashi Ouji no Anyaku Teii Arasoi": "最强废渣皇子暗中活跃于帝位之争", + "Chainsmoker Cat": "尼古喵喵", + "Fushigi no Kuni de Alice to Dive in Wonderland": "不可思议之国的爱丽丝 -Dive in Wonderland-", + "Katainaka no Ossan Kensei ni Naru": "乡下大叔成为剑圣", + "Hokuto no Ken Kenougun Zako tachi no Banka Part 2": "北斗神拳 拳王军杂兵们的挽歌", + "Yoroi Shin Den Samurai Troopers": "铠真传 武士军团", + "My Mother The Animation": "My Mother The Animation", + "Kimi wo Tsumugu Anime PV": "Kimi wo Tsumugu 动画PV", + "Iseleve TVSP": "在异世界获得超强能力的我,在现实世界照样无敌 TVSP", + "ONE PIECE HEROINES": "海贼王女英雄们", + "Shibou Yuugi de Meshi wo Kuu 44 Cloudy Beach": "靠死亡游戏混饭吃。" + ,"从后面来的神威先生 年龄限制版": "从后面来的神威先生" + ,"终末起点": "最强王者的第二人生" + ,"勇者之渣": "勇者之屑" + ,"夜樱家大作战 第二季": "夜樱家的大作战" + ,"上伊那牡丹,醉姿如百合": "上伊那牡丹,酒醉身姿似百合花般" + ,"魔法姊妹露露特莉莉": "魔法姐妹露露特莉莉" + ,"朱音落语": "落语朱音" + ,"你又被杀了呢,侦探大人": "又被杀掉了呢,侦探大人" + ,"溜掉的大鱼比不上自己钓到的鱼": "关于虽然逃走的鱼很大、但钓上来的鱼却太大了这件事" + ,"容易对付的恶魔大人": "迦楠大人的白给是恶魔级" + ,"入间同学入魔了!第四季": "入间同学入魔了" + ,"关于我转生变成史莱姆这档事 第四季": "关于我转生变成史莱姆这档事" + ,"异兽魔都 第二季": "异兽魔都" + ,"钻石王牌 act2 第二季": "钻石王牌 act2" + ,"无职转生~到了异世界就拿出真本事~第三季": "无职转生~到了异世界就拿出真本事~" + ,"欢迎来到实力至上主义的教室 第四季 2年级篇 第一学期": "欢迎来到实力至上主义的教室" + ,"小书痴的下克上 为了成为图书管理员不择手段!领主的养女": "小书痴的下克上" + ,"神之雫": "神之水滴" + ,"我的英雄学院 FINAL SEASON": "我的英雄学院" + ,"MAO 摩绪": "摩绪" + ,"世界最强后卫 ~迷宫国的新人探索者~": "世界最强的后卫 ~迷宫国的新人探索者~" + ,"直到我死去的那天也想与你相爱": "与你相恋到生命尽头" + ,"Animatica 北斗之拳 拳王军杂兵们的挽歌": "北斗神拳 拳王军杂兵们的挽歌" + ,"加油!中村同学!!": "加油吧!中村君!!" + ,"THE WORLD IS DANCING 世界在起舞": "世界在起舞" + ,"世界正在跳舞": "世界在起舞" + ,"GRAND BLUE 碧蓝之海 3": "碧蓝之海" + ,"虽说是未成熟的恶女": "我是不才恶女" + ,"乙女怪兽卡列尼策": "少女怪兽焦糖恋心" + ,"Clevatess Ⅱ 魔兽之王与虚假的勇者传承": "克雷瓦提斯 魔兽之王与婴儿与尸之勇者" + ,"葬送的芙莉莲 Sousou no Frieren": "葬送的芙莉莲" + }, + "season_layouts": { + "葬送的芙莉莲": [28, 28], + "咒术回战": [24, 23, 24], + "关于我转生变成史莱姆这档事": [24, 24, 24, 24], + "Re:从零开始的异世界生活": [25, 25, 16, 24], + "我的英雄学院": [13, 25, 25, 25, 25, 25, 21, 24], + "夜樱家的大作战": [27, 27] + ,"地狱模式~喜欢挑战特殊成就的玩家在废设定的异世界成为无双~": [12, 12] + ,"公主殿下,“拷问”的时间到了": [12, 12] + ,"擅长逃跑的殿下": [12, 12] + ,"相反的你和我": [12, 12] + ,"异兽魔都": [12, 12] + ,"杖与剑的魔剑谭": [12, 12] + ,"地狱乐": [13, 13] + }, + "episode_defaults": { + "My Mother The Animation": [0, 1], + "Kimi wo Tsumugu Anime PV": [0, 1], + "Iseleve TVSP": [0, 1], + "ONE PIECE HEROINES": [0, 1], + "Shibou Yuugi de Meshi wo Kuu 44 Cloudy Beach": [1, 1] + }, + "season_defaults": { + "碧蓝航线 微速前进!2!!": 2, + "GRAND BLUE 碧蓝之海 3": 3, + "Clevatess Ⅱ-魔兽之王与虚假的勇者传承-": 2, + "Dr Stone Science Future": 4, + "石纪元 科学与未来": 4, + "Katainaka no Ossan Kensei ni Naru II": 2 + } +} diff --git a/autoanime_v3/db/__init__.py b/autoanime_v3/db/__init__.py new file mode 100644 index 0000000..966a5ff --- /dev/null +++ b/autoanime_v3/db/__init__.py @@ -0,0 +1,6 @@ +"""SQLite persistence for the AutoAnime Web console.""" + +from .migrations import connect_database, run_migrations + +__all__ = ["connect_database", "run_migrations"] + diff --git a/autoanime_v3/db/engine.py b/autoanime_v3/db/engine.py new file mode 100644 index 0000000..2ba62bf --- /dev/null +++ b/autoanime_v3/db/engine.py @@ -0,0 +1,41 @@ +"""SQLite engine and connection configuration.""" + +import sqlite3 +from pathlib import Path + +from sqlalchemy import create_engine, event +from sqlalchemy.engine import Engine, URL + + +BUSY_TIMEOUT_MS = 10000 + + +def _configure_dbapi_connection(connection, connection_record=None): + cursor = connection.cursor() + try: + cursor.execute("PRAGMA foreign_keys=ON") + cursor.execute("PRAGMA busy_timeout=%d" % BUSY_TIMEOUT_MS) + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + finally: + cursor.close() + + +def create_engine_for_path(database_path): + path = Path(database_path).resolve() + path.parent.mkdir(parents=True, exist_ok=True) + engine = create_engine( + URL.create("sqlite+pysqlite", database=str(path)), + future=True, + connect_args={"check_same_thread": False, "timeout": BUSY_TIMEOUT_MS / 1000}, + ) + event.listen(engine, "connect", _configure_dbapi_connection) + return engine + + +def connect_sqlite(database_path): + path = Path(database_path).resolve() + path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(str(path), timeout=BUSY_TIMEOUT_MS / 1000) + _configure_dbapi_connection(connection) + return connection diff --git a/autoanime_v3/db/migrations.py b/autoanime_v3/db/migrations.py new file mode 100644 index 0000000..3135494 --- /dev/null +++ b/autoanime_v3/db/migrations.py @@ -0,0 +1,49 @@ +"""Idempotent schema bootstrap for a new v3 Web console database.""" + +from pathlib import Path + +from sqlalchemy import insert, select + +from .engine import connect_sqlite, create_engine_for_path +from .schema import metadata, schema_migrations + + +SCHEMA_VERSION = 3 + + +def connect_database(database_path): + return connect_sqlite(database_path) + + +def run_migrations(database_path): + path = Path(database_path).resolve() + engine = create_engine_for_path(path) + try: + metadata.create_all(engine) + with engine.begin() as connection: + schedule_columns = { + row[1] + for row in connection.exec_driver_sql("PRAGMA table_info(schedules)").fetchall() + } + if "revision" not in schedule_columns: + connection.exec_driver_sql( + "ALTER TABLE schedules ADD COLUMN revision INTEGER NOT NULL DEFAULT 1" + ) + webhook_columns = { + row[1] + for row in connection.exec_driver_sql("PRAGMA table_info(webhook_sources)").fetchall() + } + if "revision" not in webhook_columns: + connection.exec_driver_sql( + "ALTER TABLE webhook_sources ADD COLUMN revision INTEGER NOT NULL DEFAULT 1" + ) + existing = connection.execute( + select(schema_migrations.c.version).where( + schema_migrations.c.version == SCHEMA_VERSION + ) + ).scalar_one_or_none() + if existing is None: + connection.execute(insert(schema_migrations).values(version=SCHEMA_VERSION)) + finally: + engine.dispose() + return path diff --git a/autoanime_v3/db/repositories/__init__.py b/autoanime_v3/db/repositories/__init__.py new file mode 100644 index 0000000..c61c059 --- /dev/null +++ b/autoanime_v3/db/repositories/__init__.py @@ -0,0 +1,2 @@ +"""Persistence adapters returning domain DTOs rather than database rows.""" + diff --git a/autoanime_v3/db/repositories/auth.py b/autoanime_v3/db/repositories/auth.py new file mode 100644 index 0000000..59636bb --- /dev/null +++ b/autoanime_v3/db/repositories/auth.py @@ -0,0 +1,90 @@ +"""Authentication persistence adapter.""" + +from autoanime_v3.domain.entities import UserPublic + + +def public_user(row): + return UserPublic(id=int(row["id"]), username=str(row["username"]), is_active=bool(row["is_active"])) + + +class AuthRepository: + def __init__(self, connection): + self.connection = connection + + def user_count(self): + return int(self.connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]) + + def create_user(self, username, password_hash, now): + cursor = self.connection.execute( + """ + INSERT INTO users(username, password_hash, password_changed_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + """, + (username, password_hash, now, now, now), + ) + return self.get_user_by_id(cursor.lastrowid) + + def get_user_by_id(self, user_id): + return self.connection.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + + def get_user_by_username(self, username): + return self.connection.execute( + "SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username,) + ).fetchone() + + def create_session(self, user_id, token_hash, csrf_hash, now, expires_at, client_ip, user_agent): + cursor = self.connection.execute( + """ + INSERT INTO user_sessions( + user_id, token_hash, csrf_hash, created_at, last_seen_at, expires_at, + client_ip, user_agent + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (user_id, token_hash, csrf_hash, now, now, expires_at, client_ip, user_agent), + ) + return cursor.lastrowid + + def find_session(self, token_hash): + return self.connection.execute( + """ + SELECT s.*, u.username, u.is_active + FROM user_sessions s JOIN users u ON u.id = s.user_id + WHERE s.token_hash = ? + """, + (token_hash,), + ).fetchone() + + def touch_session(self, session_id, now): + self.connection.execute( + "UPDATE user_sessions SET last_seen_at = ? WHERE id = ?", (now, session_id) + ) + + def revoke_session(self, token_hash, now): + return self.connection.execute( + "UPDATE user_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL", + (now, token_hash), + ).rowcount + + def get_login_attempt(self, attempt_key): + return self.connection.execute( + "SELECT * FROM login_attempts WHERE attempt_key = ?", (attempt_key,) + ).fetchone() + + def save_login_failure(self, attempt_key, count, window_started_at, locked_until, now): + self.connection.execute( + """ + INSERT INTO login_attempts( + attempt_key, failure_count, window_started_at, locked_until, updated_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(attempt_key) DO UPDATE SET + failure_count = excluded.failure_count, + window_started_at = excluded.window_started_at, + locked_until = excluded.locked_until, + updated_at = excluded.updated_at + """, + (attempt_key, count, window_started_at, locked_until, now), + ) + + def clear_login_attempt(self, attempt_key): + self.connection.execute("DELETE FROM login_attempts WHERE attempt_key = ?", (attempt_key,)) + diff --git a/autoanime_v3/db/repositories/jobs.py b/autoanime_v3/db/repositories/jobs.py new file mode 100644 index 0000000..6e69f34 --- /dev/null +++ b/autoanime_v3/db/repositories/jobs.py @@ -0,0 +1,101 @@ +"""Persistent job and event repository.""" + +import json + +from autoanime_v3.domain.entities import Job, JobEvent + + +def job_from_row(row): + return Job( + id=int(row["id"]), + job_type=str(row["job_type"]), + status=str(row["status"]), + priority=int(row["priority"]), + payload=json.loads(row["payload_json"] or "{}"), + idempotency_key=row["idempotency_key"], + progress_current=int(row["progress_current"]), + progress_total=int(row["progress_total"]), + current_stage=row["current_stage"], + error_code=row["error_code"], + error_summary=row["error_summary"], + lease_owner=row["lease_owner"], + lease_until=row["lease_until"], + cancel_requested=row["cancel_requested_at"] is not None, + created_at=str(row["created_at"]), + ) + + +def event_from_row(row): + return JobEvent( + id=int(row["id"]), + job_id=int(row["job_id"]), + sequence=int(row["sequence"]), + level=str(row["level"]), + event_type=str(row["event_type"]), + message=str(row["message"]), + payload=json.loads(row["payload_json"] or "{}"), + created_at=str(row["created_at"]), + ) + + +class JobRepository: + def __init__(self, connection): + self.connection = connection + + def get(self, job_id): + row = self.connection.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone() + return job_from_row(row) if row is not None else None + + def find_by_idempotency_key(self, key): + if key is None: + return None + row = self.connection.execute( + "SELECT * FROM jobs WHERE idempotency_key = ?", (key,) + ).fetchone() + return job_from_row(row) if row is not None else None + + def enqueue(self, job_type, payload, idempotency_key, priority, now): + cursor = self.connection.execute( + """ + INSERT INTO jobs(job_type, status, priority, payload_json, idempotency_key, created_at) + VALUES (?, 'queued', ?, ?, ?, ?) + """, + (job_type, priority, json.dumps(payload, ensure_ascii=False), idempotency_key, now), + ) + return self.get(cursor.lastrowid) + + def next_queued(self): + row = self.connection.execute( + "SELECT * FROM jobs WHERE status = 'queued' ORDER BY priority DESC, id ASC LIMIT 1" + ).fetchone() + return job_from_row(row) if row is not None else None + + def events(self, job_id, after_sequence=0): + rows = self.connection.execute( + """ + SELECT * FROM job_events + WHERE job_id = ? AND sequence > ? ORDER BY sequence + """, + (job_id, after_sequence), + ).fetchall() + return tuple(event_from_row(row) for row in rows) + + def append_event(self, job_id, event_type, payload, message, level, now): + sequence = int( + self.connection.execute( + "SELECT COALESCE(MAX(sequence), 0) + 1 FROM job_events WHERE job_id = ?", + (job_id,), + ).fetchone()[0] + ) + cursor = self.connection.execute( + """ + INSERT INTO job_events(job_id, sequence, level, event_type, message, payload_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (job_id, sequence, level, event_type, message, json.dumps(payload, ensure_ascii=False), now), + ) + row = self.connection.execute( + "SELECT * FROM job_events WHERE id = ?", (cursor.lastrowid,) + ).fetchone() + return event_from_row(row) + diff --git a/autoanime_v3/db/repositories/library.py b/autoanime_v3/db/repositories/library.py new file mode 100644 index 0000000..afee9bf --- /dev/null +++ b/autoanime_v3/db/repositories/library.py @@ -0,0 +1,130 @@ +"""Repository for physical file generations and their locations.""" + +import os +from pathlib import Path + +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.entities import FileLocation, MediaFile +from autoanime_v3.domain.errors import NotFoundError +from autoanime_v3.services.roots import normalize_windows_path + + +def location_from_row(row): + return FileLocation( + id=int(row["id"]), + media_file_id=int(row["media_file_id"]), + root_id=int(row["root_id"]), + path=str(row["path"]), + normalized_path=str(row["normalized_path"]), + role=str(row["role"]), + state=str(row["state"]), + ) + + +class LibraryRepository: + def __init__(self, database_path): + self.database_path = Path(database_path) + run_migrations(self.database_path) + + def observe_path(self, root_id, path, role, media_kind): + display_path = str(Path(path).resolve(strict=True)) + normalized = normalize_windows_path(path) + stat = os.stat(display_path) + size = int(stat.st_size) + mtime_ns = int(stat.st_mtime_ns) + volume_serial = str(stat.st_dev) + file_index = str(stat.st_ino) if int(stat.st_ino) else None + + with SqliteUnitOfWork(self.database_path) as uow: + existing_location = uow.connection.execute( + "SELECT * FROM file_locations WHERE normalized_path = ? AND state = 'present'", + (normalized,), + ).fetchone() + if existing_location is not None: + existing_media = uow.connection.execute( + "SELECT * FROM media_files WHERE id = ?", + (existing_location["media_file_id"],), + ).fetchone() + same_generation = ( + int(existing_media["size"]) == size + and int(existing_media["mtime_ns"]) == mtime_ns + and (existing_media["file_index"] or None) == file_index + ) + if same_generation: + media_id = int(existing_media["id"]) + uow.connection.execute( + "UPDATE file_locations SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?", + (existing_location["id"],), + ) + uow.commit() + return self.get_media(media_id) + uow.connection.execute( + "UPDATE file_locations SET state = 'replaced', last_seen_at = CURRENT_TIMESTAMP WHERE id = ?", + (existing_location["id"],), + ) + + media_row = None + if file_index is not None: + media_row = uow.connection.execute( + """ + SELECT * FROM media_files + WHERE volume_serial = ? AND file_index = ? AND size = ? + AND generation_status = 'current' + ORDER BY id DESC LIMIT 1 + """, + (volume_serial, file_index, size), + ).fetchone() + if media_row is None: + cursor = uow.connection.execute( + """ + INSERT INTO media_files( + size, mtime_ns, volume_serial, file_index, media_kind, generation_status + ) VALUES (?, ?, ?, ?, ?, 'current') + """, + (size, mtime_ns, volume_serial, file_index, media_kind), + ) + media_id = int(cursor.lastrowid) + else: + media_id = int(media_row["id"]) + + uow.connection.execute( + """ + INSERT INTO file_locations( + media_file_id, root_id, path, normalized_path, role, state + ) VALUES (?, ?, ?, ?, ?, 'present') + """, + (media_id, root_id, display_path, normalized, role), + ) + uow.commit() + return self.get_media(media_id) + + def get_media(self, media_file_id): + from autoanime_v3.db.engine import connect_sqlite + + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + row = connection.execute( + "SELECT * FROM media_files WHERE id = ?", (media_file_id,) + ).fetchone() + if row is None: + raise NotFoundError("Media file does not exist", {"id": media_file_id}) + locations = connection.execute( + "SELECT * FROM file_locations WHERE media_file_id = ? ORDER BY id", + (media_file_id,), + ).fetchall() + return MediaFile( + id=int(row["id"]), + size=int(row["size"]), + mtime_ns=int(row["mtime_ns"]), + volume_serial=row["volume_serial"], + file_index=row["file_index"], + sha256=row["sha256"], + media_kind=str(row["media_kind"]), + generation_status=str(row["generation_status"]), + locations=tuple(location_from_row(item) for item in locations), + ) + finally: + connection.close() + diff --git a/autoanime_v3/db/repositories/operations.py b/autoanime_v3/db/repositories/operations.py new file mode 100644 index 0000000..b1d4146 --- /dev/null +++ b/autoanime_v3/db/repositories/operations.py @@ -0,0 +1,46 @@ +"""Operation batch DTO mapping.""" + +import json + +from autoanime_v3.domain.entities import OperationBatchView, OperationItemView + + +class OperationRepository: + def __init__(self, connection): + self.connection = connection + + def get(self, batch_id): + row = self.connection.execute( + "SELECT * FROM operation_batches WHERE id = ?", (batch_id,) + ).fetchone() + if row is None: + return None + item_rows = self.connection.execute( + "SELECT * FROM operation_items WHERE batch_id = ? ORDER BY sequence", (batch_id,) + ).fetchall() + items = tuple( + OperationItemView( + id=int(item["id"]), + sequence=int(item["sequence"]), + action=str(item["action"]), + source_path=str(item["source_path"]), + destination_path=str(item["destination_path"]), + status=str(item["status"]), + result_sha256=item["result_sha256"], + error_code=item["error_code"], + compensation_status=item["compensation_status"], + ) + for item in item_rows + ) + return OperationBatchView( + id=int(row["id"]), + plan_id=int(row["plan_id"]) if row["plan_id"] is not None else None, + parent_batch_id=( + int(row["parent_batch_id"]) if row["parent_batch_id"] is not None else None + ), + kind=str(row["kind"]), + status=str(row["status"]), + summary=json.loads(row["summary_json"] or "{}"), + items=items, + ) + diff --git a/autoanime_v3/db/repositories/plans.py b/autoanime_v3/db/repositories/plans.py new file mode 100644 index 0000000..caf37cd --- /dev/null +++ b/autoanime_v3/db/repositories/plans.py @@ -0,0 +1,62 @@ +"""Plan DTO mapping helpers.""" + +from pathlib import Path + +from autoanime_v3.domain.entities import PlanItemView, PlanView + + +def plan_from_rows(plan_row, item_rows): + items = [] + for row in item_rows: + destination = str(Path(row["root_path"]) / row["destination_relative_path"]) + items.append( + PlanItemView( + id=int(row["id"]), + source_location_id=int(row["source_location_id"]), + source_path=str(row["source_path"]), + destination_root_id=int(row["destination_root_id"]), + destination_path=destination, + destination_relative_path=str(row["destination_relative_path"]), + action=str(row["action"]), + reason=str(row["reason"] or ""), + risk_level=str(row["risk_level"]), + source_size=int(row["source_size"]), + source_mtime_ns=int(row["source_mtime_ns"]), + source_file_index=row["source_file_index"], + source_sha256=row["source_sha256"], + execution_status=str(row["execution_status"]), + ) + ) + return PlanView( + id=int(plan_row["id"]), + scan_run_id=int(plan_row["scan_run_id"]), + profile_id=int(plan_row["profile_id"]), + profile_revision=int(plan_row["profile_revision"]), + rule_version=str(plan_row["rule_version"]), + library_revision=int(plan_row["library_revision"]), + revision=int(plan_row["revision"]), + status=str(plan_row["status"]), + items=tuple(items), + ) + + +class PlanRepository: + def __init__(self, connection): + self.connection = connection + + def get(self, plan_id): + plan = self.connection.execute("SELECT * FROM plans WHERE id = ?", (plan_id,)).fetchone() + if plan is None: + return None + items = self.connection.execute( + """ + SELECT pi.*, fl.path AS source_path, sr.path AS root_path + FROM plan_items pi + JOIN file_locations fl ON fl.id = pi.source_location_id + JOIN storage_roots sr ON sr.id = pi.destination_root_id + WHERE pi.plan_id = ? ORDER BY pi.id + """, + (plan_id,), + ).fetchall() + return plan_from_rows(plan, items) + diff --git a/autoanime_v3/db/repositories/profiles.py b/autoanime_v3/db/repositories/profiles.py new file mode 100644 index 0000000..c3649a7 --- /dev/null +++ b/autoanime_v3/db/repositories/profiles.py @@ -0,0 +1,78 @@ +from autoanime_v3.domain.entities import ScanProfile + + +def profile_from_row(row): + return ScanProfile( + id=int(row["id"]), + name=str(row["name"]), + source_root_id=int(row["source_root_id"]), + library_root_id=int(row["library_root_id"]), + mode=str(row["mode"]), + execution_policy=str(row["execution_policy"]), + min_confidence=int(row["min_confidence"]), + stability_seconds=int(row["stability_seconds"]), + watch_enabled=bool(row["watch_enabled"]), + enabled=bool(row["enabled"]), + revision=int(row["revision"]), + ) + + +class ProfileRepository: + def __init__(self, connection): + self.connection = connection + + def create(self, command): + cursor = self.connection.execute( + """ + INSERT INTO scan_profiles( + name, source_root_id, library_root_id, mode, execution_policy, + min_confidence, stability_seconds, watch_enabled, enabled + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + command.name, + command.source_root_id, + command.library_root_id, + command.mode, + command.execution_policy, + command.min_confidence, + command.stability_seconds, + int(command.watch_enabled), + int(command.enabled), + ), + ) + return self.get(cursor.lastrowid) + + def get(self, profile_id): + row = self.connection.execute( + "SELECT * FROM scan_profiles WHERE id = ?", (profile_id,) + ).fetchone() + return profile_from_row(row) if row is not None else None + + def update(self, profile_id, revision, patch): + allowed = { + "name", + "mode", + "execution_policy", + "min_confidence", + "stability_seconds", + "watch_enabled", + "enabled", + } + fields = [] + values = [] + for key, value in patch.items(): + if key not in allowed: + continue + fields.append("%s = ?" % key) + values.append(int(value) if key in {"watch_enabled", "enabled"} else value) + if not fields: + return self.get(profile_id), False + fields.extend(["revision = revision + 1", "updated_at = CURRENT_TIMESTAMP"]) + values.extend([profile_id, revision]) + cursor = self.connection.execute( + "UPDATE scan_profiles SET %s WHERE id = ? AND revision = ?" % ", ".join(fields), + tuple(values), + ) + return self.get(profile_id), cursor.rowcount == 1 + diff --git a/autoanime_v3/db/repositories/reviews.py b/autoanime_v3/db/repositories/reviews.py new file mode 100644 index 0000000..4c052f3 --- /dev/null +++ b/autoanime_v3/db/repositories/reviews.py @@ -0,0 +1,18 @@ +"""Review item DTO mapping.""" + +import json + +from autoanime_v3.domain.entities import ReviewItemView + + +def review_from_row(row): + return ReviewItemView( + id=int(row["id"]), + scan_run_id=int(row["scan_run_id"]), + media_file_id=int(row["media_file_id"]) if row["media_file_id"] is not None else None, + review_type=str(row["review_type"]), + status=str(row["status"]), + payload=json.loads(row["payload_json"] or "{}"), + resolution=json.loads(row["resolution_json"]) if row["resolution_json"] else None, + ) + diff --git a/autoanime_v3/db/repositories/roots.py b/autoanime_v3/db/repositories/roots.py new file mode 100644 index 0000000..43347b7 --- /dev/null +++ b/autoanime_v3/db/repositories/roots.py @@ -0,0 +1,61 @@ +from autoanime_v3.domain.entities import StorageRoot + + +def root_from_row(row): + return StorageRoot( + id=int(row["id"]), + kind=str(row["kind"]), + path=str(row["path"]), + normalized_path=str(row["normalized_path"]), + enabled=bool(row["enabled"]), + health_status=str(row["health_status"]), + volume_serial=row["volume_serial"], + filesystem_type=row["filesystem_type"], + ) + + +class RootRepository: + def __init__(self, connection): + self.connection = connection + + def create(self, kind, path, normalized_path): + cursor = self.connection.execute( + """ + INSERT INTO storage_roots(kind, path, normalized_path) + VALUES (?, ?, ?) + """, + (kind, path, normalized_path), + ) + return self.get(cursor.lastrowid) + + def get(self, root_id): + row = self.connection.execute( + "SELECT * FROM storage_roots WHERE id = ?", (root_id,) + ).fetchone() + return root_from_row(row) if row is not None else None + + def find_by_normalized_path(self, normalized_path): + row = self.connection.execute( + "SELECT * FROM storage_roots WHERE normalized_path = ?", + (normalized_path,), + ).fetchone() + return root_from_row(row) if row is not None else None + + def list_enabled(self): + rows = self.connection.execute( + "SELECT * FROM storage_roots WHERE enabled = 1 ORDER BY id" + ).fetchall() + return tuple(root_from_row(row) for row in rows) + + def update_health(self, root_id, status, checked_at, volume_serial=None): + self.connection.execute( + """ + UPDATE storage_roots + SET health_status = ?, last_checked_at = ?, volume_serial = COALESCE(?, volume_serial), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (status, checked_at, volume_serial, root_id), + ) + return self.get(root_id) + diff --git a/autoanime_v3/db/repositories/scans.py b/autoanime_v3/db/repositories/scans.py new file mode 100644 index 0000000..713a9a9 --- /dev/null +++ b/autoanime_v3/db/repositories/scans.py @@ -0,0 +1,46 @@ +"""Scan persistence helpers.""" + +import json + + +class ScanRepository: + def __init__(self, connection): + self.connection = connection + + def create_run(self, profile_id, profile_revision, rule_version, scope, started_at): + cursor = self.connection.execute( + """ + INSERT INTO scan_runs( + profile_id, profile_revision, rule_version, scope_json, + statistics_json, started_at + ) VALUES (?, ?, ?, ?, '{}', ?) + """, + (profile_id, profile_revision, rule_version, json.dumps(scope), started_at), + ) + return int(cursor.lastrowid) + + def add_item(self, run_id, media_file_id, path, normalized_path, snapshot, outcome, reason=None): + self.connection.execute( + """ + INSERT INTO scan_items( + scan_run_id, media_file_id, path, normalized_path, + snapshot_json, outcome, reason + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + run_id, + media_file_id, + path, + normalized_path, + json.dumps(snapshot, ensure_ascii=False), + outcome, + reason, + ), + ) + + def finish(self, run_id, statistics, finished_at): + self.connection.execute( + "UPDATE scan_runs SET statistics_json = ?, finished_at = ? WHERE id = ?", + (json.dumps(statistics, ensure_ascii=False), finished_at, run_id), + ) + diff --git a/autoanime_v3/db/schema.py b/autoanime_v3/db/schema.py new file mode 100644 index 0000000..8df1e07 --- /dev/null +++ b/autoanime_v3/db/schema.py @@ -0,0 +1,565 @@ +"""SQLAlchemy Core schema for the Web console database.""" + +from sqlalchemy import ( + Boolean, + CheckConstraint, + Column, + ForeignKey, + Index, + Integer, + LargeBinary, + MetaData, + String, + Table, + Text, + UniqueConstraint, + text, +) + + +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} +metadata = MetaData(naming_convention=NAMING_CONVENTION) + + +def utc_columns(): + return ( + Column("created_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), + Column("updated_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), + ) + + +schema_migrations = Table( + "schema_migrations", + metadata, + Column("version", Integer, primary_key=True), + Column("applied_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), +) + +users = Table( + "users", + metadata, + Column("id", Integer, primary_key=True), + Column("username", String(128), nullable=False, unique=True), + Column("password_hash", Text, nullable=False), + Column("is_active", Boolean, nullable=False, server_default=text("1")), + Column("password_changed_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), + *utc_columns() +) + +user_sessions = Table( + "user_sessions", + metadata, + Column("id", Integer, primary_key=True), + Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + Column("token_hash", String(128), nullable=False, unique=True), + Column("csrf_hash", String(128), nullable=False), + Column("created_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), + Column("last_seen_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), + Column("expires_at", String(32), nullable=False), + Column("revoked_at", String(32)), + Column("client_ip", String(64)), + Column("user_agent", Text), +) + +login_attempts = Table( + "login_attempts", + metadata, + Column("attempt_key", String(128), primary_key=True), + Column("failure_count", Integer, nullable=False, server_default=text("0")), + Column("window_started_at", String(32), nullable=False), + Column("locked_until", String(32)), + Column("updated_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), +) + +app_settings = Table( + "app_settings", + metadata, + Column("key", String(128), primary_key=True), + Column("value_json", Text, nullable=False), + Column("revision", Integer, nullable=False, server_default=text("1")), + Column("updated_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), +) + +secret_settings = Table( + "secret_settings", + metadata, + Column("key", String(128), primary_key=True), + Column("ciphertext", LargeBinary, nullable=False), + Column("provider", String(32), nullable=False), + Column("updated_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), +) + +audit_events = Table( + "audit_events", + metadata, + Column("id", Integer, primary_key=True), + Column("actor_user_id", ForeignKey("users.id", ondelete="SET NULL")), + Column("action", String(128), nullable=False), + Column("object_type", String(64), nullable=False), + Column("object_id", String(128)), + Column("before_json", Text), + Column("after_json", Text), + Column("reason", Text), + Column("trace_id", String(64)), + Column("client_ip", String(64)), + Column("created_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), +) + +storage_roots = Table( + "storage_roots", + metadata, + Column("id", Integer, primary_key=True), + Column("kind", String(32), nullable=False), + Column("path", Text, nullable=False), + Column("normalized_path", Text, nullable=False, unique=True), + Column("volume_serial", String(64)), + Column("filesystem_type", String(32)), + Column("enabled", Boolean, nullable=False, server_default=text("1")), + Column("health_status", String(32), nullable=False, server_default=text("'unknown'")), + Column("last_checked_at", String(32)), + *utc_columns(), +) + +scan_profiles = Table( + "scan_profiles", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(128), nullable=False, unique=True), + Column("source_root_id", ForeignKey("storage_roots.id", ondelete="RESTRICT"), nullable=False), + Column("library_root_id", ForeignKey("storage_roots.id", ondelete="RESTRICT"), nullable=False), + Column("mode", String(16), nullable=False), + Column("execution_policy", String(32), nullable=False), + Column("min_confidence", Integer, nullable=False, server_default=text("80")), + Column("stability_seconds", Integer, nullable=False, server_default=text("30")), + Column("watch_enabled", Boolean, nullable=False, server_default=text("0")), + Column("enabled", Boolean, nullable=False, server_default=text("1")), + Column("revision", Integer, nullable=False, server_default=text("1")), + CheckConstraint("source_root_id <> library_root_id", name="different_roots"), + *utc_columns(), +) + +profile_rules = Table( + "profile_rules", + metadata, + Column("id", Integer, primary_key=True), + Column("profile_id", ForeignKey("scan_profiles.id", ondelete="CASCADE"), nullable=False, unique=True), + Column("include_globs_json", Text, nullable=False, server_default=text("'[]'")), + Column("exclude_globs_json", Text, nullable=False, server_default=text("'[]'")), + Column("media_extensions_json", Text, nullable=False, server_default=text("'[]'")), + Column("subtitle_extensions_json", Text, nullable=False, server_default=text("'[]'")), + Column("temporary_suffixes_json", Text, nullable=False, server_default=text("'[]'")), + Column("ignored_directories_json", Text, nullable=False, server_default=text("'[]'")), + Column("minimum_size", Integer, nullable=False, server_default=text("0")), + *utc_columns(), +) + +schedules = Table( + "schedules", + metadata, + Column("id", Integer, primary_key=True), + Column("profile_id", ForeignKey("scan_profiles.id", ondelete="CASCADE"), nullable=False), + Column("kind", String(16), nullable=False), + Column("schedule_json", Text, nullable=False), + Column("timezone", String(64), nullable=False), + Column("next_run_at", String(32)), + Column("last_run_at", String(32)), + Column("enabled", Boolean, nullable=False, server_default=text("1")), + Column("revision", Integer, nullable=False, server_default=text("1")), + *utc_columns(), +) + +webhook_sources = Table( + "webhook_sources", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(128), nullable=False), + Column("downloader", String(64), nullable=False), + Column("token_hash", String(128), nullable=False, unique=True), + Column("profile_id", ForeignKey("scan_profiles.id", ondelete="CASCADE"), nullable=False), + Column("enabled", Boolean, nullable=False, server_default=text("1")), + Column("last_called_at", String(32)), + Column("revision", Integer, nullable=False, server_default=text("1")), + *utc_columns(), +) + +resource_leases = Table( + "resource_leases", + metadata, + Column("resource_key", String(255), primary_key=True), + Column("owner", String(128), nullable=False), + Column("lease_until", String(32), nullable=False), + Column("heartbeat_at", String(32), nullable=False), + Column("revision", Integer, nullable=False, server_default=text("1")), +) + +shows = Table( + "shows", + metadata, + Column("id", Integer, primary_key=True), + Column("canonical_title", Text, nullable=False), + Column("normalized_key", Text, nullable=False, unique=True), + Column("status", String(32), nullable=False, server_default=text("'unknown'")), + Column("title_locked", Boolean, nullable=False, server_default=text("0")), + Column("revision", Integer, nullable=False, server_default=text("1")), + *utc_columns(), +) + +seasons = Table( + "seasons", + metadata, + Column("id", Integer, primary_key=True), + Column("show_id", ForeignKey("shows.id", ondelete="CASCADE"), nullable=False), + Column("season_number", Integer, nullable=False), + Column("display_title", Text), + Column("expected_episode_count", Integer), + Column("revision", Integer, nullable=False, server_default=text("1")), + UniqueConstraint("show_id", "season_number"), + *utc_columns(), +) + +episodes = Table( + "episodes", + metadata, + Column("id", Integer, primary_key=True), + Column("season_id", ForeignKey("seasons.id", ondelete="CASCADE"), nullable=False), + Column("episode_number", String(32), nullable=False), + Column("episode_type", String(32), nullable=False, server_default=text("'episode'")), + Column("display_title", Text), + Column("sort_value", Integer, nullable=False), + Column("revision", Integer, nullable=False, server_default=text("1")), + UniqueConstraint("season_id", "episode_number", "episode_type"), + *utc_columns(), +) + +media_files = Table( + "media_files", + metadata, + Column("id", Integer, primary_key=True), + Column("size", Integer, nullable=False), + Column("mtime_ns", Integer, nullable=False), + Column("volume_serial", String(64)), + Column("file_index", String(64)), + Column("sha256", String(64)), + Column("media_kind", String(32), nullable=False), + Column("generation_status", String(32), nullable=False, server_default=text("'current'")), + *utc_columns(), +) + +file_locations = Table( + "file_locations", + metadata, + Column("id", Integer, primary_key=True), + Column("media_file_id", ForeignKey("media_files.id", ondelete="CASCADE"), nullable=False), + Column("root_id", ForeignKey("storage_roots.id", ondelete="RESTRICT"), nullable=False), + Column("path", Text, nullable=False), + Column("normalized_path", Text, nullable=False), + Column("role", String(16), nullable=False), + Column("state", String(16), nullable=False), + Column("first_seen_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), + Column("last_seen_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), +) +Index( + "uq_file_locations_present_path", + file_locations.c.normalized_path, + unique=True, + sqlite_where=file_locations.c.state == "present", +) + +media_assignments = Table( + "media_assignments", + metadata, + Column("id", Integer, primary_key=True), + Column("media_file_id", ForeignKey("media_files.id", ondelete="CASCADE"), nullable=False, unique=True), + Column("show_id", ForeignKey("shows.id", ondelete="SET NULL")), + Column("season_id", ForeignKey("seasons.id", ondelete="SET NULL")), + Column("episode_id", ForeignKey("episodes.id", ondelete="SET NULL")), + Column("release_label", String(128)), + Column("version_label", String(128)), + Column("title_locked", Boolean, nullable=False, server_default=text("0")), + Column("season_locked", Boolean, nullable=False, server_default=text("0")), + Column("episode_locked", Boolean, nullable=False, server_default=text("0")), + Column("version_locked", Boolean, nullable=False, server_default=text("0")), + Column("source", String(32), nullable=False), + Column("revision", Integer, nullable=False, server_default=text("1")), + *utc_columns(), +) + +identification_results = Table( + "identification_results", + metadata, + Column("id", Integer, primary_key=True), + Column("media_file_id", ForeignKey("media_files.id", ondelete="CASCADE"), nullable=False), + Column("decision_fingerprint", String(128), nullable=False), + Column("parser_version", String(64), nullable=False), + Column("rule_version", String(64), nullable=False), + Column("title", Text), + Column("season_number", Integer), + Column("episode_number", String(32)), + Column("media_type", String(32)), + Column("confidence", Integer, nullable=False), + Column("accepted", Boolean, nullable=False, server_default=text("0")), + Column("created_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), +) + +identification_evidence = Table( + "identification_evidence", + metadata, + Column("id", Integer, primary_key=True), + Column("result_id", ForeignKey("identification_results.id", ondelete="CASCADE"), nullable=False), + Column("agent", String(64), nullable=False), + Column("field", String(64), nullable=False), + Column("value_json", Text), + Column("confidence", Integer), + Column("detail_json", Text), + Column("created_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), +) + +metadata_records = Table( + "metadata_records", + metadata, + Column("id", Integer, primary_key=True), + Column("show_id", ForeignKey("shows.id", ondelete="CASCADE"), nullable=False), + Column("provider", String(64), nullable=False), + Column("provider_id", String(128), nullable=False), + Column("poster_url", Text), + Column("poster_cache_path", Text), + Column("synopsis", Text), + Column("broadcast_status", String(64)), + Column("fetched_at", String(32), nullable=False), + Column("expires_at", String(32)), + Column("response_digest", String(128)), + UniqueConstraint("provider", "provider_id"), +) + +jobs = Table( + "jobs", + metadata, + Column("id", Integer, primary_key=True), + Column("job_type", String(64), nullable=False), + Column("status", String(32), nullable=False), + Column("priority", Integer, nullable=False, server_default=text("0")), + Column("payload_json", Text, nullable=False), + Column("idempotency_key", String(128), unique=True), + Column("progress_current", Integer, nullable=False, server_default=text("0")), + Column("progress_total", Integer, nullable=False, server_default=text("0")), + Column("current_stage", String(128)), + Column("error_code", String(64)), + Column("error_summary", Text), + Column("lease_owner", String(128)), + Column("lease_until", String(32)), + Column("heartbeat_at", String(32)), + Column("requested_by", ForeignKey("users.id", ondelete="SET NULL")), + Column("cancel_requested_at", String(32)), + Column("created_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), + Column("started_at", String(32)), + Column("finished_at", String(32)), +) + +job_events = Table( + "job_events", + metadata, + Column("id", Integer, primary_key=True), + Column("job_id", ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False), + Column("sequence", Integer, nullable=False), + Column("level", String(16), nullable=False), + Column("event_type", String(64), nullable=False), + Column("message", Text, nullable=False), + Column("payload_json", Text), + Column("created_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), + UniqueConstraint("job_id", "sequence"), +) + +scan_runs = Table( + "scan_runs", + metadata, + Column("id", Integer, primary_key=True), + Column("job_id", ForeignKey("jobs.id", ondelete="SET NULL")), + Column("profile_id", ForeignKey("scan_profiles.id", ondelete="RESTRICT"), nullable=False), + Column("profile_revision", Integer, nullable=False), + Column("rule_version", String(64), nullable=False), + Column("scope_json", Text, nullable=False), + Column("statistics_json", Text, nullable=False, server_default=text("'{}'")), + Column("started_at", String(32), nullable=False), + Column("finished_at", String(32)), +) + +scan_items = Table( + "scan_items", + metadata, + Column("id", Integer, primary_key=True), + Column("scan_run_id", ForeignKey("scan_runs.id", ondelete="CASCADE"), nullable=False), + Column("media_file_id", ForeignKey("media_files.id", ondelete="SET NULL")), + Column("path", Text, nullable=False), + Column("normalized_path", Text, nullable=False), + Column("snapshot_json", Text, nullable=False), + Column("outcome", String(32), nullable=False), + Column("reason", String(128)), + UniqueConstraint("scan_run_id", "normalized_path"), +) + +review_items = Table( + "review_items", + metadata, + Column("id", Integer, primary_key=True), + Column("scan_run_id", ForeignKey("scan_runs.id", ondelete="CASCADE"), nullable=False), + Column("media_file_id", ForeignKey("media_files.id", ondelete="SET NULL")), + Column("review_type", String(64), nullable=False), + Column("status", String(32), nullable=False), + Column("dedup_key", String(128), nullable=False), + Column("payload_json", Text, nullable=False), + Column("resolution_json", Text), + Column("resolved_by", ForeignKey("users.id", ondelete="SET NULL")), + Column("resolved_at", String(32)), + *utc_columns(), +) +Index( + "uq_review_items_open_dedup", + review_items.c.dedup_key, + unique=True, + sqlite_where=review_items.c.status == "open", +) + +plans = Table( + "plans", + metadata, + Column("id", Integer, primary_key=True), + Column("scan_run_id", ForeignKey("scan_runs.id", ondelete="RESTRICT"), nullable=False), + Column("profile_id", ForeignKey("scan_profiles.id", ondelete="RESTRICT"), nullable=False), + Column("profile_revision", Integer, nullable=False), + Column("rule_version", String(64), nullable=False), + Column("library_revision", Integer, nullable=False), + Column("revision", Integer, nullable=False), + Column("status", String(32), nullable=False), + Column("summary_json", Text, nullable=False), + Column("approved_by", ForeignKey("users.id", ondelete="SET NULL")), + Column("approved_at", String(32)), + Column("created_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), + UniqueConstraint("scan_run_id", "revision"), +) + +plan_items = Table( + "plan_items", + metadata, + Column("id", Integer, primary_key=True), + Column("plan_id", ForeignKey("plans.id", ondelete="CASCADE"), nullable=False), + Column("source_location_id", ForeignKey("file_locations.id", ondelete="RESTRICT"), nullable=False), + Column("destination_root_id", ForeignKey("storage_roots.id", ondelete="RESTRICT"), nullable=False), + Column("destination_relative_path", Text, nullable=False), + Column("action", String(16), nullable=False), + Column("reason", Text), + Column("risk_level", String(16), nullable=False), + Column("source_file_index", String(64)), + Column("source_size", Integer, nullable=False), + Column("source_mtime_ns", Integer, nullable=False), + Column("source_sha256", String(64)), + Column("identification_snapshot_json", Text, nullable=False), + Column("execution_status", String(32), nullable=False, server_default=text("'pending'")), + UniqueConstraint("plan_id", "destination_root_id", "destination_relative_path"), +) + +operation_batches = Table( + "operation_batches", + metadata, + Column("id", Integer, primary_key=True), + Column("plan_id", ForeignKey("plans.id", ondelete="RESTRICT")), + Column("parent_batch_id", ForeignKey("operation_batches.id", ondelete="SET NULL")), + Column("job_id", ForeignKey("jobs.id", ondelete="SET NULL")), + Column("kind", String(32), nullable=False), + Column("status", String(32), nullable=False), + Column("requested_by", ForeignKey("users.id", ondelete="SET NULL")), + Column("summary_json", Text, nullable=False, server_default=text("'{}'")), + Column("created_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), + Column("finished_at", String(32)), +) + +operation_items = Table( + "operation_items", + metadata, + Column("id", Integer, primary_key=True), + Column("batch_id", ForeignKey("operation_batches.id", ondelete="CASCADE"), nullable=False), + Column("plan_item_id", ForeignKey("plan_items.id", ondelete="SET NULL")), + Column("sequence", Integer, nullable=False), + Column("action", String(16), nullable=False), + Column("source_path", Text, nullable=False), + Column("destination_path", Text, nullable=False), + Column("source_identity_json", Text, nullable=False), + Column("result_identity_json", Text), + Column("result_sha256", String(64)), + Column("status", String(32), nullable=False), + Column("error_code", String(64)), + Column("error_summary", Text), + Column("compensation_status", String(32)), + UniqueConstraint("batch_id", "sequence"), +) + +change_requests = Table( + "change_requests", + metadata, + Column("id", Integer, primary_key=True), + Column("target_type", String(64), nullable=False), + Column("target_id", Integer, nullable=False), + Column("patch_json", Text, nullable=False), + Column("old_values_json", Text, nullable=False), + Column("new_values_json", Text, nullable=False), + Column("reason", Text, nullable=False), + Column("base_revision", Integer, nullable=False), + Column("plan_id", ForeignKey("plans.id", ondelete="SET NULL")), + Column("conflict_count", Integer, nullable=False, server_default=text("0")), + Column("status", String(32), nullable=False), + Column("requested_by", ForeignKey("users.id", ondelete="SET NULL")), + *utc_columns(), +) + +rule_sets = Table( + "rule_sets", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(128), nullable=False, unique=True), + Column("active_revision_id", Integer), + *utc_columns(), +) + +rule_revisions = Table( + "rule_revisions", + metadata, + Column("id", Integer, primary_key=True), + Column("rule_set_id", ForeignKey("rule_sets.id", ondelete="CASCADE"), nullable=False), + Column("revision", Integer, nullable=False), + Column("document_json", Text, nullable=False), + Column("content_hash", String(64)), + Column("status", String(32), nullable=False), + Column("validation_errors_json", Text), + Column("created_by", ForeignKey("users.id", ondelete="SET NULL")), + Column("created_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), + UniqueConstraint("rule_set_id", "revision"), +) + +backup_records = Table( + "backup_records", + metadata, + Column("id", Integer, primary_key=True), + Column("path", Text, nullable=False, unique=True), + Column("kind", String(32), nullable=False), + Column("size", Integer, nullable=False), + Column("sha256", String(64), nullable=False), + Column("schema_version", Integer, nullable=False), + Column("sanitized", Boolean, nullable=False, server_default=text("0")), + Column("created_by", ForeignKey("users.id", ondelete="SET NULL")), + Column("created_at", String(32), nullable=False, server_default=text("CURRENT_TIMESTAMP")), +) + +# SQLite cannot add this cyclic foreign key through ALTER TABLE. Keeping the +# active revision as an indexed scalar allows rollback while revisions still +# retain a strict foreign key back to their owning rule set. +Index("ix_rule_sets_active_revision_id", rule_sets.c.active_revision_id) +Index("ix_jobs_status_priority", jobs.c.status, jobs.c.priority) +Index("ix_job_events_job_sequence", job_events.c.job_id, job_events.c.sequence) +Index("ix_file_locations_media_file", file_locations.c.media_file_id) +Index("ix_identification_results_media_file", identification_results.c.media_file_id) +Index("ix_plans_status", plans.c.status) diff --git a/autoanime_v3/db/uow.py b/autoanime_v3/db/uow.py new file mode 100644 index 0000000..d0a1af9 --- /dev/null +++ b/autoanime_v3/db/uow.py @@ -0,0 +1,33 @@ +"""Small explicit Unit of Work used by SQLite repositories.""" + +from .engine import connect_sqlite + + +class SqliteUnitOfWork: + def __init__(self, database_path): + self.database_path = database_path + self.connection = None + self.committed = False + + def __enter__(self): + self.connection = connect_sqlite(self.database_path) + self.connection.row_factory = __import__("sqlite3").Row + self.connection.execute("BEGIN IMMEDIATE") + return self + + def commit(self): + self.connection.commit() + self.committed = True + + def rollback(self): + if self.connection is not None: + self.connection.rollback() + + def __exit__(self, exc_type, exc_value, traceback): + try: + if exc_type is not None or not self.committed: + self.rollback() + finally: + self.connection.close() + self.connection = None + diff --git a/autoanime_v3/domain/__init__.py b/autoanime_v3/domain/__init__.py new file mode 100644 index 0000000..f115226 --- /dev/null +++ b/autoanime_v3/domain/__init__.py @@ -0,0 +1,2 @@ +"""Domain types shared by the Web console, Worker, and CLI.""" + diff --git a/autoanime_v3/domain/entities.py b/autoanime_v3/domain/entities.py new file mode 100644 index 0000000..b84644e --- /dev/null +++ b/autoanime_v3/domain/entities.py @@ -0,0 +1,259 @@ +"""Immutable data transfer objects returned by application services.""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + + +@dataclass(frozen=True) +class StorageRoot: + id: int + kind: str + path: str + normalized_path: str + enabled: bool + health_status: str + volume_serial: Optional[str] = None + filesystem_type: Optional[str] = None + + +@dataclass(frozen=True) +class RootHealth: + root_id: int + exists: bool + readable: bool + writable: bool + health_status: str + + +@dataclass(frozen=True) +class CreateProfile: + name: str + source_root_id: int + library_root_id: int + mode: str = "link" + execution_policy: str = "review_all" + min_confidence: int = 80 + stability_seconds: int = 30 + watch_enabled: bool = False + enabled: bool = True + + +@dataclass(frozen=True) +class ScanProfile: + id: int + name: str + source_root_id: int + library_root_id: int + mode: str + execution_policy: str + min_confidence: int + stability_seconds: int + watch_enabled: bool + enabled: bool + revision: int + + +@dataclass(frozen=True) +class FileLocation: + id: int + media_file_id: int + root_id: int + path: str + normalized_path: str + role: str + state: str + + +@dataclass(frozen=True) +class MediaFile: + id: int + size: int + mtime_ns: int + volume_serial: Optional[str] + file_index: Optional[str] + sha256: Optional[str] + media_kind: str + generation_status: str + locations: Tuple[FileLocation, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class UserPublic: + id: int + username: str + is_active: bool + + +@dataclass(frozen=True) +class SessionCredentials: + session_token: str + csrf_token: str + expires_at: str + user: UserPublic + + +@dataclass(frozen=True) +class SecretStatus: + key: str + configured: bool + provider: Optional[str] + updated_at: Optional[str] + + +@dataclass(frozen=True) +class Job: + id: int + job_type: str + status: str + priority: int + payload: Dict[str, Any] + idempotency_key: Optional[str] + progress_current: int + progress_total: int + current_stage: Optional[str] + error_code: Optional[str] + error_summary: Optional[str] + lease_owner: Optional[str] + lease_until: Optional[str] + cancel_requested: bool + created_at: str + + +@dataclass(frozen=True) +class JobEvent: + id: int + job_id: int + sequence: int + level: str + event_type: str + message: str + payload: Dict[str, Any] + created_at: str + + +@dataclass(frozen=True) +class ScanOutcome: + scan_run_id: int + plan_id: int + discovered_count: int + review_count: int + plan_item_count: int + plan_status: str + + +@dataclass(frozen=True) +class PlanItemView: + id: int + source_location_id: int + source_path: str + destination_root_id: int + destination_path: str + destination_relative_path: str + action: str + reason: str + risk_level: str + source_size: int + source_mtime_ns: int + source_file_index: Optional[str] + source_sha256: Optional[str] + execution_status: str + + +@dataclass(frozen=True) +class PlanView: + id: int + scan_run_id: int + profile_id: int + profile_revision: int + rule_version: str + library_revision: int + revision: int + status: str + items: Tuple[PlanItemView, ...] + + +@dataclass(frozen=True) +class ReviewItemView: + id: int + scan_run_id: int + media_file_id: Optional[int] + review_type: str + status: str + payload: Dict[str, Any] + resolution: Optional[Dict[str, Any]] + + +@dataclass(frozen=True) +class OperationItemView: + id: int + sequence: int + action: str + source_path: str + destination_path: str + status: str + result_sha256: Optional[str] + error_code: Optional[str] + compensation_status: Optional[str] + + +@dataclass(frozen=True) +class OperationBatchView: + id: int + plan_id: Optional[int] + parent_batch_id: Optional[int] + kind: str + status: str + summary: Dict[str, Any] + items: Tuple[OperationItemView, ...] + + +@dataclass(frozen=True) +class RuleSetView: + id: int + name: str + active_revision_id: Optional[int] + + +@dataclass(frozen=True) +class RuleRevisionView: + id: int + rule_set_id: int + revision: int + document: Dict[str, Any] + content_hash: Optional[str] + status: str + + +@dataclass(frozen=True) +class ShowView: + id: int + canonical_title: str + normalized_key: str + status: str + title_locked: bool + revision: int + + +@dataclass(frozen=True) +class ChangeRequestView: + id: int + target_type: str + target_id: int + old_values: Dict[str, Any] + new_values: Dict[str, Any] + reason: str + base_revision: int + status: str + + +@dataclass(frozen=True) +class BackupRecordView: + id: int + path: str + kind: str + size: int + sha256: str + schema_version: int + sanitized: bool + created_at: str diff --git a/autoanime_v3/domain/enums.py b/autoanime_v3/domain/enums.py new file mode 100644 index 0000000..080adc9 --- /dev/null +++ b/autoanime_v3/domain/enums.py @@ -0,0 +1,92 @@ +"""Stable persisted values for the AutoAnime Web console. + +Values in this module are stored as strings in SQLite. Renaming a Python +member is safe; changing its value requires a database migration. +""" + +from enum import Enum + + +class StringEnum(str, Enum): + def __str__(self): + return self.value + + +class RootKind(StringEnum): + SOURCE = "source" + LIBRARY = "library" + OPERATIONS = "operations" + METADATA_CACHE = "metadata_cache" + + +class OperationMode(StringEnum): + LINK = "link" + COPY = "copy" + MOVE = "move" + + +class ExecutionPolicy(StringEnum): + REVIEW_ALL = "review_all" + AUTO_APPLY_SAFE = "auto_apply_safe" + DRY_RUN = "dry_run" + + +class JobStatus(StringEnum): + QUEUED = "queued" + LEASED = "leased" + RUNNING = "running" + WAITING_REVIEW = "waiting_review" + SUCCEEDED = "succeeded" + FAILED = "failed" + CANCELLED = "cancelled" + INTERRUPTED = "interrupted" + + +class ReviewStatus(StringEnum): + OPEN = "open" + RESOLVED = "resolved" + DISMISSED = "dismissed" + SUPERSEDED = "superseded" + + +class PlanStatus(StringEnum): + DRAFT = "draft" + READY = "ready" + APPROVED = "approved" + EXECUTING = "executing" + COMPLETED = "completed" + STALE = "stale" + CANCELLED = "cancelled" + FAILED_ROLLED_BACK = "failed_rolled_back" + FAILED_PARTIAL_ROLLBACK = "failed_partial_rollback" + FAILED_NEEDS_ATTENTION = "failed_needs_attention" + + +class ChangeRequestStatus(StringEnum): + DRAFT = "draft" + VALIDATED = "validated" + APPROVED = "approved" + APPLIED = "applied" + STALE = "stale" + REJECTED = "rejected" + REVERTED = "reverted" + + +class LocationRole(StringEnum): + SOURCE = "source" + LIBRARY = "library" + STAGING = "staging" + + +class LocationState(StringEnum): + PRESENT = "present" + MISSING = "missing" + REPLACED = "replaced" + DELETED = "deleted" + + +class RuleRevisionStatus(StringEnum): + DRAFT = "draft" + VALIDATED = "validated" + ACTIVE = "active" + RETIRED = "retired" diff --git a/autoanime_v3/domain/errors.py b/autoanime_v3/domain/errors.py new file mode 100644 index 0000000..c88a14e --- /dev/null +++ b/autoanime_v3/domain/errors.py @@ -0,0 +1,82 @@ +"""Stable business errors exposed by services and the HTTP API.""" + + +class DomainError(Exception): + code = "domain_error" + + def __init__(self, message, details=None): + super().__init__(message) + self.message = message + self.details = details or {} + + +class NotFoundError(DomainError): + code = "not_found" + + +class DuplicateRootError(DomainError): + code = "duplicate_root" + + +class UnsafeRootError(DomainError): + code = "unsafe_root" + + +class PathOutsideRootError(DomainError): + code = "path_outside_root" + + +class RevisionConflictError(DomainError): + code = "revision_conflict" + + +class ValidationError(DomainError): + code = "validation_error" + + +class AuthenticationError(DomainError): + code = "authentication_failed" + + +class LoginThrottledError(DomainError): + code = "login_throttled" + + +class CsrfValidationError(DomainError): + code = "csrf_validation_failed" + + +class AlreadyBootstrappedError(DomainError): + code = "already_bootstrapped" + + +class BootstrapLocalOnlyError(DomainError): + code = "bootstrap_local_only" + + +class LocalOnlyError(DomainError): + code = "local_only" + + +class LeaseConflictError(DomainError): + code = "lease_conflict" + + +class InvalidStateError(DomainError): + code = "invalid_state" + + +class ExecutionPolicyError(DomainError): + code = "execution_policy_forbidden" + + +class StalePlanError(DomainError): + code = "stale_plan" + + +class PlanConflictError(DomainError): + code = "plan_conflict" + + +class ImmutablePlanError(DomainError): + code = "immutable_plan" diff --git a/autoanime_v3/executor.py b/autoanime_v3/executor.py new file mode 100644 index 0000000..c6ee35b --- /dev/null +++ b/autoanime_v3/executor.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import uuid +from datetime import datetime +from pathlib import Path +from typing import Iterable, List, Optional, Tuple + +from .repository import LibraryRepository, fingerprint +from .models import PlanEntry +from .normalize import same_path +from .path_safety import validate_library_destination + + +class ExecutionError(RuntimeError): + pass + + +class ExecutionFailure(ExecutionError): + """Execution failure with enough durable context for operator recovery.""" + + def __init__( + self, + message, + *, + log_path, + applied_records=(), + rollback_results=(), + rollback_errors=(), + ): + super().__init__(message) + self.log_path = Path(log_path) + self.applied_records = tuple(dict(record) for record in applied_records) + self.rollback_results = tuple(dict(result) for result in rollback_results) + self.rollback_errors = tuple(str(error) for error in rollback_errors) + + @property + def partial_rollback(self): + return bool(self.rollback_errors) + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + return digest.hexdigest() + + +def _validate_scanned_source(entry: PlanEntry, path: Optional[Path] = None) -> None: + if entry.companion_of: + return + checked_path = path or entry.source + stat = checked_path.stat() + media = entry.resolution.media + if int(stat.st_size) != int(media.size) or int(stat.st_mtime_ns) != int(media.mtime_ns): + raise ExecutionError("源文件自扫描后已变化,拒绝移动:%s" % entry.source) + + +def _stat_signature(path: Path) -> Tuple[int, int, int, int]: + stat = path.stat() + return ( + int(stat.st_dev), + int(stat.st_ino), + int(stat.st_size), + int(stat.st_mtime_ns), + ) + + +def _unique_partial_path(path: Path, label: str) -> Path: + return path.with_name(".%s.%s.%s.partial" % (path.name, label, uuid.uuid4().hex)) + + +def _restore_staging(staging: Path, source: Path) -> Optional[str]: + if not staging.exists(): + return None + if os.name == "nt": + try: + os.rename(str(staging), str(source)) + except FileExistsError: + return "原路径已被新文件占用,待恢复文件保留在:%s" % staging + except OSError as error: + return "无法自动恢复源文件,待恢复文件保留在:%s(%s)" % (staging, error) + return None + try: + os.link(str(staging), str(source)) + except FileExistsError: + return "原路径已被新文件占用,待恢复文件保留在:%s" % staging + except OSError as error: + return "无法自动恢复源文件,待恢复文件保留在:%s(%s)" % (staging, error) + try: + staging.unlink() + except Exception as error: + return "源文件已恢复,但 staging 清理失败并保留在:%s(%s)" % (staging, error) + return None + + +def _remove_created_destination(destination: Path, identity: Optional[Tuple[int, int]]) -> Optional[str]: + if not destination.exists(): + return None + if identity is None: + return "无法确认失败目标的身份,已保留:%s" % destination + stat = destination.stat() + current_identity = (int(stat.st_dev), int(stat.st_ino)) + if current_identity != identity: + return "失败目标已被替换,拒绝删除并保留:%s" % destination + try: + destination.unlink() + except Exception as error: + return "失败目标清理失败并保留在:%s(%s)" % (destination, error) + return None + + +def _copy_exclusive(source: Path, destination: Path) -> None: + created = False + destination_identity: Optional[Tuple[int, int]] = None + try: + with source.open("rb") as source_handle, destination.open("xb") as destination_handle: + created = True + destination_stat = os.fstat(destination_handle.fileno()) + destination_identity = ( + int(destination_stat.st_dev), + int(destination_stat.st_ino), + ) + shutil.copyfileobj(source_handle, destination_handle, 1024 * 1024) + shutil.copystat(str(source), str(destination)) + except Exception as error: + if created: + cleanup_error = _remove_created_destination(destination, destination_identity) + if cleanup_error: + raise ExecutionError("%s;%s" % (error, cleanup_error)) from error + raise + + +def _apply_move(entry: PlanEntry) -> str: + assert entry.destination is not None + staging = _unique_partial_path(entry.source, "move") + destination_identity: Optional[Tuple[int, int]] = None + entry.source.rename(staging) + try: + _validate_scanned_source(entry, staging) + initial_signature = _stat_signature(staging) + try: + os.link(str(staging), str(entry.destination)) + except OSError: + _copy_exclusive(staging, entry.destination) + destination_stat = entry.destination.stat() + destination_identity = (int(destination_stat.st_dev), int(destination_stat.st_ino)) + if _stat_signature(staging) != initial_signature: + raise ExecutionError("移动 staging 在建立目标期间已变化:%s" % staging) + source_digest = _sha256_file(staging) + if _stat_signature(staging) != initial_signature: + raise ExecutionError("移动 staging 在摘要校验期间已变化:%s" % staging) + destination_size = int(entry.destination.stat().st_size) + if destination_size != initial_signature[2]: + raise ExecutionError("移动目标大小校验失败:%s" % entry.destination) + destination_digest = _sha256_file(entry.destination) + if _stat_signature(staging) != initial_signature: + raise ExecutionError("移动 staging 在目标校验期间已变化:%s" % staging) + if source_digest != destination_digest: + raise ExecutionError("移动目标摘要校验失败:%s" % entry.destination) + staging.unlink() + return destination_digest + except Exception as error: + cleanup_error = _remove_created_destination(entry.destination, destination_identity) + restore_error = _restore_staging(staging, entry.source) + details = [str(error)] + if cleanup_error: + details.append(cleanup_error) + if restore_error: + details.append(restore_error) + raise ExecutionError(";".join(details)) + + +def _apply_one(entry: PlanEntry, mode: str) -> Optional[str]: + assert entry.destination is not None + if entry.destination_root is not None: + validate_library_destination(entry.destination_root, entry.destination) + entry.destination.parent.mkdir(parents=True, exist_ok=True) + if entry.destination.exists(): + raise FileExistsError(str(entry.destination)) + if entry.destination_root is not None: + validate_library_destination(entry.destination_root, entry.destination) + if mode == "move": + return _apply_move(entry) + elif mode == "copy": + _copy_exclusive(entry.source, entry.destination) + elif mode == "link": + os.link(str(entry.source), str(entry.destination)) + else: + raise ValueError("unsupported mode: " + mode) + return None + + +def _rollback_one(entry: PlanEntry, mode: str, expected_sha256: Optional[str] = None) -> None: + assert entry.destination is not None + if not entry.destination.exists(): + return + if expected_sha256 is not None and _sha256_file(entry.destination) != expected_sha256: + raise ExecutionError("自动回滚目标摘要已变化,拒绝删除或移动:%s" % entry.destination) + if mode == "move": + if entry.source.exists(): + raise FileExistsError(str(entry.source)) + entry.source.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(entry.destination), str(entry.source)) + elif mode in {"copy", "link"}: + entry.destination.unlink() + + +def execute_plan( + plan: Iterable[PlanEntry], + mode: str, + apply: bool, + cache: LibraryRepository, + operation_dir: Optional[Path], +) -> Path: + run_id = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + log_dir = operation_dir or (Path.cwd() / ".autoanime-v3" / "operations") + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / (run_id + ".jsonl") + completed: List[Tuple[PlanEntry, Optional[str]]] = [] + applied_records = [] + with log_path.open("w", encoding="utf-8") as handle: + for entry in plan: + record = entry.to_dict() + record["resolution"]["fingerprint"] = entry.resolution.fingerprint or fingerprint(entry.resolution.media) + record["run_id"] = run_id + record["mode"] = mode + record["applied"] = False + if entry.action != "organize" or entry.destination is None: + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + continue + if not apply: + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + continue + try: + applied_sha256 = _apply_one(entry, mode) + completed.append((entry, None)) + record["applied"] = True + applied_records.append(dict(record)) + result_stat = entry.destination.stat() + record["result_size"] = int(result_stat.st_size) + record["result_mtime_ns"] = int(result_stat.st_mtime_ns) + result_sha256 = applied_sha256 or _sha256_file(entry.destination) + record["result_sha256"] = result_sha256 + completed[-1] = (entry, result_sha256) + applied_records[-1] = dict(record) + if not entry.companion_of: + cache.mark_organized(entry.resolution, entry.destination) + cache.record_operation(run_id, mode, entry.source, entry.destination, "success") + except Exception as error: + record["error"] = str(error) + cache.record_operation(run_id, mode, entry.source, entry.destination, "failed", str(error)) + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + rollback_errors = [] + rollback_results = [] + for previous, expected_sha256 in reversed(completed): + try: + _rollback_one(previous, mode, expected_sha256) + if not previous.companion_of: + cache.mark_reverted(previous.resolution) + cache.record_operation(run_id, "auto_rollback", previous.destination, previous.source, "success") + rollback_record = previous.to_dict() + rollback_record.update( + { + "run_id": run_id, + "mode": mode, + "applied": False, + "auto_rollback": True, + "rollback_status": "success", + } + ) + handle.write(json.dumps(rollback_record, ensure_ascii=False) + "\n") + rollback_results.append( + { + "source": str(previous.source), + "destination": str(previous.destination), + "status": "success", + } + ) + except Exception as rollback_error: + rollback_errors.append(str(rollback_error)) + rollback_record = previous.to_dict() + rollback_record.update( + { + "run_id": run_id, + "mode": mode, + "applied": True, + "auto_rollback": True, + "rollback_status": "failed", + "rollback_error": str(rollback_error), + } + ) + handle.write(json.dumps(rollback_record, ensure_ascii=False) + "\n") + rollback_results.append( + { + "source": str(previous.source), + "destination": str(previous.destination), + "status": "failed", + "error": str(rollback_error), + } + ) + message = "整理失败,已自动回滚本批次:%s" % error + if rollback_errors: + message += ";部分回滚失败:" + " | ".join(rollback_errors) + raise ExecutionFailure( + message, + log_path=log_path, + applied_records=applied_records, + rollback_results=rollback_results, + rollback_errors=rollback_errors, + ) from error + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + return log_path + + +def rollback(log_path: Path, cache: Optional[LibraryRepository] = None) -> int: + records = [] + with log_path.open("r", encoding="utf-8") as handle: + for line in handle: + if line.strip(): + records.append(json.loads(line)) + restored = 0 + for record in reversed(records): + if not record.get("applied"): + continue + source = Path(record["source"]) + destination = Path(record["destination"]) + mode = record.get("mode") + if not destination.exists(): + continue + result_stat = destination.stat() + expected_size = record.get("result_size") + expected_mtime_ns = record.get("result_mtime_ns") + expected_sha256 = record.get("result_sha256") + if expected_size is not None and int(expected_size) != int(result_stat.st_size): + raise ExecutionError("回滚目标大小已变化,拒绝删除或移动:%s" % destination) + if expected_mtime_ns is not None and int(expected_mtime_ns) != int(result_stat.st_mtime_ns): + raise ExecutionError("回滚目标修改时间已变化,拒绝删除或移动:%s" % destination) + if mode in {"copy", "link"} and not expected_sha256: + raise ExecutionError("旧操作日志缺少内容摘要,拒绝删除:%s" % destination) + if expected_sha256 and _sha256_file(destination) != str(expected_sha256): + raise ExecutionError("回滚目标内容摘要已变化,拒绝删除或移动:%s" % destination) + if mode == "link" and source.exists() and not same_path(source, destination): + raise ExecutionError("回滚目标已不再是原硬链接,拒绝删除:%s" % destination) + if mode == "move": + if source.exists(): + raise ExecutionError("回滚目标已存在,拒绝覆盖:%s" % source) + source.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(destination), str(source)) + elif mode in {"copy", "link"}: + destination.unlink() + if cache is not None and not record.get("companion_of"): + cache.mark_reverted_path(source) + cache.record_operation( + str(record.get("run_id") or "manual_rollback"), + "manual_rollback", + destination, + source, + "success", + ) + restored += 1 + return restored diff --git a/autoanime_v3/integrations/__init__.py b/autoanime_v3/integrations/__init__.py new file mode 100644 index 0000000..3ec90b0 --- /dev/null +++ b/autoanime_v3/integrations/__init__.py @@ -0,0 +1,2 @@ +"""Optional external metadata, downloader, and media-server boundaries.""" + diff --git a/autoanime_v3/integrations/metadata.py b/autoanime_v3/integrations/metadata.py new file mode 100644 index 0000000..3b1a8c8 --- /dev/null +++ b/autoanime_v3/integrations/metadata.py @@ -0,0 +1,34 @@ +"""Read-only metadata adapter whose failure never blocks file organization.""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class MetadataResult: + available: bool + status: str + poster_url: Optional[str] = None + synopsis: Optional[str] = None + broadcast_status: Optional[str] = None + error: Optional[str] = None + + +class SafeMetadataAdapter: + def __init__(self, provider): + self.provider = provider + + def fetch(self, title): + try: + value = self.provider(title) + if isinstance(value, MetadataResult): + return value + return MetadataResult( + True, + "available", + value.get("poster_url"), + value.get("synopsis"), + value.get("broadcast_status"), + ) + except Exception as error: + return MetadataResult(False, "unavailable", error=str(error)) diff --git a/autoanime_v3/jobs/__init__.py b/autoanime_v3/jobs/__init__.py new file mode 100644 index 0000000..b32aa64 --- /dev/null +++ b/autoanime_v3/jobs/__init__.py @@ -0,0 +1,2 @@ +"""Persistent Worker queue, scheduler, and directory watcher.""" + diff --git a/autoanime_v3/jobs/queue.py b/autoanime_v3/jobs/queue.py new file mode 100644 index 0000000..e8d1ad8 --- /dev/null +++ b/autoanime_v3/jobs/queue.py @@ -0,0 +1,224 @@ +"""Lease-based SQLite job queue.""" + +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from autoanime_v3.db.engine import connect_sqlite +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.db.repositories.jobs import JobRepository, job_from_row +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.errors import InvalidStateError, LeaseConflictError, NotFoundError + + +def utc_now(): + return datetime.now(timezone.utc) + + +def iso(value): + return value.astimezone(timezone.utc).isoformat() + + +class JobQueue: + def __init__(self, database_path, clock=None): + self.database_path = Path(database_path) + self.clock = clock or utc_now + run_migrations(self.database_path) + + def enqueue(self, job_type, payload, idempotency_key=None, priority=0): + with SqliteUnitOfWork(self.database_path) as uow: + repository = JobRepository(uow.connection) + existing = repository.find_by_idempotency_key(idempotency_key) + if existing is not None: + return existing + job = repository.enqueue( + job_type, payload, idempotency_key, int(priority), iso(self.clock()) + ) + uow.commit() + return job + + def _interrupt_expired(self, connection, now): + connection.execute( + """ + UPDATE jobs + SET status = 'interrupted', error_code = 'lease_expired', + error_summary = 'Worker lease expired before completion', + lease_owner = NULL, lease_until = NULL, heartbeat_at = NULL, + finished_at = ? + WHERE status IN ('leased', 'running') + AND lease_until IS NOT NULL AND lease_until <= ? + """, + (iso(now), iso(now)), + ) + + def lease_next(self, worker_id, lease_seconds): + now = self.clock() + with SqliteUnitOfWork(self.database_path) as uow: + self._interrupt_expired(uow.connection, now) + repository = JobRepository(uow.connection) + candidate = repository.next_queued() + if candidate is None: + uow.commit() + return None + lease_until = now + timedelta(seconds=lease_seconds) + updated = uow.connection.execute( + """ + UPDATE jobs + SET status = 'leased', lease_owner = ?, lease_until = ?, heartbeat_at = ? + WHERE id = ? AND status = 'queued' + """, + (worker_id, iso(lease_until), iso(now), candidate.id), + ).rowcount + if updated != 1: + uow.commit() + return None + leased = repository.get(candidate.id) + uow.commit() + return leased + + def start(self, job_id, worker_id): + now = iso(self.clock()) + with SqliteUnitOfWork(self.database_path) as uow: + updated = uow.connection.execute( + """ + UPDATE jobs SET status = 'running', started_at = COALESCE(started_at, ?) + WHERE id = ? AND status = 'leased' AND lease_owner = ? + """, + (now, job_id, worker_id), + ).rowcount + if updated != 1: + raise LeaseConflictError("Worker does not own the leased job") + job = JobRepository(uow.connection).get(job_id) + uow.commit() + return job + + def heartbeat(self, job_id, worker_id, lease_seconds=60): + now = self.clock() + with SqliteUnitOfWork(self.database_path) as uow: + updated = uow.connection.execute( + """ + UPDATE jobs SET heartbeat_at = ?, lease_until = ? + WHERE id = ? AND lease_owner = ? AND status IN ('leased', 'running') + AND lease_until > ? + """, + ( + iso(now), + iso(now + timedelta(seconds=lease_seconds)), + job_id, + worker_id, + iso(now), + ), + ).rowcount + if updated != 1: + raise LeaseConflictError("Worker cannot renew this job lease") + job = JobRepository(uow.connection).get(job_id) + uow.commit() + return job + + def append_event(self, job_id, event_type, payload, message="", level="info"): + with SqliteUnitOfWork(self.database_path) as uow: + repository = JobRepository(uow.connection) + if repository.get(job_id) is None: + raise NotFoundError("Job does not exist", {"id": job_id}) + event = repository.append_event( + job_id, event_type, payload, message, level, iso(self.clock()) + ) + uow.commit() + return event + + def events(self, job_id, after_sequence=0): + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + return JobRepository(connection).events(job_id, after_sequence) + finally: + connection.close() + + def request_cancel(self, job_id): + now = iso(self.clock()) + with SqliteUnitOfWork(self.database_path) as uow: + repository = JobRepository(uow.connection) + job = repository.get(job_id) + if job is None: + raise NotFoundError("Job does not exist", {"id": job_id}) + if job.status == "queued": + uow.connection.execute( + "UPDATE jobs SET status = 'cancelled', finished_at = ? WHERE id = ?", + (now, job_id), + ) + elif job.status in {"leased", "running"}: + uow.connection.execute( + "UPDATE jobs SET cancel_requested_at = ? WHERE id = ?", (now, job_id) + ) + else: + raise InvalidStateError("Job cannot be cancelled in its current state") + result = repository.get(job_id) + uow.commit() + return result + + def cancel_at_safe_boundary(self, job_id, worker_id): + now = iso(self.clock()) + with SqliteUnitOfWork(self.database_path) as uow: + updated = uow.connection.execute( + """ + UPDATE jobs + SET status = 'cancelled', lease_owner = NULL, lease_until = NULL, + heartbeat_at = NULL, finished_at = ? + WHERE id = ? AND lease_owner = ? AND status IN ('leased', 'running') + AND cancel_requested_at IS NOT NULL + """, + (now, job_id, worker_id), + ).rowcount + if updated != 1: + raise InvalidStateError("Cancellation is not pending at a safe boundary") + job = JobRepository(uow.connection).get(job_id) + uow.commit() + return job + + def complete(self, job_id, worker_id): + now = iso(self.clock()) + with SqliteUnitOfWork(self.database_path) as uow: + updated = uow.connection.execute( + """ + UPDATE jobs + SET status = 'succeeded', lease_owner = NULL, lease_until = NULL, + heartbeat_at = NULL, finished_at = ? + WHERE id = ? AND lease_owner = ? AND status = 'running' + """, + (now, job_id, worker_id), + ).rowcount + if updated != 1: + raise LeaseConflictError("Worker cannot complete this job") + job = JobRepository(uow.connection).get(job_id) + uow.commit() + return job + + def fail(self, job_id, worker_id, error_code, summary): + now = iso(self.clock()) + with SqliteUnitOfWork(self.database_path) as uow: + updated = uow.connection.execute( + """ + UPDATE jobs + SET status = 'failed', error_code = ?, error_summary = ?, + lease_owner = NULL, lease_until = NULL, heartbeat_at = NULL, + finished_at = ? + WHERE id = ? AND lease_owner = ? AND status IN ('leased', 'running') + """, + (error_code, summary, now, job_id, worker_id), + ).rowcount + if updated != 1: + raise LeaseConflictError("Worker cannot fail this job") + job = JobRepository(uow.connection).get(job_id) + uow.commit() + return job + + def get(self, job_id): + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + job = JobRepository(connection).get(job_id) + if job is None: + raise NotFoundError("Job does not exist", {"id": job_id}) + return job + finally: + connection.close() + diff --git a/autoanime_v3/jobs/scheduler.py b/autoanime_v3/jobs/scheduler.py new file mode 100644 index 0000000..cb2d87c --- /dev/null +++ b/autoanime_v3/jobs/scheduler.py @@ -0,0 +1,14 @@ +"""Schedule producer; it can only enqueue jobs.""" + + +class Scheduler: + def __init__(self, queue): + self.queue = queue + + def enqueue_due(self, profile_id, schedule_id, occurrence): + return self.queue.enqueue( + "scan", + {"profile_id": profile_id, "trigger": "schedule", "schedule_id": schedule_id}, + "schedule:%s:%s" % (schedule_id, occurrence), + ) + diff --git a/autoanime_v3/jobs/watcher.py b/autoanime_v3/jobs/watcher.py new file mode 100644 index 0000000..dce58ae --- /dev/null +++ b/autoanime_v3/jobs/watcher.py @@ -0,0 +1,56 @@ +"""Pure event coalescing used by the watchdog producer.""" + +from datetime import datetime, timezone +from pathlib import Path + +from autoanime_v3.scanner import INCOMPLETE_SUFFIXES + + +def utc_now(): + return datetime.now(timezone.utc) + + +class StableFileBuffer: + def __init__(self, clock=None, debounce_seconds=2, stability_seconds=30): + self.clock = clock or utc_now + self.debounce_seconds = debounce_seconds + self.stability_seconds = stability_seconds + self.pending = {} + + def record(self, path, size, mtime_ns): + value = Path(path) + if value.name.casefold().endswith(tuple(suffix.casefold() for suffix in INCOMPLETE_SUFFIXES)): + return + key = str(value).casefold() + now = self.clock() + previous = self.pending.get(key) + stable_since = now if previous is None or previous[1:3] != (size, mtime_ns) else previous[3] + self.pending[key] = (value, size, mtime_ns, stable_since, now) + + def refresh(self, path, size, mtime_ns): + value = Path(path) + key = str(value).casefold() + previous = self.pending.get(key) + if previous is None: + return + now = self.clock() + stable_since = previous[3] if previous[1:3] == (size, mtime_ns) else now + self.pending[key] = (value, size, mtime_ns, stable_since, previous[4]) + + def discard(self, path): + self.pending.pop(str(Path(path)).casefold(), None) + + def paths(self): + return tuple(item[0] for item in self.pending.values()) + + def ready(self): + now = self.clock() + ready = [] + for key, (path, size, mtime_ns, stable_since, last_event) in list(self.pending.items()): + if ( + (now - last_event).total_seconds() >= self.debounce_seconds + and (now - stable_since).total_seconds() >= self.stability_seconds + ): + ready.append(path) + del self.pending[key] + return tuple(sorted(ready, key=lambda item: str(item).casefold())) diff --git a/autoanime_v3/jobs/worker.py b/autoanime_v3/jobs/worker.py new file mode 100644 index 0000000..0b3cedb --- /dev/null +++ b/autoanime_v3/jobs/worker.py @@ -0,0 +1,58 @@ +"""Worker orchestration that never auto-retries interrupted file changes.""" + +import threading + + +class Worker: + def __init__(self, worker_id, queue, handlers): + self.worker_id = worker_id + self.queue = queue + self.handlers = dict(handlers) + + def acquire(self, lease_seconds=60): + return self.queue.lease_next(self.worker_id, lease_seconds) + + def run_once(self, lease_seconds=60): + job = self.acquire(lease_seconds) + if job is None: + return None + handler = self.handlers.get(job.job_type) + if handler is None: + self.queue.fail(job.id, self.worker_id, "unknown_job_type", job.job_type) + return self.queue.get(job.id) + self.queue.start(job.id, self.worker_id) + heartbeat_stop = threading.Event() + heartbeat_error = [] + + def renew_lease(): + interval = max(float(lease_seconds) / 3.0, 0.05) + while not heartbeat_stop.wait(interval): + try: + self.queue.heartbeat(job.id, self.worker_id, lease_seconds) + except Exception as error: + heartbeat_error.append(error) + return + + heartbeat_thread = threading.Thread( + target=renew_lease, + name="autoanime-heartbeat-%s" % job.id, + daemon=True, + ) + heartbeat_thread.start() + try: + handler(job) + except Exception as error: + heartbeat_stop.set() + heartbeat_thread.join() + self.queue.fail(job.id, self.worker_id, "handler_failed", str(error)) + else: + heartbeat_stop.set() + heartbeat_thread.join() + if heartbeat_error: + return self.queue.get(job.id) + current = self.queue.get(job.id) + if current.cancel_requested: + self.queue.cancel_at_safe_boundary(job.id, self.worker_id) + else: + self.queue.complete(job.id, self.worker_id) + return self.queue.get(job.id) diff --git a/autoanime_v3/library_service.py b/autoanime_v3/library_service.py new file mode 100644 index 0000000..82eb6b3 --- /dev/null +++ b/autoanime_v3/library_service.py @@ -0,0 +1,88 @@ +"""未来 CLI/WebUI 共用的应用服务边界。 + +WebUI 不应直接拼 SQL 或移动文件;所有查询、纠正预览和后续执行都通过此层。 +当前只开放只读查询与“生成纠正草案”,真正应用纠正留到后续版本。 +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .repository import LibraryRepository +from .normalize import safe_component, same_path + + +class LibraryService: + def __init__(self, repository: LibraryRepository, output_root: Path) -> None: + self.repository = repository + self.output_root = output_root + + def list_shows(self) -> List[Dict[str, Any]]: + return self.repository.list_show_progress() + + def get_show(self, show_id: int) -> Optional[Dict[str, Any]]: + return self.repository.show_detail(show_id) + + def preview_show_title_change(self, show_id: int, new_title: str, reason: str = "") -> Dict[str, Any]: + if not str(new_title or "").strip(): + raise ValueError("new title must not be empty") + detail = self.repository.show_detail(show_id) + if detail is None: + raise KeyError("show not found: %s" % show_id) + old_title = str(detail["show"]["canonical_title"]) + clean_title = safe_component(new_title) + moves = [] + reserved = set() + for row in detail["episodes"]: + current = row.get("current_path") + if not current or row.get("status") != "organized": + continue + current_path = Path(current) + season = int(row["season_number"]) + episode = int(row["episode_number"]) + is_movie = bool(row.get("is_movie")) + old_stem = current_path.stem + if is_movie: + old_prefix = safe_component(old_title) + tail = old_stem[len(old_prefix):] if old_stem.casefold().startswith(old_prefix.casefold()) else "" + new_stem = clean_title + tail + destination = self.output_root / clean_title / (new_stem + current_path.suffix.lower()) + else: + episode_prefix = "S%02dE%02d - " % (season, episode) + old_prefix = episode_prefix + safe_component(old_title) + tail = old_stem[len(old_prefix):] if old_stem.casefold().startswith(old_prefix.casefold()) else "" + new_stem = episode_prefix + clean_title + tail + destination = self.output_root / clean_title / ("Season %02d" % season) / (new_stem + current_path.suffix.lower()) + destination_key = str(destination).casefold() + action = "move" + move_reason = "" + if destination_key in reserved: + action, move_reason = "conflict", "duplicate_destination" + elif destination.exists() and not same_path(current_path, destination): + action, move_reason = "conflict", "destination_exists" + elif same_path(current_path, destination): + action, move_reason = "skip", "already_in_place" + reserved.add(destination_key) + moves.append({ + "source": str(current_path), + "destination": str(destination), + "action": action, + "reason": move_reason, + }) + correction_id = self.repository.create_correction( + "show", show_id, "canonical_title", old_title, clean_title, reason, moves + ) + return { + "correction_id": correction_id, + "status": "draft", + "old_title": old_title, + "new_title": clean_title, + "moves": moves, + "conflicts": sum(1 for move in moves if move["action"] == "conflict"), + } + + def apply_correction(self, correction_id: int) -> None: + raise NotImplementedError( + "v3.1 仅生成可审计迁移计划;WebUI 写入、锁与原子迁移将在后续版本实现。" + ) diff --git a/autoanime_v3/models.py b/autoanime_v3/models.py new file mode 100644 index 0000000..8f9e889 --- /dev/null +++ b/autoanime_v3/models.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + + +@dataclass(frozen=True) +class MediaFile: + path: Path + input_root: Path + context_name: str + relative_path: str + size: int + mtime_ns: int + + +@dataclass(frozen=True) +class ParsedName: + raw_title: str + season: Optional[int] + episode: Optional[int] + is_movie: bool = False + explicit_season: bool = False + explicit_episode: bool = False + title_candidates: Tuple[str, ...] = () + release_tag: str = "" + warnings: Tuple[str, ...] = () + + +@dataclass(frozen=True) +class Evidence: + agent: str + value: str + confidence: float + detail: str = "" + + +@dataclass +class Resolution: + media: MediaFile + canonical_title: str = "" + season: Optional[int] = None + episode: Optional[Any] = None + is_movie: bool = False + confidence: float = 0.0 + accepted: bool = False + release_tag: str = "" + evidence: List[Evidence] = field(default_factory=list) + warnings: List[str] = field(default_factory=list) + fingerprint: str = "" + media_type: str = "" + + def identity_key(self) -> Tuple[Any, ...]: + media_type = self.media_type or ("movie" if self.is_movie else "episode") + return ( + self.canonical_title, + self.season if self.season is not None else 0, + self.episode if self.episode is not None else 0, + media_type, + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "source": str(self.media.path), + "relative_path": self.media.relative_path, + "title": self.canonical_title, + "season": self.season, + "episode": self.episode, + "is_movie": self.is_movie, + "media_type": self.media_type or ("movie" if self.is_movie else "episode"), + "confidence": round(self.confidence, 4), + "accepted": self.accepted, + "release_tag": self.release_tag, + "warnings": list(self.warnings), + "evidence": [e.__dict__ for e in self.evidence], + "fingerprint": self.fingerprint, + } + + +@dataclass +class PlanEntry: + source: Path + destination: Optional[Path] + action: str + resolution: Resolution + reason: str = "" + companion_of: str = "" + destination_root: Optional[Path] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "source": str(self.source), + "destination": str(self.destination) if self.destination else "", + "action": self.action, + "reason": self.reason, + "companion_of": self.companion_of, + "resolution": self.resolution.to_dict(), + } diff --git a/autoanime_v3/normalize.py b/autoanime_v3/normalize.py new file mode 100644 index 0000000..d99143e --- /dev/null +++ b/autoanime_v3/normalize.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import re +import unicodedata +import json +from pathlib import Path +from typing import Iterable, List + + +_CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]") +_INVALID_WINDOWS = re.compile(r'[<>:"/\\|?*\x00-\x1f]') +_SPACES = re.compile(r"\s+") +_SEPARATORS = re.compile(r"[._\-]+") +_ZHCONV_READY = False + + +def _init_zhconv_safely() -> None: + global _ZHCONV_READY + if _ZHCONV_READY: + return + _ZHCONV_READY = True + try: + import zhconv.zhconv as module + + if getattr(module, "zhcdicts", None) is not None: + return + dictionary = getattr(module, "DICTIONARY", "zhcdict.json") + raw = b"" + if dictionary == getattr(module, "_DEFAULT_DICT", "zhcdict.json"): + try: + from importlib.resources import open_binary + + with open_binary("zhconv", dictionary) as handle: + raw = handle.read() + except Exception: + stream = module.get_module_res(dictionary) + try: + raw = stream.read() + finally: + if hasattr(stream, "close"): + stream.close() + else: + with open(dictionary, "rb") as handle: + raw = handle.read() + data = json.loads(raw.decode("utf-8")) + data["SIMPONLY"] = frozenset(data.get("SIMPONLY", [])) + data["TRADONLY"] = frozenset(data.get("TRADONLY", [])) + module.zhcdicts = data + except Exception: + return + + +def to_simplified(value: str) -> str: + text = unicodedata.normalize("NFC", str(value or "")) + try: + _init_zhconv_safely() + from zhconv import convert + + return convert(text, "zh-cn") + except Exception: + return text + + +def contains_cjk(value: str) -> bool: + return bool(_CJK_RE.search(str(value or ""))) + + +def cjk_count(value: str) -> int: + return len(_CJK_RE.findall(str(value or ""))) + + +def alias_key(value: str) -> str: + text = unicodedata.normalize("NFKD", to_simplified(value)).casefold() + text = "".join(ch for ch in text if not unicodedata.combining(ch)) + text = re.sub(r"(?:complete|全集|全\s*\d+\s*[集話话])", "", text, flags=re.I) + text = re.sub(r"\b(?:19|20)\d{2}\b", "", text) + text = re.sub(r"\b(?:s(?:eason)?\s*)?0*([1-9]\d*)\s*(?:st|nd|rd|th)?\s*season\b", "", text, flags=re.I) + text = re.sub(r"\bs0*([1-9]\d*)\b", "", text, flags=re.I) + return "".join(ch for ch in text if ch.isalnum() or "\u3400" <= ch <= "\u9fff") + + +def display_title(value: str) -> str: + text = to_simplified(value) + text = _SEPARATORS.sub(" ", text) + text = _SPACES.sub(" ", text).strip(" ._-[]【】()()") + return text + + +def strip_season_markers(value: str) -> str: + text = display_title(value) + text = re.sub(r"\s*第\s*[一二三四五六七八九十百\d]+\s*季", " ", text) + text = re.sub(r"\s*(?:FINAL|\d+(?:st|nd|rd|th))\s+SEASON\b", " ", text, flags=re.I) + text = re.sub(r"\s+S(?:eason)?\s*0*\d+\b", " ", text, flags=re.I) + return _SPACES.sub(" ", text).strip(" ._-") + + +def safe_component(value: str, max_length: int = 120) -> str: + text = to_simplified(value).strip() + text = _INVALID_WINDOWS.sub(" ", text) + text = _SPACES.sub(" ", text).strip(" .") + if not text: + text = "Unknown" + reserved = {"CON", "PRN", "AUX", "NUL"} + reserved.update("COM%d" % i for i in range(1, 10)) + reserved.update("LPT%d" % i for i in range(1, 10)) + if text.split(".", 1)[0].upper() in reserved: + text = "_" + text + if len(text) > max_length: + text = text[:max_length].rstrip(" .") + return text + + +def unique_nonempty(values: Iterable[str]) -> List[str]: + result: List[str] = [] + seen = set() + for value in values: + text = display_title(value) + key = alias_key(text) + if text and key and key not in seen: + seen.add(key) + result.append(text) + return result + + +def same_path(left: Path, right: Path) -> bool: + try: + return left.exists() and right.exists() and left.samefile(right) + except (OSError, ValueError): + return False diff --git a/autoanime_v3/parser.py b/autoanime_v3/parser.py new file mode 100644 index 0000000..e28f8e1 --- /dev/null +++ b/autoanime_v3/parser.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import List, Optional, Tuple + +from .models import ParsedName +from .normalize import alias_key, cjk_count, contains_cjk, display_title, unique_nonempty + + +_SXXEXX = re.compile(r"(? Optional[int]: + if value.isdigit(): + return int(value) + if value in _CN_NUMBERS: + return _CN_NUMBERS[value] + if value.startswith("十"): + return 10 + _CN_NUMBERS.get(value[1:], 0) + if value.endswith("十"): + return _CN_NUMBERS.get(value[:-1], 1) * 10 + if "十" in value: + left, right = value.split("十", 1) + return _CN_NUMBERS.get(left, 1) * 10 + _CN_NUMBERS.get(right, 0) + return None + + +def _bracket_title_candidates(stem: str) -> List[str]: + candidates: List[str] = [] + matches = list(re.finditer(r"[\[【]([^\]】]+)[\]】]", stem)) + for index, match in enumerate(matches): + value = match.group(1) + if index == 0 and match.start() == 0 and len(matches) > 1: + next_value = matches[1].group(1) + if " / " in next_value or " / " in next_value or not contains_cjk(value): + continue + if index == 0 and match.start() == 0 and not contains_cjk(value): + after = stem[match.end():].lstrip() + if after and not after.startswith(("[", "【")): + continue + if ( + _QUALITY.search(value) + or _GROUP_ONLY.match(value.strip()) + or _TECHNICAL_BRACKET.match(value.strip()) + or _TITLE_METADATA.search(value) + ): + continue + if value.strip().isdigit(): + continue + segments = re.split(r"\s+/\s+|\s+/\s+", value) + selected = "" + for segment in segments: + if cjk_count(segment) >= 2: + selected = segment + break + if not selected: + selected = segments[0].strip() + if len(re.findall(r"[A-Za-z]", selected)) >= 3 or cjk_count(selected) >= 2: + candidates.append(selected) + return candidates + + +def _strip_leading_groups(stem: str) -> str: + text = stem + match = re.match(r"^\s*(?:\[[^\]]+\]|【[^】]+】)\s*", text) + if match: + content = match.group(0).strip(" []【】") + after = text[match.end():] + is_group = bool(_GROUP_ONLY.match(content) or re.search(r"字幕|汉化|漢化|压制|壓制|发布|發佈", content)) + if re.match(r"^\d{1,2}月$", content): + is_group = True + if not is_group and not contains_cjk(content): + if after.startswith(("[", "【")): + next_match = re.match(r"^[\[【]([^\]】]+)[\]】]", after) + next_value = next_match.group(1).strip() if next_match else "" + is_group = bool(next_value and not next_value.isdigit() and not _TECHNICAL_BRACKET.match(next_value)) + else: + is_group = bool(after and re.match(r"[A-Za-z0-9\u3400-\u9fff]", after)) + if is_group: + text = after + return text.strip() + + +def _title_before_episode(stem: str) -> str: + text = _strip_leading_groups(stem) + positions = [] + for pattern in (_SXXEXX, _CN_EPISODE, _EP_TOKEN, _DASH_EP, _BRACKET_EP, _STAR_EP, _TRAIL_EP): + match = pattern.search(text) + if match: + positions.append(match.start()) + if positions: + text = text[: min(positions)] + for match in re.finditer(r"[\[【((]([^\]】))]+)[\]】))]", text): + metadata = match.group(1).strip() + if ( + _QUALITY.search(metadata) + or _TECHNICAL_BRACKET.match(metadata) + or _TITLE_METADATA.search(metadata) + or re.search(r"uncensored|multi[- ]?subs?|CHS|CHT|JPN|SRT|ASS", metadata, re.I) + ): + text = text[: match.start()] + break + text = re.sub(r"\b(?:19|20)\d{2}\b.*$", "", text) + text = re.sub(r"\b(?:S\d{1,2}|Season\s*\d{1,2}|\d+(?:st|nd|rd|th)\s*Season)\b.*$", "", text, flags=re.I) + text = re.sub(r"\bComplete\b.*$", "", text, flags=re.I) + text = re.sub(r"\s*[-–—]?\s*(?:电影|電影|Movie)\s*$", "", text, flags=re.I) + return display_title(text) + + +def _leading_cjk_title(value: str) -> str: + text = re.sub(r"[((](?:仅限|僅限)[^))]*[))]", "", value).strip() + roman_tail = re.search( + r"\s+[A-Za-z][A-Za-z0-9-]*(?:\s+[A-Za-z][A-Za-z0-9-]*){3,}(?:\s|$)", text + ) + if roman_tail and cjk_count(text[: roman_tail.start()]) >= 3: + return display_title(text[: roman_tail.start()]) + return "" + + +def _season_episode(stem: str) -> Tuple[Optional[int], Optional[int], bool, bool]: + match = _SXXEXX.search(stem) + if match: + return int(match.group(1)), int(match.group(2)), True, True + + season: Optional[int] = None + episode: Optional[int] = None + explicit_season = False + explicit_episode = False + cn_season = _CN_SEASON.search(stem) + if cn_season: + season = _cn_number(cn_season.group(1)) + explicit_season = season is not None + if season is None: + season_match = _SEASON.search(stem) + if season_match: + season = int(next(value for value in season_match.groups() if value is not None)) + explicit_season = True + for pattern in (_CN_EPISODE, _EP_TOKEN, _DASH_EP, _STAR_EP, _BRACKET_EP, _TRAIL_EP): + episode_match = pattern.search(stem) + if episode_match: + episode = int(episode_match.group(1)) + explicit_episode = True + break + return season, episode, explicit_season, explicit_episode + + +def _release_tag(stem: str) -> str: + known = ("Baha", "friDay", "LINETV", "CR", "Netflix", "Disney+", "AMZN", "ABEMA") + found = [value for value in known if re.search(r"(? ParsedName: + stem = path.stem + season, episode, explicit_season, explicit_episode = _season_episode(stem) + folder_season, _, folder_explicit_season, _ = _season_episode(context_name) + if season is None and folder_season is not None: + season = folder_season + explicit_season = folder_explicit_season + is_movie = bool(_MOVIE.search(stem) or _MOVIE.search(context_name)) + if is_movie and episode is None: + season, episode = 1, 1 + if episode is not None and season is None: + season = 1 + + bracket_candidates = _bracket_title_candidates(stem) + generic_context = alias_key(context_name) in {alias_key(value) for value in _GENERIC_CONTEXT_KEYS} + folder_brackets = [] if generic_context else _bracket_title_candidates(context_name) + file_title = _title_before_episode(stem) + folder_title = "" if generic_context else _title_before_episode(context_name) + leading_file_title = _leading_cjk_title(file_title) + leading_folder_title = _leading_cjk_title(folder_title) + bracket_cjk = [value for value in bracket_candidates if contains_cjk(value)] + bracket_other = [value for value in bracket_candidates if not contains_cjk(value)] + folder_cjk = [value for value in folder_brackets if contains_cjk(value)] + folder_other = [value for value in folder_brackets if not contains_cjk(value)] + candidates = unique_nonempty( + bracket_cjk + + folder_cjk + + [leading_file_title, leading_folder_title, file_title, folder_title] + + bracket_other + + folder_other + ) + raw_title = candidates[0] if candidates else display_title(file_title or folder_title) + warnings: List[str] = [] + if episode is None and not is_movie: + warnings.append("episode_missing") + if not raw_title: + warnings.append("title_missing") + if raw_title and not contains_cjk(raw_title): + warnings.append("title_not_chinese") + return ParsedName( + raw_title=raw_title, + season=season, + episode=episode, + is_movie=is_movie, + explicit_season=explicit_season, + explicit_episode=explicit_episode, + title_candidates=tuple(candidates), + release_tag=_release_tag(stem), + warnings=tuple(warnings), + ) diff --git a/autoanime_v3/path_safety.py b/autoanime_v3/path_safety.py new file mode 100644 index 0000000..58380f0 --- /dev/null +++ b/autoanime_v3/path_safety.py @@ -0,0 +1,57 @@ +"""Physical destination containment checks for file-changing operations.""" + +import os +import stat +from pathlib import Path + +from autoanime_v3.domain.errors import PlanConflictError + + +WINDOWS_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + + +def _is_symlink_or_reparse_point(path): + try: + metadata = os.lstat(str(path)) + except OSError: + return False + return stat.S_ISLNK(metadata.st_mode) or bool( + int(getattr(metadata, "st_file_attributes", 0)) & WINDOWS_REPARSE_POINT + ) + + +def validate_library_destination(root_path, destination_path): + root = Path(root_path) + destination = Path(destination_path) + try: + relative = destination.relative_to(root) + except ValueError: + raise PlanConflictError( + "Destination escapes its registered library root", + {"root": str(root), "destination": str(destination)}, + ) + if relative.is_absolute() or ".." in relative.parts: + raise PlanConflictError( + "Destination escapes its registered library root", + {"root": str(root), "destination": str(destination)}, + ) + + current = root + for part in (None,) + relative.parts: + if part is not None: + current = current / part + if os.path.lexists(str(current)) and _is_symlink_or_reparse_point(current): + raise PlanConflictError( + "Destination path contains a symlink or reparse point", + {"path": str(current)}, + ) + + try: + physical_root = root.resolve(strict=False) + physical_destination = destination.resolve(strict=False) + physical_destination.relative_to(physical_root) + except (OSError, RuntimeError, ValueError): + raise PlanConflictError( + "Destination resolves outside its registered library root", + {"root": str(root), "destination": str(destination)}, + ) diff --git a/autoanime_v3/planner.py b/autoanime_v3/planner.py new file mode 100644 index 0000000..e45c0ce --- /dev/null +++ b/autoanime_v3/planner.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from collections import defaultdict +import hashlib +import os +import re +import unicodedata +from pathlib import Path +from typing import Any, Dict, Iterable, List, Tuple + +from .models import PlanEntry, Resolution +from .normalize import alias_key, safe_component, same_path +from .scanner import SUBTITLE_EXTENSIONS + + +def _media_type(resolution: Resolution) -> str: + return resolution.media_type or ("movie" if resolution.is_movie else "episode") + + +def _number_token(value: Any) -> str: + if isinstance(value, int): + return "%02d" % value + if isinstance(value, float): + return str(value).rstrip("0").rstrip(".") if "." in str(value) else str(value) + return safe_component(str(value), 32) + + +def _episode_basename(resolution: Resolution) -> str: + title = safe_component(resolution.canonical_title) + media_type = _media_type(resolution) + if media_type == "movie": + return title + if media_type == "special": + episode = _number_token(resolution.episode) + label = episode if episode.casefold().startswith("sp") else "SP" + episode + return "%s - %s" % (label, title) + return "S%02dE%s - %s" % (resolution.season, _number_token(resolution.episode), title) + + +def _destination(output_root: Path, resolution: Resolution, version_label: str = "") -> Path: + title = safe_component(resolution.canonical_title) + media_type = _media_type(resolution) + if media_type == "movie": + directory = output_root / title + elif media_type == "special": + directory = output_root / title / "Specials" + else: + directory = output_root / title / ("Season %02d" % resolution.season) + basename = _episode_basename(resolution) + if version_label: + basename += " [%s]" % safe_component(version_label, 50) + return directory / (basename + resolution.media.path.suffix.lower()) + + +def _version_label(resolution: Resolution) -> str: + name = resolution.media.path.stem + parts = [] + if resolution.release_tag: + parts.append(resolution.release_tag) + group = re.match(r"^[\[【]([^\]】]+)[\]】]", name) + if group: + value = group.group(1).strip() + catalog_aliases = set() + for evidence in resolution.evidence: + prefix, separator, alias = evidence.detail.partition("=") + if evidence.agent == "catalog" and separator and prefix.strip().casefold() == "alias": + catalog_aliases.add(alias_key(alias.strip())) + value_key = alias_key(value) + if ( + value + and value.casefold() not in {"1080p", "720p"} + and value_key != alias_key(resolution.canonical_title) + and value_key not in catalog_aliases + ): + parts.append(value) + flags = ( + (r"年[龄齡]限制|uncensored|無修|无修", "Uncensored"), + (r"中(?:文|字)配音|國語|国语|mandarin", "zh-dub"), + ) + for pattern, label in flags: + if re.search(pattern, name, re.I): + parts.append(label) + version = re.search(r"\bV(\d+)\b", name, re.I) + if version: + parts.append("V%s" % version.group(1)) + clean = [] + seen = set() + for value in parts: + key = value.casefold() + if key not in seen: + seen.add(key) + clean.append(value) + return "-".join(clean) + + +def _stable_source_digest(resolution: Resolution) -> str: + source_identity = "%s\0%s" % ( + os.path.abspath(os.fspath(resolution.media.path)), + resolution.media.relative_path.strip(), + ) + normalized = unicodedata.normalize("NFC", source_identity.replace("\\", "/")).casefold() + normalized = re.sub(r"/+", "/", normalized) + return hashlib.sha1(normalized.encode("utf-8", "surrogatepass")).hexdigest()[:8] + + +def build_plan(resolutions: Iterable[Resolution], output_root: Path) -> List[PlanEntry]: + values = list(resolutions) + grouped: Dict[Tuple[Any, ...], List[Resolution]] = defaultdict(list) + for resolution in values: + if resolution.accepted: + grouped[resolution.identity_key()].append(resolution) + + plan: List[PlanEntry] = [] + reserved = set() + intrinsic_labels = { + id(resolution): _version_label(resolution) + for resolution in values + if resolution.accepted + } + labels = {} + for resolution in values: + if not resolution.accepted: + continue + label = intrinsic_labels[id(resolution)] + labels[id(resolution)] = label or "version-" + _stable_source_digest(resolution) + for versions in grouped.values(): + if len(versions) <= 1: + continue + label_counts = defaultdict(int) + for resolution in versions: + label_counts[intrinsic_labels[id(resolution)].casefold()] += 1 + for resolution in versions: + label = intrinsic_labels[id(resolution)] + if label and label_counts[label.casefold()] > 1: + labels[id(resolution)] = label + "-" + _stable_source_digest(resolution) + subtitle_dirs = {} + assigned_subtitles = set() + + def subtitles_for(video: Path): + parent = video.parent + if parent not in subtitle_dirs: + subtitle_dirs[parent] = [ + path for path in parent.iterdir() + if path.is_file() and path.suffix.casefold() in SUBTITLE_EXTENSIONS + ] + video_stem = video.stem.casefold() + return sorted( + [ + path for path in subtitle_dirs[parent] + if path.stem.casefold() == video_stem + or path.stem.casefold().startswith(video_stem + ".") + or video_stem.startswith(path.stem.casefold() + ".") + ], + key=lambda value: value.name.casefold(), + ) + for resolution in values: + if not resolution.accepted: + plan.append(PlanEntry(resolution.media.path, None, "review", resolution, "unsafe_resolution")) + continue + destination = _destination(output_root, resolution, labels.get(id(resolution), "")) + key = str(destination).casefold() + if key in reserved: + plan.append(PlanEntry(resolution.media.path, destination, "conflict", resolution, "duplicate_destination")) + continue + reserved.add(key) + if destination.exists(): + if same_path(resolution.media.path, destination): + plan.append(PlanEntry(resolution.media.path, destination, "skip", resolution, "already_linked")) + else: + plan.append(PlanEntry(resolution.media.path, destination, "conflict", resolution, "destination_exists")) + continue + else: + plan.append(PlanEntry(resolution.media.path, destination, "organize", resolution)) + for subtitle in subtitles_for(resolution.media.path): + subtitle_source_key = str(subtitle.resolve()).casefold() + source_stem = resolution.media.path.stem + suffix = subtitle.name[len(source_stem):] if subtitle.name.casefold().startswith(source_stem.casefold()) else subtitle.suffix + subtitle_destination = destination.with_suffix("").with_name(destination.stem + suffix) + subtitle_destination_key = str(subtitle_destination).casefold() + if subtitle_source_key in assigned_subtitles: + plan.append( + PlanEntry(subtitle, subtitle_destination, "conflict", resolution, "subtitle_matches_multiple_videos", str(resolution.media.path)) + ) + continue + if subtitle_destination_key in reserved: + plan.append( + PlanEntry(subtitle, subtitle_destination, "conflict", resolution, "duplicate_subtitle_destination", str(resolution.media.path)) + ) + continue + if subtitle_destination.exists(): + action = "skip" if same_path(subtitle, subtitle_destination) else "conflict" + reason = "already_linked" if action == "skip" else "subtitle_destination_exists" + plan.append(PlanEntry(subtitle, subtitle_destination, action, resolution, reason, str(resolution.media.path))) + assigned_subtitles.add(subtitle_source_key) + reserved.add(subtitle_destination_key) + continue + assigned_subtitles.add(subtitle_source_key) + reserved.add(subtitle_destination_key) + plan.append( + PlanEntry(subtitle, subtitle_destination, "organize", resolution, "subtitle", str(resolution.media.path)) + ) + for entry in plan: + if entry.destination is not None: + entry.destination_root = output_root + return plan diff --git a/autoanime_v3/remote.py b/autoanime_v3/remote.py new file mode 100644 index 0000000..fe3cf59 --- /dev/null +++ b/autoanime_v3/remote.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from typing import Any, Dict, Optional + +from .config import AppConfig +from .models import MediaFile, ParsedName +from .normalize import contains_cjk, display_title + + +class OpenAIResolverAgent: + """仅处理本地无法安全收敛的条目;返回结果仍需本地策略校验。""" + + name = "openai" + + def __init__(self, config: AppConfig) -> None: + self.config = config + + def enabled(self) -> bool: + return bool(self.config.openai_enabled and self.config.openai_api_key) + + def resolve(self, media: MediaFile, parsed: ParsedName) -> Optional[Dict[str, Any]]: + if not self.enabled(): + return None + prompt = ( + "识别这个动画视频。只输出 JSON,不要 markdown。字段:" + "title_zh(简体中文正式常用名), season(整数), episode(整数), " + "is_movie(布尔), confidence(0到1), reason(短句)。" + "不得根据相同集数把不同作品合并;不确定时 confidence 必须低于0.8。\n" + "文件名:%s\n上层文件夹:%s\n本地解析:%s" + % (media.path.name, media.context_name, json.dumps(parsed.__dict__, ensure_ascii=False, default=list)) + ) + body = { + "model": self.config.openai_model, + "temperature": 0, + "response_format": {"type": "json_object"}, + "messages": [ + {"role": "system", "content": "你是严格的动画文件名元数据识别器。"}, + {"role": "user", "content": prompt}, + ], + } + endpoint = self.config.openai_base_url.rstrip("/") + if not endpoint.endswith("/v1/chat/completions"): + endpoint += "/v1/chat/completions" + request = urllib.request.Request( + endpoint, + data=json.dumps(body, ensure_ascii=False).encode("utf-8"), + headers={ + "Authorization": "Bearer " + self.config.openai_api_key, + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=self.config.openai_timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + content = payload["choices"][0]["message"]["content"] + result = json.loads(content) + except (OSError, KeyError, IndexError, ValueError, json.JSONDecodeError, urllib.error.URLError): + return None + title = display_title(str(result.get("title_zh", ""))) + if not title or not contains_cjk(title): + return None + try: + season = int(result.get("season")) + episode = int(result.get("episode")) + confidence = float(result.get("confidence", 0.0)) + except (TypeError, ValueError): + return None + if season < 1 or episode < 1: + return None + movie_flag = result.get("is_movie", False) + if not isinstance(movie_flag, bool): + return None + return { + "title": title, + "season": season, + "episode": episode, + "is_movie": movie_flag, + "confidence": max(0.0, min(1.0, confidence)), + "reason": str(result.get("reason", "")), + } diff --git a/autoanime_v3/repository.py b/autoanime_v3/repository.py new file mode 100644 index 0000000..3766a2a --- /dev/null +++ b/autoanime_v3/repository.py @@ -0,0 +1,9 @@ +"""资料库公共入口。 + +底层当前使用 SQLite;CLI、未来 WebUI 和应用服务只依赖 ``LibraryRepository``, +不依赖具体表或文件格式。 +""" + +from .cache import ResolutionCache as LibraryRepository, fingerprint + +__all__ = ["LibraryRepository", "fingerprint"] diff --git a/autoanime_v3/resolver.py b/autoanime_v3/resolver.py new file mode 100644 index 0000000..747c52c --- /dev/null +++ b/autoanime_v3/resolver.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from typing import List, Optional + +from .repository import LibraryRepository, fingerprint +from .catalog import TitleCatalog +from .config import AppConfig +from .models import Evidence, MediaFile, Resolution +from .normalize import contains_cjk, display_title, strip_season_markers +from .parser import parse_name +from .remote import OpenAIResolverAgent + + +class Resolver: + def __init__(self, catalog: TitleCatalog, config: AppConfig, cache: LibraryRepository) -> None: + self.catalog = catalog + self.config = config + self.cache = cache + self.remote = OpenAIResolverAgent(config) + + def resolve(self, media: MediaFile, use_cache: bool = True) -> Resolution: + if use_cache: + cached = self.cache.get(media, self.catalog.version) + if cached is not None: + cached.evidence.append(Evidence("cache", cached.canonical_title, 1.0, "fingerprint_match")) + return cached + + parsed = parse_name(media.path, media.context_name) + result = Resolution( + media=media, + season=parsed.season, + episode=parsed.episode, + is_movie=parsed.is_movie, + release_tag=parsed.release_tag, + warnings=list(parsed.warnings), + fingerprint=fingerprint(media, self.catalog.version), + ) + catalog_hit = self.catalog.resolve(parsed.title_candidates) + if catalog_hit: + title, matched = catalog_hit + result.canonical_title = title + result.confidence = 0.99 + result.evidence.append(Evidence("catalog", title, 0.99, "alias=" + matched)) + if result.episode is None: + default_episode = self.catalog.default_episode(parsed.title_candidates) + if default_episode is not None: + result.season, result.episode = default_episode + result.evidence.append( + Evidence("catalog_default", "%dx%d" % default_episode, 0.99, "explicit_special_default") + ) + if "episode_missing" in result.warnings: + result.warnings.remove("episode_missing") + else: + chinese = [value for value in parsed.title_candidates if contains_cjk(value)] + if chinese: + result.canonical_title = ( + strip_season_markers(chinese[0]) if parsed.explicit_season else display_title(chinese[0]) + ) + result.confidence = 0.93 + result.evidence.append(Evidence("filename", result.canonical_title, 0.93, "chinese_title")) + elif parsed.raw_title: + result.canonical_title = display_title(parsed.raw_title) + result.confidence = 0.55 + result.evidence.append(Evidence("filename", result.canonical_title, 0.55, "non_chinese_unverified")) + + if not parsed.explicit_season: + default_season = self.catalog.default_season(parsed.title_candidates) + if default_season is not None: + result.season = default_season + result.evidence.append( + Evidence("catalog_season", str(default_season), 0.99, "explicit_season_default") + ) + + needs_remote = ( + not result.canonical_title + or not contains_cjk(result.canonical_title) + or result.episode is None + or result.confidence < self.config.min_confidence + ) + if needs_remote: + remote = self.remote.resolve(media, parsed) + if remote and remote["confidence"] >= 0.8: + local_episode_conflict = parsed.explicit_episode and parsed.episode != remote["episode"] + local_season_conflict = parsed.explicit_season and parsed.season != remote["season"] + if local_episode_conflict or local_season_conflict: + result.warnings.append("remote_local_episode_conflict") + else: + result.canonical_title = remote["title"] + result.season = remote["season"] + result.episode = remote["episode"] + result.is_movie = remote["is_movie"] + result.confidence = min(0.97, remote["confidence"]) + result.evidence.append(Evidence("openai", remote["title"], result.confidence, remote["reason"])) + + if result.canonical_title and result.season and result.episode: + new_season, new_episode, remapped = self.catalog.remap_absolute_episode( + result.canonical_title, result.season, result.episode, parsed.explicit_season + ) + if remapped: + result.evidence.append( + Evidence("season_layout", "%dx%d" % (new_season, new_episode), 0.98, "absolute_episode_remap") + ) + result.season, result.episode = new_season, new_episode + + required = bool( + result.canonical_title + and result.season is not None + and result.episode is not None + and result.episode > 0 + and result.season >= 0 + ) + chinese_title = contains_cjk(result.canonical_title) + trusted_catalog = any(item.agent == "catalog" for item in result.evidence) + result.accepted = bool( + required and (chinese_title or trusted_catalog) and result.confidence >= self.config.min_confidence + ) + if not result.accepted: + if not chinese_title and not trusted_catalog: + result.warnings.append("unverified_non_chinese_title") + if not required: + result.warnings.append("incomplete_identity") + if result.confidence < self.config.min_confidence: + result.warnings.append("confidence_below_threshold") + if result.accepted: + self.cache.put(result) + return result diff --git a/autoanime_v3/scanner.py b/autoanime_v3/scanner.py new file mode 100644 index 0000000..c16391f --- /dev/null +++ b/autoanime_v3/scanner.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Iterable, List, Optional, Sequence, Set + +from .models import MediaFile + + +VIDEO_EXTENSIONS: Set[str] = { + ".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".webm" +} +SUBTITLE_EXTENSIONS: Set[str] = {".ass", ".ssa", ".srt", ".vtt", ".sub"} +INCOMPLETE_SUFFIXES = (".!qb", ".part", ".partial", ".aria2", ".crdownload", ".tmp") +SKIP_DIR_NAMES = {"logs", ".cache", ".autoanime-v3", "@eadir", "$recycle.bin"} + + +def _is_relative_to(path: Path, parent: Path) -> bool: + try: + path.resolve().relative_to(parent.resolve()) + return True + except (ValueError, OSError): + return False + + +def _context_name(path: Path, input_root: Path, input_was_file: bool) -> str: + if input_was_file: + return path.parent.name + try: + relative = path.relative_to(input_root) + except ValueError: + return path.parent.name + if len(relative.parts) > 1: + return relative.parts[0] + return input_root.name + + +def scan_media( + source: Path, + output_root: Optional[Path] = None, + scope_paths: Optional[Sequence[Path]] = None, +) -> List[MediaFile]: + source = source.resolve() + if not source.exists(): + raise FileNotFoundError(str(source)) + input_was_file = source.is_file() + input_root = source.parent if input_was_file else source + resolved_output = output_root.resolve() if output_root else None + skip_output_tree = ( + resolved_output + if resolved_output is not None and _is_relative_to(resolved_output, input_root) + else None + ) + candidates: Iterable[Path] + if scope_paths: + scoped = [] + seen = set() + for scope in scope_paths: + value = Path(scope).resolve(strict=False) + values = [value] if value.is_file() else value.rglob("*") if value.is_dir() else [] + for candidate in values: + key = str(candidate).casefold() + if key not in seen: + seen.add(key) + scoped.append(candidate) + candidates = scoped + elif input_was_file: + candidates = [source] + else: + candidates = source.rglob("*") + + result: List[MediaFile] = [] + for path in candidates: + if not path.is_file(): + continue + if skip_output_tree and _is_relative_to(path, skip_output_tree): + continue + lowered_parts = {part.casefold() for part in path.parts} + if lowered_parts.intersection(name.casefold() for name in SKIP_DIR_NAMES): + continue + lower_name = path.name.casefold() + if lower_name.endswith(INCOMPLETE_SUFFIXES): + continue + if path.suffix.casefold() not in VIDEO_EXTENSIONS: + continue + stat = path.stat() + result.append( + MediaFile( + path=path, + input_root=input_root, + context_name=_context_name(path, input_root, input_was_file), + relative_path=str(path.relative_to(input_root)), + size=int(stat.st_size), + mtime_ns=int(stat.st_mtime_ns), + ) + ) + result.sort(key=lambda item: (item.relative_path.casefold(), item.mtime_ns)) + return result + + +def companion_subtitles(video: Path) -> Sequence[Path]: + siblings = [] + video_stem = video.stem.casefold() + for path in video.parent.iterdir(): + if not path.is_file() or path.suffix.casefold() not in SUBTITLE_EXTENSIONS: + continue + stem = path.stem.casefold() + if stem == video_stem or stem.startswith(video_stem + ".") or video_stem.startswith(stem + "."): + siblings.append(path) + return sorted(siblings, key=lambda value: value.name.casefold()) diff --git a/autoanime_v3/security/__init__.py b/autoanime_v3/security/__init__.py new file mode 100644 index 0000000..a02aa07 --- /dev/null +++ b/autoanime_v3/security/__init__.py @@ -0,0 +1,2 @@ +"""Authentication, CSRF, and encrypted-secret primitives.""" + diff --git a/autoanime_v3/security/csrf.py b/autoanime_v3/security/csrf.py new file mode 100644 index 0000000..6ea033b --- /dev/null +++ b/autoanime_v3/security/csrf.py @@ -0,0 +1,8 @@ +"""CSRF token validation kept separate for API dependency reuse.""" + +from .sessions import token_matches + + +def csrf_matches(token, expected_hash): + return token_matches(token, expected_hash) + diff --git a/autoanime_v3/security/network.py b/autoanime_v3/security/network.py new file mode 100644 index 0000000..abf82cc --- /dev/null +++ b/autoanime_v3/security/network.py @@ -0,0 +1,17 @@ +"""Network helpers for loopback trust decisions.""" + +import ipaddress + + +def is_loopback_host(host): + """Return True when the client address is IPv4/IPv6 loopback (incl. IPv4-mapped).""" + if not host: + return False + try: + address = ipaddress.ip_address(str(host).split("%", 1)[0]) + except ValueError: + return False + if address.is_loopback: + return True + mapped = getattr(address, "ipv4_mapped", None) + return bool(mapped and mapped.is_loopback) diff --git a/autoanime_v3/security/passwords.py b/autoanime_v3/security/passwords.py new file mode 100644 index 0000000..fbaf57c --- /dev/null +++ b/autoanime_v3/security/passwords.py @@ -0,0 +1,34 @@ +"""Argon2id password hashing.""" + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError +from argon2.low_level import Type + + +_HASHER = PasswordHasher( + time_cost=3, + memory_cost=65536, + parallelism=4, + hash_len=32, + salt_len=16, + type=Type.ID, +) + + +def hash_password(password): + return _HASHER.hash(password) + + +def verify_password(password_hash, password): + try: + return bool(_HASHER.verify(password_hash, password)) + except (VerificationError, InvalidHashError): + return False + + +def password_needs_rehash(password_hash): + try: + return _HASHER.check_needs_rehash(password_hash) + except InvalidHashError: + return True + diff --git a/autoanime_v3/security/secrets.py b/autoanime_v3/security/secrets.py new file mode 100644 index 0000000..009fdf8 --- /dev/null +++ b/autoanime_v3/security/secrets.py @@ -0,0 +1,89 @@ +"""Machine-bound and development secret-encryption adapters.""" + +import ctypes +import os +from pathlib import Path + +from cryptography.fernet import Fernet + + +class EncryptedFileSecretStore: + provider = "fernet_file" + + def __init__(self, directory): + self.directory = Path(directory) + self.directory.mkdir(parents=True, exist_ok=True) + self.key_path = self.directory / "master.key" + if not self.key_path.exists(): + self.key_path.write_bytes(Fernet.generate_key()) + try: + os.chmod(str(self.key_path), 0o600) + except OSError: + pass + self.fernet = Fernet(self.key_path.read_bytes()) + + def protect(self, value): + return self.fernet.encrypt(value.encode("utf-8")) + + def unprotect(self, ciphertext): + return self.fernet.decrypt(bytes(ciphertext)).decode("utf-8") + + +class _DataBlob(ctypes.Structure): + _fields_ = [("cbData", ctypes.c_ulong), ("pbData", ctypes.POINTER(ctypes.c_byte))] + + +def _blob_from_bytes(data): + buffer = ctypes.create_string_buffer(data, len(data)) + blob = _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte))) + return blob, buffer + + +class DpapiSecretStore: + provider = "dpapi" + + def __init__(self, entropy=b"AutoAnime-v3-WebConsole"): + if os.name != "nt": + raise OSError("Windows DPAPI is only available on Windows") + self.entropy = entropy + + def protect(self, value): + plaintext, plaintext_buffer = _blob_from_bytes(value.encode("utf-8")) + entropy, entropy_buffer = _blob_from_bytes(self.entropy) + output = _DataBlob() + success = ctypes.windll.crypt32.CryptProtectData( + ctypes.byref(plaintext), + "AutoAnime secret", + ctypes.byref(entropy), + None, + None, + 0x01, + ctypes.byref(output), + ) + if not success: + raise ctypes.WinError() + try: + return ctypes.string_at(output.pbData, output.cbData) + finally: + ctypes.windll.kernel32.LocalFree(output.pbData) + + def unprotect(self, ciphertext): + encrypted, encrypted_buffer = _blob_from_bytes(bytes(ciphertext)) + entropy, entropy_buffer = _blob_from_bytes(self.entropy) + output = _DataBlob() + success = ctypes.windll.crypt32.CryptUnprotectData( + ctypes.byref(encrypted), + None, + ctypes.byref(entropy), + None, + None, + 0x01, + ctypes.byref(output), + ) + if not success: + raise ctypes.WinError() + try: + return ctypes.string_at(output.pbData, output.cbData).decode("utf-8") + finally: + ctypes.windll.kernel32.LocalFree(output.pbData) + diff --git a/autoanime_v3/security/sessions.py b/autoanime_v3/security/sessions.py new file mode 100644 index 0000000..1f0dfc7 --- /dev/null +++ b/autoanime_v3/security/sessions.py @@ -0,0 +1,18 @@ +"""Opaque session-token creation and hashing.""" + +import hashlib +import hmac +import secrets + + +def random_token(): + return secrets.token_urlsafe(32) + + +def token_hash(token): + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def token_matches(token, expected_hash): + return hmac.compare_digest(token_hash(token), str(expected_hash)) + diff --git a/autoanime_v3/services/__init__.py b/autoanime_v3/services/__init__.py new file mode 100644 index 0000000..748ee48 --- /dev/null +++ b/autoanime_v3/services/__init__.py @@ -0,0 +1,2 @@ +"""Application services shared by Web, Worker, and CLI entry points.""" + diff --git a/autoanime_v3/services/auth.py b/autoanime_v3/services/auth.py new file mode 100644 index 0000000..00bd17c --- /dev/null +++ b/autoanime_v3/services/auth.py @@ -0,0 +1,302 @@ +"""Single-administrator authentication and secret-setting services.""" + +import hashlib +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.db.repositories.auth import AuthRepository, public_user +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.entities import SecretStatus, SessionCredentials, UserPublic +from autoanime_v3.domain.errors import ( + AlreadyBootstrappedError, + AuthenticationError, + CsrfValidationError, + LocalOnlyError, + LoginThrottledError, + ValidationError, +) +from autoanime_v3.security.csrf import csrf_matches +from autoanime_v3.security.passwords import hash_password, password_needs_rehash, verify_password +from autoanime_v3.security.sessions import random_token, token_hash + + +DEFAULT_ADMIN_USERNAME = "admin" +DEFAULT_ADMIN_PASSWORD = "AutoAnime-Admin-ChangeMe!" +AUTH_LOCAL_BYPASS_KEY = "auth.local_bypass" +LOCAL_HOOK_TRUST_KEY = "hooks.local_trust" + + +def utc_now(): + return datetime.now(timezone.utc) + + +def iso(value): + return value.astimezone(timezone.utc).isoformat() + + +def parse_time(value): + return datetime.fromisoformat(str(value)) + + +class AuthService: + failure_limit = 5 + failure_window = timedelta(minutes=15) + lock_duration = timedelta(minutes=15) + + def __init__(self, database_path, clock=None, session_ttl_seconds=43200): + self.database_path = Path(database_path) + self.clock = clock or utc_now + self.session_ttl = timedelta(seconds=session_ttl_seconds) + run_migrations(self.database_path) + + def ensure_default_admin(self): + """Create the documented default administrator when the database is empty.""" + now = iso(self.clock()) + with SqliteUnitOfWork(self.database_path) as uow: + repository = AuthRepository(uow.connection) + if repository.user_count() != 0: + return None + row = repository.create_user( + DEFAULT_ADMIN_USERNAME, + hash_password(DEFAULT_ADMIN_PASSWORD), + now, + ) + uow.commit() + return public_user(row) + + def bootstrap_admin(self, username, password): + username = str(username).strip() + if len(username) < 3 or len(password) < 12: + raise ValidationError("Administrator username or password is too short") + now = iso(self.clock()) + with SqliteUnitOfWork(self.database_path) as uow: + repository = AuthRepository(uow.connection) + if repository.user_count() != 0: + raise AlreadyBootstrappedError("Administrator has already been created") + row = repository.create_user(username, hash_password(password), now) + uow.commit() + return public_user(row) + + def _setting_bool(self, key, default=False): + with SqliteUnitOfWork(self.database_path) as uow: + row = uow.connection.execute( + "SELECT value_json FROM app_settings WHERE key = ?", (key,) + ).fetchone() + if row is None: + return bool(default) + try: + return bool(json.loads(row[0])) + except (TypeError, ValueError, json.JSONDecodeError): + return bool(default) + + def local_bypass_enabled(self): + return self._setting_bool(AUTH_LOCAL_BYPASS_KEY, default=True) + + def local_hook_trust_enabled(self): + return self._setting_bool(LOCAL_HOOK_TRUST_KEY, default=True) + + def issue_session_for_user(self, user_row, client_ip=None, user_agent=None): + now = self.clock() + with SqliteUnitOfWork(self.database_path) as uow: + repository = AuthRepository(uow.connection) + session_token = random_token() + csrf_token = random_token() + expires_at = now + self.session_ttl + repository.create_session( + user_row["id"], + token_hash(session_token), + token_hash(csrf_token), + iso(now), + iso(expires_at), + client_ip, + user_agent, + ) + uow.commit() + return SessionCredentials( + session_token=session_token, + csrf_token=csrf_token, + expires_at=iso(expires_at), + user=public_user(user_row), + ) + + def local_session(self, client_ip=None, user_agent=None, is_loopback=False): + if not is_loopback: + raise LocalOnlyError("Passwordless local login is only available on loopback") + if not self.local_bypass_enabled(): + raise AuthenticationError("Local passwordless login is disabled") + self.ensure_default_admin() + with SqliteUnitOfWork(self.database_path) as uow: + repository = AuthRepository(uow.connection) + user = repository.get_user_by_username(DEFAULT_ADMIN_USERNAME) + if user is None: + user = repository.connection.execute( + "SELECT * FROM users WHERE is_active = 1 ORDER BY id LIMIT 1" + ).fetchone() + if user is None or not bool(user["is_active"]): + raise AuthenticationError("No active administrator is available") + return self.issue_session_for_user(user, client_ip=client_ip, user_agent=user_agent) + + def _attempt_key(self, username, client_ip): + value = "%s\0%s" % (str(username).casefold(), client_ip or "unknown") + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + def _check_throttle(self, repository, attempt_key, now): + attempt = repository.get_login_attempt(attempt_key) + if attempt is None or attempt["locked_until"] is None: + return + if parse_time(attempt["locked_until"]) > now: + raise LoginThrottledError( + "Too many failed login attempts", + {"retry_after": attempt["locked_until"]}, + ) + + def _record_failure(self, repository, attempt_key, now): + attempt = repository.get_login_attempt(attempt_key) + if attempt is None or now - parse_time(attempt["window_started_at"]) > self.failure_window: + count = 1 + window_started = now + else: + count = int(attempt["failure_count"]) + 1 + window_started = parse_time(attempt["window_started_at"]) + locked_until = now + self.lock_duration if count >= self.failure_limit else None + repository.save_login_failure( + attempt_key, + count, + iso(window_started), + iso(locked_until) if locked_until is not None else None, + iso(now), + ) + + def login(self, username, password, client_ip=None, user_agent=None): + now = self.clock() + attempt_key = self._attempt_key(username, client_ip) + with SqliteUnitOfWork(self.database_path) as uow: + repository = AuthRepository(uow.connection) + self._check_throttle(repository, attempt_key, now) + user = repository.get_user_by_username(str(username).strip()) + valid = user is not None and bool(user["is_active"]) and verify_password( + user["password_hash"], password + ) + if not valid: + self._record_failure(repository, attempt_key, now) + uow.commit() + raise AuthenticationError("Invalid username or password") + repository.clear_login_attempt(attempt_key) + if password_needs_rehash(user["password_hash"]): + uow.connection.execute( + "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?", + (hash_password(password), iso(now), user["id"]), + ) + session_token = random_token() + csrf_token = random_token() + expires_at = now + self.session_ttl + repository.create_session( + user["id"], + token_hash(session_token), + token_hash(csrf_token), + iso(now), + iso(expires_at), + client_ip, + user_agent, + ) + uow.commit() + return SessionCredentials( + session_token=session_token, + csrf_token=csrf_token, + expires_at=iso(expires_at), + user=public_user(user), + ) + + def _session_row(self, repository, session_token, now): + row = repository.find_session(token_hash(session_token)) + if ( + row is None + or row["revoked_at"] is not None + or not bool(row["is_active"]) + or parse_time(row["expires_at"]) <= now + ): + raise AuthenticationError("Session is expired or invalid") + return row + + def authenticate(self, session_token): + now = self.clock() + with SqliteUnitOfWork(self.database_path) as uow: + repository = AuthRepository(uow.connection) + row = self._session_row(repository, session_token, now) + repository.touch_session(row["id"], iso(now)) + uow.commit() + return UserPublic(int(row["user_id"]), str(row["username"]), bool(row["is_active"])) + + def require_csrf(self, session_token, csrf_token): + now = self.clock() + with SqliteUnitOfWork(self.database_path) as uow: + repository = AuthRepository(uow.connection) + row = self._session_row(repository, session_token, now) + if not csrf_matches(csrf_token, row["csrf_hash"]): + raise CsrfValidationError("CSRF token does not match the session") + repository.touch_session(row["id"], iso(now)) + uow.commit() + return UserPublic(int(row["user_id"]), str(row["username"]), bool(row["is_active"])) + + def logout(self, session_token): + with SqliteUnitOfWork(self.database_path) as uow: + AuthRepository(uow.connection).revoke_session(token_hash(session_token), iso(self.clock())) + uow.commit() + + +class SecretService: + def __init__(self, database_path, secret_store): + self.database_path = Path(database_path) + self.secret_store = secret_store + run_migrations(self.database_path) + + def set_secret(self, key, value): + if not value: + raise ValidationError("Secret value cannot be empty") + ciphertext = self.secret_store.protect(value) + now = iso(utc_now()) + with SqliteUnitOfWork(self.database_path) as uow: + uow.connection.execute( + """ + INSERT INTO secret_settings(key, ciphertext, provider, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + ciphertext = excluded.ciphertext, + provider = excluded.provider, + updated_at = excluded.updated_at + """, + (key, ciphertext, self.secret_store.provider, now), + ) + uow.commit() + return SecretStatus(key, True, self.secret_store.provider, now) + + def status(self, key): + from autoanime_v3.db.engine import connect_sqlite + + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + row = connection.execute( + "SELECT key, provider, updated_at FROM secret_settings WHERE key = ?", (key,) + ).fetchone() + if row is None: + return SecretStatus(key, False, None, None) + return SecretStatus(str(row["key"]), True, str(row["provider"]), str(row["updated_at"])) + finally: + connection.close() + + def reveal_for_integration(self, key): + from autoanime_v3.db.engine import connect_sqlite + + connection = connect_sqlite(self.database_path) + try: + row = connection.execute( + "SELECT ciphertext FROM secret_settings WHERE key = ?", (key,) + ).fetchone() + if row is None: + return None + return self.secret_store.unprotect(bytes(row[0])) + finally: + connection.close() diff --git a/autoanime_v3/services/automation.py b/autoanime_v3/services/automation.py new file mode 100644 index 0000000..410c1f5 --- /dev/null +++ b/autoanime_v3/services/automation.py @@ -0,0 +1,573 @@ +"""Persistent automation producers for schedules, downloader hooks, and watchdog events.""" + +import hashlib +import json +import secrets +import threading +import time +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer + +from autoanime_v3.db.engine import connect_sqlite +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.db.repositories.jobs import JobRepository +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.errors import ( + NotFoundError, + PathOutsideRootError, + RevisionConflictError, + ValidationError, +) +from autoanime_v3.jobs.queue import JobQueue +from autoanime_v3.jobs.watcher import StableFileBuffer +from autoanime_v3.services.roots import normalize_windows_path, path_is_within + + +def utc_now(): + return datetime.now(timezone.utc) + + +def iso(value): + return value.astimezone(timezone.utc).isoformat() + + +def parse_iso(value): + return datetime.fromisoformat(value).astimezone(timezone.utc) + + +def _zone(timezone_name): + if timezone_name == "UTC": + return timezone.utc + if timezone_name == "Asia/Shanghai": + return timezone(timedelta(hours=8), "Asia/Shanghai") + return ZoneInfo(timezone_name) + + +@dataclass(frozen=True) +class Schedule: + id: int + profile_id: int + kind: str + schedule: dict + timezone: str + next_run_at: str | None + last_run_at: str | None + enabled: bool + revision: int + created_at: str + updated_at: str + + +@dataclass(frozen=True) +class WebhookSource: + id: int + name: str + downloader: str + profile_id: int + enabled: bool + last_called_at: str | None + revision: int + created_at: str + updated_at: str + + +@dataclass(frozen=True) +class CreatedWebhookSource(WebhookSource): + token: str + + +def _schedule_from_row(row): + return Schedule( + id=int(row["id"]), + profile_id=int(row["profile_id"]), + kind=str(row["kind"]), + schedule=json.loads(row["schedule_json"]), + timezone=str(row["timezone"]), + next_run_at=row["next_run_at"], + last_run_at=row["last_run_at"], + enabled=bool(row["enabled"]), + revision=int(row["revision"]), + created_at=str(row["created_at"]), + updated_at=str(row["updated_at"]), + ) + + +def _webhook_from_row(row): + return WebhookSource( + id=int(row["id"]), + name=str(row["name"]), + downloader=str(row["downloader"]), + profile_id=int(row["profile_id"]), + enabled=bool(row["enabled"]), + last_called_at=row["last_called_at"], + revision=int(row["revision"]), + created_at=str(row["created_at"]), + updated_at=str(row["updated_at"]), + ) + + +def _validate_schedule(kind, schedule, timezone_name): + try: + _zone(timezone_name) + except ZoneInfoNotFoundError as error: + raise ValidationError("Unknown schedule timezone", {"timezone": timezone_name}) from error + if kind == "interval": + minutes = schedule.get("interval_minutes") + if type(minutes) is not int or minutes < 1: + raise ValidationError("interval_minutes must be an integer greater than zero") + return {"interval_minutes": minutes} + if kind == "daily": + value = schedule.get("time") + try: + hour_text, minute_text = str(value).split(":") + hour, minute = int(hour_text), int(minute_text) + except (TypeError, ValueError) as error: + raise ValidationError("Daily schedule time must use HH:MM") from error + if not (0 <= hour <= 23 and 0 <= minute <= 59) or str(value) != f"{hour:02d}:{minute:02d}": + raise ValidationError("Daily schedule time must use HH:MM") + return {"time": value} + raise ValidationError("Unsupported schedule kind", {"kind": kind}) + + +def next_run(kind, schedule, timezone_name, after): + if kind == "interval": + return after.astimezone(timezone.utc) + timedelta(minutes=schedule["interval_minutes"]) + zone = _zone(timezone_name) + local_after = after.astimezone(zone) + hour, minute = (int(part) for part in schedule["time"].split(":")) + candidate = local_after.replace(hour=hour, minute=minute, second=0, microsecond=0) + if candidate <= local_after: + candidate += timedelta(days=1) + return candidate.astimezone(timezone.utc) + + +class ScheduleService: + def __init__(self, database_path, clock=None): + self.database_path = Path(database_path) + self.clock = clock or utc_now + run_migrations(self.database_path) + + def create(self, profile_id, kind, schedule, timezone_name="UTC", enabled=True): + document = _validate_schedule(kind, schedule, timezone_name) + now = self.clock() + with SqliteUnitOfWork(self.database_path) as uow: + if uow.connection.execute( + "SELECT 1 FROM scan_profiles WHERE id = ?", (profile_id,) + ).fetchone() is None: + raise NotFoundError("Scan profile does not exist", {"id": profile_id}) + upcoming = iso(next_run(kind, document, timezone_name, now)) if enabled else None + cursor = uow.connection.execute( + """ + INSERT INTO schedules(profile_id, kind, schedule_json, timezone, next_run_at, enabled) + VALUES (?, ?, ?, ?, ?, ?) + """, + (profile_id, kind, json.dumps(document), timezone_name, upcoming, int(enabled)), + ) + row = uow.connection.execute( + "SELECT * FROM schedules WHERE id = ?", (cursor.lastrowid,) + ).fetchone() + uow.commit() + return _schedule_from_row(row) + + def list(self): + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + return tuple(_schedule_from_row(row) for row in connection.execute("SELECT * FROM schedules ORDER BY id")) + finally: + connection.close() + + def get(self, schedule_id): + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + row = connection.execute("SELECT * FROM schedules WHERE id = ?", (schedule_id,)).fetchone() + finally: + connection.close() + if row is None: + raise NotFoundError("Schedule does not exist", {"id": schedule_id}) + return _schedule_from_row(row) + + def update(self, schedule_id, revision, patch): + allowed = {"profile_id", "kind", "schedule", "timezone", "enabled"} + if not patch or set(patch) - allowed: + raise ValidationError("Unsupported or empty schedule update") + with SqliteUnitOfWork(self.database_path) as uow: + row = uow.connection.execute("SELECT * FROM schedules WHERE id = ?", (schedule_id,)).fetchone() + if row is None: + raise NotFoundError("Schedule does not exist", {"id": schedule_id}) + if int(row["revision"]) != int(revision): + raise RevisionConflictError("Schedule revision is stale") + profile_id = int(patch.get("profile_id", row["profile_id"])) + kind = str(patch.get("kind", row["kind"])) + document = patch.get("schedule", json.loads(row["schedule_json"])) + timezone_name = str(patch.get("timezone", row["timezone"])) + enabled = patch.get("enabled", bool(row["enabled"])) + if type(enabled) is not bool: + raise ValidationError("Schedule enabled state must be true or false") + document = _validate_schedule(kind, document, timezone_name) + if uow.connection.execute("SELECT 1 FROM scan_profiles WHERE id = ?", (profile_id,)).fetchone() is None: + raise NotFoundError("Scan profile does not exist", {"id": profile_id}) + upcoming = iso(next_run(kind, document, timezone_name, self.clock())) if enabled else None + uow.connection.execute( + """ + UPDATE schedules SET profile_id = ?, kind = ?, schedule_json = ?, timezone = ?, + enabled = ?, next_run_at = ?, revision = revision + 1, + updated_at = CURRENT_TIMESTAMP WHERE id = ? + """, + (profile_id, kind, json.dumps(document), timezone_name, int(enabled), upcoming, schedule_id), + ) + result = uow.connection.execute("SELECT * FROM schedules WHERE id = ?", (schedule_id,)).fetchone() + uow.commit() + return _schedule_from_row(result) + + def delete(self, schedule_id, revision): + with SqliteUnitOfWork(self.database_path) as uow: + row = uow.connection.execute("SELECT revision FROM schedules WHERE id = ?", (schedule_id,)).fetchone() + if row is None: + raise NotFoundError("Schedule does not exist", {"id": schedule_id}) + if int(row["revision"]) != int(revision): + raise RevisionConflictError("Schedule revision is stale") + uow.connection.execute("DELETE FROM schedules WHERE id = ?", (schedule_id,)) + uow.commit() + + def enqueue_due(self): + now = self.clock().astimezone(timezone.utc) + produced = [] + with SqliteUnitOfWork(self.database_path) as uow: + rows = uow.connection.execute( + """ + SELECT s.* FROM schedules s JOIN scan_profiles p ON p.id = s.profile_id + WHERE s.enabled = 1 AND p.enabled = 1 AND s.next_run_at IS NOT NULL + AND s.next_run_at <= ? ORDER BY s.next_run_at, s.id + """, + (iso(now),), + ).fetchall() + jobs = JobRepository(uow.connection) + for row in rows: + occurrence = str(row["next_run_at"]) + key = f"schedule:{row['id']}:{occurrence}" + job = jobs.find_by_idempotency_key(key) + if job is None: + job = jobs.enqueue( + "scan", + {"profile_id": int(row["profile_id"]), "paths": [], "trigger": "schedule", "schedule_id": int(row["id"])}, + key, + 0, + iso(now), + ) + document = json.loads(row["schedule_json"]) + upcoming = next_run(str(row["kind"]), document, str(row["timezone"]), parse_iso(occurrence)) + while upcoming <= now: + upcoming = next_run(str(row["kind"]), document, str(row["timezone"]), upcoming) + uow.connection.execute( + """ + UPDATE schedules SET last_run_at = ?, next_run_at = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND next_run_at = ? + """, + (occurrence, iso(upcoming), int(row["id"]), occurrence), + ) + produced.append(job) + uow.commit() + return tuple(produced) + + +class WebhookSourceService: + def __init__(self, database_path, clock=None): + self.database_path = Path(database_path) + self.clock = clock or utc_now + run_migrations(self.database_path) + + def create(self, name, downloader, profile_id, enabled=True): + if not str(name).strip() or not str(downloader).strip(): + raise ValidationError("Webhook name and downloader are required") + token = secrets.token_urlsafe(32) + digest = hashlib.sha256(token.encode("utf-8")).hexdigest() + with SqliteUnitOfWork(self.database_path) as uow: + if uow.connection.execute("SELECT 1 FROM scan_profiles WHERE id = ?", (profile_id,)).fetchone() is None: + raise NotFoundError("Scan profile does not exist", {"id": profile_id}) + cursor = uow.connection.execute( + """ + INSERT INTO webhook_sources(name, downloader, token_hash, profile_id, enabled) + VALUES (?, ?, ?, ?, ?) + """, + (str(name).strip(), str(downloader).strip(), digest, profile_id, int(bool(enabled))), + ) + row = uow.connection.execute("SELECT * FROM webhook_sources WHERE id = ?", (cursor.lastrowid,)).fetchone() + uow.commit() + item = _webhook_from_row(row) + return CreatedWebhookSource(**item.__dict__, token=token) + + def list(self): + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + return tuple(_webhook_from_row(row) for row in connection.execute("SELECT * FROM webhook_sources ORDER BY id")) + finally: + connection.close() + + def get(self, source_id): + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + row = connection.execute("SELECT * FROM webhook_sources WHERE id = ?", (source_id,)).fetchone() + finally: + connection.close() + if row is None: + raise NotFoundError("Webhook source does not exist", {"id": source_id}) + return _webhook_from_row(row) + + def update(self, source_id, revision, patch): + allowed = {"name", "downloader", "profile_id", "enabled"} + if not patch or set(patch) - allowed: + raise ValidationError("Unsupported or empty webhook update") + with SqliteUnitOfWork(self.database_path) as uow: + row = uow.connection.execute("SELECT * FROM webhook_sources WHERE id = ?", (source_id,)).fetchone() + if row is None: + raise NotFoundError("Webhook source does not exist", {"id": source_id}) + if int(row["revision"]) != int(revision): + raise RevisionConflictError("Webhook source revision is stale") + name = str(patch.get("name", row["name"])).strip() + downloader = str(patch.get("downloader", row["downloader"])).strip() + profile_id = int(patch.get("profile_id", row["profile_id"])) + enabled = patch.get("enabled", bool(row["enabled"])) + if not name or not downloader or type(enabled) is not bool: + raise ValidationError("Webhook update contains invalid values") + if uow.connection.execute("SELECT 1 FROM scan_profiles WHERE id = ?", (profile_id,)).fetchone() is None: + raise NotFoundError("Scan profile does not exist", {"id": profile_id}) + uow.connection.execute( + """ + UPDATE webhook_sources SET name = ?, downloader = ?, profile_id = ?, enabled = ?, + revision = revision + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? + """, + (name, downloader, profile_id, int(enabled), source_id), + ) + result = uow.connection.execute("SELECT * FROM webhook_sources WHERE id = ?", (source_id,)).fetchone() + uow.commit() + return _webhook_from_row(result) + + def delete(self, source_id, revision): + with SqliteUnitOfWork(self.database_path) as uow: + row = uow.connection.execute("SELECT revision FROM webhook_sources WHERE id = ?", (source_id,)).fetchone() + if row is None: + raise NotFoundError("Webhook source does not exist", {"id": source_id}) + if int(row["revision"]) != int(revision): + raise RevisionConflictError("Webhook source revision is stale") + uow.connection.execute("DELETE FROM webhook_sources WHERE id = ?", (source_id,)) + uow.commit() + + def submit_token(self, token, paths): + digest = hashlib.sha256(str(token).encode("utf-8")).hexdigest() + targets = [Path(value).expanduser().resolve(strict=False) for value in paths] + if not targets: + raise ValidationError("Webhook requires path or paths") + now = self.clock() + with SqliteUnitOfWork(self.database_path) as uow: + row = uow.connection.execute( + """ + SELECT w.*, p.enabled AS profile_enabled, r.path AS source_path, r.enabled AS root_enabled + FROM webhook_sources w + JOIN scan_profiles p ON p.id = w.profile_id + JOIN storage_roots r ON r.id = p.source_root_id + WHERE w.token_hash = ? AND w.enabled = 1 + """, + (digest,), + ).fetchone() + if row is None or not bool(row["profile_enabled"]) or not bool(row["root_enabled"]): + raise NotFoundError("Enabled webhook source does not exist") + for target in targets: + if not path_is_within(target, row["source_path"]): + raise PathOutsideRootError( + "Webhook path is outside the configured source root", + {"root": row["source_path"], "target": str(target)}, + ) + canonical = sorted({str(target) for target in targets}, key=str.casefold) + facts = [] + for target in canonical: + try: + stat = Path(target).stat() + facts.append(f"{normalize_windows_path(target)}:{stat.st_size}:{stat.st_mtime_ns}") + except OSError: + facts.append(f"{normalize_windows_path(target)}:missing") + key = "webhook:" + hashlib.sha256((f"{row['id']}:" + "|".join(facts)).encode("utf-8")).hexdigest() + jobs = JobRepository(uow.connection) + job = jobs.find_by_idempotency_key(key) + if job is None: + job = jobs.enqueue( + "scan", + {"profile_id": int(row["profile_id"]), "paths": canonical, "trigger": "webhook", "webhook_source_id": int(row["id"])}, + key, + 0, + iso(now), + ) + uow.connection.execute( + "UPDATE webhook_sources SET last_called_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (iso(now), int(row["id"])), + ) + uow.commit() + return job + + +class _ProfileEventHandler(FileSystemEventHandler): + def __init__(self, profile_id, callback): + self.profile_id = profile_id + self.callback = callback + + def _record(self, path, is_directory): + if not is_directory: + self.callback(self.profile_id, Path(path)) + + def on_created(self, event): + self._record(event.src_path, event.is_directory) + + def on_modified(self, event): + self._record(event.src_path, event.is_directory) + + def on_moved(self, event): + self._record(event.dest_path, event.is_directory) + + +class AutomationRuntime: + def __init__( + self, + database_path, + queue=None, + clock=None, + watch_enabled=True, + watch_poll_seconds=0.25, + observer_reload_seconds=2.0, + ): + self.database_path = Path(database_path) + run_migrations(self.database_path) + self.clock = clock or utc_now + self.queue = queue or JobQueue(self.database_path, clock=self.clock) + self.schedules = ScheduleService(self.database_path, clock=self.clock) + self.watch_enabled = watch_enabled + self.watch_poll_seconds = max(0.01, float(watch_poll_seconds)) + self.observer_reload_seconds = max(0.01, float(observer_reload_seconds)) + self._watchers = {} + self._watch_lock = threading.Lock() + self._last_reload = 0.0 + self.is_running = False + + def start(self): + if self.is_running: + return + self.is_running = True + if self.watch_enabled: + self._reload_watchers(force=True) + + def _profiles_to_watch(self): + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + return connection.execute( + """ + SELECT p.id, p.revision, p.stability_seconds, r.path AS source_path + FROM scan_profiles p JOIN storage_roots r ON r.id = p.source_root_id + WHERE p.enabled = 1 AND p.watch_enabled = 1 AND r.enabled = 1 ORDER BY p.id + """ + ).fetchall() + finally: + connection.close() + + @staticmethod + def _stop_observer(state): + state[1].stop() + state[1].join(timeout=3) + + def _reload_watchers(self, force=False): + now = time.monotonic() + if not force and now - self._last_reload < self.observer_reload_seconds: + return + self._last_reload = now + desired = { + int(row["id"]): (str(row["source_path"]), int(row["stability_seconds"]), int(row["revision"])) + for row in self._profiles_to_watch() + } + stopped = [] + with self._watch_lock: + for profile_id, state in list(self._watchers.items()): + if profile_id not in desired or state[0] != desired[profile_id]: + stopped.append(self._watchers.pop(profile_id)) + for state in stopped: + self._stop_observer(state) + for profile_id, signature in desired.items(): + with self._watch_lock: + if profile_id in self._watchers: + continue + source, stability_seconds, unused_revision = signature + buffer = StableFileBuffer( + clock=self.clock, + debounce_seconds=self.watch_poll_seconds, + stability_seconds=stability_seconds, + ) + observer = Observer() + observer.schedule(_ProfileEventHandler(profile_id, self._record_event), source, recursive=True) + with self._watch_lock: + self._watchers[profile_id] = (signature, observer, buffer) + observer.start() + + def _record_event(self, profile_id, path): + with self._watch_lock: + state = self._watchers.get(profile_id) + if state is None: + return + try: + stat = path.stat() + except OSError: + return + state[2].record(path.resolve(strict=False), int(stat.st_size), int(stat.st_mtime_ns)) + + def _enqueue_ready_watch_paths(self): + produced = [] + with self._watch_lock: + states = list(self._watchers.items()) + for profile_id, state in states: + buffer = state[2] + with self._watch_lock: + if self._watchers.get(profile_id) is not state: + continue + for path in buffer.paths(): + try: + stat = path.stat() + except OSError: + buffer.discard(path) + continue + buffer.refresh(path, int(stat.st_size), int(stat.st_mtime_ns)) + ready = buffer.ready() + if not ready: + continue + fingerprints = [] + for path in ready: + stat = path.stat() + fingerprints.append(f"{normalize_windows_path(path)}:{stat.st_size}:{stat.st_mtime_ns}") + key = "watch:" + hashlib.sha256((f"{profile_id}:" + "|".join(fingerprints)).encode("utf-8")).hexdigest() + produced.append( + self.queue.enqueue( + "scan", + {"profile_id": profile_id, "paths": [str(path) for path in ready], "trigger": "watch"}, + key, + ) + ) + return tuple(produced) + + def tick(self): + produced = list(self.schedules.enqueue_due()) + if self.watch_enabled and self.is_running: + self._reload_watchers() + produced.extend(self._enqueue_ready_watch_paths()) + return tuple(produced) + + def stop(self): + with self._watch_lock: + states = list(self._watchers.values()) + self._watchers.clear() + for state in states: + self._stop_observer(state) + self.is_running = False diff --git a/autoanime_v3/services/backups.py b/autoanime_v3/services/backups.py new file mode 100644 index 0000000..605a641 --- /dev/null +++ b/autoanime_v3/services/backups.py @@ -0,0 +1,90 @@ +"""SQLite online backup, checksum, and maintenance-mode restore.""" + +import hashlib +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +from autoanime_v3.db.engine import connect_sqlite +from autoanime_v3.db.migrations import SCHEMA_VERSION, run_migrations +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.entities import BackupRecordView +from autoanime_v3.domain.errors import NotFoundError, ValidationError + + +def file_sha256(path): + digest = hashlib.sha256() + with Path(path).open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +class BackupService: + def __init__(self, database_path, backup_directory): + self.database_path = Path(database_path) + self.backup_directory = Path(backup_directory) + self.backup_directory.mkdir(parents=True, exist_ok=True) + run_migrations(self.database_path) + + def create(self, kind="manual", sanitized=False): + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S_%fZ") + destination = self.backup_directory / ("autoanime_%s.sqlite3" % stamp) + source_connection = connect_sqlite(self.database_path) + destination_connection = sqlite3.connect(str(destination)) + try: + source_connection.backup(destination_connection) + finally: + destination_connection.close() + source_connection.close() + checksum = file_sha256(destination) + size = destination.stat().st_size + with SqliteUnitOfWork(self.database_path) as uow: + cursor = uow.connection.execute( + """ + INSERT INTO backup_records( + path, kind, size, sha256, schema_version, sanitized + ) VALUES (?, ?, ?, ?, ?, ?) + """, + (str(destination), kind, size, checksum, SCHEMA_VERSION, int(sanitized)), + ) + backup_id = int(cursor.lastrowid) + created_at = str( + uow.connection.execute( + "SELECT created_at FROM backup_records WHERE id = ?", (backup_id,) + ).fetchone()[0] + ) + uow.commit() + return BackupRecordView( + backup_id, str(destination), kind, size, checksum, SCHEMA_VERSION, sanitized, created_at + ) + + def restore(self, backup_id, maintenance_mode=False): + if not maintenance_mode: + raise ValidationError("Restore requires maintenance mode") + connection = connect_sqlite(self.database_path) + try: + row = connection.execute( + "SELECT path, sha256, schema_version FROM backup_records WHERE id = ?", (backup_id,) + ).fetchone() + finally: + connection.close() + if row is None: + raise NotFoundError("Backup record does not exist") + source_path = Path(row[0]) + if not source_path.is_file() or file_sha256(source_path) != row[1]: + raise ValidationError("Backup file is missing or its checksum changed") + source_connection = sqlite3.connect(str(source_path)) + target_connection = connect_sqlite(self.database_path) + try: + integrity = source_connection.execute("PRAGMA integrity_check").fetchone()[0] + versions = source_connection.execute( + "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1" + ).fetchone() + if integrity != "ok" or versions is None or int(versions[0]) != SCHEMA_VERSION: + raise ValidationError("Backup schema or integrity validation failed") + source_connection.backup(target_connection) + finally: + target_connection.close() + source_connection.close() + run_migrations(self.database_path) diff --git a/autoanime_v3/services/changes.py b/autoanime_v3/services/changes.py new file mode 100644 index 0000000..dd87f54 --- /dev/null +++ b/autoanime_v3/services/changes.py @@ -0,0 +1,105 @@ +"""Auditable library corrections with optimistic concurrency.""" + +import json +from pathlib import Path + +from autoanime_v3.db.engine import connect_sqlite +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.entities import ChangeRequestView, ShowView +from autoanime_v3.domain.errors import NotFoundError, RevisionConflictError +from autoanime_v3.normalize import alias_key + + +def show_view(row): + return ShowView( + int(row["id"]), + str(row["canonical_title"]), + str(row["normalized_key"]), + str(row["status"]), + bool(row["title_locked"]), + int(row["revision"]), + ) + + +class ChangeService: + def __init__(self, database_path): + self.database_path = Path(database_path) + run_migrations(self.database_path) + + def create_show(self, title): + with SqliteUnitOfWork(self.database_path) as uow: + cursor = uow.connection.execute( + "INSERT INTO shows(canonical_title, normalized_key) VALUES (?, ?)", + (title, alias_key(title)), + ) + row = uow.connection.execute("SELECT * FROM shows WHERE id = ?", (cursor.lastrowid,)).fetchone() + uow.commit() + return show_view(row) + + def _get_show(self, connection, show_id): + row = connection.execute("SELECT * FROM shows WHERE id = ?", (show_id,)).fetchone() + if row is None: + raise NotFoundError("Show does not exist") + return row + + def preview_show_change(self, show_id, base_revision, patch, reason): + with SqliteUnitOfWork(self.database_path) as uow: + show = self._get_show(uow.connection, show_id) + if int(show["revision"]) != int(base_revision): + raise RevisionConflictError( + "Show changed after the editor loaded it", + {"actual_revision": int(show["revision"])}, + ) + old_values = {key: show[key] for key in patch} + new_values = dict(patch) + cursor = uow.connection.execute( + """ + INSERT INTO change_requests( + target_type, target_id, patch_json, old_values_json, + new_values_json, reason, base_revision, status + ) VALUES ('show', ?, ?, ?, ?, ?, ?, 'validated') + """, + ( + show_id, + json.dumps(patch, ensure_ascii=False), + json.dumps(old_values, ensure_ascii=False), + json.dumps(new_values, ensure_ascii=False), + reason, + base_revision, + ), + ) + request = ChangeRequestView( + int(cursor.lastrowid), "show", show_id, old_values, new_values, reason, base_revision, "validated" + ) + uow.commit() + return request + + def apply(self, request_id): + with SqliteUnitOfWork(self.database_path) as uow: + request = uow.connection.execute( + "SELECT * FROM change_requests WHERE id = ?", (request_id,) + ).fetchone() + if request is None: + raise NotFoundError("Change request does not exist") + show = self._get_show(uow.connection, request["target_id"]) + if int(show["revision"]) != int(request["base_revision"]): + raise RevisionConflictError("Show changed before applying the request") + patch = json.loads(request["patch_json"]) + title = patch.get("canonical_title", show["canonical_title"]) + locked = int(bool(patch.get("title_locked", show["title_locked"]))) + uow.connection.execute( + """ + UPDATE shows SET canonical_title = ?, normalized_key = ?, title_locked = ?, + revision = revision + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? + """, + (title, alias_key(title), locked, show["id"]), + ) + uow.connection.execute( + "UPDATE change_requests SET status = 'applied', updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (request_id,), + ) + result = show_view(self._get_show(uow.connection, show["id"])) + uow.commit() + return result + diff --git a/autoanime_v3/services/jobs.py b/autoanime_v3/services/jobs.py new file mode 100644 index 0000000..0135eb7 --- /dev/null +++ b/autoanime_v3/services/jobs.py @@ -0,0 +1,22 @@ +"""Web-facing job command/query facade.""" + + +class JobService: + def __init__(self, queue): + self.queue = queue + + def submit_scan(self, profile_id, paths=None, idempotency_key=None): + return self.queue.enqueue( + "scan", + {"profile_id": profile_id, "paths": list(paths or [])}, + idempotency_key=idempotency_key, + ) + + def cancel(self, job_id): + return self.queue.request_cancel(job_id) + + def get(self, job_id): + return self.queue.get(job_id) + + def events(self, job_id, after_sequence=0): + return self.queue.events(job_id, after_sequence) diff --git a/autoanime_v3/services/operations.py b/autoanime_v3/services/operations.py new file mode 100644 index 0000000..36e14da --- /dev/null +++ b/autoanime_v3/services/operations.py @@ -0,0 +1,502 @@ +"""Safe operation batches around the existing low-level executor.""" + +import json +from datetime import datetime, timezone +from pathlib import Path + +from autoanime_v3.cache import ResolutionCache +from autoanime_v3.db.engine import connect_sqlite +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.db.repositories.library import LibraryRepository +from autoanime_v3.db.repositories.operations import OperationRepository +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.errors import ( + ExecutionPolicyError, + InvalidStateError, + NotFoundError, + PlanConflictError, + StalePlanError, +) +from autoanime_v3.executor import ( + ExecutionError, + ExecutionFailure, + execute_plan, + rollback as rollback_log, +) +from autoanime_v3.models import MediaFile as CoreMediaFile, PlanEntry, Resolution +from autoanime_v3.path_safety import validate_library_destination +from autoanime_v3.services.plans import PlanService +from autoanime_v3.services.rules import RuleService + + +def now_iso(): + return datetime.now(timezone.utc).isoformat() + + +class OperationService: + def __init__(self, database_path, operation_dir=None): + self.database_path = Path(database_path) + self.operation_dir = Path(operation_dir or self.database_path.parent / "operations") + self.cache_path = self.database_path.with_name(self.database_path.stem + "-resolver.sqlite3") + run_migrations(self.database_path) + + def get(self, batch_id): + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + batch = OperationRepository(connection).get(batch_id) + if batch is None: + raise NotFoundError("Operation batch does not exist", {"id": batch_id}) + return batch + finally: + connection.close() + + def _load_execution_rows(self, plan_id): + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + return connection.execute( + """ + SELECT pi.*, fl.path AS source_path, sr.path AS root_path, p.status AS plan_status + FROM plan_items pi + JOIN plans p ON p.id = pi.plan_id + JOIN file_locations fl ON fl.id = pi.source_location_id + JOIN storage_roots sr ON sr.id = pi.destination_root_id + WHERE pi.plan_id = ? ORDER BY pi.id + """, + (plan_id,), + ).fetchall() + finally: + connection.close() + + def _preflight(self, plan, rows): + if plan.status != "approved": + raise InvalidStateError("Only an approved plan can be executed") + prepared = [] + for row in rows: + if row["execution_status"] == "conflict" or row["action"] in {"conflict", "skip"}: + if row["action"] == "conflict": + raise PlanConflictError("Plan still contains conflicts") + continue + source = Path(row["source_path"]) + destination = Path(row["root_path"]) / row["destination_relative_path"] + validate_library_destination(Path(row["root_path"]), destination) + try: + stat = source.stat() + except OSError: + raise StalePlanError("Source file disappeared before execution", {"path": str(source)}) + file_index = str(stat.st_ino) if int(stat.st_ino) else None + if ( + int(stat.st_size) != int(row["source_size"]) + or int(stat.st_mtime_ns) != int(row["source_mtime_ns"]) + or file_index != row["source_file_index"] + ): + raise StalePlanError("Source file changed before execution", {"path": str(source)}) + if destination.exists(): + raise PlanConflictError("Destination is occupied", {"path": str(destination)}) + if row["action"] == "link": + library_stat = Path(row["root_path"]).stat() + if int(library_stat.st_dev) != int(stat.st_dev): + raise PlanConflictError("Hardlink source and destination are on different volumes") + snapshot = json.loads(row["identification_snapshot_json"]) + core_media = CoreMediaFile( + path=source, + input_root=source.parent, + context_name=source.parent.name, + relative_path=source.name, + size=int(row["source_size"]), + mtime_ns=int(row["source_mtime_ns"]), + ) + resolution = Resolution( + media=core_media, + canonical_title=str(snapshot.get("title") or ""), + season=snapshot.get("season"), + episode=snapshot.get("episode"), + is_movie=bool(snapshot.get("is_movie", False)), + confidence=float(snapshot.get("confidence", 1.0)), + accepted=True, + release_tag=str(snapshot.get("release_tag") or ""), + fingerprint=str(snapshot.get("fingerprint") or ""), + media_type=str(snapshot.get("media_type") or ""), + ) + prepared.append( + ( + row, + PlanEntry( + source, + destination, + "organize", + resolution, + row["reason"] or "", + destination_root=Path(row["root_path"]), + ), + ) + ) + return prepared + + def _validate_rule_version(self, plan): + current_rule_version = RuleService(self.database_path).get_active().content_hash + if plan.rule_version == current_rule_version: + return + with SqliteUnitOfWork(self.database_path) as uow: + uow.connection.execute( + "UPDATE plans SET status = 'stale' WHERE id = ? AND status IN ('draft', 'ready', 'approved')", + (plan.id,), + ) + uow.commit() + raise StalePlanError("Active rules changed after plan approval") + + def _claim_execution(self, plan_id, requested_by, rows): + stale = False + batch_id = None + with SqliteUnitOfWork(self.database_path) as uow: + context = uow.connection.execute( + """ + SELECT p.status, p.profile_revision, p.rule_version, sp.revision, sp.execution_policy + FROM plans p JOIN scan_profiles sp ON sp.id = p.profile_id + WHERE p.id = ? + """, + (plan_id,), + ).fetchone() + if context is None: + raise NotFoundError("Plan does not exist", {"id": plan_id}) + if str(context["execution_policy"]) == "dry_run": + raise ExecutionPolicyError( + "Dry-run plans cannot be approved or executed", + {"plan_id": plan_id, "execution_policy": "dry_run"}, + ) + current_rule_version = RuleService(self.database_path).get_active( + uow.connection + ).content_hash + if str(context["rule_version"]) != current_rule_version: + uow.connection.execute( + "UPDATE plans SET status = 'stale' WHERE id = ? AND status IN ('draft', 'ready', 'approved')", + (plan_id,), + ) + uow.commit() + stale = True + elif int(context["revision"]) != int(context["profile_revision"]): + uow.connection.execute( + "UPDATE plans SET status = 'stale' WHERE id = ? AND status = 'approved'", + (plan_id,), + ) + uow.commit() + stale = True + else: + if str(context["status"]) != "approved": + raise InvalidStateError("Only an approved plan can be executed") + for row in rows: + validate_library_destination( + Path(row["root_path"]), + Path(row["root_path"]) / row["destination_relative_path"], + ) + claimed = uow.connection.execute( + "UPDATE plans SET status = 'executing' WHERE id = ? AND status = 'approved'", + (plan_id,), + ).rowcount + if claimed != 1: + raise InvalidStateError("Plan execution was already claimed") + cursor = uow.connection.execute( + """ + INSERT INTO operation_batches(plan_id, kind, status, requested_by, summary_json) + VALUES (?, 'execute', 'running', ?, '{}') + """, + (plan_id, requested_by), + ) + batch_id = int(cursor.lastrowid) + uow.commit() + if stale: + raise StalePlanError("Plan inputs changed after plan approval") + return batch_id + + def execute(self, plan_id, requested_by=None): + plan = PlanService(self.database_path).get(plan_id) + self._validate_rule_version(plan) + rows = self._load_execution_rows(plan_id) + prepared = self._preflight(plan, rows) + if not prepared: + raise InvalidStateError("Plan has no executable items") + modes = {str(row["action"]) for row, unused in prepared} + if len(modes) != 1: + raise InvalidStateError("One operation batch must use a single file mode") + mode = next(iter(modes)) + batch_id = self._claim_execution(plan_id, requested_by, rows) + try: + with ResolutionCache(self.cache_path) as cache: + log_path = execute_plan( + [entry for unused, entry in prepared], + mode, + True, + cache, + self.operation_dir, + ) + except Exception as error: + partial_rollback = isinstance(error, ExecutionFailure) and error.partial_rollback + failure_status = ( + "failed_partial_rollback" if partial_rollback else "failed_rolled_back" + ) + summary = {"error": str(error)} + if isinstance(error, ExecutionFailure): + summary.update( + { + "log_path": str(error.log_path), + "mode": mode, + "applied_items": list(error.applied_records), + "rollback_results": list(error.rollback_results), + "rollback_errors": list(error.rollback_errors), + } + ) + with SqliteUnitOfWork(self.database_path) as uow: + rollback_by_destination = { + result.get("destination"): result + for result in getattr(error, "rollback_results", ()) + } + prepared_by_source = {str(entry.source): (row, entry) for row, entry in prepared} + for sequence, record in enumerate( + getattr(error, "applied_records", ()), start=1 + ): + matched = prepared_by_source.get(str(record.get("source"))) + if matched is None: + continue + row, entry = matched + rollback_result = rollback_by_destination.get(str(entry.destination), {}) + uow.connection.execute( + """ + INSERT INTO operation_items( + batch_id, plan_item_id, sequence, action, source_path, + destination_path, source_identity_json, result_identity_json, + result_sha256, status, error_code, error_summary, + compensation_status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'applied', ?, ?, ?) + """, + ( + batch_id, + row["id"], + sequence, + mode, + str(entry.source), + str(entry.destination), + json.dumps( + { + "size": row["source_size"], + "mtime_ns": row["source_mtime_ns"], + "file_index": row["source_file_index"], + } + ), + json.dumps( + { + "size": record.get("result_size"), + "mtime_ns": record.get("result_mtime_ns"), + } + ), + record.get("result_sha256"), + "rollback_failed" + if rollback_result.get("status") == "failed" + else None, + rollback_result.get("error"), + rollback_result.get("status"), + ), + ) + uow.connection.execute( + """ + UPDATE operation_batches + SET status = ?, summary_json = ?, finished_at = ? + WHERE id = ? + """, + ( + failure_status, + json.dumps(summary, ensure_ascii=False), + now_iso(), + batch_id, + ), + ) + uow.connection.execute( + """ + UPDATE plans SET status = ? + WHERE id = ? AND status = 'executing' + """, + (failure_status, plan_id), + ) + uow.commit() + raise + log_records = [] + with log_path.open("r", encoding="utf-8") as handle: + for line in handle: + if line.strip(): + record = json.loads(line) + if record.get("applied"): + log_records.append(record) + with SqliteUnitOfWork(self.database_path) as uow: + for sequence, ((row, entry), record) in enumerate(zip(prepared, log_records), start=1): + uow.connection.execute( + """ + INSERT INTO operation_items( + batch_id, plan_item_id, sequence, action, source_path, + destination_path, source_identity_json, result_identity_json, + result_sha256, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'success') + """, + ( + batch_id, + row["id"], + sequence, + mode, + str(entry.source), + str(entry.destination), + json.dumps( + { + "size": row["source_size"], + "mtime_ns": row["source_mtime_ns"], + "file_index": row["source_file_index"], + } + ), + json.dumps( + { + "size": record.get("result_size"), + "mtime_ns": record.get("result_mtime_ns"), + } + ), + record.get("result_sha256"), + ), + ) + uow.connection.execute( + "UPDATE plan_items SET execution_status = 'completed' WHERE id = ?", + (row["id"],), + ) + summary = {"log_path": str(log_path), "mode": mode, "item_count": len(log_records)} + uow.connection.execute( + """ + UPDATE operation_batches + SET status = 'completed', summary_json = ?, finished_at = ? WHERE id = ? + """, + (json.dumps(summary, ensure_ascii=False), now_iso(), batch_id), + ) + uow.connection.execute( + "UPDATE plans SET status = 'completed' WHERE id = ? AND status = 'executing'", + (plan_id,), + ) + uow.commit() + facts = LibraryRepository(self.database_path) + for row, entry in prepared: + facts.observe_path( + int(row["destination_root_id"]), entry.destination, "library", "video" + ) + return self.get(batch_id) + + def rollback(self, batch_id, requested_by=None): + self.validate_rollback(batch_id) + original, rollback_id, log_path = self._claim_rollback(batch_id, requested_by) + try: + with ResolutionCache(self.cache_path) as cache: + restored = rollback_log(log_path, cache) + except Exception as error: + with SqliteUnitOfWork(self.database_path) as uow: + uow.connection.execute( + """ + UPDATE operation_batches + SET status = 'failed', summary_json = ?, finished_at = ? + WHERE id = ? AND status = 'running' + """, + ( + json.dumps( + {"error": str(error), "source_log": str(log_path)}, + ensure_ascii=False, + ), + now_iso(), + rollback_id, + ), + ) + uow.commit() + raise + with SqliteUnitOfWork(self.database_path) as uow: + uow.connection.execute( + """ + UPDATE operation_batches + SET status = 'completed', summary_json = ?, finished_at = ? + WHERE id = ? AND status = 'running' + """, + ( + json.dumps({"restored": restored, "source_log": str(log_path)}), + now_iso(), + rollback_id, + ), + ) + for item in original.items: + uow.connection.execute( + """ + INSERT INTO operation_items( + batch_id, sequence, action, source_path, destination_path, + source_identity_json, status, compensation_status + ) VALUES (?, ?, 'rollback', ?, ?, '{}', 'success', 'completed') + """, + ( + rollback_id, + item.sequence, + item.destination_path, + item.source_path, + ), + ) + uow.commit() + return self.get(rollback_id) + + def _claim_rollback(self, batch_id, requested_by=None): + with SqliteUnitOfWork(self.database_path) as uow: + repository = OperationRepository(uow.connection) + original = repository.get(batch_id) + if original is None: + raise NotFoundError("Operation batch does not exist", {"id": batch_id}) + if original.status != "completed" or original.kind != "execute": + raise InvalidStateError("Only a completed execution batch can be rolled back") + existing = uow.connection.execute( + """ + SELECT 1 FROM operation_batches + WHERE parent_batch_id = ? AND kind = 'manual_rollback' + LIMIT 1 + """, + (batch_id,), + ).fetchone() + if existing is not None: + raise InvalidStateError("Operation batch rollback was already claimed") + log_path = Path(original.summary.get("log_path", "")) + if not log_path.is_file(): + raise NotFoundError("Operation log is missing", {"path": str(log_path)}) + cursor = uow.connection.execute( + """ + INSERT INTO operation_batches( + plan_id, parent_batch_id, kind, status, requested_by, + summary_json + ) VALUES (?, ?, 'manual_rollback', 'running', ?, ?) + """, + ( + original.plan_id, + original.id, + requested_by, + json.dumps({"source_log": str(log_path)}), + ), + ) + rollback_id = int(cursor.lastrowid) + uow.commit() + return original, rollback_id, log_path + + def validate_rollback(self, batch_id): + original = self.get(batch_id) + if original.status != "completed" or original.kind != "execute": + raise InvalidStateError("Only a completed execution batch can be rolled back") + connection = connect_sqlite(self.database_path) + try: + already_rolled_back = connection.execute( + """ + SELECT 1 FROM operation_batches + WHERE parent_batch_id = ? AND kind = 'manual_rollback' + LIMIT 1 + """, + (batch_id,), + ).fetchone() + finally: + connection.close() + if already_rolled_back is not None: + raise InvalidStateError("Operation batch was already rolled back") + log_path = Path(original.summary.get("log_path", "")) + if not log_path.is_file(): + raise NotFoundError("Operation log is missing", {"path": str(log_path)}) + return original diff --git a/autoanime_v3/services/plans.py b/autoanime_v3/services/plans.py new file mode 100644 index 0000000..c01b404 --- /dev/null +++ b/autoanime_v3/services/plans.py @@ -0,0 +1,304 @@ +"""Immutable plan queries and approval preflight.""" + +import os +from datetime import datetime, timezone +from pathlib import Path + +from autoanime_v3.db.engine import connect_sqlite +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.db.repositories.jobs import JobRepository +from autoanime_v3.db.repositories.plans import PlanRepository +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.errors import ( + ExecutionPolicyError, + NotFoundError, + PlanConflictError, + StalePlanError, +) +from autoanime_v3.path_safety import validate_library_destination +from autoanime_v3.services.rules import RuleService + + +AUTO_APPLY_SAFE_RISK_LEVELS = frozenset({"normal"}) + + +class PlanService: + def __init__(self, database_path): + self.database_path = Path(database_path) + run_migrations(self.database_path) + + def get(self, plan_id): + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + plan = PlanRepository(connection).get(plan_id) + if plan is None: + raise NotFoundError("Plan does not exist", {"id": plan_id}) + return plan + finally: + connection.close() + + def _set_status(self, plan_id, status): + with SqliteUnitOfWork(self.database_path) as uow: + uow.connection.execute("UPDATE plans SET status = ? WHERE id = ?", (status, plan_id)) + uow.commit() + + def _approval_context(self, plan_id): + plan = self.get(plan_id) + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + profile = connection.execute( + "SELECT revision, execution_policy FROM scan_profiles WHERE id = ?", + (plan.profile_id,), + ).fetchone() + open_reviews = int( + connection.execute( + "SELECT COUNT(*) FROM review_items WHERE scan_run_id = ? AND status = 'open'", + (plan.scan_run_id,), + ).fetchone()[0] + ) + finally: + connection.close() + return plan, profile, open_reviews + + def _validate_destinations(self, plan_id, connection=None): + owns_connection = connection is None + if owns_connection: + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + rows = connection.execute( + """ + SELECT sr.path AS root_path, pi.destination_relative_path + FROM plan_items pi + JOIN storage_roots sr ON sr.id = pi.destination_root_id + WHERE pi.plan_id = ? + """, + (plan_id,), + ).fetchall() + for row in rows: + root = Path(row["root_path"]) + validate_library_destination( + root, + root / row["destination_relative_path"], + ) + finally: + if owns_connection: + connection.close() + + def _rule_version_is_stale(self, plan, current_rule_version=None, connection=None): + if current_rule_version is not None and current_rule_version != plan.rule_version: + return True + active_rule_version = RuleService(self.database_path).get_active(connection).content_hash + return active_rule_version != plan.rule_version + + def _validate_approval(self, plan_id, current_rule_version=None, automatic=False): + plan, profile, open_reviews = self._approval_context(plan_id) + if profile is None: + self._set_status(plan_id, "stale") + raise StalePlanError("Scan profile changed after plan creation") + if int(profile["revision"]) != plan.profile_revision: + self._set_status(plan_id, "stale") + raise StalePlanError("Scan profile changed after plan creation") + execution_policy = str(profile["execution_policy"]) + if execution_policy == "dry_run": + raise ExecutionPolicyError( + "Dry-run plans cannot be approved or executed", + {"plan_id": plan_id, "execution_policy": execution_policy}, + ) + if self._rule_version_is_stale(plan, current_rule_version=current_rule_version): + self._set_status(plan_id, "stale") + raise StalePlanError("Active rules changed after plan creation") + allowed_statuses = {"ready"} if automatic else {"draft", "ready"} + if plan.status not in allowed_statuses: + raise PlanConflictError("Plan cannot be approved in its current state") + if open_reviews or any(item.execution_status == "conflict" for item in plan.items): + raise PlanConflictError("Open reviews or conflicts prevent plan approval") + self._validate_destinations(plan_id) + for item in plan.items: + source = Path(item.source_path) + try: + stat = source.stat() + except OSError: + self._set_status(plan_id, "stale") + raise StalePlanError("Source file is missing", {"path": str(source)}) + current_index = str(stat.st_ino) if int(stat.st_ino) else None + if ( + int(stat.st_size) != item.source_size + or int(stat.st_mtime_ns) != item.source_mtime_ns + or current_index != item.source_file_index + ): + self._set_status(plan_id, "stale") + raise StalePlanError("Source file identity changed", {"path": str(source)}) + if Path(item.destination_path).exists() and item.action not in {"skip"}: + raise PlanConflictError( + "Destination became occupied after preview", + {"path": item.destination_path}, + ) + return plan + + def approve(self, plan_id, user_id=None, current_rule_version=None): + plan, unused_job = self.approve_and_enqueue( + plan_id, + user_id=user_id, + current_rule_version=current_rule_version, + ) + return plan + + def approve_and_enqueue( + self, + plan_id, + user_id=None, + current_rule_version=None, + automatic=False, + ): + idempotency_key = "execute-plan:%s" % plan_id + with SqliteUnitOfWork(self.database_path) as uow: + profile = uow.connection.execute( + """ + SELECT sp.execution_policy + FROM plans p JOIN scan_profiles sp ON sp.id = p.profile_id + WHERE p.id = ? + """, + (plan_id,), + ).fetchone() + if profile is not None and str(profile["execution_policy"]) == "dry_run": + raise ExecutionPolicyError( + "Dry-run plans cannot be approved or executed", + {"plan_id": plan_id, "execution_policy": "dry_run"}, + ) + repository = JobRepository(uow.connection) + existing_job = repository.find_by_idempotency_key(idempotency_key) + plan = PlanRepository(uow.connection).get(plan_id) + if ( + plan is not None + and self._rule_version_is_stale( + plan, + current_rule_version=current_rule_version, + connection=uow.connection, + ) + ): + uow.connection.execute( + "UPDATE plans SET status = 'stale' WHERE id = ? AND status IN ('draft', 'ready', 'approved')", + (plan_id,), + ) + uow.commit() + raise StalePlanError("Active rules changed after plan creation") + if plan is not None and plan.status == "approved" and existing_job is not None: + uow.commit() + return plan, existing_job + + self._validate_approval( + plan_id, + current_rule_version=current_rule_version, + automatic=automatic, + ) + with SqliteUnitOfWork(self.database_path) as uow: + plan = PlanRepository(uow.connection).get(plan_id) + if plan is None: + raise NotFoundError("Plan does not exist", {"id": plan_id}) + profile = uow.connection.execute( + "SELECT revision, execution_policy FROM scan_profiles WHERE id = ?", + (plan.profile_id,), + ).fetchone() + open_reviews = int( + uow.connection.execute( + "SELECT COUNT(*) FROM review_items WHERE scan_run_id = ? AND status = 'open'", + (plan.scan_run_id,), + ).fetchone()[0] + ) + if profile is None or int(profile["revision"]) != plan.profile_revision: + raise StalePlanError("Scan profile changed after plan creation") + if ( + self._rule_version_is_stale( + plan, + current_rule_version=current_rule_version, + connection=uow.connection, + ) + ): + uow.connection.execute( + "UPDATE plans SET status = 'stale' WHERE id = ? AND status IN ('draft', 'ready', 'approved')", + (plan_id,), + ) + uow.commit() + raise StalePlanError("Active rules changed after plan creation") + if str(profile["execution_policy"]) == "dry_run": + raise ExecutionPolicyError( + "Dry-run plans cannot be approved or executed", + {"plan_id": plan_id, "execution_policy": "dry_run"}, + ) + repository = JobRepository(uow.connection) + existing_job = repository.find_by_idempotency_key(idempotency_key) + if plan.status == "approved" and existing_job is not None: + uow.commit() + return plan, existing_job + allowed_statuses = {"ready"} if automatic else {"draft", "ready"} + if plan.status not in allowed_statuses: + raise PlanConflictError("Plan cannot be approved in its current state") + if open_reviews or any(item.execution_status == "conflict" for item in plan.items): + raise PlanConflictError("Open reviews or conflicts prevent plan approval") + self._validate_destinations(plan_id, uow.connection) + if automatic and ( + str(profile["execution_policy"]) != "auto_apply_safe" + or not plan.items + or not any(item.action not in {"skip", "conflict"} for item in plan.items) + or any( + item.risk_level not in AUTO_APPLY_SAFE_RISK_LEVELS + for item in plan.items + ) + ): + raise PlanConflictError("Plan does not meet the automatic safety threshold") + approved_at = datetime.now(timezone.utc).isoformat() + updated = uow.connection.execute( + """ + UPDATE plans SET status = 'approved', approved_by = ?, approved_at = ? + WHERE id = ? AND status IN ('draft', 'ready') + """, + (user_id, approved_at, plan_id), + ).rowcount + if updated != 1: + raise PlanConflictError("Plan cannot be approved in its current state") + job = repository.find_by_idempotency_key(idempotency_key) + if job is None: + job = repository.enqueue( + "execute_plan", + {"plan_id": plan_id}, + idempotency_key, + 0, + approved_at, + ) + approved = PlanRepository(uow.connection).get(plan_id) + uow.commit() + return approved, job + + def auto_apply_safe(self, plan_id, current_rule_version=None): + plan, profile, open_reviews = self._approval_context(plan_id) + if ( + profile is None + or str(profile["execution_policy"]) != "auto_apply_safe" + or plan.status != "ready" + or open_reviews + or not plan.items + or not any(item.action not in {"skip", "conflict"} for item in plan.items) + or any(item.execution_status == "conflict" for item in plan.items) + or any(item.risk_level not in AUTO_APPLY_SAFE_RISK_LEVELS for item in plan.items) + ): + return None + try: + self._validate_approval( + plan_id, + current_rule_version=current_rule_version, + automatic=True, + ) + except (PlanConflictError, StalePlanError): + return None + try: + return self.approve_and_enqueue( + plan_id, + user_id=None, + current_rule_version=current_rule_version, + automatic=True, + ) + except (PlanConflictError, StalePlanError): + return None diff --git a/autoanime_v3/services/profiles.py b/autoanime_v3/services/profiles.py new file mode 100644 index 0000000..335b141 --- /dev/null +++ b/autoanime_v3/services/profiles.py @@ -0,0 +1,88 @@ +"""Scan-profile creation and optimistic updates.""" + +from pathlib import Path + +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.db.repositories.profiles import ProfileRepository +from autoanime_v3.db.repositories.roots import RootRepository +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.errors import NotFoundError, RevisionConflictError, ValidationError +from autoanime_v3.domain.enums import ExecutionPolicy, OperationMode, RootKind + + +class ProfileService: + def __init__(self, database_path): + self.database_path = Path(database_path) + run_migrations(self.database_path) + + def create_profile(self, command): + if command.mode not in {item.value for item in OperationMode}: + raise ValidationError("Unsupported operation mode", {"mode": command.mode}) + if command.execution_policy not in {item.value for item in ExecutionPolicy}: + raise ValidationError( + "Unsupported execution policy", {"policy": command.execution_policy} + ) + if not 0 <= int(command.min_confidence) <= 100: + raise ValidationError("Minimum confidence must be between 0 and 100") + if int(command.stability_seconds) < 0: + raise ValidationError("Stability seconds cannot be negative") + with SqliteUnitOfWork(self.database_path) as uow: + roots = RootRepository(uow.connection) + source = roots.get(command.source_root_id) + library = roots.get(command.library_root_id) + if source is None or source.kind != RootKind.SOURCE.value: + raise ValidationError("Profile source must reference a source root") + if library is None or library.kind != RootKind.LIBRARY.value: + raise ValidationError("Profile library must reference a library root") + profile = ProfileRepository(uow.connection).create(command) + uow.commit() + return profile + + def update_profile(self, profile_id, revision, patch): + allowed = { + "name", + "mode", + "execution_policy", + "min_confidence", + "stability_seconds", + "watch_enabled", + "enabled", + } + unsupported = set(patch) - allowed + if unsupported: + raise ValidationError("Unsupported profile fields", {"fields": sorted(unsupported)}) + if not patch: + raise ValidationError("Profile update is empty") + if "name" in patch and not str(patch["name"]).strip(): + raise ValidationError("Profile name cannot be empty") + if "mode" in patch and patch["mode"] not in {item.value for item in OperationMode}: + raise ValidationError("Unsupported operation mode", {"mode": patch["mode"]}) + if "execution_policy" in patch and patch["execution_policy"] not in { + item.value for item in ExecutionPolicy + }: + raise ValidationError( + "Unsupported execution policy", {"policy": patch["execution_policy"]} + ) + try: + if "min_confidence" in patch and not 0 <= int(patch["min_confidence"]) <= 100: + raise ValidationError("Minimum confidence must be between 0 and 100") + if "stability_seconds" in patch and int(patch["stability_seconds"]) < 0: + raise ValidationError("Stability seconds cannot be negative") + except (TypeError, ValueError): + raise ValidationError("Profile numeric fields must contain integers") + for field in {"watch_enabled", "enabled"} & set(patch): + if type(patch[field]) is not bool: + raise ValidationError("Profile boolean fields must be true or false", {"field": field}) + with SqliteUnitOfWork(self.database_path) as uow: + repository = ProfileRepository(uow.connection) + existing = repository.get(profile_id) + if existing is None: + raise NotFoundError("Scan profile does not exist", {"id": profile_id}) + profile, updated = repository.update(profile_id, revision, patch) + if not updated: + raise RevisionConflictError( + "Scan profile was changed by another request", + {"expected_revision": revision, "actual_revision": existing.revision}, + ) + uow.commit() + return profile diff --git a/autoanime_v3/services/reviews.py b/autoanime_v3/services/reviews.py new file mode 100644 index 0000000..26791b5 --- /dev/null +++ b/autoanime_v3/services/reviews.py @@ -0,0 +1,360 @@ +"""Review resolution that produces a new immutable plan revision.""" + +import json +import math +import re +from pathlib import Path + +from autoanime_v3.db.engine import connect_sqlite +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.db.repositories.plans import PlanRepository +from autoanime_v3.db.repositories.reviews import review_from_row +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.errors import ( + InvalidStateError, + NotFoundError, + PlanConflictError, + ValidationError, +) +from autoanime_v3.models import MediaFile as CoreMediaFile, Resolution +from autoanime_v3.planner import build_plan +from autoanime_v3.services.roots import normalize_windows_path + + +MEDIA_TYPES = {"episode", "movie", "special"} +RESOLUTION_FIELDS = { + "title", + "media_type", + "season", + "episode", + "is_movie", + "release_tag", + "manual_lock", +} +EPISODE_TOKEN = re.compile(r"^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$") +INTEGER_TOKEN = re.compile(r"^\d+$") +DECIMAL_TOKEN = re.compile(r"^\d+\.\d+$") + + +def _invalid(field, message): + raise ValidationError(message, {"field": field}) + + +def _normalize_season(value): + if isinstance(value, bool): + _invalid("season", "Season must be a non-negative integer") + if isinstance(value, str): + value = value.strip() + if not INTEGER_TOKEN.fullmatch(value): + _invalid("season", "Season must be a non-negative integer") + value = int(value) + if not isinstance(value, int) or value < 0: + _invalid("season", "Season must be a non-negative integer") + return value + + +def _normalize_episode(value): + if isinstance(value, bool) or value is None: + _invalid("episode", "Episode must be a non-negative number or safe episode label") + if isinstance(value, int): + if value < 0: + _invalid("episode", "Episode must be non-negative") + return value + if isinstance(value, float): + if not math.isfinite(value) or value < 0: + _invalid("episode", "Episode must be a finite non-negative number") + return value + if not isinstance(value, str): + _invalid("episode", "Episode must be a number or string label") + token = value.strip() + if not token or len(token) > 32 or not EPISODE_TOKEN.fullmatch(token): + _invalid("episode", "Episode label contains unsupported characters") + if INTEGER_TOKEN.fullmatch(token): + return int(token) + if DECIMAL_TOKEN.fullmatch(token): + return float(token) + return token + + +def normalize_resolution(resolution_data): + if not isinstance(resolution_data, dict): + _invalid("resolution", "Resolution must be an object") + unknown_fields = sorted(set(resolution_data) - RESOLUTION_FIELDS) + if unknown_fields: + _invalid(unknown_fields[0], "Unsupported resolution field") + + title = resolution_data.get("title") + if not isinstance(title, str) or not title.strip(): + _invalid("title", "Title is required") + title = title.strip() + if len(title) > 200: + _invalid("title", "Title is too long") + + has_explicit_type = "media_type" in resolution_data + explicit_type = resolution_data.get("media_type") + legacy_movie = resolution_data.get("is_movie") + if legacy_movie is not None and not isinstance(legacy_movie, bool): + _invalid("is_movie", "is_movie must be a boolean") + if has_explicit_type and not isinstance(explicit_type, str): + _invalid("media_type", "Media type must be a string") + media_type = explicit_type if has_explicit_type else ("movie" if legacy_movie else "episode") + if media_type not in MEDIA_TYPES: + _invalid("media_type", "Media type must be episode, movie, or special") + if has_explicit_type and legacy_movie is not None and legacy_movie != (media_type == "movie"): + _invalid("is_movie", "is_movie conflicts with media_type") + + release_tag = resolution_data.get("release_tag", "") + if not isinstance(release_tag, str): + _invalid("release_tag", "Release tag must be a string") + release_tag = release_tag.strip() + if len(release_tag) > 100: + _invalid("release_tag", "Release tag is too long") + + manual_lock = resolution_data.get("manual_lock", True) + if not isinstance(manual_lock, bool): + _invalid("manual_lock", "Manual lock must be a boolean") + + normalized = { + "title": title, + "media_type": media_type, + "is_movie": media_type == "movie", + "release_tag": release_tag, + "manual_lock": manual_lock, + } + if media_type == "movie": + for field in ("season", "episode"): + if field in resolution_data and resolution_data[field] not in (None, ""): + _invalid(field, "Movie resolutions must not include season or episode") + return normalized + + if media_type == "episode" and resolution_data.get("season") in (None, ""): + _invalid("season", "Season is required for episodes") + season = _normalize_season(resolution_data.get("season", 0)) + if resolution_data.get("episode") in (None, ""): + _invalid("episode", "Episode is required") + normalized["season"] = season + normalized["episode"] = _normalize_episode(resolution_data["episode"]) + return normalized + + +class ReviewService: + def __init__(self, database_path): + self.database_path = Path(database_path) + run_migrations(self.database_path) + + def _query(self, sql, params=()): + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + return connection.execute(sql, params).fetchall() + finally: + connection.close() + + def list_open(self): + return tuple( + review_from_row(row) + for row in self._query("SELECT * FROM review_items WHERE status = 'open' ORDER BY id") + ) + + def get(self, review_id): + rows = self._query("SELECT * FROM review_items WHERE id = ?", (review_id,)) + if not rows: + raise NotFoundError("Review item does not exist", {"id": review_id}) + return review_from_row(rows[0]) + + def resolve(self, review_id, resolution_data, user_id=None): + normalized = normalize_resolution(resolution_data) + with SqliteUnitOfWork(self.database_path) as uow: + review_row = uow.connection.execute( + "SELECT * FROM review_items WHERE id = ?", (review_id,) + ).fetchone() + if review_row is None: + raise NotFoundError("Review item does not exist", {"id": review_id}) + claimed = uow.connection.execute( + """ + UPDATE review_items SET status = 'resolving', updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND status = 'open' + """, + (review_id,), + ).rowcount + if claimed != 1: + raise InvalidStateError("Review item is not open") + review = review_from_row(review_row) + run = uow.connection.execute( + "SELECT * FROM scan_runs WHERE id = ?", (review.scan_run_id,) + ).fetchone() + profile = uow.connection.execute( + "SELECT * FROM scan_profiles WHERE id = ?", (run["profile_id"],) + ).fetchone() + library_path = Path( + uow.connection.execute( + "SELECT path FROM storage_roots WHERE id = ?", (profile["library_root_id"],) + ).fetchone()[0] + ) + latest = uow.connection.execute( + "SELECT * FROM plans WHERE scan_run_id = ? ORDER BY revision DESC LIMIT 1", + (review.scan_run_id,), + ).fetchone() + new_revision = int(latest["revision"]) + 1 + cursor = uow.connection.execute( + """ + INSERT INTO plans( + scan_run_id, profile_id, profile_revision, rule_version, + library_revision, revision, status, summary_json + ) VALUES (?, ?, ?, ?, ?, ?, 'ready', ?) + """, + ( + latest["scan_run_id"], + latest["profile_id"], + latest["profile_revision"], + latest["rule_version"], + latest["library_revision"], + new_revision, + latest["summary_json"], + ), + ) + new_plan_id = int(cursor.lastrowid) + uow.connection.execute( + """ + INSERT INTO plan_items( + plan_id, source_location_id, destination_root_id, + destination_relative_path, action, reason, risk_level, + source_file_index, source_size, source_mtime_ns, source_sha256, + identification_snapshot_json, execution_status + ) + SELECT ?, source_location_id, destination_root_id, + destination_relative_path, action, reason, risk_level, + source_file_index, source_size, source_mtime_ns, source_sha256, + identification_snapshot_json, execution_status + FROM plan_items + WHERE plan_id = ? + AND source_location_id NOT IN ( + SELECT id FROM file_locations WHERE media_file_id = ? + ) + """, + (new_plan_id, latest["id"], review.media_file_id), + ) + scan_item = uow.connection.execute( + "SELECT * FROM scan_items WHERE scan_run_id = ? AND media_file_id = ?", + (review.scan_run_id, review.media_file_id), + ).fetchone() + snapshot = json.loads(scan_item["snapshot_json"]) + core_media = CoreMediaFile( + path=Path(snapshot["path"]), + input_root=Path(snapshot["path"]).parent, + context_name=snapshot["context_name"], + relative_path=snapshot["relative_path"], + size=int(snapshot["size"]), + mtime_ns=int(snapshot["mtime_ns"]), + ) + accepted = Resolution( + media=core_media, + canonical_title=normalized["title"], + season=normalized.get("season"), + episode=normalized.get("episode"), + is_movie=normalized["is_movie"], + confidence=1.0, + accepted=True, + release_tag=normalized["release_tag"], + fingerprint="manual-review-%s" % review_id, + media_type=normalized["media_type"], + ) + entries = build_plan([accepted], library_path) + existing_destinations = { + ( + int(row["destination_root_id"]), + str(row["destination_relative_path"]).casefold(), + ) + for row in uow.connection.execute( + """ + SELECT destination_root_id, destination_relative_path + FROM plan_items WHERE plan_id = ? + """, + (new_plan_id,), + ).fetchall() + } + prepared_entries = [] + for entry in entries: + if entry.destination is None: + raise InvalidStateError( + "Resolved review produced an incomplete plan entry", + {"path": str(entry.source)}, + ) + relative_destination = str(entry.destination.relative_to(library_path)) + destination_key = ( + int(profile["library_root_id"]), + relative_destination.casefold(), + ) + if destination_key in existing_destinations: + raise PlanConflictError( + "Resolved review destination conflicts with an existing plan item", + {"field": "destination", "path": relative_destination}, + ) + existing_destinations.add(destination_key) + source_fact = uow.connection.execute( + """ + SELECT fl.id AS source_location_id, mf.file_index, mf.size, + mf.mtime_ns, mf.sha256 + FROM file_locations fl + JOIN media_files mf ON mf.id = fl.media_file_id + WHERE fl.normalized_path = ? + AND fl.role = 'source' AND fl.state = 'present' + ORDER BY fl.id DESC LIMIT 1 + """, + (normalize_windows_path(entry.source),), + ).fetchone() + if source_fact is None: + raise NotFoundError( + "Plan entry source has not been observed", + {"path": str(entry.source)}, + ) + prepared_entries.append( + (entry, source_fact, relative_destination) + ) + + identification_snapshot = json.dumps(accepted.to_dict(), ensure_ascii=False) + for entry, source_fact, relative_destination in prepared_entries: + action = str(profile["mode"]) if entry.action == "organize" else entry.action + is_conflict = entry.action == "conflict" + uow.connection.execute( + """ + INSERT INTO plan_items( + plan_id, source_location_id, destination_root_id, + destination_relative_path, action, reason, risk_level, + source_file_index, source_size, source_mtime_ns, source_sha256, + identification_snapshot_json, execution_status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + new_plan_id, + source_fact["source_location_id"], + profile["library_root_id"], + relative_destination, + action, + entry.reason or "manual_review", + "high" if is_conflict else "normal", + source_fact["file_index"], + source_fact["size"], + source_fact["mtime_ns"], + source_fact["sha256"], + identification_snapshot, + "conflict" if is_conflict else "pending", + ), + ) + resolved = uow.connection.execute( + """ + UPDATE review_items + SET status = 'resolved', resolution_json = ?, resolved_by = ?, + resolved_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND status = 'resolving' + """, + (json.dumps(normalized, ensure_ascii=False), user_id, review_id), + ).rowcount + if resolved != 1: + raise InvalidStateError("Review item is not being resolved") + uow.commit() + from autoanime_v3.services.plans import PlanService + + plans = PlanService(self.database_path) + automatic = plans.auto_apply_safe(new_plan_id) + return automatic[0] if automatic is not None else plans.get(new_plan_id) diff --git a/autoanime_v3/services/roots.py b/autoanime_v3/services/roots.py new file mode 100644 index 0000000..b88dc15 --- /dev/null +++ b/autoanime_v3/services/roots.py @@ -0,0 +1,147 @@ +"""Storage-root validation and safe target resolution.""" + +import os +from datetime import datetime, timezone +from pathlib import Path + +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.db.repositories.roots import RootRepository +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.entities import RootHealth +from autoanime_v3.domain.errors import ( + DuplicateRootError, + NotFoundError, + PathOutsideRootError, + UnsafeRootError, + ValidationError, +) +from autoanime_v3.domain.enums import RootKind + + +def normalize_windows_path(path): + resolved = Path(path).expanduser().resolve(strict=False) + return os.path.normpath(str(resolved)).casefold() + + +def path_is_within(path, parent): + normalized_path = normalize_windows_path(path) + normalized_parent = normalize_windows_path(parent) + try: + return os.path.commonpath([normalized_path, normalized_parent]) == normalized_parent + except ValueError: + return False + + +class RootService: + def __init__(self, database_path): + self.database_path = Path(database_path) + run_migrations(self.database_path) + + def create_root(self, kind, path): + valid_kinds = {item.value for item in RootKind} + if kind not in valid_kinds: + raise ValidationError("Unsupported root kind", {"kind": kind}) + display_path = str(Path(path).expanduser().resolve(strict=False)) + normalized = normalize_windows_path(path) + with SqliteUnitOfWork(self.database_path) as uow: + repository = RootRepository(uow.connection) + duplicate = repository.find_by_normalized_path(normalized) + if duplicate is not None and {duplicate.kind, kind} == { + RootKind.SOURCE.value, + RootKind.LIBRARY.value, + }: + raise UnsafeRootError( + "Source and library roots cannot use the same path", + {"path": display_path}, + ) + if duplicate is not None: + raise DuplicateRootError("Storage root already exists", {"path": display_path}) + roots = repository.list_enabled() + if kind == RootKind.LIBRARY.value: + for root in roots: + if root.kind == RootKind.SOURCE.value and path_is_within(display_path, root.path): + raise UnsafeRootError( + "Library root cannot equal or be below a source root", + {"source": root.path, "library": display_path}, + ) + if kind == RootKind.SOURCE.value: + for root in roots: + if root.kind == RootKind.LIBRARY.value and path_is_within(root.path, display_path): + raise UnsafeRootError( + "Existing library root cannot be below the new source root", + {"source": display_path, "library": root.path}, + ) + created = repository.create(kind, display_path, normalized) + uow.commit() + return created + + def get_root(self, root_id): + with SqliteUnitOfWork(self.database_path) as uow: + root = RootRepository(uow.connection).get(root_id) + if root is None: + raise NotFoundError("Storage root does not exist", {"id": root_id}) + return root + + def update_root(self, root_id, patch): + unsupported = set(patch) - {"enabled"} + if unsupported: + raise ValidationError( + "Only the enabled state can be changed for an existing root; add a new root to change paths", + {"unsupported": sorted(unsupported)}, + ) + if "enabled" not in patch: + raise ValidationError("Root update is empty") + if type(patch["enabled"]) is not bool: + raise ValidationError("Root enabled state must be true or false") + with SqliteUnitOfWork(self.database_path) as uow: + repository = RootRepository(uow.connection) + current = repository.get(root_id) + if current is None: + raise NotFoundError("Storage root does not exist", {"id": root_id}) + if patch["enabled"] and not current.enabled: + for other in repository.list_enabled(): + if current.kind == RootKind.SOURCE.value and other.kind == RootKind.LIBRARY.value: + if path_is_within(other.path, current.path): + raise UnsafeRootError( + "Library root cannot equal or be below a source root", + {"source": current.path, "library": other.path}, + ) + if current.kind == RootKind.LIBRARY.value and other.kind == RootKind.SOURCE.value: + if path_is_within(current.path, other.path): + raise UnsafeRootError( + "Library root cannot equal or be below a source root", + {"source": other.path, "library": current.path}, + ) + uow.connection.execute( + "UPDATE storage_roots SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (int(bool(patch["enabled"])), root_id), + ) + result = repository.get(root_id) + uow.commit() + return result + + def resolve_target(self, root_id, relative_path): + root = self.get_root(root_id) + relative = Path(relative_path) + if relative.is_absolute(): + raise PathOutsideRootError("Operation target must be relative to its root") + candidate = (Path(root.path) / relative).resolve(strict=False) + if not path_is_within(candidate, root.path): + raise PathOutsideRootError( + "Operation target escapes its registered root", + {"root": root.path, "target": str(candidate)}, + ) + return candidate + + def validate_root(self, root_id): + root = self.get_root(root_id) + path = Path(root.path) + exists = path.is_dir() + readable = exists and os.access(str(path), os.R_OK) + writable = exists and os.access(str(path), os.W_OK) + status = "healthy" if readable and writable else "unavailable" + checked_at = datetime.now(timezone.utc).isoformat() + with SqliteUnitOfWork(self.database_path) as uow: + RootRepository(uow.connection).update_health(root_id, status, checked_at) + uow.commit() + return RootHealth(root_id, exists, readable, writable, status) diff --git a/autoanime_v3/services/rules.py b/autoanime_v3/services/rules.py new file mode 100644 index 0000000..563c6af --- /dev/null +++ b/autoanime_v3/services/rules.py @@ -0,0 +1,205 @@ +"""Versioned JSON rule documents.""" + +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path + +from autoanime_v3.db.engine import connect_sqlite +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.entities import RuleRevisionView, RuleSetView +from autoanime_v3.domain.errors import InvalidStateError, NotFoundError, ValidationError + + +def canonical_document(document): + return json.dumps(document, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def revision_view(row): + return RuleRevisionView( + int(row["id"]), + int(row["rule_set_id"]), + int(row["revision"]), + json.loads(row["document_json"]), + row["content_hash"], + str(row["status"]), + ) + + +RULE_DOCUMENT_SECTIONS = ( + "aliases", + "season_layouts", + "episode_defaults", + "season_defaults", +) + + +@dataclass(frozen=True) +class ActiveRuleDocument: + revision_ids: tuple + document: dict + content_hash: str + + +def active_rule_document(connection): + rows = connection.execute( + """ + SELECT rr.id, rr.document_json + FROM rule_sets rs + JOIN rule_revisions rr ON rr.id = rs.active_revision_id + ORDER BY rs.id ASC + """ + ).fetchall() + merged = {section: {} for section in RULE_DOCUMENT_SECTIONS} + revision_ids = [] + for row in rows: + revision_ids.append(int(row["id"])) + document = json.loads(row["document_json"]) + for section in RULE_DOCUMENT_SECTIONS: + values = document.get(section, {}) if isinstance(document, dict) else {} + if isinstance(values, dict): + merged[section].update(values) + content_hash = hashlib.sha256(canonical_document(merged).encode("utf-8")).hexdigest() + return ActiveRuleDocument(tuple(revision_ids), merged, content_hash) + + +class RuleService: + def __init__(self, database_path): + self.database_path = Path(database_path) + run_migrations(self.database_path) + + def create_set(self, name): + with SqliteUnitOfWork(self.database_path) as uow: + cursor = uow.connection.execute("INSERT INTO rule_sets(name) VALUES (?)", (name,)) + rule_set = RuleSetView(int(cursor.lastrowid), name, None) + uow.commit() + return rule_set + + def get_set(self, rule_set_id): + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + row = connection.execute("SELECT * FROM rule_sets WHERE id = ?", (rule_set_id,)).fetchone() + if row is None: + raise NotFoundError("Rule set does not exist") + return RuleSetView(int(row["id"]), str(row["name"]), row["active_revision_id"]) + finally: + connection.close() + + def get_active(self, connection=None): + owns_connection = connection is None + if owns_connection: + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + return active_rule_document(connection) + finally: + if owns_connection: + connection.close() + + def _mark_changed_plans_stale(self, connection): + current_version = active_rule_document(connection).content_hash + connection.execute( + """ + UPDATE plans SET status = 'stale' + WHERE status IN ('draft', 'ready', 'approved', 'executing') AND rule_version != ? + """, + (current_version,), + ) + + def create_revision(self, rule_set_id, document): + with SqliteUnitOfWork(self.database_path) as uow: + if uow.connection.execute("SELECT 1 FROM rule_sets WHERE id = ?", (rule_set_id,)).fetchone() is None: + raise NotFoundError("Rule set does not exist") + number = int( + uow.connection.execute( + "SELECT COALESCE(MAX(revision), 0) + 1 FROM rule_revisions WHERE rule_set_id = ?", + (rule_set_id,), + ).fetchone()[0] + ) + cursor = uow.connection.execute( + """ + INSERT INTO rule_revisions(rule_set_id, revision, document_json, status) + VALUES (?, ?, ?, 'draft') + """, + (rule_set_id, number, canonical_document(document)), + ) + row = uow.connection.execute( + "SELECT * FROM rule_revisions WHERE id = ?", (cursor.lastrowid,) + ).fetchone() + uow.commit() + return revision_view(row) + + def _get_revision_row(self, connection, revision_id): + row = connection.execute("SELECT * FROM rule_revisions WHERE id = ?", (revision_id,)).fetchone() + if row is None: + raise NotFoundError("Rule revision does not exist") + return row + + def validate(self, revision_id): + with SqliteUnitOfWork(self.database_path) as uow: + row = self._get_revision_row(uow.connection, revision_id) + document = json.loads(row["document_json"]) + errors = [] + if not isinstance(document, dict): + errors.append("document must be an object") + if "aliases" in document and not isinstance(document["aliases"], dict): + errors.append("aliases must be an object") + if errors: + uow.connection.execute( + "UPDATE rule_revisions SET validation_errors_json = ? WHERE id = ?", + (json.dumps(errors), revision_id), + ) + uow.commit() + raise ValidationError("Rule document is invalid", {"errors": errors}) + digest = hashlib.sha256(canonical_document(document).encode("utf-8")).hexdigest() + uow.connection.execute( + """ + UPDATE rule_revisions + SET status = 'validated', content_hash = ?, validation_errors_json = NULL + WHERE id = ? + """, + (digest, revision_id), + ) + result = revision_view(self._get_revision_row(uow.connection, revision_id)) + uow.commit() + return result + + def activate(self, revision_id): + with SqliteUnitOfWork(self.database_path) as uow: + row = self._get_revision_row(uow.connection, revision_id) + if row["status"] not in {"validated", "active"}: + raise InvalidStateError("Rule revision must be validated before activation") + uow.connection.execute( + "UPDATE rule_revisions SET status = 'retired' WHERE rule_set_id = ? AND status = 'active'", + (row["rule_set_id"],), + ) + uow.connection.execute("UPDATE rule_revisions SET status = 'active' WHERE id = ?", (revision_id,)) + uow.connection.execute( + "UPDATE rule_sets SET active_revision_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (revision_id, row["rule_set_id"]), + ) + self._mark_changed_plans_stale(uow.connection) + result = revision_view(self._get_revision_row(uow.connection, revision_id)) + uow.commit() + return result + + def rollback(self, rule_set_id, revision_id): + with SqliteUnitOfWork(self.database_path) as uow: + row = self._get_revision_row(uow.connection, revision_id) + if int(row["rule_set_id"]) != int(rule_set_id) or not row["content_hash"]: + raise InvalidStateError("Revision cannot be activated for this rule set") + uow.connection.execute( + "UPDATE rule_revisions SET status = 'retired' WHERE rule_set_id = ? AND status = 'active'", + (rule_set_id,), + ) + uow.connection.execute("UPDATE rule_revisions SET status = 'active' WHERE id = ?", (revision_id,)) + uow.connection.execute( + "UPDATE rule_sets SET active_revision_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (revision_id, rule_set_id), + ) + self._mark_changed_plans_stale(uow.connection) + result = revision_view(self._get_revision_row(uow.connection, revision_id)) + uow.commit() + return result diff --git a/autoanime_v3/services/scans.py b/autoanime_v3/services/scans.py new file mode 100644 index 0000000..173d75c --- /dev/null +++ b/autoanime_v3/services/scans.py @@ -0,0 +1,271 @@ +"""Orchestrate the existing scanner/resolver/planner without file writes.""" + +import hashlib +import json +from datetime import datetime, timezone +from pathlib import Path + +from autoanime_v3.cache import ResolutionCache +from autoanime_v3.catalog import TitleCatalog +from autoanime_v3.config import AppConfig +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.db.repositories.library import LibraryRepository +from autoanime_v3.db.repositories.scans import ScanRepository +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.entities import ScanOutcome +from autoanime_v3.domain.errors import NotFoundError, PathOutsideRootError +from autoanime_v3.planner import build_plan +from autoanime_v3.resolver import Resolver +from autoanime_v3.scanner import scan_media +from autoanime_v3.services.roots import normalize_windows_path, path_is_within +from autoanime_v3.services.rules import RuleService + + +def now_iso(): + return datetime.now(timezone.utc).isoformat() + + +class CoreScanAdapter: + def __init__(self, web_database, alias_file=None): + project_root = Path(__file__).resolve().parents[2] + self.database_path = Path(web_database) + self.alias_file = alias_file or project_root / "autoanime_v3" / "data" / "aliases.json" + self.cache_path = Path(web_database).with_name(Path(web_database).stem + "-resolver.sqlite3") + + def analyze(self, source, library, min_confidence): + return self.analyze_scoped(source, library, min_confidence, None) + + def analyze_scoped(self, source, library, min_confidence, scope_paths): + active_rules = RuleService(self.database_path).get_active() + catalog = TitleCatalog.load( + self.alias_file, + overlay=active_rules.document, + ) + config = AppConfig( + database_path=self.cache_path, + alias_file=self.alias_file, + min_confidence=min_confidence, + output_root=library, + openai_enabled=False, + ) + with ResolutionCache(self.cache_path) as cache: + resolver = Resolver(catalog, config, cache) + resolutions = [ + resolver.resolve(media) + for media in scan_media(source, library, scope_paths=scope_paths) + ] + return active_rules.content_hash, resolutions, build_plan(resolutions, library) + + +class ScanService: + def __init__(self, database_path, adapter=None): + self.database_path = Path(database_path) + run_migrations(self.database_path) + self.adapter = adapter or CoreScanAdapter(self.database_path) + + def run(self, profile_id, scope_paths=None): + with SqliteUnitOfWork(self.database_path) as uow: + profile = uow.connection.execute( + "SELECT * FROM scan_profiles WHERE id = ?", (profile_id,) + ).fetchone() + if profile is None: + raise NotFoundError("Scan profile does not exist", {"id": profile_id}) + source = Path( + uow.connection.execute( + "SELECT path FROM storage_roots WHERE id = ?", (profile["source_root_id"],) + ).fetchone()[0] + ) + library = Path( + uow.connection.execute( + "SELECT path FROM storage_roots WHERE id = ?", (profile["library_root_id"],) + ).fetchone()[0] + ) + normalized_scope = [] + for value in scope_paths or []: + target = Path(value).expanduser().resolve(strict=False) + if not path_is_within(target, source): + raise PathOutsideRootError( + "Scan scope is outside the configured source root", + {"root": str(source), "target": str(target)}, + ) + normalized_scope.append(target) + if normalized_scope and hasattr(self.adapter, "analyze_scoped"): + rule_version, resolutions, entries = self.adapter.analyze_scoped( + source, + library, + int(profile["min_confidence"]) / 100.0, + normalized_scope, + ) + else: + rule_version, resolutions, entries = self.adapter.analyze( + source, library, int(profile["min_confidence"]) / 100.0 + ) + facts = LibraryRepository(self.database_path) + media_by_path = {} + for resolution in resolutions: + media_by_path[normalize_windows_path(resolution.media.path)] = facts.observe_path( + int(profile["source_root_id"]), resolution.media.path, "source", "video" + ) + + started_at = now_iso() + review_count = 0 + plan_item_count = 0 + with SqliteUnitOfWork(self.database_path) as uow: + current_rule_version = RuleService(self.database_path).get_active( + uow.connection + ).content_hash + scans = ScanRepository(uow.connection) + run_id = scans.create_run( + profile_id, + int(profile["revision"]), + rule_version, + {"paths": [str(path) for path in normalized_scope]}, + started_at, + ) + result_ids = {} + for resolution in resolutions: + normalized = normalize_windows_path(resolution.media.path) + media = media_by_path[normalized] + snapshot = { + "path": str(resolution.media.path), + "relative_path": resolution.media.relative_path, + "context_name": resolution.media.context_name, + "size": resolution.media.size, + "mtime_ns": resolution.media.mtime_ns, + } + scans.add_item( + run_id, + media.id, + str(resolution.media.path), + normalized, + snapshot, + "identified" if resolution.accepted else "review", + ) + cursor = uow.connection.execute( + """ + INSERT INTO identification_results( + media_file_id, decision_fingerprint, parser_version, rule_version, + title, season_number, episode_number, media_type, confidence, accepted + ) VALUES (?, ?, 'v3', ?, ?, ?, ?, ?, ?, ?) + """, + ( + media.id, + resolution.fingerprint, + rule_version, + resolution.canonical_title, + resolution.season, + str(resolution.episode) if resolution.episode is not None else None, + "movie" if resolution.is_movie else "episode", + int(round(resolution.confidence * 100)), + int(resolution.accepted), + ), + ) + result_ids[normalized] = int(cursor.lastrowid) + for evidence in resolution.evidence: + uow.connection.execute( + """ + INSERT INTO identification_evidence( + result_id, agent, field, value_json, confidence, detail_json + ) VALUES (?, ?, 'identity', ?, ?, ?) + """, + ( + cursor.lastrowid, + evidence.agent, + json.dumps(evidence.value, ensure_ascii=False), + int(round(evidence.confidence * 100)), + json.dumps({"detail": evidence.detail}, ensure_ascii=False), + ), + ) + if not resolution.accepted: + review_count += 1 + dedup = hashlib.sha256( + ("identity:%s" % media.id).encode("utf-8") + ).hexdigest() + uow.connection.execute( + """ + INSERT INTO review_items( + scan_run_id, media_file_id, review_type, status, + dedup_key, payload_json + ) VALUES (?, ?, 'low_confidence', 'open', ?, ?) + """, + ( + run_id, + media.id, + dedup, + json.dumps(resolution.to_dict(), ensure_ascii=False), + ), + ) + + status = "draft" if review_count else "ready" + if current_rule_version != rule_version: + status = "stale" + cursor = uow.connection.execute( + """ + INSERT INTO plans( + scan_run_id, profile_id, profile_revision, rule_version, + library_revision, revision, status, summary_json + ) VALUES (?, ?, ?, ?, 0, 1, ?, '{}') + """, + (run_id, profile_id, int(profile["revision"]), rule_version, status), + ) + plan_id = int(cursor.lastrowid) + for entry in entries: + if entry.destination is None: + continue + normalized = normalize_windows_path(entry.resolution.media.path) + media = media_by_path[normalized] + source_location = next( + item for item in media.locations if item.role == "source" and item.state == "present" + ) + try: + relative_destination = str(entry.destination.relative_to(library)) + except ValueError: + continue + action = str(profile["mode"]) if entry.action == "organize" else entry.action + execution_status = "conflict" if entry.action == "conflict" else "pending" + uow.connection.execute( + """ + INSERT INTO plan_items( + plan_id, source_location_id, destination_root_id, + destination_relative_path, action, reason, risk_level, + source_file_index, source_size, source_mtime_ns, + identification_snapshot_json, execution_status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + plan_id, + source_location.id, + int(profile["library_root_id"]), + relative_destination, + action, + entry.reason, + "high" if entry.action == "conflict" else "normal", + media.file_index, + media.size, + media.mtime_ns, + json.dumps(entry.resolution.to_dict(), ensure_ascii=False), + execution_status, + ), + ) + plan_item_count += 1 + if entry.action == "conflict": + review_count += 1 + statistics = { + "discovered": len(resolutions), + "reviews": review_count, + "plan_items": plan_item_count, + } + scans.finish(run_id, statistics, now_iso()) + uow.connection.execute( + "UPDATE plans SET summary_json = ? WHERE id = ?", + (json.dumps(statistics), plan_id), + ) + uow.commit() + from autoanime_v3.services.plans import PlanService + + plans = PlanService(self.database_path) + plans.auto_apply_safe(plan_id) + final_status = plans.get(plan_id).status + return ScanOutcome( + run_id, plan_id, len(resolutions), review_count, plan_item_count, final_status + ) diff --git a/autoanime_v3/services/settings.py b/autoanime_v3/services/settings.py new file mode 100644 index 0000000..7753535 --- /dev/null +++ b/autoanime_v3/services/settings.py @@ -0,0 +1,114 @@ +"""Revisioned non-secret application settings.""" + +import json +from pathlib import Path + +from autoanime_v3.db.engine import connect_sqlite +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.db.uow import SqliteUnitOfWork +from autoanime_v3.domain.errors import RevisionConflictError, ValidationError +from autoanime_v3.services.auth import AUTH_LOCAL_BYPASS_KEY, LOCAL_HOOK_TRUST_KEY + + +DEFAULT_SETTINGS = { + AUTH_LOCAL_BYPASS_KEY: True, + LOCAL_HOOK_TRUST_KEY: True, +} + +BOOLEAN_SETTINGS = {AUTH_LOCAL_BYPASS_KEY, LOCAL_HOOK_TRUST_KEY} + + +def setting_view(row): + return { + "key": str(row["key"]), + "value": json.loads(row["value_json"]), + "revision": int(row["revision"]), + "updated_at": str(row["updated_at"]), + } + + +class SettingsService: + def __init__(self, database_path): + self.database_path = Path(database_path) + run_migrations(self.database_path) + self.ensure_defaults() + + def ensure_defaults(self): + with SqliteUnitOfWork(self.database_path) as uow: + changed = False + for key, value in DEFAULT_SETTINGS.items(): + row = uow.connection.execute( + "SELECT 1 FROM app_settings WHERE key = ?", (key,) + ).fetchone() + if row is None: + encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + uow.connection.execute( + "INSERT INTO app_settings(key, value_json, revision) VALUES (?, ?, 1)", + (key, encoded), + ) + changed = True + if changed: + uow.commit() + + def list(self): + self.ensure_defaults() + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + return [setting_view(row) for row in connection.execute("SELECT * FROM app_settings ORDER BY key")] + finally: + connection.close() + + def get(self, key, default=None): + connection = connect_sqlite(self.database_path) + connection.row_factory = __import__("sqlite3").Row + try: + row = connection.execute("SELECT * FROM app_settings WHERE key = ?", (key,)).fetchone() + finally: + connection.close() + if row is None: + return DEFAULT_SETTINGS.get(key, default) + return json.loads(row["value_json"]) + + def update(self, key, value, revision): + normalized_key = str(key).strip() + if not normalized_key: + raise ValidationError("Setting key cannot be empty") + if normalized_key in BOOLEAN_SETTINGS and type(value) is not bool: + raise ValidationError("Setting value must be a boolean", {"key": normalized_key}) + encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + with SqliteUnitOfWork(self.database_path) as uow: + row = uow.connection.execute( + "SELECT * FROM app_settings WHERE key = ?", (normalized_key,) + ).fetchone() + if row is None: + if int(revision) != 0: + raise RevisionConflictError( + "Setting does not exist at the requested revision", + {"expected_revision": int(revision), "actual_revision": 0}, + ) + uow.connection.execute( + "INSERT INTO app_settings(key, value_json, revision) VALUES (?, ?, 1)", + (normalized_key, encoded), + ) + else: + updated = uow.connection.execute( + """ + UPDATE app_settings + SET value_json = ?, revision = revision + 1, updated_at = CURRENT_TIMESTAMP + WHERE key = ? AND revision = ? + """, + (encoded, normalized_key, int(revision)), + ).rowcount + if updated != 1: + raise RevisionConflictError( + "Setting was changed by another request", + {"expected_revision": int(revision), "actual_revision": int(row["revision"])}, + ) + result = setting_view( + uow.connection.execute( + "SELECT * FROM app_settings WHERE key = ?", (normalized_key,) + ).fetchone() + ) + uow.commit() + return result diff --git a/autoanime_v3/services/webhooks.py b/autoanime_v3/services/webhooks.py new file mode 100644 index 0000000..b73170f --- /dev/null +++ b/autoanime_v3/services/webhooks.py @@ -0,0 +1,22 @@ +"""Backward-compatible profile webhook facade.""" + +from pathlib import Path + +from autoanime_v3.db.migrations import run_migrations +from autoanime_v3.jobs.queue import JobQueue +from autoanime_v3.services.automation import WebhookSourceService + + +class WebhookService: + def __init__(self, database_path, queue=None): + self.database_path = Path(database_path) + run_migrations(self.database_path) + self.queue = queue or JobQueue(self.database_path) + + def submit(self, profile_id, path): + service = WebhookSourceService(self.database_path) + created = service.create("legacy", "generic", profile_id) + try: + return service.submit_token(created.token, [Path(path)]) + finally: + service.delete(created.id, created.revision) diff --git a/config.ini.Template b/config.ini.Template deleted file mode 100644 index 25e210e..0000000 --- a/config.ini.Template +++ /dev/null @@ -1,103 +0,0 @@ -[Settings] - -# ===== AI 与外部 API ===== - -# 是否启用 OpenAI 兼容接口进行名称识别。 -USEOPENAIAPI = True -# True 时优先走 AI;False 时先走传统回退 API。 -OPENAI_PRIORITY_FIRST = True -# True 时让 AI 直接识别剧名/季/集;False 时只用 AI 标准化标题。 -OPENAI_IDENTIFY_ALL = True -# OpenAI 兼容网关基础地址;程序会自动拼接 /v1/chat/completions。 -OPENAI_BASE_URL = https://api.longcat.chat/openai -# 调用的模型名称,需与所选网关支持的模型一致。 -OPENAI_MODEL = LongCat-Flash-Chat -# OpenAI 请求超时秒数。 -OPENAI_TIMEOUT_SECONDS = 60 - -# 推荐只在模板中保留环境变量名,不要写真实 key。 -OPENAI_API_KEY_ENV = OPENAI_API_KEY -# 本地直填 OpenAI key;仅限本机临时调试,公开仓库必须留空。 -OPENAI_API_KEY = -# 推荐只在模板中保留环境变量名,不要写真实 token。 -TMDB_BEARER_TOKEN_ENV = TMDB_BEARER_TOKEN -# 本地直填 TMDB token;仅限本机临时调试,公开仓库必须留空。 -TMDB_BEARER_TOKEN = - -# 是否启用 BGM 回退搜索。 -USEBGMAPI = True -# 是否启用 TMDB 回退搜索。 -USETMDBAPI = True -# 是否启用 Bangumi 中文优化回退搜索。 -USEBANGUMIAPI = True - -# ===== 缓存与网络 ===== - -# API 持久化缓存目录;建议保持在忽略列表中。 -CACHE_DIR = .cache -# 缓存有效期,单位秒;86400 约等于 1 天。 -CACHE_TTL_SECONDS = 86400 -# 网络错误后的重试次数;0 表示失败后不重试。 -NETERRRECTRYTIMS = 2 - -# 是否手动启用代理配置。 -USEPROXY = False -# True 时优先读取系统代理设置。 -USESYSPROXY = False -# HTTP 代理地址,例如 http://host:port 。 -HTTPPROXY = -# HTTPS 代理地址,例如 http://host:port 。 -HTTPSPROXY = -# 全协议代理地址,例如 socks5://host:port 。 -ALLPROXY = - -# ===== 文件整理策略 ===== - -# True 时优先使用硬链接;适合保种场景。 -USELINK = True -# True 时硬链接失败不自动降级为移动。 -STRICT_MODE = True -# True 时硬链接失败后自动改为 move;做种场景通常建议 False。 -LINKFAILSUSEMOVEFLAGS = False -# True 时目标文件已存在也允许覆盖。 -MANDATORYCOVER = True -# True 时 episode 文件名带上番剧标题,例如 S01E01.番名。 -USETITLTOEP = True -# True 时字幕后缀使用 Jellyfin 友好的语言标识,如 .简体中文.chi。 -JELLYFINFORMAT = False -# 命名风格:default 或 emby。 -NAMING_STYLE = default -# 单个路径组件的最大长度;过长标题会被截断。 -MAX_FILENAME_LENGTH = 180 -# 可选输出目录;留空表示整理到当前扫描目录;相对路径会基于扫描目录解析。 -OUTPUT_PATH = - -# ===== 运行与日志 ===== - -# True 时只预览不真正 move/link,但会记录操作日志。 -DRY_RUN = False -# 是否写入操作日志,供审计和 rollback 使用。 -OPERATION_LOG_ENABLE = True -# 操作日志目录;相对路径通常位于扫描目录下。 -OPERATION_LOG_DIR = logs - -# 是否在终端打印运行日志。 -PRINTLOGFLAG = True -# 自动清理多少天前的日志文件。 -RMLOGSFLAG = 7 - -# ===== 兼容与扩展 ===== - -# 预留项:是否启用 Telegram Bot 通知。 -USEBOTFLAG = False -# 启动后延时处理的秒数;0 表示不延时。 -TIMELAPSE = 0 -# True 时季/集不补零,例如 01 -> 1。 -SEEPSINGLECHARACTER = False -# True 时 API 搜索只取文件名中的中文部分。 -APIREQUESTSONLYUSECH = False -# True 时仅处理带 anime tag 的任务。 -USEANIMETAG = False - -# 扩展模块排除列表;填写 `Ext` 目录下不想加载的模块名(不带 .py)。 -NOTLOADEXTLIST = [] diff --git a/config.v3.ini.Template b/config.v3.ini.Template new file mode 100644 index 0000000..32771e7 --- /dev/null +++ b/config.v3.ini.Template @@ -0,0 +1,19 @@ +[autoanime] +# CLI 与未来 WebUI 共用的 SQLite 资料库。 +database_path = .autoanime-v3/library.sqlite3 +alias_file = autoanime_v3/data/aliases.json +min_confidence = 0.86 + +# 留空时可通过命令行 --output 指定。 +output_root = +# link(保种)、copy、move。 +mode = link +operation_dir = .autoanime-v3/operations + +# 远程 agent 只处理本地未收敛条目。默认关闭,密钥建议只放环境变量。 +openai_enabled = false +openai_base_url = https://api.openai.com +openai_model = gpt-4.1-mini +openai_api_key_env = OPENAI_API_KEY +openai_api_key = +openai_timeout = 30 diff --git a/deploy/windows/AutoAnimeWeb.xml b/deploy/windows/AutoAnimeWeb.xml new file mode 100644 index 0000000..ff6c9de --- /dev/null +++ b/deploy/windows/AutoAnimeWeb.xml @@ -0,0 +1,9 @@ + + AutoAnimeWebAutoAnime Web Console + AutoAnime LAN administration Web/API service + C:\Program Files\AutoAnime\.venv\Scripts\python.exe + "C:\Program Files\AutoAnime\AutoAnimeWeb.py" --data-dir "C:\ProgramData\AutoAnime" --host 127.0.0.1 + C:\Program Files\AutoAnime + C:\ProgramData\AutoAnime\logs + 30 sec + diff --git a/deploy/windows/AutoAnimeWorker.xml b/deploy/windows/AutoAnimeWorker.xml new file mode 100644 index 0000000..90a3c0b --- /dev/null +++ b/deploy/windows/AutoAnimeWorker.xml @@ -0,0 +1,9 @@ + + AutoAnimeWorkerAutoAnime Worker + AutoAnime scanner and safe file-operation Worker + C:\Program Files\AutoAnime\.venv\Scripts\python.exe + "C:\Program Files\AutoAnime\AutoAnimeWorker.py" --data-dir "C:\ProgramData\AutoAnime" + C:\Program Files\AutoAnime + C:\ProgramData\AutoAnime\logs + 60 sec + diff --git a/deploy/windows/Caddyfile.example b/deploy/windows/Caddyfile.example new file mode 100644 index 0000000..ce6afdc --- /dev/null +++ b/deploy/windows/Caddyfile.example @@ -0,0 +1,13 @@ +autoanime.lan { + @remote_bootstrap { + path /api/v1/auth/bootstrap + not remote_ip 127.0.0.0/8 ::1 + } + respond @remote_bootstrap 403 + + reverse_proxy 127.0.0.1:8765 + tls internal +} + +# Create the first administrator locally before exposing Caddy to the LAN. +# Trust Caddy's local root CA on each LAN client before opening the console. diff --git a/deploy/windows/README.md b/deploy/windows/README.md new file mode 100644 index 0000000..fe61805 --- /dev/null +++ b/deploy/windows/README.md @@ -0,0 +1,87 @@ +# Windows 一键启动 / 开机自启 + +> 推荐直接使用项目根目录脚本(打开文件夹就能看到): +> +> - `start-autoanime.bat` +> - `stop-autoanime.bat` +> - `install-autostart.bat` +> - `uninstall-autostart.bat` +> +> 本目录下的同名脚本是兼容包装,会转发到根目录。 + +## 文件 + +| 文件 | 作用 | +|------|------| +| `../../start-autoanime.bat` | 启动 Web + Worker(双击即可) | +| `../../stop-autoanime.bat` | 停止 Web + Worker | +| `../../install-autostart.bat` | 注册登录后自动启动 | +| `../../uninstall-autostart.bat` | 取消开机自启 | + +## 首次使用前 + +在项目根目录准备好 Python 依赖,并确保能构建前端: + +```powershell +cd C:\path\to\autoanime-webui +python -m pip install -r requirements.txt +pnpm --dir webui install +pnpm --dir webui build +``` + +若尚未构建 `webui\dist`,`start-autoanime.bat` 会在本机已安装 `pnpm` 时尝试自动构建。 + +## 立即启动 + +双击项目根目录: + +```text +start-autoanime.bat +``` + +默认: + +- 数据目录:`C:\ProgramData\AutoAnime` +- 地址:`http://127.0.0.1:8765` +- 日志:`C:\ProgramData\AutoAnime\logs\` +- 使用 `--insecure-http`(适合本机/可信局域网直连) +- 默认管理员:`admin` / `AutoAnime-Admin-ChangeMe!` +- 本机 loopback 默认免密登录(可在 WebUI「系统设置」关闭) + +自定义示例: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\start-autoanime.ps1 -DataDir "D:\AutoAnimeData" -Port 8765 +``` + +## 开机自动运行 + +双击: + +```text +install-autostart.bat +``` + +会注册: + +1. 当前用户计划任务 `AutoAnime WebUI`(登录约 20 秒后启动) +2. 开始菜单「启动」文件夹快捷方式(备份) + +取消: + +```text +uninstall-autostart.bat +``` + +## 停止服务 + +```text +stop-autoanime.bat +``` + +## 注意 + +- **Web 与 Worker 必须同时运行**,且使用相同 `--data-dir`。 +- 默认会自动创建管理员账号;远程访问仍需账号密码。 +- 若项目路径移动,请重新运行 `install-autostart.bat`。 +- 开机自启不会替你安装 Python/Node;请先在本机装好依赖。 diff --git a/deploy/windows/install-autostart.bat b/deploy/windows/install-autostart.bat new file mode 100644 index 0000000..f3bf8c2 --- /dev/null +++ b/deploy/windows/install-autostart.bat @@ -0,0 +1,7 @@ +@echo off +setlocal +cd /d "%~dp0" +title AutoAnime Install Autostart +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-autostart.ps1" %* +echo. +pause diff --git a/deploy/windows/install-autostart.ps1 b/deploy/windows/install-autostart.ps1 new file mode 100644 index 0000000..493fffe --- /dev/null +++ b/deploy/windows/install-autostart.ps1 @@ -0,0 +1,6 @@ +#Requires -Version 5.1 +$ErrorActionPreference = "Stop" +$RootScript = Join-Path (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path "install-autostart.ps1" +if (-not (Test-Path $RootScript)) { throw "Missing $RootScript" } +& $RootScript @args +exit $LASTEXITCODE diff --git a/deploy/windows/start-autoanime.bat b/deploy/windows/start-autoanime.bat new file mode 100644 index 0000000..0a2b845 --- /dev/null +++ b/deploy/windows/start-autoanime.bat @@ -0,0 +1,16 @@ +@echo off +setlocal +cd /d "%~dp0" +title AutoAnime Start +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-autoanime.ps1" %* +set ERR=%ERRORLEVEL% +if not "%ERR%"=="0" ( + echo. + echo [AutoAnime] 启动失败,错误码 %ERR% + pause + exit /b %ERR% +) +echo. +echo 窗口可关闭;服务已在后台运行。 +timeout /t 5 >nul +exit /b 0 diff --git a/deploy/windows/start-autoanime.ps1 b/deploy/windows/start-autoanime.ps1 new file mode 100644 index 0000000..12b3f6c --- /dev/null +++ b/deploy/windows/start-autoanime.ps1 @@ -0,0 +1,7 @@ +#Requires -Version 5.1 +# Thin wrapper: canonical scripts live at the project root. +$ErrorActionPreference = "Stop" +$RootScript = Join-Path (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path "start-autoanime.ps1" +if (-not (Test-Path $RootScript)) { throw "Missing $RootScript" } +& $RootScript @args +exit $LASTEXITCODE diff --git a/deploy/windows/stop-autoanime.bat b/deploy/windows/stop-autoanime.bat new file mode 100644 index 0000000..775fbeb --- /dev/null +++ b/deploy/windows/stop-autoanime.bat @@ -0,0 +1,7 @@ +@echo off +setlocal +cd /d "%~dp0" +title AutoAnime Stop +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0stop-autoanime.ps1" %* +echo. +pause diff --git a/deploy/windows/stop-autoanime.ps1 b/deploy/windows/stop-autoanime.ps1 new file mode 100644 index 0000000..7e0c205 --- /dev/null +++ b/deploy/windows/stop-autoanime.ps1 @@ -0,0 +1,6 @@ +#Requires -Version 5.1 +$ErrorActionPreference = "Continue" +$RootScript = Join-Path (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path "stop-autoanime.ps1" +if (-not (Test-Path $RootScript)) { throw "Missing $RootScript" } +& $RootScript @args +exit $LASTEXITCODE diff --git a/deploy/windows/uninstall-autostart.bat b/deploy/windows/uninstall-autostart.bat new file mode 100644 index 0000000..66decc6 --- /dev/null +++ b/deploy/windows/uninstall-autostart.bat @@ -0,0 +1,7 @@ +@echo off +setlocal +cd /d "%~dp0" +title AutoAnime Uninstall Autostart +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0uninstall-autostart.ps1" %* +echo. +pause diff --git a/deploy/windows/uninstall-autostart.ps1 b/deploy/windows/uninstall-autostart.ps1 new file mode 100644 index 0000000..c4a7022 --- /dev/null +++ b/deploy/windows/uninstall-autostart.ps1 @@ -0,0 +1,6 @@ +#Requires -Version 5.1 +$ErrorActionPreference = "Continue" +$RootScript = Join-Path (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path "uninstall-autostart.ps1" +if (-not (Test-Path $RootScript)) { throw "Missing $RootScript" } +& $RootScript @args +exit $LASTEXITCODE diff --git "a/docs/00_\346\226\207\346\241\243\346\200\273\347\233\256\345\275\225.md" "b/docs/00_\346\226\207\346\241\243\346\200\273\347\233\256\345\275\225.md" index 8a8b834..d22ba8e 100644 --- "a/docs/00_\346\226\207\346\241\243\346\200\273\347\233\256\345\275\225.md" +++ "b/docs/00_\346\226\207\346\241\243\346\200\273\347\233\256\345\275\225.md" @@ -1,54 +1,25 @@ # AutoAnime 文档总目录 -> 说明:本文件是项目文档统一入口。每次新增/调整正式文档后,需同步更新本索引。 +仓库已经收敛为单一 v3.1 架构,不再保留旧入口、旧 JSON 缓存实现及对应文档。 -| 文档名称 | 主题说明 | 适用模块 | 关联功能 | 更新时间 | -| --- | --- | --- | --- | --- | -| `docs/01_项目架构与模块职责.md` | 项目整体架构、核心模块职责与调用主链路 | 主程序、配置、API识别、整理流程 | 番剧识别与重命名整理 | 2026-04-06 | -| `docs/09_模块化拆分与包结构.md` | `autoanime` 包目录、双轨入口、文件/函数迁移映射 | `AutoAnimeMv2.py`、`autoanime/*` | 新推荐入口与模块化实现 | 2026-04-22 | -| `docs/10_缓存Schema_v2设计.md` | 多子文件缓存、`trust`、审计、迁移与 `cache_doctor` | `autoanime/cache/*`、`scripts/cache_doctor.py` | 新入口持久化与排障 | 2026-05-01 | -| `autoanime/cache/README.md` | 缓存子包使用说明:磁盘布局、Get/Set 路由、别名/trust、迁移、`cache_doctor`(含白名单/改中文名/独立 `--rename-episodes` 等) | `autoanime/cache/*`、`autoanime/episode_dst_rename.py` | 二次开发与集成 | 2026-05-01 | -| `autoanime/cache/cache_doctor_重命名与剧名纠偏_使用说明.md` | `cache_doctor` **七子命令**专题目录:inspect/审计/revert/rebuild/白名单/set-title-zh/rename-episodes;共用参数、决策表、真实 PowerShell 与 `organization` 样例 | `scripts/cache_doctor.py`、`autoanime/episode_dst_rename.py` | 重命名、剧名纠偏与缓存运维 | 2026-04-23 | -| `docs/02_开发环境与构建部署.md` | 环境准备、依赖安装、运行方式与部署要点 | 运行环境、依赖安装、执行入口 | 本地批处理 / QB 模式 | 2026-04-06 | -| `docs/03_硬件平台与资源映射.md` | 硬件/系统资源映射与约束说明 | 系统网络、文件系统、代理 | API访问、文件读写链路 | 2026-04-06 | -| `docs/04_接口协议与外部依赖.md` | 外部 API、协议、`api_cache.json` 新分区(含 `ShowOrganizationIndex`)、剧名链与须清空旧缓存说明 | OpenAI/Bangumi/TMDB/缓存索引 | 名称标准化 + 整部番整理进度 | 2026-04-16 | -| `docs/05_调试记录与常见问题.md` | 调试命令、常见故障与定位流程(含同集最老优先与别名收敛排障) | 日志、配置、网络访问、缓存索引 | 识别失败排查、配置排查 | 2026-05-01 | -| `docs/06_AI识别优先方案.md` | AI 识别优先方案专题(设计、实现、风险;含 OpenAI `user` 剥除首部方括号标签) | `Auxiliary_Api` / 配置解析 / AI 文件识别 | AI优先识别 + 回退策略 + 中英罗别名复用 | 2026-04-23 | -| `docs/07_整理链路稳健化与回滚机制.md` | 稳健化改造专题(JSON解析、命名、缓存、dry-run、回滚) | 主处理链路、命名模块、日志模块 | 批量整理可预览与可回滚 + 同集最老优先 + 中文标点落盘 | 2026-04-16 | -| `docs/08_公开仓库发布与隐私清理.md` | 公开发布前的清理策略、忽略规则与历史重写说明 | README、配置模板、Git 历史、忽略规则 | 开源发布与隐私治理 | 2026-04-06 | -| `docs/plans/2026-04-06_AI识别优先方案_plan.md` | 本次功能开发前后计划与落地记录 | 配置/识别链路改造 | 新增 AI 识别方案 | 2026-04-06 | -| `docs/plans/2026-04-06_整理链路稳健化_plan.md` | 稳健化与可回滚改造计划文档 | API、命名、缓存、回滚、测试 | P0/P1/P2 渐进实施 | 2026-04-06 | -| `docs/plans/2026-04-07_番剧命名统一_plan.md` | 同番多名与标点分叉治理计划,含别名索引、AI 中英罗马音、同集最老优先策略 | 识别链路、缓存链路、命名链路、主流程去重 | 番剧命名统一与识别降耗 | 2026-04-07 | -| `docs/plans/2026-04-22_模块化重构_plan.md` | 模块化与识别增强:验收表、新入口与 `autoanime` 包 | 全链路 | 双轨运行、测试与文档 | 2026-04-22 | -| `docs/plans/2026-04-23_缓存Schema重设计_plan.md` | 缓存 v2 子文件、迁移与工具脚本落地 | `autoanime/cache`、`cli`、scripts | 持久化与别名校验 | 2026-04-23 | +| 文档 | 内容 | +| --- | --- | +| [12_v3_架构与迁移.md](12_v3_架构与迁移.md) | 当前包结构、完整处理链路、文件安全策略和 SQLite 资料库设计 | +| [11_v3_WebUI与数据层规划.md](11_v3_WebUI与数据层规划.md) | 已实施 WebUI 的页面、服务边界、人工纠正、任务和安全应用设计 | +| [superpowers/plans/2026-07-23-autoanime-web-console.md](superpowers/plans/2026-07-23-autoanime-web-console.md) | Web Console 分阶段实施与验证清单 | -## 使用建议 -- 开发前先阅读:`docs/00_文档总目录.md`、对应专题文档和本次 `plan` 文档。 -- 涉及旧功能改造时,优先原地更新已有文档,不重复创建同主题文档。 -- 中大型改动请在对应文档维护“变更记录”。 +## 阅读顺序 -## 变更记录 -| 日期 | 修改来源 | 修改原因 | 影响范围 | -| --- | --- | --- | --- | -| 2026-04-06 | Agent | 建立项目文档体系并登记 AI 识别改造文档 | `docs/` 全局入口 | -| 2026-04-06 | Agent | 新增稳健化改造专题与对应计划文档入口 | `docs/00_文档总目录.md` | -| 2026-04-06 | Agent | 完成稳健化改造落地后同步更新架构/部署/接口/调试文档索引 | `docs/01`、`docs/02`、`docs/04`、`docs/05`、`docs/06`、`docs/07` | -| 2026-04-06 | Agent | 新增公开仓库发布与隐私清理专题入口 | `docs/08_公开仓库发布与隐私清理.md` | -| 2026-04-07 | Agent | 同步登记 OpenAI 全信息识别中文剧名约束与缓存修正文档更新 | `docs/04`、`docs/05`、`docs/06` | -| 2026-04-07 | Agent | 新增番剧命名统一改造计划文档并登记索引入口 | `docs/plans/2026-04-07_番剧命名统一_plan.md`、`docs/00_文档总目录.md` | -| 2026-04-07 | Agent | 同步登记命名统一改造落地后的接口/调试/AI/稳健化文档更新 | `docs/04`、`docs/05`、`docs/06`、`docs/07` | -| 2026-04-07 | Agent | 同步登记“x.0集数误判Season00、单集漂移纠偏、非中文剧名二次标准化”文档更新 | `docs/05`、`docs/06`、`docs/07` | -| 2026-04-07 | Agent | 同步登记 `zhconv` 资源句柄告警修复及排障说明更新 | `docs/05` | -| 2026-04-14 | Agent | 同步登记“非日期日志解析异常 + 扫描日志刷屏拖慢输出”排障文档更新 | `docs/05` | -| 2026-04-16 | Agent | 登记 `Auxiliary_NormalizeDisplayTitle` 英文标点转中文全角与缓存批量规范化脚本说明 | `docs/07`、`AutoAnimeMv.py`、`scripts/normalize_api_cache_cn_punct.py` | -| 2026-04-16 | Agent | 登记 `api_cache.json` 分区说明与去冗原则 | `docs/04` | -| 2026-04-16 | Agent | 登记「整部番单条缓存 + 剧名链 + OpenAI 季集主路径」落地:`ShowOrganizationIndex`、删除旧 Resolved/OpenAIFileInfo 持久化兼容、中文标点归一落盘 | `docs/04`、`docs/00`、`AutoAnimeMv.py`、`tests/test_refactor_features.py` | -| 2026-04-22 | Agent | 登记 `docs/09_模块化拆分与包结构.md`、`docs/plans/2026-04-22_模块化重构_plan.md` 及 01/05/06/07 配套变更 | `docs/00`~`09`、`autoanime/`、`tests/test_autoanime_package.py` | -| 2026-04-23 | Agent | 登记 `docs/10_缓存Schema_v2设计.md`、缓存重设计 plan;CLI 迁移/flush、cache_doctor、v2 单测 | `docs/00`、`docs/04`、`docs/05`、`autoanime/cli.py`、`scripts/cache_doctor.py` | -| 2026-04-23 | Agent | 新增 `autoanime/cache/README.md`(使用说明),`docs/10` 增加交叉引用 | `autoanime/cache/README.md`、`docs/10`、`docs/00` | -| 2026-04-23 | Agent | 扩充 `autoanime/cache/README.md`:`cache_doctor` 全指令与 PowerShell 实例、`scripts/` 各文件说明 | `autoanime/cache/README.md` | -| 2026-04-23 | Agent | 落地 `episode_dst_rename` 与 `cache_doctor --set-whitelist` / `--set-title-zh`(可选 `--apply-rename`);同步 `docs/00`、`docs/05` | `autoanime/episode_dst_rename.py`、`scripts/cache_doctor.py`、`docs/00`、`docs/05` | -| 2026-04-23 | Agent | 专题目录重命名为 `autoanime/cache/cache_doctor_重命名与剧名纠偏_使用说明.md`(`cache_doctor` 七子命令全说明+实例) | `autoanime/cache/`、`docs/00` | -| 2026-04-23 | Agent | `docs/10` 变更记录:单文件整理时审计 JSONL 降噪(同 canonical 高信任已存在则静默;`Upsert` 别名去重) | `docs/10`、`autoanime/cache/trust.py`、`autoanime/cache/canonical.py` | -| 2026-04-23 | Agent | `docs/06`:OpenAI 全信息识别对 `user` 提示剥除首部 `[…]`/`【…】` 与 system 说明;`docs/00` 登记 `docs/06` 更新 | `docs/06`、`docs/00`、`autoanime/naming.py`、`openai_identify.py`、`AutoAnimeMv.py` | -| 2026-05-01 | Agent | 别名键长度上限调至 100(`ALIAS_KEY_MAX_LEN`);同步 `docs/05`、`docs/10`、`cache/README`、`tests` | `trust.py`、`cache_doctor.py`、`docs/00`~`05`、`docs/10`、`autoanime/cache/README.md` | +1. 新用户先阅读仓库根目录的 [README.md](../README.md)。 +2. 二次开发先阅读架构文档,了解 scanner → parser → resolver → planner → executor 主链路。 +3. 部署或开发 WebUI、Worker、人工纠正功能时,再阅读 WebUI 与数据层规划及实施清单。 + +## 当前事实来源 + +- 标题及季度规则:`autoanime_v3/data/aliases.json` +- 运行配置模板:`config.v3.ini.Template` +- Web Schema 与迁移:`autoanime_v3/db/schema.py`、`autoanime_v3/db/migrations.py` +- Web/API 应用服务边界:`autoanime_v3/services/`、`autoanime_v3/api/app.py` +- WebUI:`webui/src/` +- Windows 服务入口:`AutoAnimeWeb.py`、`AutoAnimeWorker.py`、`deploy/windows/` +- 回归测试:`tests/test_v3_*.py` diff --git "a/docs/01_\351\241\271\347\233\256\346\236\266\346\236\204\344\270\216\346\250\241\345\235\227\350\201\214\350\264\243.md" "b/docs/01_\351\241\271\347\233\256\346\236\266\346\236\204\344\270\216\346\250\241\345\235\227\350\201\214\350\264\243.md" deleted file mode 100644 index 62a6c06..0000000 --- "a/docs/01_\351\241\271\347\233\256\346\236\266\346\236\204\344\270\216\346\250\241\345\235\227\350\201\214\350\264\243.md" +++ /dev/null @@ -1,85 +0,0 @@ -# 项目架构与模块职责 - -## 功能背景 -`AutoAnimeMv.py` 仍是项目唯一核心脚本,但本次已完成稳健化升级:API 解析改为 JSON 校验、主循环改为视频优先、命名策略可配置、支持 dry-run 与回滚。 - -## 功能目标 -- 对下载目录内番剧视频与字幕进行可控整理。 -- 识别链路保持 AI 优先并支持多源回退。 -- 支持 `default` / `emby` 命名风格,支持预览执行与回滚。 - -## 功能边界 -- 继续按单次命令触发运行,不引入常驻服务。 -- 历史单文件 `AutoAnimeMv.py` 不修改;**新推荐入口**为 `AutoAnimeMv2.py`,逻辑在 `autoanime/` 包(与旧入口**双轨并存**)。 - -## 包结构(`autoanime` / `AutoAnimeMv2`) -- **入口双轨**:`python AutoAnimeMv.py ...` 仍用未改动的单文件;`python AutoAnimeMv2.py ...` 调用 `autoanime.cli`(`argparse`、单文件/目录 `NormalizeSingleFileInput`、主流水线、回滚)。 -- **主链路落点**:`autoanime/pipeline/main.py`(`Processing_Main`)、`autoanime/identification`(OpenAI + `local_fallback`)、`autoanime/sorting`、`autoanime/cache`。 -- 详见专题:`docs/09_模块化拆分与包结构.md`。 - -## 架构概览 -1. `Start_PATH()`:初始化默认配置、运行态缓存、`RuntimeContext`。 -2. `Start_GetArgv()`:迁移到 `argparse`,兼容旧参数并新增 `rollback` 模式;`rollback` 分支会同步刷新运行时上下文。 -3. `Processing_Mode()`:递归扫描目录(含子文件夹)并按 CLI 最终参数刷新上下文;qB 回调模式下会过滤 `.!qB` 等未完成下载临时文件。 -4. `Processing_Main()`:tuple 模式仅遍历视频列表,字幕仅做附属匹配,并优先命中文件级最终识别结果缓存。 -5. `Processing_Identification()`:启用 OpenAI 时优先由 AI 返回剧名/季/集,失败回退本地规则。 -6. `Auxiliary_Api()`:统一 JSON 解析 + 字段校验 + 内存/持久化缓存。 -7. `Sorting_Mv()`:命名模板、文件名安全清洗、move/link 执行器、dry-run 记录(支持可选输出目录);硬链接模式下若目标已存在则默认保留原文件。 -8. `Auxiliary_WriteOperationLog()` / `Auxiliary_RollbackFromLog()`:操作日志与回滚。 - -## 模块职责 -| 模块 | 入口函数 | 职责 | 上游 | 下游 | -| --- | --- | --- | --- | --- | -| 初始化模块 | `Start_PATH()` | 设置默认配置、加载缓存、创建运行上下文 | 程序入口 | 参数解析/主处理 | -| 参数与模式模块 | `Start_GetArgv()` / `Processing_Mode()` | 解析参数(含 rollback)、刷新 `RuntimeContext`、递归扫描目录、过滤未完成下载临时文件、构建文件列表 | 初始化模块 | 识别模块 | -| 识别模块 | `Processing_Identification()` | 提取剧名/剧季/剧集(AI 优先 + 本地回退) | 模式模块 | API模块/整理模块 | -| API 标准化模块 | `Auxiliary_Api()` | OpenAI + Bangumi/BGM/TMDB 轮询,JSON 校验,缓存命中 | 识别模块 | 整理模块 | -| 整理模块 | `Sorting_Mv()` | 命名模板、路径清洗、执行 move/link 或 dry-run(含严格模式) | API模块 | 文件系统 | -| 操作日志与回滚模块 | `Auxiliary_RecordOperation()` / `Auxiliary_RollbackFromLog()` | 记录批处理动作、支持逆序回滚 | 整理模块 | 文件系统 | - -## 文件清单 -| 文件路径 | 类型 | 作用 | 谁会使用它 | 它依赖谁 | 备注 | -| --- | --- | --- | --- | --- | --- | -| `AutoAnimeMv.py` | Python 脚本 | 主流程、识别、整理、缓存、回滚 | 用户执行入口、自动化任务 | `requests`、`zhconv`、外部 API | 核心 | -| `config.ini.Template` | 配置模板 | 命名、缓存、dry-run、token 注入示例 | 使用者 | 配置解析逻辑 | 不含真实密钥 | -| `tests/` | 测试目录 | 回归用例与样本夹具 | 开发者 | Python `unittest` | 本次新增 | -| `docs/07_整理链路稳健化与回滚机制.md` | 专题文档 | 记录稳健化方案和运维方法 | 开发者/维护者 | 本文档体系 | 本次新增 | - -## 函数清单(核心) -| 函数/方法 | 所在文件 | 作用 | 输入 | 输出 | 调用方 | 被调对象 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | --- | -| `RuntimeContext` | `AutoAnimeMv.py` | 承载 dry-run、命名风格、操作日志路径等运行状态 | 初始化参数 | 对象实例 | `Start_PATH` | `Config` | 新增 | -| `Start_GetArgv` | `AutoAnimeMv.py` | argparse 参数解析(含 rollback) | CLI 参数 | `filepath`/`tuple` | `__main__` | `argparse` | 已升级 | -| `Processing_Main` | `AutoAnimeMv.py` | 视频主循环 + 字幕附属匹配 | 扫描结果 | 无 | `__main__` | `Processing_Identification` | tuple 风险已修复 | -| `Auxiliary_Http` | `AutoAnimeMv.py` | HTTP 请求与 JSON 返回解析 | URL/方法/参数 | `dict` 或文本 | `Auxiliary_Api` | `requests` | 已替换 `literal_eval` | -| `Sorting_Mv` | `AutoAnimeMv.py` | 命名模板、路径清洗、执行器调用 | 文件信息 | 无 | `Processing_Main` | `Auxiliary_ExecuteFileOperation` | dry-run/回滚关键 | - -## 调用关系 -- 主链路:`__main__ -> Start_PATH -> Start_GetArgv -> Processing_Mode -> Processing_Main -> Processing_Identification -> Auxiliary_Api -> Sorting_Mv` -- 缓存链路:`Start_PATH -> Auxiliary_LoadPersistentCache -> Processing_Main(ShowOrganizationIndex 已整理集跳过) / Auxiliary_Api(Read/WriteCache) -> Auxiliary_SavePersistentCache` -- 回滚链路:`Start_GetArgv(rollback) -> Auxiliary_RollbackFromLog` - -## 依赖关系 -- 第三方库:`requests`、`zhconv` -- 外部接口:OpenAI 兼容接口、Bangumi/BGM、TMDB -- 环境变量:`OPENAI_API_KEY`、`TMDB_BEARER_TOKEN` -- 系统资源:本地文件系统、网络、可选代理 - -## 风险与约束 -- API 结构变更仍可能导致识别质量下降。 -- 覆盖写入时回滚依赖备份日志完整性。 -- 文件名清洗在极端长标题下会触发裁剪。 - -## 变更记录 -| 日期 | 修改来源 | 修改原因 | 影响范围 | 对应功能/文件/模块 | -| --- | --- | --- | --- | --- | -| 2026-04-06 | Agent | 新增 AI 识别优先与密钥保护机制 | 初始化、配置、API 模块 | `AutoAnimeMv.py`、`config.ini.Template` | -| 2026-04-06 | Agent | 落地稳健化改造(JSON 校验、tuple 修复、命名模板、缓存、dry-run、回滚、argparse/pathlib) | 主处理链路、文件执行链路、日志与回滚链路 | `AutoAnimeMv.py`、`tests/`、`docs/07_*` | -| 2026-04-06 | Agent | 增加子文件夹递归扫描,并将子目录文件整理到传入目录下的目标结构 | 扫描模块、主处理链路、落盘链路 | `Auxiliary_ScanDIR`、`Processing_Main`、`Sorting_Mv` | -| 2026-04-06 | Agent | 启用 OpenAI 全信息识别,识别入口改为 AI 优先返回剧名/季/集 | 识别模块、API 调用链路 | `Processing_Identification`、`Auxiliary_OpenAIIdentifyFileInfo` | -| 2026-04-06 | Agent | 增加严格模式、硬链接默认策略与可选输出目录参数 | 执行模块、参数模块、落盘路径策略 | `Auxiliary_ExecuteFileOperation`、`Start_GetArgv`、`RuntimeContext.output_path` | -| 2026-04-07 | Agent | 补充 CLI 参数覆盖后的运行时上下文刷新说明,并修正 rollback 分支上下文同步 | 参数模块、运行时上下文 | `Start_GetArgv`、`Processing_Mode`、`RuntimeContext` | -| 2026-04-07 | Agent | 增加未完成下载临时文件过滤,避免 qB 回调误处理 `.!qB` 等文件 | 参数模式模块、主处理入口 | `Processing_Mode`、`Processing_Main`、未完成下载文件识别 | -| 2026-04-07 | Agent | 增加重复资源保留原硬链接与同集决策,减少重复替换 | 主处理链路、执行链路 | `Processing_Main`、`Sorting_Mv`、`Auxiliary_ExecuteFileOperation` | -| 2026-04-16 | Agent | 以 `ShowOrganizationIndex` 记录每部番已整理集,替代历史文件级 `ResolvedFileInfo` 持久化 | 主处理、缓存 | `Processing_Main`、`Auxiliary_ShowHasOrganizedEpisode` | -| 2026-04-22 | Agent | 补充 `autoanime` 包与 `AutoAnimeMv2` 双轨说明,指向 `docs/09_模块化拆分与包结构.md` | 新入口、包结构 | `AutoAnimeMv2.py`、`autoanime/*` | diff --git "a/docs/02_\345\274\200\345\217\221\347\216\257\345\242\203\344\270\216\346\236\204\345\273\272\351\203\250\347\275\262.md" "b/docs/02_\345\274\200\345\217\221\347\216\257\345\242\203\344\270\216\346\236\204\345\273\272\351\203\250\347\275\262.md" deleted file mode 100644 index 1b94a07..0000000 --- "a/docs/02_\345\274\200\345\217\221\347\216\257\345\242\203\344\270\216\346\236\204\345\273\272\351\203\250\347\275\262.md" +++ /dev/null @@ -1,87 +0,0 @@ -# 开发环境与构建部署 - -## 功能背景 -本项目为 Python 单脚本工具,按命令触发执行。当前版本新增了 dry-run、回滚、命名模板和持久化缓存,部署时需额外关注日志目录、缓存目录与环境变量。 - -## 功能目标 -- 提供可复现的本地开发和运行步骤。 -- 明确 API 密钥/Token 的环境变量注入方法。 -- 给出最小化部署成本的执行方案。 - -## 功能边界 -- 不包含容器编排或云端服务部署方案。 -- 不负责第三方 API 账号申请与额度管理。 - -## 环境要求 -| 项目 | 要求 | -| --- | --- | -| OS | Windows / Linux / macOS(脚本内已做分隔符兼容) | -| Python | 建议 3.9+ | -| 网络 | 能访问配置的 API 网关与第三方接口 | -| 权限 | 对目标下载目录有读写权限(硬链接模式需额外文件系统支持) | - -## 依赖安装 -| 命令 | 执行位置 | 用途 | 示例 | 风险 | -| --- | --- | --- | --- | --- | -| `python -m pip install -r requirements.txt` | 项目根目录 | 安装运行依赖 | `python -m pip install -r requirements.txt` | 版本冲突或网络慢 | - -## 配置准备 -1. 复制 `config.ini.Template` 为 `config.ini`(本地文件,不提交仓库)。 -2. 默认 AI 已启用并优先,关键配置如下: - - `USEOPENAIAPI = True` - - `OPENAI_PRIORITY_FIRST = True` - - `OPENAI_IDENTIFY_ALL = True`(启用后由 AI 直接识别剧名/季/集) - - `OPENAI_BASE_URL = https://api.longcat.chat/openai` - - `OPENAI_MODEL = LongCat-Flash-Chat` -3. 推荐通过环境变量注入认证信息: - - Windows PowerShell:`$env:OPENAI_API_KEY="你的key"` - - Windows PowerShell:`$env:TMDB_BEARER_TOKEN="你的tmdb_token"` - - Linux/macOS:`export OPENAI_API_KEY="你的key"` - - Linux/macOS:`export TMDB_BEARER_TOKEN="你的tmdb_token"` -4. 新增行为配置: - - `NAMING_STYLE = default|emby` - - `DRY_RUN = True|False` - - `USELINK = True|False`(默认 `True`,便于 qBittorrent 持续做种) - - `STRICT_MODE = True|False`(默认 `True`,硬链接失败不降级移动) - - `OUTPUT_PATH = 目标目录`(可留空,默认输出到当前扫描目录) - - `CACHE_DIR` / `CACHE_TTL_SECONDS` - - `OPERATION_LOG_ENABLE` / `OPERATION_LOG_DIR` - -## 运行方式 -| 模式 | 命令示例 | 说明 | -| --- | --- | --- | -| 本地批处理 | `python AutoAnimeMv.py "待整理目录"` | 扫描目录内番剧文件并整理 | -| QB 下载回调 | `python AutoAnimeMv.py "%D" "%N" "%C" "%L"` | 由 qBittorrent 任务完成后触发;`.!qB` 等未完成临时文件会自动跳过 | -| Dry-run 预览 | `python AutoAnimeMv.py "待整理目录" --dry-run` | 仅输出和记录操作,不移动文件 | -| Emby 命名 | `python AutoAnimeMv.py "待整理目录" --naming-style emby` | 输出 `剧名 - S01E01` 风格命名 | -| 指定输出目录 | `python AutoAnimeMv.py "待整理目录" --output-path "目标目录"` | 扫描源目录并整理到指定目标目录 | -| 链接开关 | `python AutoAnimeMv.py "待整理目录" --use-link/--no-link` | 强制启用或禁用硬链接 | -| 严格模式 | `python AutoAnimeMv.py "待整理目录" --strict-mode true/false` | 控制硬链接失败时是否允许降级移动 | -| 回滚模式 | `python AutoAnimeMv.py rollback --log "操作日志路径"` | 依据日志逆序回滚 move/link 操作 | - -## 部署要点 -- 建议将脚本与 `config.ini` 放在固定目录,便于下载器调用。 -- 若启用代理,确保 `USEPROXY/USESYSPROXY` 与代理地址匹配。 -- 若使用硬链接,目标目录需与源文件位于同一文件系统并支持硬链接。 -- 若硬链接模式下目标文件已存在,程序会默认保留原有目标文件,不用新的重复资源替换旧硬链接。 -- 当重复资源被保留跳过后,程序会将该新资源的最终识别结果写入本地缓存,降低后续重复运行时的 AI/API 识别开销。 -- 若 `OUTPUT_PATH` 与扫描目录不同,且启用了硬链接保留源文件,程序会在目标文件已与源文件指向同一物理文件时自动跳过重复整理。 -- 若 `OUTPUT_PATH` 位于扫描目录内部,扫描阶段会自动忽略输出子树,避免把已整理结果再次扫入。 -- 建议将 `CACHE_DIR` 与 `OPERATION_LOG_DIR` 放在可长期保留的位置,便于复跑提速与回滚。 - -## 常见安全约束 -- `config.ini` 可能包含敏感配置,应只保存在本地并加入忽略列表。 -- 不在 README、代码、提交记录中写入真实 API Key/TMDB Token。 -- 日志已对 key/token/secret/password 相关配置做脱敏。 - -## 变更记录 -| 日期 | 修改来源 | 修改原因 | 影响范围 | 对应功能/文件/模块 | -| --- | --- | --- | --- | --- | -| 2026-04-06 | Agent | 补充 AI 识别优先方案相关部署与密钥注入说明 | 运行配置、部署步骤 | `config.ini.Template`、`.gitignore` | -| 2026-04-06 | Agent | 补充 dry-run/回滚、Emby 命名、TMDB token 环境变量与缓存部署说明 | 运行命令、配置准备、运维流程 | `AutoAnimeMv.py`、`config.ini.Template` | -| 2026-04-06 | Agent | 补充 OpenAI 全信息识别配置说明(剧名/季/集) | 识别配置与运行行为 | `OPENAI_IDENTIFY_ALL`、`Processing_Identification` | -| 2026-04-06 | Agent | 增加严格模式、硬链接默认开关与可选输出目录参数说明 | 文件执行策略、CLI 参数、部署配置 | `USELINK`、`STRICT_MODE`、`OUTPUT_PATH` | -| 2026-04-07 | Agent | 补充独立输出目录重复整理抑制与输出子树自动忽略说明 | 输出目录部署、硬链接复跑行为 | `OUTPUT_PATH`、`USELINK`、扫描链路 | -| 2026-04-07 | Agent | 补充 qB 回调模式下未完成下载临时文件自动跳过说明 | qB 调用模式、运行行为 | `Processing_Mode`、未完成下载文件过滤 | -| 2026-04-07 | Agent | 补充重复资源保留原硬链接与文件级识别缓存说明 | 硬链接部署策略、缓存复跑行为 | `USELINK`、`Processing_Main`、(现以 `ShowOrganizationIndex` 判重已整理集) | -| 2026-04-16 | Agent | 大版本升级须清空或更换 `CACHE_DIR` 下旧 `api_cache.json`(新 schema 不兼容) | 缓存与首次运行 | `docs/04`、`CACHE_DIR` | diff --git "a/docs/03_\347\241\254\344\273\266\345\271\263\345\217\260\344\270\216\350\265\204\346\272\220\346\230\240\345\260\204.md" "b/docs/03_\347\241\254\344\273\266\345\271\263\345\217\260\344\270\216\350\265\204\346\272\220\346\230\240\345\260\204.md" deleted file mode 100644 index 17f2aa0..0000000 --- "a/docs/03_\347\241\254\344\273\266\345\271\263\345\217\260\344\270\216\350\265\204\346\272\220\346\230\240\345\260\204.md" +++ /dev/null @@ -1,55 +0,0 @@ -# 硬件平台与资源映射 - -## 功能背景 -本项目非嵌入式驱动类工程,不直接控制硬件设备节点;但依赖主机文件系统、网络栈和下载器所在运行环境。 - -## 功能目标 -- 明确“资源映射”在本项目中的实际对象。 -- 说明不同系统下与运行效果相关的资源约束。 - -## 功能边界 -- 不涉及串口、摄像头、GPIO、内核驱动节点等硬件控制。 -- 不包含板端 SDK 或交叉编译工具链。 - -## 架构概览 -- 输入资源:下载目录中的视频/字幕文件。 -- 处理资源:CPU(正则识别、字符串清洗)、内存(缓存 API 返回)。 -- 输出资源:整理后的目录结构、日志文件。 -- 网络资源:AI 网关 + 第三方番剧 API。 - -## 模块职责(资源视角) -| 模块 | 占用资源 | 资源用途 | 关键约束 | -| --- | --- | --- | --- | -| 目录扫描 | 文件系统 IO | 枚举待处理文件 | 路径权限、目录可读 | -| 名称识别 | CPU + 网络 | 本地规则 + 远端 API | 网络可达、接口稳定 | -| 文件整理 | 文件系统 IO | move/link 重命名归档 | 同盘硬链接限制 | -| 日志模块 | 文件系统 IO | 记录运行过程 | 日志目录写权限 | - -## 文件清单 -| 文件路径 | 类型 | 作用 | 谁会使用它 | 它依赖谁 | 备注 | -| --- | --- | --- | --- | --- | --- | -| `AutoAnimeMv.py` | 脚本 | 资源调用总入口 | 运行用户/任务调度器 | OS 文件系统与网络 | 核心 | -| `config.ini`(本地) | 配置文件 | 资源策略(代理、API、链接方式) | 部署者 | 脚本配置解析 | 不建议提交仓库 | - -## 依赖清单 -| 依赖项 | 类型 | 用途 | 所在位置 | 使用入口 | 备注 | -| --- | --- | --- | --- | --- | --- | -| 本地磁盘目录 | 系统资源 | 读取源文件、写入目标文件 | 用户指定路径 | `Processing_Mode` / `Sorting_Mv` | 需读写权限 | -| 网络连接 | 系统资源 | 访问 AI 与番剧 API | 操作系统网络栈 | `Auxiliary_Http` / `OpenAIApi` | 需稳定连接 | -| 代理配置 | 配置资源 | 解决网络访问限制 | `config.ini`/系统代理 | `Auxiliary_PROXY` | 可选启用 | - -## 调试命令表 -| 命令 | 执行位置 | 用途 | 示例 | 风险 | -| --- | --- | --- | --- | --- | -| `python AutoAnimeMv.py "目录"` | 项目根目录 | 触发一次完整处理流程 | `python AutoAnimeMv.py "D:\Anime"` | 可能移动文件,请先备份 | -| `ping api.longcat.chat` | 系统终端 | 粗略检查 AI 网关连通性 | `ping api.longcat.chat` | 仅网络连通,不代表接口可用 | - -## 风险点 -- 网络波动导致 API 请求失败,触发回退或原名使用。 -- 跨分区硬链接失败时会回退移动,需确认磁盘策略。 -- 路径权限不足可能导致整理失败。 - -## 变更记录 -| 日期 | 修改来源 | 修改原因 | 影响范围 | 对应功能/文件/模块 | -| --- | --- | --- | --- | --- | -| 2026-04-06 | Agent | 建立资源映射文档并纳入 AI 网关依赖 | 网络/文件系统资源说明 | `AutoAnimeMv.py` 运行链路 | diff --git "a/docs/04_\346\216\245\345\217\243\345\215\217\350\256\256\344\270\216\345\244\226\351\203\250\344\276\235\350\265\226.md" "b/docs/04_\346\216\245\345\217\243\345\215\217\350\256\256\344\270\216\345\244\226\351\203\250\344\276\235\350\265\226.md" deleted file mode 100644 index f113aed..0000000 --- "a/docs/04_\346\216\245\345\217\243\345\215\217\350\256\256\344\270\216\345\244\226\351\203\250\344\276\235\350\265\226.md" +++ /dev/null @@ -1,94 +0,0 @@ -# 接口协议与外部依赖 - -## 功能背景 -项目通过多源 API 对识别出的文件名信息进行标准化。当前识别链路为:OpenAI 全信息识别季/集与剧名线索 → **剧名解析链**收敛中文主名 → 内存与持久化缓存降压;HTTP 返回统一 `response.json()` 解析。 - -## 功能目标 -- 明确所有外部接口用途、协议和调用入口。 -- 记录关键配置项与安全注入方式,避免密钥硬编码。 - -## 功能边界 -- 仅覆盖当前脚本实际调用接口。 -- 不包含第三方平台的账号申请流程。 - -## 架构概览 -- **季/集**:`Processing_Identification()` → `Auxiliary_OpenAIIdentifyFileInfo()`(须 `USEOPENAIAPI` 且 `OPENAI_IDENTIFY_ALL`);失败则 `Auxiliary_Exit`,不再依赖「截断文件名推季集」。 -- **中文主名(剧名链)**:`Auxiliary_ResolvePlannedTitleChain()` / `Auxiliary_Api()`:**TMDB 中文 → Bangumi 中文 → TMDB 英文(`TMDB_EN` 缓存组)→ OpenAI 译中文**;四步均无法得到可用简体中文剧名则 `Auxiliary_Exit`。 -- **OpenAI 文件识别**:结果仅 **进程内** 缓存在 `OpenAIIdentifyFileMemoryCache`(按完整文件名键控),**不**再持久化 `OpenAIFileInfo` 分区。 -- **整部番整理进度**:持久化组 **`ShowOrganizationIndex`**,`canonical_id` → `{ title_zh, title_en, title_romaji, organized_episodes[], v }`;已收录的 `SxxExx` 在 `Processing_Main` 中跳过(`already_organized_show_cache`)。 -- **别名与主名索引**:`TitleAliasIndex`、`CanonicalTitleIndex`(与 `ShowOrganizationIndex` 一并列入永不过期集合)。 -- **路径**:`Auxiliary_NormalizeChinesePunctuation` 在 **每级新建目录名** 与 **最终媒体/字幕文件名** 上调用,再经 `Auxiliary_SanitizePathComponent` 等落盘(见 `Sorting_Mv`)。 - -## 外部接口清单 -| 接口名称 | 协议 | 默认地址 | 使用入口 | 用途 | 备注 | -| --- | --- | --- | --- | --- | --- | -| OpenAI 兼容接口 | HTTPS + JSON | `https://api.longcat.chat/openai/v1/chat/completions` | `OpenAIApi()` / `Auxiliary_OpenAIIdentifyFileInfo()` / `Auxiliary_OpenAITranslateForeignTitleToChinese()` | 全信息识别;外文剧名译中文 | 可替换同协议网关 | -| Bangumi 搜索 | HTTPS + JSON | `https://api.bgm.tv/search/subject/{name}` | `BangumiApi()` / `Auxiliary_QueryBangumiChineseTitle` | 剧名链第二步 | | -| TMDB TV 搜索 | HTTPS + JSON | `https://api.themoviedb.org/3/search/tv` 等 | `TMDBApi()` / `Auxiliary_QueryTMDBChineseTitle` / `Auxiliary_QueryTMDBEnglishTitle` | 剧名链第一、三步 | 需 bearer | - -## 配置说明 -| 配置项 | 类型 | 默认值 | 作用 | 安全建议 | -| --- | --- | --- | --- | --- | -| `USEOPENAIAPI` | bool | `True` | 是否启用 AI | 剧名链末步与译名依赖 | -| `OPENAI_IDENTIFY_ALL` | bool | `True` | 是否由 AI 识别剧名/季/集 | **必须为 True**(当前主路径) | -| `OPENAI_BASE_URL` | str | `https://api.longcat.chat/openai` | OpenAI 兼容网关 | 可按供应商替换 | -| `OPENAI_MODEL` | str | `LongCat-Flash-Chat` | 调用模型 | 与网关一致 | -| `OPENAI_TIMEOUT_SECONDS` | int | `60` | 请求超时秒数 | | -| `OPENAI_API_KEY` | str | 空 | 本地直填 key(可选) | 公开仓库禁提交 | -| `OPENAI_API_KEY_ENV` | str | `OPENAI_API_KEY` | 环境变量名 | 推荐环境变量 | -| `TMDB_BEARER_TOKEN` | str | 空 | TMDB token(可选) | 公开仓库禁提交 | -| `TMDB_BEARER_TOKEN_ENV` | str | `TMDB_BEARER_TOKEN` | 环境变量名 | 推荐环境变量 | -| `CACHE_DIR` | str | `.cache` | 持久化缓存目录 | 大版本升级见下文 **须清空** | -| `CACHE_TTL_SECONDS` | int | `86400` | 多数 API 缓存 TTL | 索引组永不过期 | -| `USEBANGUMIAPI` / `USETMDBAPI` | bool | `True` | 剧名链开关 | 全关可能导致剧名链失败退出 | - -## 依赖清单表 -| 依赖项 | 类型 | 用途 | 所在位置 | 使用入口 | 备注 | -| --- | --- | --- | --- | --- | --- | -| `requests` | Python 库 | HTTP | `requirements.txt` | `Auxiliary_Http` / `OpenAIApi` | 必需 | -| `zhconv` | Python 库 | 繁简 | `requirements.txt` | `Auxiliary_UniformOTSTR` | 必需 | -| LongCat API 网关 | 外部服务 | AI | 云端 | `OpenAIApi` 等 | 可替换 | -| Bangumi / TMDB | 外部服务 | 剧名 | 云端 | 各查询函数 | | -| Schema v2 子文件 + `cache_meta.json` | 本地存储 | `organization.json` / `titles.json` / `api_responses.json` 等 | 与 `api_cache.json` 同目录(由 `Auxiliary_GetCacheStorePath` 推得) | `Auxiliary_GetPersistentCache` / `Auxiliary_SavePersistentCache` | 新入口首次运行将旧 `api_cache.json` 迁入 `backups/`;单文件与 v2 二选一,见 `docs/10_缓存Schema_v2设计.md` | -| `api_cache.json`(旧版) | 本地存储 | 单文件全量 | `CACHE_DIR/api_cache.json` | 同上(仅 v1 布局未迁移时) | 旧 `AutoAnimeMv.py` 或回滚后仍可用 | - -## 调用关系 -- `Processing_Main -> Processing_Identification -> Auxiliary_OpenAIIdentifyFileInfo -> Auxiliary_ResolvePlannedTitleChain(剧名链)` -- `Processing_Main -> Auxiliary_ShowHasOrganizedEpisode -> 跳过已整理集` -- `Auxiliary_Api -> Auxiliary_GetStandardTitleFromCache(Bangumi/TMDB 粗命中)或 Auxiliary_ResolvePlannedTitleChain` -- 程序退出:`Auxiliary_SavePersistentCache`;Schema v2 下按子文件脏标志增量写;旧版 v1 单文件仍为整文件 JSON 写回(`sort_keys=True`)。 - -## 持久化:Schema v2 与 `CacheGroup` 逻辑分区 - -**v2 物理布局**(见 `docs/10_缓存Schema_v2设计.md`):`cache_meta.json` 为入口;`organization.json` 存 `ShowOrganizationIndex`;`titles.json` 存 `CanonicalTitleIndex` 与 `TitleAliasIndex`;`api_responses.json` 存各 TTL 组。首次启用时,若存在旧 `api_cache.json` 会移至 `backups/api_cache_legacy_.json` 后冷启动空表。 - -**对业务代码仍表现为下列逻辑分区**(`Auxiliary_GetPersistentCache` 路由): - -| 分区 / `CacheGroup` | 作用 | 备注 | -| --- | --- | --- | -| `CanonicalTitleIndex` | `canonical_id -> {zh,en,romaji,source,...}` | 落 `titles.json`;永不过期 | -| `TitleAliasIndex` | `alias_key -> canonical_id` + 元数据 | 同上;`trust` 与写入校验见 `autoanime/cache/trust.py` | -| `ShowOrganizationIndex` | `canonical_id -> {title_zh,title_en,title_romaji,organized_episodes,v}` | 落 `organization.json`;永不过期 | -| `Bangumi` / `TMDB` / `TMDB_EN` / `TMDBTvSeriesId` / `TMDBTvSeasons` 等 | 搜索与 TV 元数据 | 落 `api_responses.json` 对应桶;分 TTL | -|(其他组,如扩展)| 可落入 `ext` 桶 | 由 `autoanime/cache/persistent.py` 路由 | - -**旧版单文件**(未出现 `cache_meta.json` 时仍读 `api_cache.json`):分区含义上表仍适用,仅物理为单文件。 - -**升级注意**:v2 **不**恢复历史 `ResolvedFileInfo` / `OpenAIFileInfo` 等已废弃键;大版本切换仍建议先备份后删除或更换 `CACHE_DIR`。 - -## 风险点 -- 网关或第三方 API 变更导致兼容或字段解析失败。 -- 错误配置导致无法读取 key/token。 -- **强依赖 OpenAI 季/集**:未启用或识别失败将直接退出。 -- 中文标点归一与 `Auxiliary_SanitizePathComponent` 的顺序固定,避免引入非法路径字符。 - -## 变更记录 -| 日期 | 修改来源 | 修改原因 | 影响范围 | 对应功能/文件/模块 | -| --- | --- | --- | --- | --- | -| 2026-04-06 | Agent | 增加 OpenAI 兼容识别接口与配置说明 | API 依赖与协议文档 | `AutoAnimeMv.py` `config.ini.Template` | -| 2026-04-06 | Agent | API 解析统一改为 JSON 校验,新增 TMDB token 外置与持久化缓存配置说明 | 接口调用、配置说明、缓存依赖 | `AutoAnimeMv.py`、`config.ini.Template` | -| 2026-04-06 | Agent | 启用 OpenAI 全信息识别(剧名/季/集),减少本地截断与匹配依赖 | 识别入口、配置项、OpenAI 调用链路 | `Processing_Identification`、`Auxiliary_OpenAIIdentifyFileInfo` | -| 2026-04-07 | Agent | 补充 OpenAI 全信息识别的简体中文剧名约束,以及标题标准化缓存复用说明 | OpenAI 调用链路、缓存依赖 | `Auxiliary_OpenAIIdentifyFileInfo`、`api_cache.json` | -| 2026-04-07 | Agent | 新增别名索引与中文主名称索引,并将 OpenAI 英文/罗马音字段接入统一链路 | 接口返回约束、缓存依赖、命名统一链路 | `Auxiliary_OpenAIIdentifyFileInfo`、`Auxiliary_Api`、`TitleAliasIndex`、`CanonicalTitleIndex` | -| 2026-04-16 | Agent | **重构**:剧名链 TMDB 中文→Bangumi→TMDB 英文→OpenAI 译中文;`ShowOrganizationIndex` 替代历史文件级 Resolved 持久化;OpenAI 识别仅内存缓存;须清空旧 `api_cache.json` | 识别、缓存、主流程 | `Auxiliary_ResolvePlannedTitleChain`、`ShowOrganizationIndex`、`Processing_Main` | -| 2026-04-23 | Agent | 文档与实现对齐:持久化 v2 多子文件、逻辑 `CacheGroup` 不变、退出按子文件 flush;旧单文件为回退路径 | 缓存、新入口 | `autoanime/cache/persistent.py`、`autoanime/cli.py`、`docs/10_缓存Schema_v2设计.md` | diff --git "a/docs/05_\350\260\203\350\257\225\350\256\260\345\275\225\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" "b/docs/05_\350\260\203\350\257\225\350\256\260\345\275\225\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" deleted file mode 100644 index dbc593f..0000000 --- "a/docs/05_\350\260\203\350\257\225\350\256\260\345\275\225\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" +++ /dev/null @@ -1,124 +0,0 @@ -# 调试记录与常见问题 - -## 功能背景 -识别链路涉及本地正则与多外部 API,且本次新增了缓存、dry-run、回滚与命名模板。实际问题主要集中在配置、网络、路径清洗与回滚日志可用性。 - -## 功能目标 -- 给出可重复的排查流程。 -- 固化常见问题的定位方法与修复建议。 - -## 调试方法 -1. 先检查 `config.ini` 是否存在且分区为 `[Settings]` 或 `[#Config]`。 -2. 再确认 `OPENAI_API_KEY`、`TMDB_BEARER_TOKEN` 是否已注入环境变量。 -3. 若启用了 `OPENAI_IDENTIFY_ALL`,先确认日志里是否出现“OpenAI文件识别成功”。 -4. 终端默认只输出番剧识别、命名、文件操作、回滚和异常信息;配置项、环境参数、缓存命中等技术细节不再刷屏。 -5. 若需查看完整运行细节,检查程序按天写出的 `.log` 文件;敏感值仍会脱敏。 -6. 先用 `--dry-run` 验证目标命名与落盘路径,再执行真实整理。 -7. 出错时使用操作日志进行回滚验证;`rollback` 命令本身只消费已有操作日志,不会再额外生成新的整理操作日志。 -8. 若目录下只有“合集子文件夹”,确认使用的是上级目录路径,程序会递归扫描子文件夹中的媒体文件。 -9. 若需“扫描一个目录、整理到另一个目录”,使用 `--output-path` 或配置 `OUTPUT_PATH`。 -10. 若使用 qBittorrent 回调,像 `.!qB`、`.part`、`.partial`、`.aria2`、`.crdownload` 这类未完成下载临时文件会自动跳过,不参与整理。 -11. 若同一剧集下载了重复资源(如不同字幕组版本),流程会优先保留最早文件;较新文件会记录 `newer_duplicate_kept_oldest` 并跳过,不覆盖旧文件。 -12. 命名统一新增别名索引缓存:`TitleAliasIndex` + `CanonicalTitleIndex`。遇到同一番剧多译名/英文名/罗马音分叉时,优先检查这两个缓存组是否已建立主名称映射。 -13. 若日志中出现 `季:00 集:08.0/06.0` 这类结果,优先确认是否为旧缓存残留;新版会先将 `x.0` 归一为整数集,避免误入 `Season00`。 -14. 若 AI 返回的剧名不含中文(如 `GNOSIA`、`MAO`),新版会自动触发一次二次 API 标准化;排查时可关注“OpenAI剧名缺少中文,已执行二次 API 标准化”。 -15. 若同一番剧仅个别单集漂移到其他作品名,优先检查日志是否出现“单集剧名漂移/别名冲突纠偏”告警;新版会优先采用文件名历史别名映射做纠偏。 -16. Schema v2 下若怀疑 **别名表被污染**(如极长 `alias` 键、异常 `trust`):用 `python scripts/cache_doctor.py --inspect --cache-dir <你的 .cache 路径>` 看「别名键长度超过 `trust.ALIAS_KEY_MAX_LEN`(当前 100)」等统计与 `titles.json` 规模;可配合 `--export-audit` 按时间筛 `pollution_audit.jsonl`;对误写入的 `alias_written` 事件可用 `--revert --audit-id` 撤销;灾难恢复时慎用 `--rebuild-from-organization`(会 **覆盖** `titles.json`)。 - -## 调试命令表 -| 命令 | 执行位置 | 用途 | 示例 | 风险 | -| --- | --- | --- | --- | --- | -| `python AutoAnimeMv.py "待整理目录"` | 项目根目录 | 触发完整流程并生成日志 | `python AutoAnimeMv.py "D:\Anime"` | 可能移动/重命名文件 | -| `python AutoAnimeMv.py "待整理目录" --dry-run` | 项目根目录 | 预览本次整理操作与目标路径 | 同左 | 不移动文件,但会写操作日志 | -| `python AutoAnimeMv.py "待整理目录" --naming-style emby` | 项目根目录 | 以 Emby 风格命名整理 | 同左 | 目标目录结构会变化 | -| `python AutoAnimeMv.py rollback --log "日志路径"` | 项目根目录 | 按操作日志逆序回滚 | `python AutoAnimeMv.py rollback --log ".\\logs\\AutoAnime_operations_xxx.json"` | 日志缺失时无法完整回滚 | -| `python -m unittest discover -s tests -v` | 项目根目录 | 运行本地回归测试集 | 同左 | 需确保 Python 环境可运行测试 | -| `python -m pip show requests` | 项目根目录 | 检查网络依赖是否安装 | `python -m pip show requests` | 无 | -| `python -m pip show zhconv` | 项目根目录 | 检查繁简转换依赖 | `python -m pip show zhconv` | 无 | -| `echo $env:OPENAI_API_KEY`(PowerShell) | 系统终端 | 检查 key 环境变量是否已注入 | `echo $env:OPENAI_API_KEY` | 输出敏感信息,勿截图/共享 | -| `echo $env:TMDB_BEARER_TOKEN`(PowerShell) | 系统终端 | 检查 TMDB token 是否已注入 | `echo $env:TMDB_BEARER_TOKEN` | 输出敏感信息,勿截图/共享 | -| `python scripts/cache_doctor.py --inspect` | 项目根 | v2 子文件体积、sha256、条数、别名嫌疑键 | 可加 `--cache-dir` | 只读 | -| `python scripts/cache_doctor.py --export-audit --since YYYY-MM-DD` | 项目根 | 导出 `pollution_audit.jsonl` 中某日起事件 | 见上 | 只读 | -| `python scripts/cache_doctor.py --revert --audit-id ` | 项目根 | 按审计撤销一次 `alias_written` | 需先 `export-audit` 查 id | 改 `titles.json` | -| `python scripts/cache_doctor.py --set-whitelist --alias --zh <中文> [--apply-rename …]` | 项目根 | 写 `manual_title_whitelist.json`;可选按 `episode_last_dst` 迁移并同步 `titles`+`organization` | 见 `autoanime/cache/README.md` §8.2 | `--apply-rename` 会 **move** 媒体并改缓存 | -| `python scripts/cache_doctor.py --set-title-zh --canonical-id --zh <中文> [--apply-rename]` | 项目根 | 同步 `titles.canonical.zh` 与 `organization.title_zh`;可选同上迁移 | 见 `autoanime/cache/README.md` §8.2 | 无 `--apply-rename` 时只改 JSON | -| `python scripts/cache_doctor.py --rename-episodes --zh <新> --canonical-id [--apply-rename]` | 项目根 | **仅**按 `episode_last_dst` 预览或迁盘;未加 `--apply-rename` 时只打印、不改 JSON;加则 move 并写缓存。亦可用 `--old-title-zh` 代替 `canonical-id` 唯一定位 | 见 `autoanime/cache/cache_doctor_重命名与剧名纠偏_使用说明.md` | `--apply-rename` 会 **move** | - -## 常见问题 -| 问题现象 | 可能原因 | 排查点 | 建议处理 | -| --- | --- | --- | --- | -| AI 一直不生效 | 未注入 key 或网关不可达 | 日志是否提示“未检测到可用密钥” | 设置 `OPENAI_API_KEY` 并检查网络 | -| TMDB 回退始终失败 | 未配置 token | 日志是否提示 TMDB token 未配置 | 设置 `TMDB_BEARER_TOKEN` 或关闭 `USETMDBAPI` | -| 配置未生效 | 分区名不匹配或格式错误 | 是否为 `[Settings]`/`[#Config]`,是否含 `=` | 修正配置格式 | -| 识别结果不理想 | 原始文件名噪声过多 | 观察 `Auxiliary_UniformOTSTR` 后字符串 | 调整命名源或补充回退策略 | -| OpenAI 全信息识别返回英文名 | 提示词未明确要求中文,或剧名链未收敛到中文 | 查看剧名链日志与 `CanonicalTitleIndex` | 确认 TMDB/Bangumi/OpenAI 译名可用;必要时清空旧 `api_cache.json` 后重跑 | -| 硬链接失败 | 跨分区或文件系统不支持 | 日志是否出现 WinError | 启用 `LINKFAILSUSEMOVEFLAGS` 回退 | -| 预览正常但真实执行失败 | 目录权限不足或目标已锁定 | 对比 dry-run 日志与错误日志 | 检查目标目录权限并重试 | -| 目录下只有子文件夹时未整理到文件 | 旧版本仅扫描顶层文件 | 查看日志是否显示“发现X个视频文件”且路径含子目录 | 升级到支持递归扫描的版本,传入上级目录执行 | -| 启用 OpenAI 后季集识别不生效 | 未开启全信息识别或 key 无效 | 检查 `OPENAI_IDENTIFY_ALL` 和日志中的 OpenAI 文件识别信息 | 设置 `OPENAI_IDENTIFY_ALL=True` 并确认 `OPENAI_API_KEY` 可用 | -| 硬链接失败后文件被移动 | 未开启严格模式或允许回退移动 | 检查 `STRICT_MODE` 与 `LINKFAILSUSEMOVEFLAGS` 配置 | 做种场景建议 `USELINK=True`、`STRICT_MODE=True`、`LINKFAILSUSEMOVEFLAGS=False` | -| 结果目录不在期望位置 | 未设置输出路径或传参错误 | 检查 `OUTPUT_PATH` / `--output-path` | 显式传入 `--output-path "目标路径"` | -| 独立输出目录时重复整理同一文件 | 源文件保留(如硬链接场景),而目标文件已与源文件指向同一物理文件 | 查看日志是否出现“目标文件已与源文件一致,跳过重复整理” | 升级到包含 samefile/hardlink 去重跳过逻辑的版本 | -| 报错 `time data 'xxx' does not match format '%Y-%m-%d'` | 日志清理把非日期命名日志(如 `XSCL-106.log`)误当成日期日志解析 | 查看报错前是否在执行日志清理,确认 `logs` 目录含非 `YYYY-MM-DD.log` 文件 | 升级到仅清理 `YYYY-MM-DD.log` 的版本,或手动迁移这类特殊日志文件 | -| 启动后“发现 X 个视频文件”阶段耗时明显 | 一次性打印超长文件列表(几百到上千项)导致终端输出阻塞 | 观察日志是否整段输出完整文件列表 | 升级到“数量 + 预览列表”输出版本,必要时关闭终端实时输出仅看日志文件 | -| qB 回调误处理未下载完成文件 | 回调传入了 `.!qB`/`.part` 等临时文件名 | 查看日志是否出现“跳过未完成下载文件” | 升级到包含未完成下载文件过滤逻辑的版本 | -| 重复资源反复触发 OpenAI 识别 | 同集未命中 `ShowOrganizationIndex.organized_episodes` 或同集决策键不一致 | 查看是否出现 `already_organized_show_cache` / `newer_duplicate_kept_oldest` | 确认首轮整理已成功写入 Show 记录;必要时检查 `canonical_id` 是否漂移 | -| 同一番剧被拆成多个目录(仅标点或译名差异) | 未启用别名归一索引或历史缓存未收敛 | 查看 `api_cache.json` 中 `TitleAliasIndex/CanonicalTitleIndex` 是否存在映射 | 升级到包含别名归一策略的版本,并让新任务跑一轮以逐步收敛旧缓存 | -| 同一集多个资源仍重复识别 | 较新资源未在 PreDetect 同集键或主流程 Episode 决策阶段提前跳过 | 查看是否出现 `newer_duplicate_kept_oldest` 或 `already_organized_show_cache` | 检查 basename 是否可提取季集;已整理集依赖 Show 缓存 | -| `S2-08` 被整理成 `Season00/S00E08.0` | 识别结果把整数集写成了小数(如 `8.0`),旧逻辑把小数直接判定为特典季 | 查看 `OpenAI文件识别成功` 行里是否出现 `集:8.0/6.0` | 升级到包含 `x.0 -> x` 归一与特典判定修正的版本 | -| 同一番剧仅个别单集被识别成另一部番(单集漂移) | AI 单次识别波动,且错误结果写入缓存后被复用 | 查看是否出现“OpenAI识别结果与文件名别名冲突”“单集剧名漂移” | 升级到包含“文件名历史别名冲突纠偏”的版本,优先采用历史 canonical 映射 | -| AI 已识别成功但目录仍是英文名 | AI 返回 `anime_name_zh` 非中文或为空,且旧逻辑未触发二次标准化 | 查看是否出现“OpenAI剧名缺少中文,已执行二次 API 标准化” | 升级到包含“非中文 AI 结果自动二次 API 标准化”的版本 | -| 测试输出出现 `zhconv` `ResourceWarning: unclosed file` | 第三方库默认词典加载直接 `.read()`,未显式关闭资源流 | 检查是否仍走第三方默认 `loaddict` 路径 | 新入口在 `autoanime/zhconv_safe.py` 用 `importlib.resources` 的 `with` 预读;仍告警时检查是否未走新入口或第三方懒加载未命中预加载 | -| 删除目标后仍被 `already_organized_show_cache` 盲跳 | 旧逻辑仅看 Show 标签未校验目标文件是否仍在 | 查看是否新入口;日志是否出现 `already_organized_show_cache_stale` 自愈 | 使用 `AutoAnimeMv2` + `autoanime` 流水线:目标缺失会剔 tag 并重整理 | -| 已配置 `OUTPUT_PATH` 仍刷「Log文件保存在工具目录下」 | 该 WARN 在独立输出场景易误导 | 新入口下若 `OUTPUT_PATH` 非空则不再打此条 | `autoanime.logging_utils.Auxiliary_WriteLog` | -| 多字幕因无法解集刷屏「字幕文件无法提取剧集」 | 单条匹配失败逐条 WARNING | 观察是否合并为一次「跳过 N 个」 | `autoanime.naming.Auxiliary_IDEASS` 批汇总;OpenAI 预失败在开启回退时多为 INFO | - -## alias 污染排查(Schema v2) -- **现象**:识别结果跳番、同番多名无法收敛、或 `titles.json` 体积异常大。 -- **先验**:`pollution_audit.jsonl` 中 `alias_rejected` 为正常拦截;`alias_written` 为已落盘映射,可结合 `--revert` 撤销误操作。 -- **工具**:`scripts/cache_doctor.py`(**七子命令**互斥:`--inspect` / `--export-audit` / `--revert` / `--rebuild-from-organization` / `--set-whitelist` / `--set-title-zh` / `--rename-episodes`),详见 `autoanime/cache/README.md` §8、专题目录 **`autoanime/cache/cache_doctor_重命名与剧名纠偏_使用说明.md`** 与 `docs/10_缓存Schema_v2设计.md`。 -- **剧名/磁盘与缓存对齐**:在 **不加 `--apply-rename`** 时,`--set-title-zh` 与 `--set-whitelist` **不会**对媒体做 `move`;**`--rename-episodes` 默认也只预览**。需要按 `organization.episode_last_dst` 迁盘时须显式 **`--apply-rename`**(或 `rename-episodes`+`--apply-rename`),并先备份;规则见 `autoanime/episode_dst_rename.py`。 - -## 调试记录 -| 日期 | 现象 | 根因 | 处理结果 | -| --- | --- | --- | --- | -| 2026-04-06 | 需要新增 AI 优先识别且保护 key | 缺少 AI 通道与安全注入约束 | 新增 OpenAI 兼容识别 + 环境变量密钥读取 + 日志脱敏 | -| 2026-04-06 | 批量整理缺少预览与回滚能力 | 文件执行链路无统一操作清单 | 增加 dry-run、操作日志与 rollback 入口 | -| 2026-04-07 | `OpenAI 全信息识别` 将 `Jujutsu Kaisen` 直接落为目录名 | AI 提示词未强制简体中文,剧名链未生效 | 补充中文提示词与剧名链;依赖 `CanonicalTitleIndex` 收敛 | -| 2026-04-07 | `08`/`06` 等普通剧集被识别为 `08.0/06.0` 后落入 `Season00` | 旧逻辑把所有小数剧集一律判定为特典季 | 新增剧集小数归一规则(`x.0 -> x`),并仅在真实特典条件下进入 `Season00` | -| 2026-04-07 | 同番仅个别单集漂移到其他作品目录 | AI 单次识别波动导致别名冲突,错误结果污染单文件缓存 | 新增“文件名历史别名冲突纠偏”,并在冲突时抑制错误中英罗别名写入 | -| 2026-04-07 | 单测阶段持续出现 `zhconv` 资源句柄告警 | 第三方词典加载未主动关闭资源流 | 新增安全预加载逻辑,启动时显式关闭词典资源流并复用已加载词典 | -| 2026-04-14 | 运行中抛出 `time data ... does not match format '%Y-%m-%d'` 且扫描日志刷屏导致体感变慢 | 日志清理误解析非日期日志名,且扫描阶段输出了超长文件全量列表 | 日志清理仅处理 `YYYY-MM-DD.log`;扫描日志改为数量+预览,降低终端输出耗时 | - -## 已知问题 -- AI 返回文本仍可能包含额外说明,当前逻辑仍以首行清洗为主。 -- 覆盖写入回滚依赖备份文件和日志完整性,外部手工改动会影响恢复结果。 - -## 后续优化建议 -- 增加可选“仅使用 AI”与“禁用某回退 API”策略组合。 -- 增加操作日志签名/校验,防止手工改动日志导致回滚偏差。 -- 为标题清洗增加更细粒度白名单与分语言策略。 - -## 变更记录 -| 日期 | 修改来源 | 修改原因 | 影响范围 | 对应功能/文件/模块 | -| --- | --- | --- | --- | --- | -| 2026-04-06 | Agent | 新增 AI 方案后补充调试与 FAQ | 运行排障流程 | API 识别链路、配置链路 | -| 2026-04-06 | Agent | 补充 dry-run/rollback 与 Emby 命名调试路径 | 批量整理排障与回滚流程 | `Sorting_Mv`、操作日志链路、CLI 参数 | -| 2026-04-06 | Agent | 补充子文件夹递归扫描后的排障说明 | 扫描与整理排障流程 | `Auxiliary_ScanDIR`、`Processing_Main` | -| 2026-04-06 | Agent | 补充 OpenAI 全信息识别后的排障说明 | 识别链路排障流程 | `Processing_Identification`、`Auxiliary_OpenAIIdentifyFileInfo` | -| 2026-04-06 | Agent | 补充严格模式、硬链接策略与输出目录参数排障说明 | 文件执行与目标路径排障流程 | `Auxiliary_ExecuteFileOperation`、`OUTPUT_PATH`、`Start_GetArgv` | -| 2026-04-07 | Agent | 补充 OpenAI 全信息识别英文标题落盘问题的排障说明 | 识别链路排障、缓存问题定位 | `Auxiliary_OpenAIIdentifyFileInfo`、`api_cache.json` | -| 2026-04-07 | Agent | 收敛终端输出,仅保留番剧整理关键信息,减少配置/缓存类噪音 | 终端输出、调试方法 | `Auxiliary_Log`、启动配置日志、缓存日志 | -| 2026-04-07 | Agent | 补充独立输出目录重复整理问题的排障与跳过判定说明 | 输出目录场景排障、重复整理定位 | `Auxiliary_ExecuteFileOperation`、`OUTPUT_PATH`、硬链接场景 | -| 2026-04-07 | Agent | 补充 rollback 分支上下文同步与“不额外写新操作日志”的说明 | 回滚排障、日志预期 | `Start_GetArgv`、`Auxiliary_WriteOperationLog` | -| 2026-04-07 | Agent | 补充未完成下载临时文件(如 `.!qB`)自动跳过说明 | qB 回调排障、临时文件过滤 | `Processing_Mode`、`Processing_Main`、未完成下载文件识别 | -| 2026-04-07 | Agent | 补充重复资源保留原硬链接与文件级识别缓存命中的排障说明 | 重复资源排障、缓存命中预期 | `Processing_Main`、`Auxiliary_ExecuteFileOperation` | -| 2026-04-16 | Agent | 排障说明改为 `ShowOrganizationIndex` / 剧名链 / 须清空旧 `api_cache.json` | FAQ | `docs/05`、`docs/04` | -| 2026-04-07 | Agent | 补充别名归一缓存、同集最老优先和较新重复资源跳过日志说明 | 命名统一排障、重复识别排障 | `TitleAliasIndex`、`CanonicalTitleIndex`、`Processing_Main` | -| 2026-04-07 | Agent | 补充“AI 小数集误判 Season00、单集漂移、非中文剧名自动二次标准化”的排障说明 | 识别链路排障、缓存冲突定位 | `Auxiliary_OpenAIIdentifyFileInfo`、`Processing_Main`、`Auxiliary_NormalizeEpisodeToken` | -| 2026-04-07 | Agent | 补充 `zhconv` 资源句柄告警的根因与修复说明 | 第三方依赖排障 | `Auxiliary_InitZhconvDictionarySafely`、`Start_PATH`、`zhconv` 预加载链路 | -| 2026-04-14 | Agent | 补充“非日期日志名触发日期解析异常”和“超长扫描列表拖慢终端输出”的排障说明 | 日志清理与扫描阶段排障 | `Auxiliary_DeleteLogs`、`Auxiliary_ScanDIR`、`Auxiliary_FormatListPreview` | -| 2026-04-22 | Agent | 补充 `already_organized` 盲跳排障、zhconv/字幕/OpenAI/OUTPUT_PATH 降噪说明 | FAQ | `autoanime/pipeline/main.py`、`autoanime/zhconv_safe.py`、`autoanime/naming.py`、`openai_identify` | -| 2026-04-23 | Agent | 补充 Schema v2 别名污染排查与 `cache_doctor.py` 命令 | 调试流程 | `scripts/cache_doctor.py`、`docs/10_缓存Schema_v2设计.md` | -| 2026-04-23 | Agent | 登记 `cache_doctor` 白名单/改中文名/可选 `--apply-rename` 与 `episode_dst_rename` 排障 | 命令表、alias 小节 | `autoanime/episode_dst_rename.py`、`autoanime/cache/README.md` | -| 2026-04-23 | Agent | 专题目录 `cache_doctor_重命名与剧名纠偏_使用说明.md`;alias 小节与命令表链到该文 | `autoanime/cache/`、`docs/05` | diff --git "a/docs/06_AI\350\257\206\345\210\253\344\274\230\345\205\210\346\226\271\346\241\210.md" "b/docs/06_AI\350\257\206\345\210\253\344\274\230\345\205\210\346\226\271\346\241\210.md" deleted file mode 100644 index 2df94c8..0000000 --- "a/docs/06_AI\350\257\206\345\210\253\344\274\230\345\205\210\346\226\271\346\241\210.md" +++ /dev/null @@ -1,167 +0,0 @@ -# AI识别优先方案 - -## 功能背景 -原有剧名标准化流程主要依赖 `Bangumi/BGM/TMDB`,在命名噪声较高或中文别名场景下存在识别不稳定问题。为提升识别覆盖与鲁棒性,引入 OpenAI 兼容接口作为首选识别通道。 - -## 功能目标 -- 新增 AI 识别通道,默认优先执行。 -- AI 不可用时自动回退到原有 API 轮询。 -- 密钥不落库:支持环境变量注入并对日志敏感值脱敏。 -- 在启用 `OPENAI_IDENTIFY_ALL` 时,由 AI 直接输出剧名/季/集,减少本地规则误判。 -- **OpenAI 请求体中的 `user` 内容**对文件基名先做 **`Auxiliary_StripLeadingBracketReleaseTags`**:去掉首部连续的半角 `[字幕组/发行标签]` 与全角 `【…】`,避免组名(如 `[Tsukigakirei]`)被误认为番名;内存缓存 key、`Auxiliary_PreDetectEpisodeHint` 等仍使用**原始 `path.basename`**,避免破坏已有缓存与集数提示。 -- 在 AI 全信息识别场景中,同步输出英文名与罗马音,并通过别名索引复用历史中文主名称。 -- 对 `episode=x.0` 等小数噪声做归一,避免普通剧集被误判到 `Season00`。 -- 当 AI 结果与文件名历史别名冲突时,优先保留历史 canonical 映射,抑制“单集漂移”。 -- AI 返回剧名线索后,统一走 **`Auxiliary_ResolvePlannedTitleChain`**:**TMDB 中文 → Bangumi → TMDB 英文 → OpenAI 译中文**;链失败则 `Auxiliary_Exit`(无旧式 `try_apis` 多路轮询)。 -- **季/集**仅来自 OpenAI 全信息;识别失败则退出,不回退本地截断推季集。 - -## 功能边界 -- 默认改造“剧名标准化识别”链路;当 `OPENAI_IDENTIFY_ALL=True` 时也改造季集识别入口。 -- 不新增第三方 Python 依赖。 -- 不引入守护进程或服务端改造。 - -## 架构概览 -1. 在 `Start_PATH()` 初始化新增 OpenAI 默认配置。 -2. 在 `Processing_Identification()` 中 **仅** `Auxiliary_OpenAIIdentifyFileInfo()`: - - 调用 `POST {OPENAI_BASE_URL}/v1/chat/completions`;`messages` 中 `user` 使用剥除首部方括号标签后的基名;`system` 提示中说明方括号内多为字幕组/发行方。 - - 解析 JSON:`anime_name_zh/.../season/episode/...` - - 成功后走 **`Auxiliary_ResolvePlannedTitleChain`** 得中文主名与 `canonical_id`;OpenAI 识别结果 **仅内存缓存**(`OpenAIIdentifyFileMemoryCache`),不持久化 `OpenAIFileInfo` - - 对 `episode` 做后处理(如 `8.0 -> 8`),仅在真实特典条件下落入 `Season00` - - 发生“AI结果别名”和“文件名历史别名”冲突时,优先采用历史 canonical 映射(`Auxiliary_PreDetectEpisodeHint` 等) -3. `Auxiliary_Api()` 在缓存未命中时调用 **`Auxiliary_ResolvePlannedTitleChain`**(必要时内部使用 `OpenAIApi` 做译名);不再使用历史 `try_apis` 顺序。 -5. 在配置链路中新增: - - `[Settings]` 与 `[#Config]` 兼容 - - 配置值解析与敏感字段脱敏 -6. 在接口层新增: - - `Auxiliary_Http()` 统一 JSON 解析与状态码校验 - - API 结果支持内存缓存 + 持久化缓存(TTL) - - 标题别名统一索引:`TitleAliasIndex` + `CanonicalTitleIndex` - -## 模块职责 -| 模块 | 文件 | 职责 | 输入 | 输出 | -| --- | --- | --- | --- | --- | -| 默认配置模块 | `AutoAnimeMv.py` (`Start_PATH`) | 初始化 AI 开关、网关、模型、超时、优先级 | 内置默认值 | 全局配置变量 | -| 配置读取模块 | `AutoAnimeMv.py` (`Auxiliary_READConfig`) | 读取 `config.ini` 并兼容不同分区名 | 本地配置文件 | `ConfigMagdict` | -| 配置应用模块 | `AutoAnimeMv.py` (`Auxiliary_ApplyConfig`) | 解析值并写入运行态全局变量 | `ConfigMagdict['#Config']` | 生效配置 | -| AI 文件识别模块 | `AutoAnimeMv.py` (`Auxiliary_OpenAIIdentifyFileInfo`) | 通过 OpenAI 兼容协议提取剧名/季/集,并优先用罗马音/英文查询 TMDB 回填中文 | 原始文件名 | `(SE,EP,RAWSE,RAWEP,RAWName)` + `NameEN/NameRomaji/CanonicalID` | -| AI 标题识别模块 | `AutoAnimeMv.py` (`OpenAIApi`) | 通过 OpenAI 兼容协议提取标题 | 原始番剧名 | 标准化标题/空 | -| 剧名链模块 | `AutoAnimeMv.py` (`Auxiliary_ResolvePlannedTitleChain`) | TMDB 中文→Bangumi→TMDB 英文→OpenAI 译中文 | 中英罗线索 | 中文主名或退出 | - -## 文件清单 -| 文件路径 | 类型 | 作用 | 谁会使用它 | 它依赖谁 | 备注 | -| --- | --- | --- | --- | --- | --- | -| `AutoAnimeMv.py` | Python | 新增 AI 识别、优先级控制、配置脱敏 | 运行主程序 | `requests`、外部 API | 核心改造文件 | -| `config.ini.Template` | 配置模板 | 给出 AI 配置和安全示例 | 部署者 | 配置解析逻辑 | 不含真实密钥 | -| `.gitignore` | Git 配置 | 防止 `config.ini/.env` 误提交 | 开发者 | Git | 安全防护 | - -## 函数清单 -| 函数/方法 | 所在文件 | 作用 | 输入 | 输出 | 调用方 | 被调对象 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | --- | -| `OpenAIApi` | `AutoAnimeMv.py` | 调用 AI 网关并提取标准标题 | `Name` | `ApiName or None` | `Auxiliary_ResolvePlannedTitleChain`(译名等) | `requests.post` | | -| `Auxiliary_QueryTMDBChineseTitle` | `AutoAnimeMv.py` | 仅通过 TMDB 查询中文标题,并写入 canonical 与 TMDB 缓存 | `QueryName/NameEN/NameRomaji` | `中文标题或None` | `Auxiliary_OpenAIIdentifyFileInfo` / `TMDBApi` | `Auxiliary_Http` | 2026-04-09 新增 | -| `Auxiliary_GetStandardTitleFromCache` | `AutoAnimeMv.py` | 从 Bangumi/TMDB 标题缓存中复用标准中文名 | `QueryName` | `标准标题或None` | `Auxiliary_Api` / `Auxiliary_OpenAIIdentifyFileInfo` | `Auxiliary_GetPersistentCache` | | -| `Auxiliary_UpsertCanonicalTitle` | `AutoAnimeMv.py` | 维护中文主名称与英文/罗马音别名索引 | 中英罗标题、来源 | `canonical_id`+中文主名称 | `Auxiliary_OpenAIIdentifyFileInfo`/`Auxiliary_Api` | `Auxiliary_SetPersistentCache` | 2026-04-07 新增 | -| `Auxiliary_NormalizeConfigSection` | `AutoAnimeMv.py` | 统一配置分区名 | `section_name` | `normalized_section` | `Auxiliary_READConfig` | 无 | 新增 | -| `Auxiliary_ParseConfigValue` | `AutoAnimeMv.py` | 解析 bool/int/list/string | 原始字符串 | Python 值 | `Auxiliary_ApplyConfig` | `literal_eval` | 新增 | -| `Auxiliary_MaskConfigValue` | `AutoAnimeMv.py` | 脱敏敏感配置输出 | 配置名、配置值 | 脱敏结果 | `Auxiliary_ApplyConfig` | 正则匹配 | 新增 | -| `Auxiliary_StripLeadingBracketReleaseTags` | `autoanime/naming.py`、`AutoAnimeMv.py` | 仅用于展示/LLM:去掉基名首部连续 `[…]`/`【…】` 标签,剥空回退原串 | 基名字符串 | 剥标签后的基名 | `Auxiliary_OpenAIIdentifyFileInfo` | 无 | 2026-04-23 新增 | - -## 调用关系 -- 主链路:`Processing_Main -> Processing_Identification -> Auxiliary_OpenAIIdentifyFileInfo -> Auxiliary_ResolvePlannedTitleChain` -- 剧名链:`Auxiliary_ResolvePlannedTitleChain -> TMDB 中文 -> Bangumi -> TMDB 英文 -> OpenAI 译中文`(失败 `Auxiliary_Exit`) -- 缓存链路:`ReadApiCache -> API调用 -> WriteApiCache -> 程序退出写缓存文件`(索引组 + `ShowOrganizationIndex` 永不过期) -- 标题统一链路:`英文名/罗马音/线索 -> TitleAliasIndex/CanonicalTitleIndex`(由 `Auxiliary_UpsertCanonicalTitle` 维护) -- 配置链路:`Start_PATH -> Auxiliary_READConfig -> Auxiliary_ApplyConfig` - -## 依赖关系 -| 依赖项 | 类型 | 用途 | 入口 | -| --- | --- | --- | --- | -| `requests` | Python 库 | OpenAI/其他 API 请求 | `OpenAIApi` / `Auxiliary_Http` | -| LongCat OpenAI 兼容网关 | 外部服务 | AI 标题抽取 | `OpenAIApi` | -| 环境变量 `OPENAI_API_KEY` | 系统配置 | 安全注入密钥 | `OpenAIApi` | -| 缓存文件 `api_cache.json` | 本地存储 | 降低重复 API 调用并统一多语言别名 | `Auxiliary_SavePersistentCache` | - -## 配置说明 -| 配置项 | 默认值 | 说明 | -| --- | --- | --- | -| `USEOPENAIAPI` | `True` | 是否启用 AI 识别 | -| `OPENAI_PRIORITY_FIRST` | `True` | 是否优先调用 AI | -| `OPENAI_IDENTIFY_ALL` | `True` | 是否由 AI 直接识别剧名/季/集 | -| `OPENAI_BASE_URL` | `https://api.longcat.chat/openai` | OpenAI 兼容网关地址 | -| `OPENAI_MODEL` | `LongCat-Flash-Chat` | 使用模型 | -| `OPENAI_TIMEOUT_SECONDS` | `60` | 请求超时秒数 | -| `OPENAI_API_KEY` | 空 | 可选本地直填,公开仓库不建议 | -| `OPENAI_API_KEY_ENV` | `OPENAI_API_KEY` | 读取 key 的环境变量名 | -| `CACHE_DIR` | `.cache` | 持久化缓存目录 | -| `CACHE_TTL_SECONDS` | `86400` | 缓存过期时间(秒) | - -## 编译部署方法 -- 本项目无需编译,按 Python 脚本直接运行。 -- 部署步骤: - 1. 安装依赖:`python -m pip install -r requirements.txt` - 2. 复制模板:`config.ini.Template -> config.ini` - 3. 设置密钥环境变量:`OPENAI_API_KEY` - 4. 执行脚本:`python AutoAnimeMv.py "目标目录"` - -## 运行链路 -1. 读取默认配置与本地配置。 -2. 扫描目录获取待处理文件。 -3. 提取原始名称、季集信息。 -4. 若 `OPENAI_IDENTIFY_ALL=True`,优先由 AI 识别剧名/季/集;失败回退本地规则。 -5. AI 全信息识别会优先使用 `anime_name_romaji/anime_name_en` 查询 TMDB 中文名称,未命中再回退 `anime_name_zh`。 -6. 仅当 AI 文件识别失败时,才走本地规则与传统 API 标准化回退链路。 -7. 按 `SeasonXX/SXXEXX` 规则整理到目标目录。 - -## 调试方法 -- 重点检查日志中的: - - `OpenAIApi已启用,但未检测到可用密钥` - - `OpenAIApi请求失败,状态码 ...` - - `成功通过 OpenAI API获取到结果` - - `OpenAI文件识别结果标题已按标准化缓存修正: 英文名 => 中文名` - - `OpenAI文件识别已通过TMDB回填中文剧名` - - `TMDB未命中中文剧名,回退使用OpenAI提供的中文名称` - - `OpenAI识别结果与文件名别名冲突,优先保留历史别名` - - `OpenAI文件识别缺少可用剧名` -- 若 AI 连续失败,检查: - - `OPENAI_API_KEY` 是否已设置 - - `OPENAI_BASE_URL` 与 `OPENAI_MODEL` 是否正确 - - 网络/代理是否可达 - -## 验证结果 -| 验证项 | 结果 | 说明 | -| --- | --- | --- | -| 代码改造完成 | 通过 | 已新增 AI 识别与优先级配置,回退链路可用 | -| 安全保护项 | 通过 | 模板不含真实 key/token,日志脱敏 | -| 配置兼容性 | 通过 | 支持 `[Settings]` 与 `[#Config]` | -| 本地回归测试 | 通过 | `unittest` 覆盖 API JSON 解析与缓存命中逻辑 | -| 在线网关验证 | 待执行 | 需使用真实 API key/token 与可联网环境验证 | - -## 风险点 -- AI 网关或模型返回结构变化导致解析异常。 -- 用户未配置 key 时会直接走回退 API,可能误判为 AI 不生效。 -- 不同命名噪声下 AI 输出质量仍存在波动;若既无缓存又未按提示词返回中文名,仍可能落入外文标题。 -- 别名索引纠偏策略若过激,可能导致中文名称在短期内频繁变动。 -- 历史缓存若已写入错误别名,需要一轮新任务运行或定向清理对应缓存键,才能完全收敛到新策略。 - -## 已知问题 -- 当前 AI 输出仅做基础清洗,复杂格式仍可能残留噪声。 -- 未引入识别置信度字段,暂不支持多候选评分。 - -## 后续优化点 -- 增加 AI 结果缓存失效策略。 -- 增加可配置提示词模板与语言偏好。 -- 增加“AI 结果与传统 API 交叉验证”策略。 - -## 变更记录 -| 日期 | 修改来源 | 修改原因 | 影响范围 | 对应功能 / 文件 / 模块 | -| --- | --- | --- | --- | --- | -| 2026-04-06 | Agent | 新增 AI 识别优先方案并加强密钥保护 | API 识别链路、配置链路、安全策略 | `AutoAnimeMv.py` / `config.ini.Template` / `.gitignore` | -| 2026-04-06 | Agent | 接口解析改为 JSON 校验并接入持久化缓存 | API 识别链路、缓存链路 | `Auxiliary_Http` / `Auxiliary_Api` / `api_cache.json` | -| 2026-04-06 | Agent | 增加 OpenAI 全信息识别:由 AI 直接输出剧名/季/集 | 识别入口、命名链路、配置说明 | `Processing_Identification` / `Auxiliary_OpenAIIdentifyFileInfo` / `config.ini.Template` | -| 2026-04-07 | Agent | 修正 OpenAI 全信息识别剧名语言约束,并复用标题标准化缓存修正旧缓存英文名 | OpenAI 文件识别链路、缓存修正链路 | `Auxiliary_OpenAIIdentifyFileInfo` / `Auxiliary_GetStandardTitleFromCache` / `api_cache.json` | -| 2026-04-07 | Agent | 扩展 OpenAI 全信息识别返回英文名/罗马音并接入别名索引,支持外文别名复用历史中文主名称 | OpenAI 文件识别链路、标题统一链路、缓存结构 | `Auxiliary_OpenAIIdentifyFileInfo` / `Auxiliary_UpsertCanonicalTitle` / `TitleAliasIndex` / `CanonicalTitleIndex` | -| 2026-04-07 | Agent | 修复 `x.0` 剧集误判 `Season00`、单集剧名漂移和非中文剧名未二次标准化问题 | OpenAI 文件识别链路、主流程标准化链路、缓存收敛策略 | `Auxiliary_NormalizeEpisodeToken` / `Auxiliary_OpenAIIdentifyFileInfo` / `Processing_Main` | -| 2026-04-09 | Agent | 调整 AI 到 TMDB 中文回填策略:AI 识别后优先用罗马音/英文查询 TMDB,未命中才回退 AI 中文 | OpenAI 文件识别链路、TMDB 查询链路、主流程回退策略 | `Auxiliary_QueryTMDBChineseTitle` / `Auxiliary_OpenAIIdentifyFileInfo` / `Processing_Main` | -| 2026-04-16 | Agent | 剧名统一为 `Auxiliary_ResolvePlannedTitleChain`;OpenAI 识别仅内存缓存;`Auxiliary_Api` 不再走 `try_apis` | 识别、缓存、`api_cache.json` | `Auxiliary_ResolvePlannedTitleChain`、`Auxiliary_OpenAIIdentifyFileInfo` | -| 2026-04-22 | Agent | AI 主路径失败时默认走 `OPENAI_FALLBACK_ON_FAILURE`:本地规则 → BGM → Bangumi → TMDB;启用回退时 OpenAI 预失败日志降为 INFO 以减少误警 | 识别链路 | `autoanime/identification/__init__.py`、`local_fallback.py`、`openai_identify.py` | -| 2026-04-23 | Agent | OpenAI 全信息识别:`user` 提示使用 `Auxiliary_StripLeadingBracketReleaseTags` 剥除首部 `[…]`/`【…】`;`system` 补强组名非番名说明;实现见 `autoanime/naming.py` 与 `AutoAnimeMv.py` 同名辅助函数 | OpenAI 文件识别、命名辅助 | `Auxiliary_OpenAIIdentifyFileInfo`、`Auxiliary_StripLeadingBracketReleaseTags` | diff --git "a/docs/07_\346\225\264\347\220\206\351\223\276\350\267\257\347\250\263\345\201\245\345\214\226\344\270\216\345\233\236\346\273\232\346\234\272\345\210\266.md" "b/docs/07_\346\225\264\347\220\206\351\223\276\350\267\257\347\250\263\345\201\245\345\214\226\344\270\216\345\233\236\346\273\232\346\234\272\345\210\266.md" deleted file mode 100644 index 75ecabd..0000000 --- "a/docs/07_\346\225\264\347\220\206\351\223\276\350\267\257\347\250\263\345\201\245\345\214\226\344\270\216\345\233\236\346\273\232\346\234\272\345\210\266.md" +++ /dev/null @@ -1,159 +0,0 @@ -# 整理链路稳健化与回滚机制 - -## 功能背景 -当前整理流程在 API 解析、字幕遍历、文件名落盘安全、批量回滚等方面存在可预期风险。为降低批量整理失败成本,需要将“可预览、可审计、可回滚”纳入主流程设计。 - -## 功能目标 -- 统一 API JSON 解析与字段校验,减少 `literal_eval` 带来的异常。 -- 修复 tuple 模式下的字幕处理链路,确保“视频主循环 + 字幕附属匹配”。 -- 支持递归扫描子文件夹中的媒体文件,并统一整理到传入目录的目标结构中。 -- 启用 OpenAI 时支持由 AI 直接识别剧名/季/集,减少本地规则误判。 -- 引入文件名安全清洗,适配 Windows 非法字符与保留名限制。 -- 新增 `DRY_RUN` 与操作日志,支持回滚。 -- 增加持久化缓存和命名模板配置,提升重复任务效率与整理一致性。 -- 默认启用硬链接,并通过严格模式控制“硬链接失败是否降级移动”。 -- 支持传入可选输出目录,源目录扫描与目标目录整理解耦。 -- 新增标题别名统一索引,收敛同番多译名/中英标点差异导致的多目录分叉。 -- 同一集多文件时保留最老文件,较新文件记录跳过结果并尽量避免重复识别。 -- 修复 AI 结果中的 `x.0` 集数噪声,避免普通剧集误入 `Season00`。 -- 当 AI 单集识别与历史别名冲突时,优先保留历史 canonical,抑制同番单集漂移。 - -## 功能边界 -- 仅覆盖 `AutoAnimeMv.py` 当前主链路,不进行多文件服务化重构。 -- 保留历史调用方式兼容,不移除旧配置项。 - -## 架构概览 -1. CLI 入口改为 `argparse`,保留旧参数兼容;解析完成后会按最终参数刷新 `RuntimeContext`,并在 qB 回调模式下过滤 `.!qB` 等未完成下载临时文件。 -2. 运行期引入 `RuntimeContext`,集中承载 dry-run、操作日志和路径上下文;`rollback` 分支也会同步记录回滚日志路径。 -3. 识别层支持 `Auxiliary_OpenAIIdentifyFileInfo()`:AI 一次返回剧名/季/集。 -4. API 层统一走 JSON 解析助手,支持持久化缓存(TTL);**整部番**整理进度写入 `ShowOrganizationIndex`(每 `canonical_id` 一条,含 `organized_episodes`)。 -5. 标题统一层新增 `TitleAliasIndex` + `CanonicalTitleIndex`,OpenAI 英文名/罗马音会与中文主名称建立映射。 -6. OpenAI 识别结果新增后处理:`episode` 小数归一(如 `8.0 -> 8`)+ 文件名历史别名冲突纠偏 + 非中文剧名二次 API 标准化。 -7. 主循环新增同集决策缓存:按 `canonical_title + season + episode + ext_bucket` 只保留最早文件,较新重复资源提前跳过并复用已有识别结果。 -8. 文件整理层在落盘前统一命名模板和文件名清洗。 -9. 扫描层递归返回相对路径,识别层按 basename 识别,落盘层按源相对路径搬运;若 `OUTPUT_PATH` 位于扫描目录内部,则自动跳过输出子树,避免把已整理结果再次扫入。 -10. 所有落盘动作先写操作清单,再执行(或 dry-run 预览);若目标文件已与源文件指向同一物理文件(含硬链接),则直接跳过重复整理;若硬链接模式下目标文件已存在且不是同一物理文件,则默认保留原有目标文件,不用新重复资源替换;`rollback` 模式仅消费既有操作日志,不额外再写新的操作日志文件。 - -## 模块职责 -| 模块 | 所在文件 | 职责 | 上游 | 下游 | -| --- | --- | --- | --- | --- | -| 参数与上下文模块 | `AutoAnimeMv.py` | 解析参数、初始化 `RuntimeContext` | `__main__` | 扫描/整理模块 | -| API 识别模块 | `AutoAnimeMv.py` | JSON 解析、字段校验、缓存命中、别名索引纠偏 | 识别模块 | 命名模块 | -| 命名与路径模块 | `AutoAnimeMv.py` | 命名模板与安全清洗 | API 结果 | 落盘模块 | -| 执行与回滚模块 | `AutoAnimeMv.py` | move/link/dry-run、保留旧硬链接、同集最老优先、记录操作日志、回滚执行 | 命名模块 | 文件系统 | - -## 文件清单 -| 文件路径 | 类型 | 作用 | 谁会使用它 | 它依赖谁 | 备注 | -| --- | --- | --- | --- | --- | --- | -| `AutoAnimeMv.py` | Python | 主流程与稳健化改造实现 | 用户/自动化任务 | `requests`、`zhconv` | 本次核心改动 | -| `config.ini.Template` | 配置模板 | 新增命名、缓存、dry-run、token 环境变量配置 | 使用者 | 配置解析逻辑 | 不含敏感值 | -| `tests/` | 测试目录 | 回归样本、单元与集成测试 | 开发者/CI | Python `unittest` | 本次新增 | - -## 函数清单 -| 函数/方法 | 所在文件 | 作用 | 输入 | 输出 | 调用方 | 被调对象 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | --- | -| `Processing_Main` | `AutoAnimeMv.py` | 主循环只处理视频,字幕仅匹配 | 扫描结果 | 无 | `__main__` | `Processing_Identification` 等 | tuple 修复重点 | -| `Sorting_Mv` | `AutoAnimeMv.py` | 命名 + 清洗 + 落盘/预览 | 文件元信息 | 无 | `Processing_Main` | 文件执行器 | 命名与回滚核心 | -| `Auxiliary_Http` | `AutoAnimeMv.py` | HTTP 请求和 JSON 返回校验 | URL/方法/headers | JSON 或文本 | `Auxiliary_Api` | `requests` | 解析鲁棒性核心 | -| `Auxiliary_Api` | `AutoAnimeMv.py` | API 轮询、缓存、回退 | `RAWName` | 标准名 | `Processing_Main` | 各 API 函数 | 识别核心 | -| `Auxiliary_ShowHasOrganizedEpisode` | `AutoAnimeMv.py` | 判断该番该集是否已在 Show 索引中标记为已整理 | `canonical_id`, `SE`, `EP` | `bool` | `Processing_Main` | `Auxiliary_GetShowOrganizationRecord` | 跳过重复整理 | -| `Auxiliary_UpsertCanonicalTitle` | `AutoAnimeMv.py` | 维护中文主名称与中英罗别名映射 | 中英罗标题 | `canonical_id`+中文主名称 | `Processing_Main`/`Auxiliary_Api` | `Auxiliary_SetPersistentCache` | 命名统一核心 | - -## 调用关系 -- `__main__ -> Start_PATH -> Start_GetArgv -> Processing_Mode -> Processing_Main -> Sorting_Mv` -- `Processing_Main -> ShowOrganizationIndex 已整理集 -> skip(already_organized_show_cache)` -- `Processing_Main -> Processing_Identification -> Auxiliary_Api -> Sorting_Mv` -- `Processing_Main/Auxiliary_Api -> TitleAliasIndex/CanonicalTitleIndex -> 中文主名称收敛` -- `Auxiliary_OpenAIIdentifyFileInfo -> episode归一化 + 历史别名冲突纠偏 -> Processing_Main` -- `Processing_Main -> EpisodeDecisionDataCache -> newer_duplicate_kept_oldest(skip)` -- `Sorting_Mv -> OperationLog -> ExecuteOrDryRun` -- `rollback入口 -> 读取操作日志 -> 逆序回滚` - -## 依赖关系 -| 依赖项 | 类型 | 用途 | 所在位置 | 使用入口 | 备注 | -| --- | --- | --- | --- | --- | --- | -| `requests` | Python 库 | API 网络访问 | `requirements.txt` | `Auxiliary_Http`/`OpenAIApi` | 必需 | -| `zhconv` | Python 库 | 繁简转换 | `requirements.txt` | `Auxiliary_UniformOTSTR` | 必需 | -| `TMDB_BEARER_TOKEN` | 环境变量 | TMDB 认证 | 系统环境 | `Auxiliary_Http` | 本次外置 | -| `OPENAI_API_KEY` | 环境变量 | OpenAI 认证 | 系统环境 | `OpenAIApi` | 推荐外置 | - -## 配置说明 -| 配置项 | 默认值 | 说明 | -| --- | --- | --- | -| `NAMING_STYLE` | `default` | 命名风格,支持 `default` 和 `emby` | -| `DRY_RUN` | `False` | 仅预览操作,不实际落盘 | -| `OPENAI_IDENTIFY_ALL` | `True` | 启用 OpenAI 时是否直接识别剧名/季/集 | -| `USELINK` | `True` | 是否默认使用硬链接(适合做种场景) | -| `STRICT_MODE` | `True` | 硬链接失败时是否禁止降级为移动 | -| `OUTPUT_PATH` | 空 | 可选输出路径,空值时使用扫描目录 | -| `CACHE_TTL_SECONDS` | `86400` | API 缓存有效期(秒) | -| `CACHE_DIR` | `.cache` | 本地缓存目录 | -| `TMDB_BEARER_TOKEN_ENV` | `TMDB_BEARER_TOKEN` | TMDB token 的环境变量名 | - -## 编译部署方法 -- 无编译步骤,按脚本运行。 -- 推荐流程: - 1. 安装依赖 `python -m pip install -r requirements.txt` - 2. 复制模板为本地 `config.ini` - 3. 注入 `OPENAI_API_KEY` 与 `TMDB_BEARER_TOKEN` 环境变量 - 4. 使用 dry-run 先预览,再执行真实整理 - -## 运行链路 -1. 读取默认配置与 `config.ini`。 -2. 创建运行上下文与缓存对象。 -3. 扫描目录并识别视频/字幕。 -4. API 标准化命名,命中缓存则跳过外网请求。 -5. 生成目标路径,执行或预览操作并记录日志。 -6. 可按日志执行回滚。 - -## 调试方法 -| 命令 | 执行位置 | 用途 | 示例 | 风险 | -| --- | --- | --- | --- | --- | -| `python -m unittest discover -s tests -v` | 项目根目录 | 运行回归测试 | 同左 | 需可用 Python 环境 | -| `python AutoAnimeMv.py \"D:\\Anime\" --dry-run` | 项目根目录 | 预览整理操作 | 同左 | 不改文件但会写日志 | -| `python AutoAnimeMv.py rollback --log \"操作日志路径\"` | 项目根目录 | 按日志回滚 | 同左 | 回滚依赖日志完整性 | - -## 验证结果 -| 验证项 | 结果 | 说明 | -| --- | --- | --- | -| API JSON 鲁棒化 | 通过 | 移除 API 解析中的 `literal_eval`,统一 `response.json()` + 字段校验 | -| tuple 字幕链路修复 | 通过 | `Processing_Main` 仅遍历视频列表,字幕改为附属匹配 | -| 子文件夹递归扫描 | 通过 | `Auxiliary_ScanDIR` 支持递归扫描并返回相对路径,子目录文件可被整理到上级目录 | -| OpenAI 全信息识别 | 通过 | `Processing_Identification` 优先使用 `Auxiliary_OpenAIIdentifyFileInfo` 获取剧名/季/集 | -| 严格模式/硬链接开关 | 通过 | 默认使用硬链接,`STRICT_MODE=True` 时硬链接失败不会自动降级移动 | -| 输出目录参数 | 通过 | `--output-path` 可指定整理目标目录,空值默认扫描目录 | -| dry-run / rollback | 通过 | 支持 `--dry-run` 预览与 `rollback --log` 回滚入口 | -| Emby 命名模板 | 通过 | 支持 `NAMING_STYLE=emby` 输出 `剧名 - S01E01` 风格 | -| 持久化缓存 | 通过 | 引入 `CACHE_DIR/api_cache.json`,支持 TTL 过期策略 | -| 独立输出目录重复整理抑制 | 通过 | `USELINK=True` 且目标已与源文件为同一物理文件时直接跳过,不再备份/重复整理 | -| 未完成下载文件过滤 | 通过 | qB 回调传入 `.!qB` / `.part` 等临时文件时会直接跳过,不进入识别与整理链路 | -| 重复资源保留原硬链接 | 通过 | `USELINK=True` 且目标已存在时保留原有目标文件,不用新重复资源替换旧硬链接 | -| 已整理集跳过 | 通过 | 成功整理后写入 `ShowOrganizationIndex.organized_episodes`,同番同集再次运行跳过(仍可能调用 OpenAI 识别后早退) | -| 命名别名统一索引 | 通过 | 新增 `TitleAliasIndex` 与 `CanonicalTitleIndex`,中英罗别名与标点差异可收敛到同一中文主名称 | -| 同集最老优先 | 通过 | 同一集多个文件时保留最早文件,较新文件标记 `newer_duplicate_kept_oldest` 并跳过 | -| `x.0` 剧集归一 | 通过 | `8.0/6.0` 归一为整数集,普通剧集不再默认落入 `Season00` | -| 单集漂移纠偏 | 通过 | 当 AI 单集识别与文件名历史别名冲突时,优先采用历史 canonical 映射 | - -## 风险点 -- 历史命名规则与新命名策略同时兼容时,路径可能变化。 -- rollback 对“外部删除或手工改动”场景只能尽力恢复。 -- 缓存命中策略需平衡速度和准确度。 -- `ShowOrganizationIndex` 以 `canonical_id` 维度记录已整理集;若单集 `canonical_id` 漂移,可能出现重复目录(依赖别名与剧名链收敛)。 -- 别名索引自动纠偏依赖来源优先级,若外部接口返回异常名称可能造成短期波动。 - -## 变更记录 -| 日期 | 修改来源 | 修改原因 | 影响范围 | 对应功能 / 文件 / 模块 | -| --- | --- | --- | --- | --- | -| 2026-04-06 | Agent | 建立稳健化改造专题文档,统一记录 P0/P1/P2 目标与落地范围 | 识别、整理、缓存、日志与回滚链路 | `AutoAnimeMv.py` / `tests/` / `config.ini.Template` | -| 2026-04-06 | Agent | 增加子目录文件递归扫描能力,满足“文件夹内文件整理到上级目录”场景 | 扫描链路、识别链路、落盘链路 | `Auxiliary_ScanDIR` / `Processing_Main` / `Sorting_Mv` | -| 2026-04-06 | Agent | 增加 OpenAI 全信息识别能力,启用后季集识别优先交给 AI | 识别链路、配置项、命名链路 | `Processing_Identification` / `Auxiliary_OpenAIIdentifyFileInfo` | -| 2026-04-06 | Agent | 增加严格模式与可选输出路径,默认硬链接适配做种场景 | 文件执行策略、命令行参数、落盘路径策略 | `Auxiliary_ExecuteFileOperation` / `Start_GetArgv` / `Sorting_Mv` | -| 2026-04-07 | Agent | 修复独立输出目录 + 保留源文件场景下的重复整理问题,新增 samefile/hardlink 跳过与输出子树排除 | 扫描链路、文件执行链路、输出目录行为 | `Auxiliary_ScanDIR` / `Auxiliary_ExecuteFileOperation` / `OUTPUT_PATH` | -| 2026-04-07 | Agent | 补充 CLI 参数覆盖后的 `RuntimeContext` 刷新说明,并修正 rollback 分支上下文同步与空操作日志写入 | 参数链路、回滚链路、操作日志链路 | `Start_GetArgv` / `Processing_Mode` / `Auxiliary_WriteOperationLog` | -| 2026-04-07 | Agent | 增加未完成下载临时文件过滤,避免 qB 回调误处理 `.!qB` 等文件 | 参数链路、主处理链路、qB 回调行为 | `Processing_Mode` / `Processing_Main` / 未完成下载文件识别 | -| 2026-04-07 | Agent | 增加重复资源保留原硬链接与同集决策,避免重复替换 | 主处理链路、硬链接执行链路 | `Processing_Main` / `Auxiliary_ExecuteFileOperation` | -| 2026-04-16 | Agent | `ShowOrganizationIndex` 替代文件级 `ResolvedFileInfo`;剧名链与中文标点归一落盘 | 缓存、识别、整理 | `Processing_Main`、`Auxiliary_ResolvePlannedTitleChain`、`Sorting_Mv` | -| 2026-04-07 | Agent | 增加标题别名统一索引与同集最老优先策略,收敛同番多目录并减少重复识别 | 识别链路、缓存链路、主处理去重链路 | `Auxiliary_UpsertCanonicalTitle` / `TitleAliasIndex` / `CanonicalTitleIndex` / `Processing_Main` | -| 2026-04-07 | Agent | 修复 AI `x.0` 集数误判 Season00、单集剧名漂移与非中文剧名未标准化问题 | OpenAI 识别链路、主流程标准化链路 | `Auxiliary_OpenAIIdentifyFileInfo` / `Auxiliary_NormalizeEpisodeToken` / `Processing_Main` | -| 2026-04-16 | Agent | `Auxiliary_NormalizeDisplayTitle` 在含汉字时将常见英文标点转为中文全角(`Auxiliary_ConvertAsciiPunctuationToFullwidthCn`),并移除原先「全角压成半角」的 ReplaceMap,避免落盘时引号等再被清洗成下划线;纯英文/罗马字标题不改动;批量修正缓存可运行 `scripts/normalize_api_cache_cn_punct.py` | 展示名、缓存、API 归一、`Auxiliary_SanitizePathComponent` | `Auxiliary_NormalizeDisplayTitle` / `Auxiliary_ConvertAsciiPunctuationToFullwidthCn` / `scripts/normalize_api_cache_cn_punct.py` | -| 2026-04-22 | Agent | `ShowOrganizationIndex` 与目标路径校验 + 目标缺失时自愈;CLI 支持单文件完整路径与 `--file`;字幕匹配失败批汇总 | 主处理、扫描、日志 | `autoanime/pipeline/main.py`、`autoanime/cli.py`、`autoanime/naming.py` | diff --git "a/docs/08_\345\205\254\345\274\200\344\273\223\345\272\223\345\217\221\345\270\203\344\270\216\351\232\220\347\247\201\346\270\205\347\220\206.md" "b/docs/08_\345\205\254\345\274\200\344\273\223\345\272\223\345\217\221\345\270\203\344\270\216\351\232\220\347\247\201\346\270\205\347\220\206.md" deleted file mode 100644 index 88bba74..0000000 --- "a/docs/08_\345\205\254\345\274\200\344\273\223\345\272\223\345\217\221\345\270\203\344\270\216\351\232\220\347\247\201\346\270\205\347\220\206.md" +++ /dev/null @@ -1,103 +0,0 @@ -# 公开仓库发布与隐私清理 - -## 功能背景 -项目准备以公开仓库形式发布,需要在发布前统一清理本地配置、缓存、历史归属信息和不必要的开发过程文件,避免把旧作者信息、私有入口、真实凭据或本地环境痕迹一并公开。 - -## 功能目标 -- 清理当前工作区中的旧仓库、旧作者、旧群组等公开信息。 -- 保留最小可运行依赖与公开可读文档。 -- 通过历史重写移除旧提交中的作者信息和旧项目痕迹。 -- 固化忽略规则,避免后续再次误提交本地文件。 - -## 功能边界 -- 不负责远端仓库平台缓存清理。 -- 不自动执行远端强推;是否推送由维护者手动决定。 - -## 架构概览 -1. 工作区清理:重写 `README.md`、`README_en.md`、`config.ini.Template`。 -2. 元信息清理:修正 `AutoAnimeMv.py` 顶部说明、帮助文本、请求头标识。 -3. 仓库边界收敛:删除 `get-pip.py`,补充 `.gitignore`。 -4. 历史清理:用全新根提交替换旧历史,移除原提交链路。 - -## 模块职责 -| 模块 | 所在文件 | 职责 | 备注 | -| --- | --- | --- | --- | -| 对外说明模块 | `README.md` / `README_en.md` | 提供公开仓库介绍、安装与使用方式 | 不保留旧仓库和私有社群信息 | -| 配置模板模块 | `config.ini.Template` | 提供无敏感值的公开配置模板 | 强调凭据走环境变量 | -| 核心脚本元信息模块 | `AutoAnimeMv.py` | 清理头注释、帮助文案、`User-Agent` | 不改运行逻辑 | -| 忽略规则模块 | `.gitignore` | 忽略本地配置、缓存、日志、计划文档等 | 约束公开边界 | -| 文档模块 | `docs/` | 记录发布清理策略与残余风险 | 便于后续复用 | - -## 文件清单 -| 文件路径 | 类型 | 作用 | 谁会使用它 | 它依赖谁 | 备注 | -| --- | --- | --- | --- | --- | --- | -| `README.md` | Markdown | 中文公开说明 | 用户/维护者 | 项目当前能力 | 已重写 | -| `README_en.md` | Markdown | 英文公开说明 | 用户/维护者 | 项目当前能力 | 已重写 | -| `config.ini.Template` | INI 模板 | 无敏感值配置模板 | 部署者 | 配置解析逻辑 | 已补足注释 | -| `AutoAnimeMv.py` | Python | 主脚本与帮助信息 | 用户 | `requests`、`zhconv` | 已清理旧归属字符串 | -| `.gitignore` | Git 配置 | 限制本地文件进入版本库 | 开发者 | Git | 已扩充 | -| `requirements.txt` | 依赖清单 | 仅保留当前运行依赖 | 用户/CI | Python/pip | 已收敛 | - -## 函数清单 -| 函数/方法 | 所在文件 | 作用 | 输入 | 输出 | 调用方 | 被调对象 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | --- | -| `Auxiliary_Help()` | `AutoAnimeMv.py` | 输出帮助文案 | 无 | 控制台文本 | CLI 入口 | `print` | 已清理旧仓库 URL | -| `Auxiliary_Http()` | `AutoAnimeMv.py` | 统一网络请求头 | URL/参数 | HTTP 响应 | 各 API 调用 | `requests` | `User-Agent` 已去旧归属 | -| `OpenAIApi()` | `AutoAnimeMv.py` | 调用 OpenAI 兼容接口识别标题 | `Name` | 标准名/空 | `try_apis` | `requests.post` | 请求头已清理 | -| `Auxiliary_OpenAIIdentifyFileInfo()` | `AutoAnimeMv.py` | 调用 OpenAI 识别剧名/季/集 | 文件名 | 识别元组 | `Processing_Identification` | `requests.post` | 请求头已清理 | - -## 调用关系 -- 发布清理链路:`README/config 模板/脚本元信息 -> .gitignore -> 历史重写` -- 运行依赖链路:`requirements.txt -> requests / zhconv -> AutoAnimeMv.py` - -## 依赖关系 -| 依赖项 | 类型 | 用途 | 所在位置 | 使用入口 | 备注 | -| --- | --- | --- | --- | --- | --- | -| `requests` | Python 库 | 网络请求 | `requirements.txt` | `Auxiliary_Http` / `OpenAIApi` | 必需 | -| `zhconv` | Python 库 | 繁简转换 | `requirements.txt` | `Auxiliary_UniformOTSTR` | 必需 | -| Git | 工具 | 历史重写与忽略规则 | 本地环境 | 仓库维护 | 发布前使用 | - -## 配置说明 -| 配置项 | 默认值 | 说明 | -| --- | --- | --- | -| `OPENAI_API_KEY_ENV` | `OPENAI_API_KEY` | 公开模板仅保留环境变量名 | -| `TMDB_BEARER_TOKEN_ENV` | `TMDB_BEARER_TOKEN` | 公开模板仅保留环境变量名 | -| `DRY_RUN` | `False` | 建议先预览再执行真实整理 | -| `OPERATION_LOG_ENABLE` | `True` | 便于追踪和回滚 | - -## 编译部署方法 -- 依赖安装:`python -m pip install -r requirements.txt` -- 本地配置:复制 `config.ini.Template` 为 `config.ini` -- 凭据注入:通过环境变量提供,不写入仓库 - -## 运行链路 -1. 安装依赖。 -2. 准备本地配置。 -3. 通过环境变量注入凭据。 -4. 先执行 `--dry-run` 验证结果。 -5. 需要公开发布时,确认忽略规则和历史清理状态。 - -## 调试方法 -| 命令 | 执行位置 | 用途 | 示例 | 风险 | -| --- | --- | --- | --- | --- | -| `git status --short` | 项目根目录 | 查看公开前变更面 | 同左 | 无 | -| `git log --all --format="%h %an <%ae>"` | 项目根目录 | 检查历史作者信息 | 同左 | 会显示历史元数据 | -| `rg "OPENAI_API_KEY\\s*=|TMDB_BEARER_TOKEN\\s*=|t\\.me/|qq\\.com" .` | 项目根目录 | 检查敏感或旧归属字符串 | 同左 | 需人工复核 | - -## 验证结果 -| 验证项 | 结果 | 说明 | -| --- | --- | --- | -| 工作区旧归属清理 | 通过 | 旧仓库、旧群组、旧邮箱等字符串已从当前工作区移除 | -| 配置模板安全化 | 通过 | 模板仅保留空凭据示例 | -| 忽略规则收敛 | 通过 | `config.ini`、`.cache/`、`logs/`、`docs/plans/` 等已忽略 | -| 历史重写 | 待执行/已执行后更新 | 需在本地生成新的根提交并替换旧历史 | - -## 风险点 -- 即使本地历史已清理,远端仓库在未强推前仍保留旧历史。 -- 第三方平台的缓存、副本或 fork 不会自动同步清除。 -- 若后续使用旧 remote 再次 fetch,未清理的旧远端引用可能回流。 - -## 变更记录 -| 日期 | 修改来源 | 修改原因 | 影响范围 | 对应功能 / 文件 / 模块 | -| --- | --- | --- | --- | --- | -| 2026-04-06 | Agent | 为公开仓库发布补充清理策略与验证结论 | README、配置模板、忽略规则、历史重写流程 | `README.md`、`README_en.md`、`.gitignore`、`AutoAnimeMv.py` | diff --git "a/docs/09_\346\250\241\345\235\227\345\214\226\346\213\206\345\210\206\344\270\216\345\214\205\347\273\223\346\236\204.md" "b/docs/09_\346\250\241\345\235\227\345\214\226\346\213\206\345\210\206\344\270\216\345\214\205\347\273\223\346\236\204.md" deleted file mode 100644 index 25f627b..0000000 --- "a/docs/09_\346\250\241\345\235\227\345\214\226\346\213\206\345\210\206\344\270\216\345\214\205\347\273\223\346\236\204.md" +++ /dev/null @@ -1,44 +0,0 @@ -# 模块化拆分与包结构(`autoanime` / `AutoAnimeMv2`) - -## 功能背景 -在保留历史单文件 `AutoAnimeMv.py` 不改动的前提下,将新逻辑与回归测试收敛到 `autoanime/` 包,由 `AutoAnimeMv2.py` 作为推荐入口,实现双轨运行与可测性提升。 - -## 双轨入口 - -| 入口 | 行为 | -| --- | --- | -| `python AutoAnimeMv.py ...` | 历史单文件实现(仓库内不修改) | -| `python AutoAnimeMv2.py ...` | 调用 `autoanime.cli.main`,走模块化实现 | - -## 包目录与职责(文件表) - -| 文件路径 | 类型 | 作用 | 调用方 | 依赖项 | 备注 | -| --- | --- | --- | --- | --- | --- | -| `AutoAnimeMv2.py` | 入口 | 转调 `from autoanime.cli import main` | 用户 / 自动化 | `autoanime` | 推荐入口 | -| `autoanime/cli.py` | 模块 | `Start_PATH` / `Start_GetArgv` / `main`、rollback 分派 | `AutoAnimeMv2.py` | `state`、`config_loader`、`scanning` | 单文件/目录/--file | -| `autoanime/pipeline/main.py` | 模块 | `Processing_Main`、ShowIndex 校验与自愈 | `cli.main` | `identification`、`sorting`、`show_index` | | -| `autoanime/pipeline/mode.py` | 模块 | `Processing_Mode`、单文件短路 | `cli` | `scanning` | | -| `autoanime/identification/openai_identify.py` | 模块 | `Auxiliary_OpenAIIdentifyFileInfo`;回退开时预失败降 INFO | `identification` | `local_fallback`(级别判定) | | -| `autoanime/identification/local_fallback.py` | 模块 | `Auxiliary_ResolveFileInfoWithFallback` 等 | `identification` | `naming`、各 API | | -| `autoanime/cache/show_index.py` | 模块 | Show 缓存、`Auxiliary_ShowClearOrganizedEpisode` | `pipeline.main` | `persistent` | | -| `autoanime/naming.py` | 模块 | `Auxiliary_IDEASS` 等;字幕匹配失败批汇总 | `pipeline` | `logging_utils` | | -| `autoanime/zhconv_safe.py` | 模块 | `importlib.resources` 预读词典,关句柄 | `cli.Start_PATH` | `zhconv` | | -| `autoanime/logging_utils.py` | 模块 | 日志与 `Auxiliary_WriteLog` | 全局 | `state` | 有 `OUTPUT_PATH` 抑制工具目录日志 WARN | -| `tests/test_autoanime_package.py` | 测试 | CLI/ShowIndex/回退/字幕/zhconv | 开发者 | `unittest` | | - -## 函数迁移映射(摘要) - -| 原语义(`AutoAnimeMv.py` 时期) | 现位置 | -| --- | --- | -| `Start_PATH` / `Start_GetArgv` | `autoanime/cli.py` | -| `Processing_Main` / `Processing_Mode` | `autoanime/pipeline/main.py`、`mode.py` | -| `Processing_Identification` | `autoanime/identification/__init__.py` | -| `Auxiliary_OpenAIIdentifyFileInfo` | `autoanime/identification/openai_identify.py` | -| `Auxiliary_InitZhconvDictionarySafely` | `autoanime/zhconv_safe.py` | -| `Sorting_Mv` | `autoanime/sorting/pipeline.py` | - -## 变更记录 - -| 日期 | 修改来源 | 说明 | -| --- | --- | --- | -| 2026-04-22 | Agent | 初版:包图、文件表、迁移映射、双轨说明 | diff --git "a/docs/10_\347\274\223\345\255\230Schema_v2\350\256\276\350\256\241.md" "b/docs/10_\347\274\223\345\255\230Schema_v2\350\256\276\350\256\241.md" deleted file mode 100644 index 25459fb..0000000 --- "a/docs/10_\347\274\223\345\255\230Schema_v2\350\256\276\350\256\241.md" +++ /dev/null @@ -1,65 +0,0 @@ -# 缓存 Schema v2 设计 - -> 开发者速查(API 示例、命令表、FAQ):[`autoanime/cache/README.md`](../autoanime/cache/README.md) - -## 功能背景 -将原单文件 `api_cache.json` 拆为 `.cache` 下多子文件,降低高写入子集与大体量 API 响应之间的互相拖累;对外仍通过 `Auxiliary_GetPersistentCache` / `Auxiliary_SetPersistentCache` 访问,业务调用点无感。 - -## 功能边界 -- 单文件回退:删除 v2 元数据与子文件、将 `backups/api_cache_legacy_*.json` 移回 `api_cache.json` 可恢复旧行为(与旧 `AutoAnimeMv.py` 一致)。 -- 不引入 SQLite;审计为 JSONL 仅追加。 - -## 子文件与路径 - -| 文件路径 | 类型 | 作用 | 调用方 | 依赖项 | 备注 | -| --- | --- | --- | --- | --- | --- | -| `.cache/cache_meta.json` | JSON | `schema_version`、子文件 sha256/计数、`legacy_archive` | `autoanime/cache/v2_data.py` `Auxiliary_WriteV2CacheMeta` | 各 v2 子文件 | flush 时部分更新 | -| `.cache/organization.json` | JSON | 整理进度 `ShowOrganizationIndex` | `autoanime/cache/persistent.py` 路由 + `show_index` | `state.PersistentApiCache` | 永不过期 | -| `.cache/titles.json` | JSON | `CanonicalTitleIndex` + `TitleAliasIndex` | `persistent` + `canonical` | 同上 | 永不过期;别名含 `trust_level` | -| `.cache/api_responses.json` | JSON | TMDB / Bangumi / 扩展组 / OpenAI 等 | `persistent`、`apis/*` | TTL 配置 | 分区 TTL | -| `.cache/pollution_audit.jsonl` | JSONL | 别名拒绝、成功写入等审计 | `autoanime/cache/audit.py`、工具脚本 | 无 | 仅追加 | -| `.cache/backups/api_cache_legacy_.json` | JSON | 首次迁移时旧 `api_cache.json` 备份 | `autoanime/cache/migrate.py` | 原 `.cache/api_cache.json` | 一次性 | - -## 路由与对外函数 - -| 函数/方法 | 所在文件 | 作用 | 入参/出参 | 上下游依赖 | 备注 | -| --- | --- | --- | --- | --- | --- | -| `Auxiliary_MigrateCacheToV2IfNeeded` | `autoanime/cache/migrate.py` | 无 `cache_meta` 时归档旧文件并初始化 v2 空表 | 无;返回 `legacy_archive` 或 `None` | `state`、v2 路径 | 与 `LoadPersistentCache` 内调用幂等 | -| `Auxiliary_LoadPersistentCache` | `autoanime/cache/persistent.py` | 先 migrate 再按 v1/v2 加载内存 | 无 | 全局 `state` | | -| `Auxiliary_SavePersistentCache` | 同上 | v2 只 flush `CacheSubfileDirty` 为真的子文件 | `force: bool` | 磁盘子文件、`cache_meta` | 退出时 `force=False` 即子文件粒度 | -| `Auxiliary_GetPersistentCache` / `Auxiliary_SetPersistentCache` | 同上 | 与旧签名一致,按 `CacheGroup` 路由子文件与路径 | 同历史 | 剧名/Show/API 全链路 | | -| `Auxiliary_ValidateAliasWrite` | `autoanime/cache/trust.py` | 写入别名前校验 | 返回 `(allow, reason)` | `canonical` | 失败不入库 | -| `cmd_inspect` / 各子命令 | `scripts/cache_doctor.py` | 只读/修复工具 | 见命令表 | 直接读 `.cache` 下文件 | | - -## 信任等级(摘要) - -| 等级 | 典型来源 | 覆盖规则(摘要) | -| --- | --- | --- | -| 100 | 手动手名单 | 可覆盖;自动来源不可改 locked | -| 80 | TMDB / Bangumi | 可覆盖 ≤80 | -| 60 | OpenAI 推断 | 可覆盖 ≤60 | -| — | 校验拒绝 | 仅 `pollution_audit.jsonl` 记录,不落 titles | - -## 启动与退出链路 -- 启动:`autoanime/cli.py` 中 `Start_PATH` 在 `Auxiliary_LoadPersistentCache` 之前显式调用 `Auxiliary_MigrateCacheToV2IfNeeded`;`LoadPersistentCache` 内会再次调用(幂等)。 -- 退出:`main` 的 `finally` 中 `Auxiliary_SavePersistentCache(force=False)`,v2 下仅写脏子文件。 - -## 命令表 -| 调试命令 | 执行位置 | 用途 | 示例 | 风险 | -| --- | --- | --- | --- | --- | -| `python scripts/cache_doctor.py --inspect` | 项目根 | 子文件大小、sha256、条目、别名异常键 | `python scripts/cache_doctor.py --inspect --cache-dir .cache` | 只读 | -| `python scripts/cache_doctor.py --export-audit --since YYYY-MM-DD` | 项目根 | 按时间筛审计行 | 同左加 `--since 2026-01-01` | 只读 | -| `python scripts/cache_doctor.py --revert --audit-id ` | 项目根 | 对 `type=alias_written` 撤销 titles 中别名字段 | 需与审计中 `audit_id` 一致 | 修改 `titles.json` | -| `python scripts/cache_doctor.py --rebuild-from-organization` | 项目根 | 从 `organization.json` 重建最小 `titles.json` | 先备份 | **覆盖** `titles.json` | - -## 测试 -- `tests/test_cache_schema_v2.py`:路由、信任、原子写、迁移、兼容、doctor 等 10 组用例。 - -## 变更记录 -| 日期 | 修改来源 | 修改原因 | 影响范围 | -| --- | --- | --- | --- | -| 2026-05-01 | Agent | 别名键长度上限 30→100(`trust.ALIAS_KEY_MAX_LEN`),减少长罗马音 `alias_key_too_long`;inspect/rebuild 与校验同源常量 | `trust.py`、`cache_doctor`、`README`、`docs/05`、`tests/test_cache_schema_v2` | -| 2026-04-23 | Agent | 落地 v2 文档、CLI 迁移钩子、cache_doctor、单测与索引 | `docs/10`、CLI、scripts、`tests` | -| 2026-04-23 | Agent | 增补 `autoanime/cache/README.md` 使用说明;本文档顶部交叉引用 | `autoanime/cache/README.md` | -| 2026-04-23 | Agent | README 增补 `cache_doctor` 全参数/子命令与实例、`scripts/` 目录说明 | `autoanime/cache/README.md` | -| 2026-04-23 | Agent | 信任校验区分「同 canonical 低信任重复写入」与真冲突;`Upsert` 别名循环按归一化键去重,减少单文件整理时审计 JSONL 噪声 | `autoanime/cache/trust.py`、`autoanime/cache/canonical.py` | diff --git "a/docs/11_v3_WebUI\344\270\216\346\225\260\346\215\256\345\261\202\350\247\204\345\210\222.md" "b/docs/11_v3_WebUI\344\270\216\346\225\260\346\215\256\345\261\202\350\247\204\345\210\222.md" new file mode 100644 index 0000000..f6acdf0 --- /dev/null +++ "b/docs/11_v3_WebUI\344\270\216\346\225\260\346\215\256\345\261\202\350\247\204\345\210\222.md" @@ -0,0 +1,685 @@ +# AutoAnime v3:WebUI 与数据层完整设计 + +## 1. 文档状态 + +- 目标版本:AutoAnime v3 Web Console +- 目标平台:Windows 常驻服务器、局域网访问 +- 用户模型:单管理员账号 +- 产品范围:完整接管扫描、识别、审核、计划、执行、回滚、资料库查看与修改 +- 存储范围:Windows 本地磁盘,多下载根、多媒体库根、逐目录策略 +- 自动化范围:手动扫描、定时扫描、目录监听、通用下载器回调 +- 外部集成:qBittorrent、Transmission、Emby、Jellyfin、Plex 首期只保留适配器边界 +- 元数据范围:文件识别事实是核心;海报、简介和放送状态只作附加展示 +- 数据迁移:采用新 Schema;提供可回滚迁移工具,不要求保留现有生产数据 + +## 2. 目标与非目标 + +### 2.1 目标 + +1. 在浏览器中完成当前 CLI 的全部主流程。 +2. 所有高风险操作都先生成不可变计划,再经管理员批准执行。 +3. 浏览器关闭、Web 服务重启或 Worker 重启不能丢失任务状态。 +4. SQLite 同时保存资料库事实、任务状态、审核记录、操作历史和审计事件。 +5. 同一套应用服务供 WebUI、Worker 和 CLI 使用,禁止不同入口各自拼 SQL 或移动文件。 +6. 在 Windows 本地磁盘上安全支持 hardlink、copy 和 move。 +7. 自动化事件只负责创建任务,不能绕过审核和执行策略直接修改文件。 +8. 人工修改必须保留旧值、原因、修订号、受影响文件和回滚信息。 + +### 2.2 非目标 + +- 首期不支持多租户、多人协作或复杂角色权限。 +- 首期不依赖 Redis、PostgreSQL、Celery 或 Kubernetes。 +- 首期不把海报和简介作为识别或命名的强依赖。 +- 首期不实现完整下载器客户端和媒体服务器客户端。 +- 不提供跳过文件身份校验的强制删除、强制覆盖或强制回滚按钮。 + +## 3. 推荐架构 + +采用 FastAPI、React、SQLite 和独立 Worker 组成的模块化单体。 + +```mermaid +flowchart LR + Browser[局域网浏览器] --> Proxy[Caddy HTTPS] + Proxy --> Web[FastAPI Web/API] + Web --> DB[(SQLite WAL)] + Web --> Services[应用服务] + Services --> Jobs[持久化任务队列] + Jobs --> DB + Worker[AutoAnime Worker] --> Jobs + Worker --> Core[scanner/parser/resolver/planner] + Worker --> Executor[安全文件执行器] + Core --> Disk[Windows 本地磁盘] + Executor --> Disk + Scheduler[定时器] --> Jobs + Watcher[目录监听器] --> Jobs + Hook[下载器 Webhook] --> Web + Worker -.可选.-> Metadata[元数据适配器] +``` + +### 3.1 Web/API 进程 + +- 登录、退出、会话和 CSRF。 +- 页面静态资源和 `/api/v1` JSON API。 +- 配置、目录、计划、审核、资料库和历史查询。 +- 创建任务、批准计划、提交纠正和请求回滚。 +- 使用 SSE 推送任务事件。 +- 不直接扫描磁盘,不直接执行 link/copy/move。 + +### 3.2 Worker 进程 + +- 领取持久化任务并维护租约和心跳。 +- 执行扫描、识别、计划、元数据刷新、备份和一致性检查。 +- 执行批准后的文件计划和补偿回滚。 +- 运行定时器和 Windows 目录监听器。 +- 同一时刻只允许一个有效 Worker 持有文件写入租约。 + +### 3.3 核心算法层 + +- Scanner 只发现文件并生成快照。 +- Parser、Resolver 尽量保持无副作用。 +- Planner 只生成不可变计划。 +- Executor 只处理已经批准且预检查通过的计划项。 +- 核心算法不能依赖 FastAPI 或 React。 + +## 4. 包结构 + +```text +autoanime_v3/ +├─ scanner.py +├─ parser.py +├─ resolver.py +├─ planner.py +├─ executor.py +├─ domain/ +│ ├─ enums.py +│ ├─ entities.py +│ ├─ commands.py +│ └─ events.py +├─ db/ +│ ├─ engine.py +│ ├─ schema.py +│ ├─ migrations.py +│ └─ repositories/ +├─ services/ +│ ├─ auth.py +│ ├─ library.py +│ ├─ profiles.py +│ ├─ scans.py +│ ├─ reviews.py +│ ├─ plans.py +│ ├─ operations.py +│ ├─ rules.py +│ ├─ settings.py +│ └─ backups.py +├─ jobs/ +│ ├─ queue.py +│ ├─ worker.py +│ ├─ scheduler.py +│ └─ watcher.py +├─ api/ +│ ├─ app.py +│ ├─ dependencies.py +│ ├─ errors.py +│ └─ routes/ +├─ security/ +│ ├─ passwords.py +│ ├─ sessions.py +│ ├─ csrf.py +│ └─ secrets.py +└─ integrations/ + ├─ metadata.py + ├─ downloaders.py + └─ media_servers.py + +webui/ +├─ src/ +│ ├─ app/ +│ ├─ api/ +│ ├─ components/ +│ ├─ features/ +│ ├─ pages/ +│ ├─ styles/ +│ └─ test/ +└─ vite.config.ts +``` + +现有 `library_service.py` 拆入按业务能力组织的 service。现有 `repository.py` 不再只是 `ResolutionCache` 别名,而是明确的 repository 接口集合。 + +## 5. 数据层 + +### 5.1 数据库约束 + +- SQLite 开启 `foreign_keys=ON`、`journal_mode=WAL`、`busy_timeout`。 +- Schema 通过迁移管理,禁止在 `__enter__` 中临时执行大段建表和 ALTER。 +- 时间统一保存 UTC ISO 8601。 +- Windows 路径保存原始显示值和规范化比较值。 +- 业务服务通过 Unit of Work 提交一组数据库变更。 +- 文件系统与 SQLite 不能宣称为同一原子事务,使用预检查、操作日志、补偿和最终核对。 + +### 5.2 系统和认证表 + +#### `users` + +- `id` +- `username`,唯一 +- `password_hash` +- `is_active` +- `password_changed_at` +- `created_at`、`updated_at` + +#### `user_sessions` + +- `id` +- `user_id` +- `token_hash`,唯一 +- `csrf_hash` +- `created_at`、`last_seen_at`、`expires_at` +- `revoked_at` +- `client_ip`、`user_agent` + +#### `app_settings` + +- `key`,主键 +- `value_json` +- `revision` +- `updated_at` + +#### `secret_settings` + +- `key`,主键 +- `ciphertext` +- `provider`,Windows 默认 `dpapi` +- `updated_at` + +API 只返回是否已配置和更新时间,不返回密文或明文。 + +#### `audit_events` + +- 操作者、动作、对象类型和对象 ID。 +- 修改前后摘要、原因、请求 trace ID、IP 和时间。 +- 登录、配置修改、计划批准、执行、纠正、规则激活、密钥更新、备份和回滚必须写入。 + +### 5.3 存储和扫描配置表 + +#### `storage_roots` + +- `kind`:`source`、`library`、`operations`、`metadata_cache`。 +- `path`、`normalized_path`。 +- `volume_serial`、`filesystem_type`。 +- `enabled`、`health_status`、`last_checked_at`。 + +当前存在的 `normalized_path` 唯一。输出根不能等于输入根或位于输入根内部。 + +#### `scan_profiles` + +- `name` +- `source_root_id`、`library_root_id` +- `mode`:`link`、`copy`、`move` +- `execution_policy`:`review_all`、`auto_apply_safe`、`dry_run` +- `min_confidence` +- `stability_seconds` +- `watch_enabled` +- `enabled` +- `revision` + +#### `profile_rules` + +- include/exclude glob。 +- 支持的媒体和字幕扩展名。 +- 未完成下载后缀。 +- 最小文件大小和忽略目录。 + +#### `schedules` + +- `profile_id` +- `kind`:`interval` 或 `daily` +- `schedule_json` +- `timezone` +- `next_run_at`、`last_run_at` +- `enabled` + +#### `webhook_sources` + +- 下载器名称、token 哈希、绑定 profile、启用状态和最后调用时间。 +- Webhook 只能提交已配置根目录内的路径。 + +### 5.4 文件事实模型 + +#### `media_files` + +表示某一代物理内容,不等同于某个路径。 + +- `id` +- `size`、`mtime_ns` +- `volume_serial`、`file_index` +- 可选 `sha256` +- `media_kind` +- `generation_status` +- `created_at`、`updated_at` + +路径被不同大小、mtime 或文件 ID 的新文件复用时,创建新的 `media_files`,不能覆盖旧历史。 + +#### `file_locations` + +- `media_file_id` +- `root_id` +- `path`、`normalized_path` +- `role`:`source`、`library`、`staging` +- `state`:`present`、`missing`、`replaced`、`deleted` +- `first_seen_at`、`last_seen_at` + +一个 `media_files` 可以同时拥有下载源位置和媒体库硬链接位置。 + +#### `media_assignments` + +- 当前接受的 show、season、episode。 +- release/version 标签。 +- title、season、episode、version 的人工锁。 +- `revision` 和修改来源。 + +#### `identification_results` + +- `media_file_id` +- 决策指纹、解析器版本和规则版本。 +- 标题、季度、集号、类型、置信度、接受状态。 +- `created_at`。 + +旧识别结果保留为历史,但只有当前 assignment 参与资料库事实和计划。 + +#### `identification_evidence` + +- `result_id` +- agent、字段、值、置信度和 detail。 +- 保存原始证据 JSON 供调试。 + +### 5.5 番剧和附加元数据表 + +#### `shows` + +- 规范标题、规范化键、状态、修订号。 +- 人工标题锁。 + +#### `seasons` + +- `show_id`、季度号、显示标题和预期集数。 + +#### `episodes` + +- `season_id`、集号、类型、显示标题和排序值。 +- 特殊项使用 Season 00 和明确集号,不能全局猜为 E01。 + +#### `metadata_records` + +- provider、provider ID、海报、本地海报缓存、简介、放送状态。 +- `fetched_at`、`expires_at`、原始响应摘要。 +- 元数据不可用时不阻塞核心整理流程。 + +### 5.6 任务和审核表 + +#### `jobs` + +- 类型、状态、优先级、请求参数和幂等键。 +- 进度计数、当前阶段、错误码和错误摘要。 +- `lease_owner`、`lease_until`、`heartbeat_at`。 +- `requested_by`、`created_at`、`started_at`、`finished_at`。 + +#### `job_events` + +- `job_id`、递增序号、level、event_type、message、payload 和时间。 +- SSE 按最后事件序号续传。 + +#### `scan_runs`、`scan_items` + +- 保存扫描范围、文件快照、发现/忽略/识别统计和规则版本。 + +#### `review_items` + +- 类型:低置信度、季集缺失、证据冲突、路径冲突、文件变化、规则失效。 +- 状态:`open`、`resolved`、`dismissed`、`superseded`。 +- 使用稳定 `dedup_key` 防止同一问题重复堆积。 + +### 5.7 计划、执行和纠正表 + +#### `plans` + +- 不可变计划头。 +- 来源 scan run、profile 修订号、规则版本和基础资料库修订号。 +- 状态、摘要统计、批准人和批准时间。 + +#### `plan_items` + +- source location、destination root 和相对路径。 +- 动作、原因和风险级别。 +- 源文件 ID、大小、mtime 和可选摘要快照。 +- 识别结果快照和执行状态。 + +#### `operation_batches`、`operation_items` + +- 保存执行批次、前后路径、摘要、结果、错误和补偿状态。 +- 手动和自动回滚都创建新的 operation batch。 + +#### `change_requests` + +- 修改目标、字段补丁、旧值、新值、原因和 `base_revision`。 +- 文件迁移计划、冲突统计和状态。 +- 修改影响路径时必须走批准和执行流程。 + +### 5.8 规则版本表 + +#### `rule_sets`、`rule_revisions` + +- 规则以版本化 JSON 文档保存。 +- 状态:草稿、已校验、已激活、已废弃。 +- 激活版本生成内容哈希并进入识别决策指纹。 +- 支持导入、导出、校验、激活和回退。 +- 激活新规则不会自动移动已有文件,只会让相关识别结果失效并产生重新审核任务。 + +## 6. 状态机 + +### 6.1 Job + +```text +queued -> leased -> running -> waiting_review | succeeded | failed | cancelled + \-> interrupted +``` + +租约过期的 running job 进入 interrupted,由恢复逻辑判断是否可安全重试。 + +### 6.2 Review + +```text +open -> resolved | dismissed | superseded +``` + +### 6.3 Plan + +```text +draft -> ready -> approved -> executing -> completed + \-> stale + \-> cancelled +executing -> failed_rolled_back | failed_needs_attention +``` + +批准后计划不可编辑。源文件、规则或基础修订发生变化时变成 stale。 + +### 6.4 Change Request + +```text +draft -> validated -> approved -> applied + \-> stale + \-> rejected +applied -> reverted +``` + +## 7. 扫描和自动化流程 + +所有触发方式都只能创建扫描任务: + +1. 手动扫描。 +2. 定时扫描。 +3. Watchdog 文件事件经去重和稳定性检测后创建 targeted scan。 +4. 通用下载器 Webhook 创建指定 profile 的 targeted scan。 + +Watcher 必须: + +- 合并短时间内重复事件。 +- 等待大小和 mtime 稳定。 +- 忽略临时后缀和未完成下载。 +- 同一 profile 有活动扫描时合并请求。 +- 不直接调用 Executor。 + +## 8. 计划批准和文件执行 + +1. Scanner 生成文件快照。 +2. Resolver 保存识别结果和证据。 +3. 不安全结果创建 review item。 +4. 安全结果由 Planner 生成不可变 plan。 +5. 管理员解决全部冲突并批准。 +6. Worker 获取 profile、根目录和 plan 租约。 +7. 执行前一次性检查整个批次: + - 源文件存在且身份、大小、mtime 未变。 + - 目标路径不存在且位于 library root 内。 + - hardlink 位于同一卷。 + - copy/move 空间充足。 + - 计划、profile 和规则修订仍有效。 +8. 任一预检查失败时,在修改文件前终止整个批次。 +9. 逐项执行,保存结果摘要和文件身份。 +10. 失败后逆序补偿。 +11. 最终重新扫描受影响路径,更新 file locations。 + +执行过程中不允许覆盖已有目标。用户取消只能发生在安全边界;已经发生写入时必须完成当前文件并进入补偿流程。 + +## 9. 资料库修改 + +以下修改必须生成 change request: + +- 规范番名。 +- 季度和集号。 +- 电影、OVA、SP 类型。 +- 发布版本标签。 +- 人工字段锁。 +- 合并或拆分番剧。 + +修改预览必须展示字段差异、受影响文件、目标路径、冲突、数据库更新和文件迁移。使用 `base_revision` 做乐观并发控制。 + +## 10. API + +统一前缀 `/api/v1`,同源部署,不开放任意 CORS。 + +```text +POST /auth/login +POST /auth/logout +GET /auth/me + +GET /dashboard +GET /system/health +GET /system/version + +GET /roots +POST /roots +PATCH /roots/{id} +POST /roots/{id}/validate + +GET /profiles +POST /profiles +PATCH /profiles/{id} + +POST /jobs/scans +GET /jobs +GET /jobs/{id} +POST /jobs/{id}/cancel +POST /jobs/{id}/retry +GET /jobs/{id}/events + +GET /reviews +POST /reviews/{id}/resolve +POST /reviews/bulk-resolve + +GET /plans +GET /plans/{id} +POST /plans/{id}/approve +POST /plans/{id}/cancel + +GET /library/shows +GET /library/shows/{id} +GET /library/files/{id} +POST /library/changes/preview +POST /library/changes/{id}/approve + +GET /operations +GET /operations/{id} +POST /operations/{id}/rollback + +GET /rules +POST /rules/revisions +POST /rules/revisions/{id}/validate +POST /rules/revisions/{id}/activate +POST /rules/revisions/{id}/rollback + +GET /settings +PATCH /settings +PUT /settings/secrets/{key} + +POST /backups +GET /backups +POST /backups/{id}/restore + +POST /hooks/downloaders/{token} +``` + +约束: + +- 写请求支持 `Idempotency-Key`。 +- 更新使用 ETag/`If-Match` 或明确修订号。 +- 列表使用游标分页。 +- 错误统一返回 `code`、`message`、`details`、`trace_id`。 +- SSE 支持 `Last-Event-ID` 断线续传。 + +## 11. WebUI 页面 + +### 11.1 视觉系统 + +- 浅色 Windows 运维控制台。 +- 真实白色或中性近白背景、石墨色文本、深靛蓝主色。 +- 琥珀色表示待处理,红色表示危险或失败。 +- 侧边栏约 220px,主体使用开放式列表、表格和信息轨。 +- 避免嵌套卡片、bento grid、玻璃拟态、霓虹和装饰性动画。 +- 8px 圆角;阴影只用于弹窗、抽屉等覆盖层。 +- 使用一致的细线 outline 图标。 + +### 11.2 导航 + +- 概览 +- 扫描配置 +- 任务中心 +- 审核队列 +- 整理计划 +- 资料库 +- 规则与别名 +- 操作历史 +- 系统设置 + +### 11.3 关键页面 + +- 首次启动:创建管理员、添加根目录、验证权限和卷、创建第一个 profile。 +- 概览:活动任务、待审核、冲突、失败、根目录健康和系统心跳。 +- 扫描配置:多根目录映射、模式、阈值、监听、定时和执行策略。 +- 任务中心:实时阶段、进度、当前文件、事件日志、取消和重试。 +- 审核队列:证据对比、人工字段、批量处理和人工锁。 +- 整理计划:文件级差异、冲突、预计大小、批准和执行状态。 +- 资料库:番剧、季度、剧集、多版本、所有文件位置、证据和附加元数据。 +- 规则与别名:草稿、校验、激活、回退和影响预览。 +- 操作历史:执行、自动回滚、手动回滚和人工恢复说明。 +- 系统设置:密码、密钥、备份、健康、日志和维护模式。 + +桌面浏览器提供完整功能。移动端支持状态查看、简单审核和任务观察,复杂批量路径操作以桌面端为主。 + +## 12. 安全 + +- 密码使用 Argon2id。 +- Session token 只保存哈希;Cookie 使用 HttpOnly、SameSite=Strict,HTTPS 时启用 Secure。 +- 修改请求使用 CSRF token。 +- 登录失败限速和短期锁定。 +- 密钥使用 Windows DPAPI 加密;不在 API、日志和脱敏导出中回显。 +- Webhook token 只保存哈希并绑定 profile。 +- 所有文件路径必须位于登记根目录内。 +- 规范路径后检查符号链接、目录联接和 reparse point。 +- Windows 服务使用专用低权限账号。 +- 所有关键操作写审计事件。 + +## 13. Windows 部署 + +建议生产环境使用 Python 3.11 或更高版本。开发和迁移阶段的核心解析器继续保持可测试的 Python 3.8 兼容性,Web 服务依赖在独立环境中运行。 + +```text +C:\ProgramData\AutoAnime\ +├─ config\ +├─ data\library.sqlite3 +├─ backups\ +├─ logs\ +├─ operations\ +└─ metadata-cache\ +``` + +服务: + +- `AutoAnimeWeb` +- `AutoAnimeWorker` +- 可选 `Caddy` + +使用 WinSW 注册服务并配置失败重启。前端构建产物由 FastAPI 静态托管。 + +## 14. 备份、恢复和诊断 + +- 使用 SQLite Online Backup API。 +- 默认保留 14 个日备份和 8 个周备份。 +- 恢复前进入维护模式并停止 Worker/Watcher。 +- 恢复后执行外键、Schema、目录和文件位置核对。 +- DPAPI 密文跨机器恢复后要求重新录入密钥。 +- 提供数据库完整性检查、孤儿 staging 扫描和文件位置重新核对。 +- 数据库备份不等同于媒体文件备份,UI 必须明确提示。 + +## 15. 日志和可观测性 + +- JSON 结构化日志包含 `trace_id`、`job_id`、`run_id`。 +- Web、Worker、Watcher 和 Scheduler 保存心跳。 +- `/health/live` 检查进程;`/health/ready` 检查数据库、迁移和 Worker。 +- Job events 是任务恢复和 SSE 的事实来源,普通日志不承担状态恢复。 +- 审计日志与调试日志分离。 + +## 16. 测试策略 + +### 16.1 后端单元测试 + +- Schema、迁移和约束。 +- 路径规范化和根目录逃逸。 +- 密码、session、CSRF 和密钥脱敏。 +- Job、Review、Plan 和 Change Request 状态机。 +- 规则版本和决策指纹。 + +### 16.2 文件操作集成测试 + +- Windows hardlink、copy、move。 +- 跨卷 hardlink 拒绝。 +- 源文件变化、目标替换和路径复用。 +- 中断、补偿、staging 保留和安全回滚。 +- 源位置和媒体库位置同时存在。 + +### 16.3 API 测试 + +- 登录、过期、撤销、CSRF 和限速。 +- 幂等键和乐观并发。 +- SSE 续传。 +- 未登录和失效计划拒绝。 +- 密钥不回显。 + +### 16.4 前端测试 + +- Vitest 组件和状态测试。 +- Playwright 登录、配置、扫描、审核、批准、执行和回滚流程。 +- 桌面和移动布局。 +- 键盘导航、focus、颜色对比和 reduced motion。 + +### 16.5 验收标准 + +- 重启不丢任务。 +- Worker 崩溃不会重复执行未知状态的文件操作。 +- 源文件变化导致计划拒绝。 +- 路径逃逸、覆盖和不安全回滚被拒绝。 +- 人工锁不会被 agent 覆盖。 +- 密钥不出现在 API、页面源码、日志和导出中。 +- Watcher 只创建任务。 +- 元数据不可用不阻塞整理。 +- 完整自动化测试、前端构建和浏览器核心流程通过。 + +## 17. 实施顺序 + +1. Schema v3、迁移、repository 和领域状态机。 +2. 认证、配置、根目录和安全边界。 +3. 持久化任务、Worker、SSE 和手动扫描。 +4. 资料库、审核队列和不可变计划。 +5. 批准、文件执行、操作批次和回滚。 +6. 资料库修改和规则版本管理。 +7. 定时、Watcher 和通用 Webhook。 +8. 元数据、备份、诊断和 Windows 服务部署。 +9. 完整 WebUI、响应式、可访问性和视觉一致性验证。 diff --git "a/docs/12_v3_\346\236\266\346\236\204\344\270\216\350\277\201\347\247\273.md" "b/docs/12_v3_\346\236\266\346\236\204\344\270\216\350\277\201\347\247\273.md" new file mode 100644 index 0000000..b9c0ea3 --- /dev/null +++ "b/docs/12_v3_\346\236\266\346\236\204\344\270\216\350\277\201\347\247\273.md" @@ -0,0 +1,72 @@ +# AutoAnime v3 架构与运行链路 + +## 1. 单一实现 + +项目只保留入口 `AutoAnimeMv3.py`、核心包 `autoanime_v3/`、配置 `config.v3.ini` 和资料库 `.autoanime-v3/library.sqlite3`。代码、测试和文档全部围绕同一条链路维护。 + +## 2. 模块职责 + +```text +AutoAnimeMv3.py +└─ autoanime_v3.cli + ├─ scanner 输入扫描、季度文件夹/单文件、未完成下载过滤 + ├─ parser 文件名、目录上下文、季集、电影/特殊项、发布源 + ├─ catalog 别名、季度布局、特殊项默认值、规则版本指纹 + ├─ resolver agent 编排、证据合并、置信度与安全阈值 + ├─ planner 唯一目标路径、同集多版本、字幕计划、冲突检查 + ├─ executor link/copy/move、批次自动回滚、JSONL 日志 + ├─ repository SQLite 资料库公共入口 + └─ library_service CLI/WebUI 共用查询与纠正预览边界 +``` + +## 3. 决策模型 + +识别结果只有同时满足以下条件才会进入整理计划: + +1. 标题存在,且来自中文文件名、受信别名目录或通过本地校验的远程 agent; +2. 季度和集数完整; +3. 明确季集不能与远程 agent 冲突; +4. 置信度达到 `min_confidence`; +5. 目标路径不覆盖已有不同文件。 + +所有证据写入 `resolutions.evidence_json`。别名、季度布局或特殊项默认值发生变化时,目录内容哈希会进入文件决策指纹,旧结果自动失效。 + +## 4. 季集规则 + +- 明确 `S03E02`、`Season 3 - 02`、`第三季 - 02` 优先。 +- 文件只给绝对集数时,根据受控季度布局换算。 +- 文件同时给季度和绝对集数时,仅在集数超过该季度合理范围且能唯一换算时处理。 +- PV、TVSP、OVA 等没有集号的单文件必须在 `episode_defaults` 显式声明,不能全局默认成 E01。 +- 未知英文标题不会因为“集数看起来正常”而自动整理。 + +## 5. 文件计划与执行 + +- 先解析所有文件,再一次性生成完整计划。 +- 同一番剧、季度、集数的多个发布版本加入平台、字幕组、V2、无修或配音标签。 +- 版本标签由文件自身元数据稳定决定;没有可识别发布信息时使用基于规范化源路径的 `version-xxxxxxxx`,分批加入普通文件或 Baha/friDay/V3 等版本时都不会因扫描批次不同改变目标名。 +- 目标已存在时不覆盖。 +- 已整理视频再次扫描时仍会规划后来新增的匹配字幕。 +- copy/move 写入失败时清理未完成目标,避免残留半个文件。 +- move 先把源原子重命名为同目录 `.partial` staging;随后复核大小、修改时间、文件身份和流式 SHA-256,通过后只删除 staging。原下载路径若在执行期间被重新创建不会被触碰;失败且原路径被占用时 staging 会保留并明确报告恢复位置。 +- 默认仅预览;`--apply` 才执行。 +- 批次任一文件失败时,已完成的 link/copy/move 会按逆序自动回滚。 +- 手动回滚会同步 SQLite 文件状态;若目标摘要与执行日志不一致则拒绝破坏性回滚,旧 copy/link 日志缺少摘要时同样拒绝删除。 +- 每项操作同时写 JSONL 日志和 SQLite `operations`。 + +## 6. SQLite 缓存生命周期 + +1. 扫描器生成文件元数据:绝对/相对路径、文件名、大小和修改时间。 +2. 指纹加入解析器版本与别名/季度规则目录哈希。 +3. `media_files.source_key` 按平台路径规则标识同一物理来源;规则变化时原位更新当前剧集归属,不创建第二个当前媒体实体。 + 若下载路径被大小/mtime 已变化的新文件复用,则清除旧 `organized` 状态并把当前位置重置为新源文件,避免继承旧媒体库目标。 +4. 只读取指纹和版本完全匹配、且此前安全通过的 `resolutions`;旧决策可作为历史保留,但不参与当前进度。 +5. 未命中时重新执行解析和 agent 判定;低置信度结果不写入缓存。 +6. 标题规则、季度布局或解析器版本变化后,旧结果自然失效,无需手动逐条删除。 +7. dry-run 可写识别缓存,但不会把 `media_files.status` 标成 `organized`。 +8. 实际整理成功后更新 `current_path/status`;回滚按稳定来源键恢复原路径和状态,即使决策指纹后来已改变。 + +资料库既是识别缓存,也是未来 WebUI 的事实来源。主要表包括 `shows`、`seasons`、`episodes`、`media_files`、`resolutions`、`operations` 和 `corrections`。 + +## 7. WebUI 接入 + +WebUI 必须依赖 `LibraryService`,不能直接修改 SQLite 或移动文件。v3.1.1 已提供番剧进度、详情和标题纠正迁移预览;真正写入将在加入租约锁、指纹复核、二次确认和完整事务补偿后实现。 diff --git a/docs/superpowers/plans/2026-07-22-finish-v3-real-test-and-publish.md b/docs/superpowers/plans/2026-07-22-finish-v3-real-test-and-publish.md new file mode 100644 index 0000000..09e379d --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-finish-v3-real-test-and-publish.md @@ -0,0 +1,47 @@ +# AutoAnime v3 Real-Test and Publish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Verify the simplified v3-only repository with real F-drive samples, review the full diff, then publish it on a new branch. + +**Architecture:** Keep the existing scanner → parser → catalog → resolver → planner → executor pipeline unchanged. Build a non-overwriting hard-link test library from selected high-risk files, verify SQLite and filesystem state, review all working-tree changes, then branch, commit, and push only after every gate passes. + +**Tech Stack:** Python 3.8, pytest, SQLite, PowerShell, Git. + +--- + +### Task 1: Real F-drive integration test + +**Files:** +- Read: `F:\下载` +- Create: `F:\AutoAnime_v3_RealTest_20260722\Input` +- Create: `F:\AutoAnime_v3_RealTest_20260722\Library` +- Create: `F:\AutoAnime_v3_RealTest_20260722\report.json` + +- [ ] **Step 1:** Verify the test root does not already exist. +- [ ] **Step 2:** Select 18 high-risk source files covering multi-version episodes, nested season folders, subtitles, and absolute episode remapping. +- [ ] **Step 3:** Create source-preserving hard links under `Input`. +- [ ] **Step 4:** Run `python AutoAnimeMv3.py --output --mode link --apply --no-cache --report-json `. +- [ ] **Step 5:** Verify input/output counts, hard-link identity, expected title/season/episode mappings, and SQLite organized state. + +### Task 2: Repository review and verification + +**Files:** +- Review: repository working tree +- Test: `tests/test_v3_*.py` + +- [ ] **Step 1:** Run all v3 standard-library unittest tests. +- [ ] **Step 2:** Run compileall, CLI help, alias JSON validation, and `git diff --check`. +- [ ] **Step 3:** Inspect deleted legacy scope and added v3 scope for accidental loss, secrets, generated files, or stale references. +- [ ] **Step 4:** Dispatch an independent code reviewer and resolve all Critical or Important findings. + +### Task 3: Publish branch + +**Files:** +- Stage: all intended repository changes + +- [ ] **Step 1:** Create branch `codex/autoanime-v3-refactor` from the externally managed detached worktree. +- [ ] **Step 2:** Stage only intended source, tests, documentation, and deletion changes. +- [ ] **Step 3:** Commit with a concise refactor message. +- [ ] **Step 4:** Push the branch to `origin` without force. +- [ ] **Step 5:** Report the branch, commit, remote result, real-test location, and verification evidence. diff --git a/docs/superpowers/plans/2026-07-23-autoanime-web-console.md b/docs/superpowers/plans/2026-07-23-autoanime-web-console.md new file mode 100644 index 0000000..485f3b0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-autoanime-web-console.md @@ -0,0 +1,612 @@ +# AutoAnime Web Console Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Windows LAN, single-administrator Web console that manages AutoAnime scanning, review, immutable plans, safe execution, library editing, automation, metadata, backup, and recovery. + +**Architecture:** Keep the existing v3 scanner/parser/resolver/planner/executor as the domain core. Add a SQLite-backed application layer, FastAPI Web/API process, lease-based Worker, and React/Vite frontend. The Web process only submits commands and queries state; the Worker is the sole owner of file-changing jobs. + +**Tech Stack:** Python 3.11 production runtime, Python stdlib + SQLAlchemy 2/Alembic, FastAPI/Uvicorn, Argon2, Windows DPAPI adapter, watchdog, React, TypeScript, Vite, TanStack Query, React Router, Vitest, Playwright. + +--- + +## File structure + +### Existing files retained + +- `autoanime_v3/scanner.py`: filesystem discovery and source snapshots. +- `autoanime_v3/parser.py`: filename parsing. +- `autoanime_v3/resolver.py`: evidence resolution. +- `autoanime_v3/planner.py`: core destination planning helpers. +- `autoanime_v3/executor.py`: low-level safe file operations; later adapted behind the operation service. + +### New backend packages + +- `autoanime_v3/domain/enums.py`: persisted state values. +- `autoanime_v3/domain/entities.py`: application-facing immutable DTOs. +- `autoanime_v3/domain/errors.py`: stable business error codes. +- `autoanime_v3/db/engine.py`: SQLite connection and transaction setup. +- `autoanime_v3/db/schema.py`: SQLAlchemy metadata and tables. +- `autoanime_v3/db/migrations.py`: schema version bootstrap and migration runner. +- `autoanime_v3/db/repositories/*.py`: focused persistence adapters. +- `autoanime_v3/services/*.py`: business use cases and transaction boundaries. +- `autoanime_v3/security/*.py`: passwords, sessions, CSRF and secret storage. +- `autoanime_v3/jobs/*.py`: persistent queue, Worker, Scheduler and Watcher. +- `autoanime_v3/api/*.py`: FastAPI app, dependencies, errors and routes. +- `autoanime_v3/integrations/*.py`: optional provider boundaries. + +### New frontend + +- `webui/src/app`: app shell, router and providers. +- `webui/src/api`: generated/shared API client and SSE client. +- `webui/src/components`: reusable code-native controls. +- `webui/src/features`: feature-owned queries, forms and views. +- `webui/src/pages`: route composition only. +- `webui/src/styles`: accepted visual system tokens and global styles. + +--- + +### Task 1: Schema v3 foundation and migrations + +**Files:** + +- Create: `autoanime_v3/domain/enums.py` +- Create: `autoanime_v3/db/__init__.py` +- Create: `autoanime_v3/db/engine.py` +- Create: `autoanime_v3/db/schema.py` +- Create: `autoanime_v3/db/migrations.py` +- Create: `tests/test_v3_web_schema.py` +- Modify: `requirements.txt` + +- [ ] **Step 1: Write the failing schema creation test** + +```python +def test_schema_creates_web_console_tables(tmp_path): + database = tmp_path / "library.sqlite3" + run_migrations(database) + names = table_names(database) + assert {"users", "storage_roots", "scan_profiles", "jobs", "job_events"} <= names + assert {"media_files", "file_locations", "plans", "plan_items"} <= names +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: `python -m unittest tests.test_v3_web_schema -v` + +Expected: import failure for `autoanime_v3.db.migrations`. + +- [ ] **Step 3: Define persisted enums and SQLAlchemy metadata** + +Define exact string enums: + +```python +class JobStatus(str, Enum): + QUEUED = "queued" + LEASED = "leased" + RUNNING = "running" + WAITING_REVIEW = "waiting_review" + SUCCEEDED = "succeeded" + FAILED = "failed" + CANCELLED = "cancelled" + INTERRUPTED = "interrupted" +``` + +Create the tables listed in `docs/11_v3_WebUI与数据层规划.md`, including foreign keys, unique normalized paths, revisions and timestamps. + +- [ ] **Step 4: Implement SQLite setup and migration bootstrap** + +`create_engine_for_path()` must enable foreign keys, WAL and busy timeout for every connection. `run_migrations()` must be idempotent and write the current schema version. + +- [ ] **Step 5: Run the focused and existing tests** + +Run: + +```powershell +python -m unittest tests.test_v3_web_schema -v +python -m unittest discover -s tests -p "test_v3_*.py" -v +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```powershell +git add requirements.txt autoanime_v3/domain autoanime_v3/db tests/test_v3_web_schema.py +git commit -m "feat: add web console schema foundation" +``` + +### Task 2: File facts, roots, profiles and repositories + +**Files:** + +- Create: `autoanime_v3/domain/entities.py` +- Create: `autoanime_v3/domain/errors.py` +- Create: `autoanime_v3/db/repositories/roots.py` +- Create: `autoanime_v3/db/repositories/profiles.py` +- Create: `autoanime_v3/db/repositories/library.py` +- Create: `autoanime_v3/services/roots.py` +- Create: `autoanime_v3/services/profiles.py` +- Create: `tests/test_v3_roots_profiles.py` +- Create: `tests/test_v3_file_facts.py` + +- [ ] **Step 1: Write failing root safety tests** + +Test exact behaviors: + +- normalized Windows paths are compared case-insensitively; +- duplicate roots are rejected; +- output equal to or beneath source is rejected; +- paths outside registered roots cannot be converted into operation targets. + +- [ ] **Step 2: Verify RED** + +Run: `python -m unittest tests.test_v3_roots_profiles -v` + +Expected: service imports fail. + +- [ ] **Step 3: Implement root and profile services** + +Public interfaces: + +```python +class RootService: + def create_root(self, kind: str, path: Path) -> StorageRoot: ... + def validate_root(self, root_id: int) -> RootHealth: ... + +class ProfileService: + def create_profile(self, command: CreateProfile) -> ScanProfile: ... + def update_profile(self, profile_id: int, revision: int, patch: dict) -> ScanProfile: ... +``` + +- [ ] **Step 4: Write failing multi-location file tests** + +Prove one media object can own a source location and a library hardlink location. Prove path reuse with a changed file creates a new media generation and marks the old location `replaced`. + +- [ ] **Step 5: Implement the library repository** + +Do not expose ORM rows. Return frozen DTOs and require explicit Unit of Work commits. + +- [ ] **Step 6: Run tests and commit** + +Run: `python -m unittest tests.test_v3_roots_profiles tests.test_v3_file_facts -v` + +Expected: all pass. + +### Task 3: Authentication, sessions, CSRF and secret storage + +**Files:** + +- Create: `autoanime_v3/security/passwords.py` +- Create: `autoanime_v3/security/sessions.py` +- Create: `autoanime_v3/security/csrf.py` +- Create: `autoanime_v3/security/secrets.py` +- Create: `autoanime_v3/services/auth.py` +- Create: `autoanime_v3/db/repositories/auth.py` +- Create: `tests/test_v3_auth_security.py` + +- [ ] **Step 1: Write failing password and session tests** + +```python +def test_login_returns_random_session_and_never_password_hash(...): ... +def test_expired_or_revoked_session_is_rejected(...): ... +def test_state_changing_request_requires_matching_csrf_token(...): ... +def test_secret_read_returns_configured_flag_not_plaintext(...): ... +``` + +- [ ] **Step 2: Verify RED** + +Run: `python -m unittest tests.test_v3_auth_security -v` + +- [ ] **Step 3: Implement security primitives** + +- Use Argon2id through `argon2-cffi`. +- Hash random session and webhook tokens before storage. +- Compare tokens with constant-time comparison. +- Implement `SecretStore` protocol and Windows `DpapiSecretStore`; provide encrypted-file fallback for tests and non-Windows development. + +- [ ] **Step 4: Test login throttling and bootstrap behavior** + +The first-run bootstrap command creates exactly one administrator. Re-running without an explicit reset must fail. + +- [ ] **Step 5: Run tests and commit** + +Run: `python -m unittest tests.test_v3_auth_security -v` + +### Task 4: Persistent jobs, leases and event stream + +**Files:** + +- Create: `autoanime_v3/db/repositories/jobs.py` +- Create: `autoanime_v3/jobs/queue.py` +- Create: `autoanime_v3/jobs/worker.py` +- Create: `autoanime_v3/services/jobs.py` +- Create: `tests/test_v3_jobs.py` + +- [ ] **Step 1: Write failing queue tests** + +Test: + +- enqueue idempotency; +- one Worker lease owner; +- heartbeat renewal; +- expired lease becomes interrupted; +- ordered event sequence; +- cancellation only at safe boundaries. + +- [ ] **Step 2: Verify RED** + +Run: `python -m unittest tests.test_v3_jobs -v` + +- [ ] **Step 3: Implement the queue state machine** + +Public interface: + +```python +class JobQueue: + def enqueue(self, job_type: str, payload: dict, idempotency_key: str) -> Job: ... + def lease_next(self, worker_id: str, lease_seconds: int) -> Optional[Job]: ... + def heartbeat(self, job_id: int, worker_id: str) -> None: ... + def append_event(self, job_id: int, event_type: str, payload: dict) -> JobEvent: ... + def complete(self, job_id: int, worker_id: str) -> None: ... +``` + +- [ ] **Step 4: Test crash recovery** + +Simulate Worker termination after lease acquisition and verify the next Worker does not silently execute an unknown file-changing job. + +- [ ] **Step 5: Run tests and commit** + +Run: `python -m unittest tests.test_v3_jobs -v` + +### Task 5: Scan jobs, review queue and immutable plans + +**Files:** + +- Create: `autoanime_v3/services/scans.py` +- Create: `autoanime_v3/services/reviews.py` +- Create: `autoanime_v3/services/plans.py` +- Create: `autoanime_v3/db/repositories/scans.py` +- Create: `autoanime_v3/db/repositories/reviews.py` +- Create: `autoanime_v3/db/repositories/plans.py` +- Create: `tests/test_v3_scan_service.py` +- Create: `tests/test_v3_review_plan_service.py` + +- [ ] **Step 1: Write the failing scan orchestration test** + +Given a temporary source root containing safe, uncertain and conflicting files, verify the service records file facts, identification evidence, review items and one draft plan without touching the output root. + +- [ ] **Step 2: Verify RED** + +Run: `python -m unittest tests.test_v3_scan_service -v` + +- [ ] **Step 3: Implement scan orchestration** + +Call existing scanner/resolver/planner through injected adapters. Persist a scan snapshot before building reviews and plans. + +- [ ] **Step 4: Write stale-plan and approval tests** + +Prove: + +- approved plans cannot be modified; +- changed source identity makes a plan stale; +- changed profile or rule revision makes a plan stale; +- conflicts prevent approval. + +- [ ] **Step 5: Implement review resolution and plan approval** + +Resolving a review must generate a new plan revision, never mutate the previous plan. + +- [ ] **Step 6: Run tests and commit** + +Run: `python -m unittest tests.test_v3_scan_service tests.test_v3_review_plan_service -v` + +### Task 6: Operation batches, execution and rollback integration + +**Files:** + +- Create: `autoanime_v3/services/operations.py` +- Create: `autoanime_v3/db/repositories/operations.py` +- Modify: `autoanime_v3/executor.py` +- Create: `tests/test_v3_operation_service.py` +- Extend: `tests/test_v3_executor_safety.py` + +- [ ] **Step 1: Write failing all-batch preflight tests** + +Verify no file changes occur when any item has a changed source, occupied target, root escape, cross-volume link or stale plan. + +- [ ] **Step 2: Verify RED** + +Run: `python -m unittest tests.test_v3_operation_service -v` + +- [ ] **Step 3: Implement operation service** + +The service must: + +1. acquire leases; +2. preflight every plan item; +3. create an operation batch; +4. call low-level executor functions; +5. record result identity and SHA-256; +6. compensate in reverse order on failure; +7. reconcile file locations. + +- [ ] **Step 4: Write and pass rollback safety tests** + +Cover replaced destinations, changed digest, recreated source path and orphan staging. + +- [ ] **Step 5: Run full file safety suite and commit** + +Run: + +```powershell +python -m unittest tests.test_v3_operation_service tests.test_v3_executor_safety tests.test_v3_planner_executor -v +``` + +### Task 7: Rules, library corrections and metadata boundary + +**Files:** + +- Create: `autoanime_v3/services/rules.py` +- Create: `autoanime_v3/services/changes.py` +- Create: `autoanime_v3/integrations/metadata.py` +- Create: `autoanime_v3/db/repositories/rules.py` +- Create: `autoanime_v3/db/repositories/metadata.py` +- Create: `tests/test_v3_rules_changes.py` +- Create: `tests/test_v3_metadata.py` + +- [ ] **Step 1: Write failing rule revision tests** + +Test JSON schema validation, immutable revisions, activation, rollback and decision hash changes. + +- [ ] **Step 2: Write failing change-request tests** + +Test title/season/episode changes, manual locks, base revision conflicts, path impact preview and reversal. + +- [ ] **Step 3: Implement rules and changes** + +Rules activate only after validation. Changes affecting paths create plan items and use the normal operation service. + +- [ ] **Step 4: Implement read-only metadata adapter contract** + +Metadata failures return an unavailable state and never fail scan or execution jobs. + +- [ ] **Step 5: Run tests and commit** + +Run: `python -m unittest tests.test_v3_rules_changes tests.test_v3_metadata -v` + +### Task 8: Scheduler, Watcher, Webhook and backup + +**Files:** + +- Create: `autoanime_v3/jobs/scheduler.py` +- Create: `autoanime_v3/jobs/watcher.py` +- Create: `autoanime_v3/services/webhooks.py` +- Create: `autoanime_v3/services/backups.py` +- Create: `tests/test_v3_automation.py` +- Create: `tests/test_v3_backups.py` + +- [ ] **Step 1: Write failing automation tests** + +Test event debounce, stable-file window, ignored temporary suffixes, active-job coalescing, schedule deduplication and webhook root scope. + +- [ ] **Step 2: Implement automation producers** + +Scheduler, Watcher and Webhook may only enqueue scan jobs. They cannot invoke the executor. + +- [ ] **Step 3: Write failing online-backup tests** + +Test backup during WAL activity, checksum, retention, maintenance-mode restore and schema verification. + +- [ ] **Step 4: Implement backup service** + +Use the SQLite online backup API. Sanitized exports exclude secret ciphertext. + +- [ ] **Step 5: Run tests and commit** + +Run: `python -m unittest tests.test_v3_automation tests.test_v3_backups -v` + +### Task 9: FastAPI app and API contract + +**Files:** + +- Create: `autoanime_v3/api/app.py` +- Create: `autoanime_v3/api/dependencies.py` +- Create: `autoanime_v3/api/errors.py` +- Create: `autoanime_v3/api/routes/*.py` +- Create: `tests/test_v3_api.py` +- Create: `tests/test_v3_api_security.py` + +- [ ] **Step 1: Write failing auth and health API tests** + +Use FastAPI TestClient/httpx ASGI transport. Verify bootstrap/login/logout/me, Cookie flags, CSRF, `/health/live`, `/health/ready` and standard error envelopes. + +- [ ] **Step 2: Verify RED** + +Run: `python -m unittest tests.test_v3_api tests.test_v3_api_security -v` + +- [ ] **Step 3: Implement app factory and dependencies** + +```python +def create_app(settings: ServerSettings, services: ServiceContainer) -> FastAPI: + ... +``` + +No module import may create a database, start a Worker or bind a socket. + +- [ ] **Step 4: Implement endpoint groups** + +Implement the endpoints specified in the design document, including cursor pagination, idempotency keys, revisions and SSE `Last-Event-ID`. + +- [ ] **Step 5: Run API and backend suites and commit** + +Run: + +```powershell +python -m unittest tests.test_v3_api tests.test_v3_api_security -v +python -m unittest discover -s tests -p "test_v3_*.py" -v +``` + +### Task 10: React application shell and design system + +**Files:** + +- Create: `webui/package.json` +- Create: `webui/vite.config.ts` +- Create: `webui/src/main.tsx` +- Create: `webui/src/app/App.tsx` +- Create: `webui/src/app/router.tsx` +- Create: `webui/src/styles/tokens.css` +- Create: `webui/src/styles/global.css` +- Create: `webui/src/components/AppShell.tsx` +- Create: `webui/src/components/AppShell.test.tsx` + +- [ ] **Step 1: Write failing AppShell test** + +Verify the approved navigation copy, selected state, keyboard focus and responsive collapse. + +- [ ] **Step 2: Verify RED** + +Run: `pnpm --dir webui test --run src/components/AppShell.test.tsx` + +- [ ] **Step 3: Implement accepted design tokens** + +Use true neutral near-white background, graphite text, deep indigo primary, amber/red semantics, 220px desktop sidebar, 8px radius, thin borders, table/rail container model and no decorative gradients. + +- [ ] **Step 4: Implement route shell** + +Keep App as composition glue. Import icons directly from the chosen icon package; avoid a barrel import. + +- [ ] **Step 5: Run tests/build and commit** + +Run: + +```powershell +pnpm --dir webui test --run +pnpm --dir webui build +``` + +### Task 11: Dashboard, jobs, reviews and plans UI + +**Files:** + +- Create: `webui/src/api/client.ts` +- Create: `webui/src/api/events.ts` +- Create: `webui/src/features/dashboard/*` +- Create: `webui/src/features/jobs/*` +- Create: `webui/src/features/reviews/*` +- Create: `webui/src/features/plans/*` +- Create: corresponding `*.test.tsx` + +- [ ] **Step 1: Write failing Dashboard tests** + +Test active task progress, operational counts, scan-root status, activity and system heartbeat using real component state and a test server adapter. + +- [ ] **Step 2: Implement Dashboard** + +Match the accepted concept. No fake charts or additional metrics. + +- [ ] **Step 3: Write failing Job and SSE tests** + +Test reconnect with last event ID, ordered event rendering, safe cancel states and retry visibility. + +- [ ] **Step 4: Implement review and plan workflows** + +Include filters, evidence, selected-row inspector, stale/conflict handling and disabled approval while conflicts remain. + +- [ ] **Step 5: Run feature tests/build and commit** + +Run: `pnpm --dir webui test --run && pnpm --dir webui build` + +### Task 12: Library, settings, automation, rules and history UI + +**Files:** + +- Create: `webui/src/features/library/*` +- Create: `webui/src/features/profiles/*` +- Create: `webui/src/features/rules/*` +- Create: `webui/src/features/operations/*` +- Create: `webui/src/features/settings/*` +- Create: corresponding `*.test.tsx` + +- [ ] **Step 1: Write failing Library and correction tests** + +Test show/season/episode navigation, multi-location display, evidence, metadata-unavailable state, change preview and stale revision errors. + +- [ ] **Step 2: Implement Library and change flow** + +High-risk edits always show a migration preview; do not provide direct inline save for path-affecting fields. + +- [ ] **Step 3: Write failing configuration and history tests** + +Test path validation, secret non-disclosure, schedule/watcher controls, rule activation impact and rollback refusal. + +- [ ] **Step 4: Implement remaining pages** + +Use feature-local queries and forms; do not place all server state in a global store. + +- [ ] **Step 5: Run tests/build and commit** + +Run: `pnpm --dir webui test --run && pnpm --dir webui build` + +### Task 13: Packaging, browser E2E and Windows verification + +**Files:** + +- Create: `AutoAnimeWeb.py` +- Create: `AutoAnimeWorker.py` +- Create: `deploy/windows/AutoAnimeWeb.xml` +- Create: `deploy/windows/AutoAnimeWorker.xml` +- Create: `deploy/windows/Caddyfile.example` +- Create: `webui/e2e/*.spec.ts` +- Modify: `README.md` +- Modify: `README_en.md` +- Modify: `docs/00_文档总目录.md` + +- [ ] **Step 1: Write E2E tests before completing entry points** + +Cover login, first root/profile, manual scan, review resolution, plan approval, execution, operation history and safe rollback using temporary directories. + +- [ ] **Step 2: Implement CLI entry points and static frontend serving** + +Web and Worker entry points must share the same settings and database but never start each other implicitly during import. + +- [ ] **Step 3: Verify Windows service definitions** + +Confirm ProgramData paths, service account instructions, restart policy, firewall restriction and optional Caddy HTTPS. + +- [ ] **Step 4: Run full verification** + +```powershell +python -m unittest discover -s tests -p "test_v3_*.py" -v +pnpm --dir webui test --run +pnpm --dir webui build +pnpm --dir webui exec playwright test +``` + +Expected: zero failures. + +- [ ] **Step 5: Visual fidelity verification** + +Run the app in the built-in browser, capture the dashboard and plan-review screens at 1536x1024 and a mobile viewport. Inspect accepted concepts and implementation screenshots with `view_image`. Record at least five comparison points for layout, typography, palette, table density, sidebar, inspector, controls and responsive behavior. + +- [ ] **Step 6: Final safety checks** + +- No secret values in API fixtures, browser bundles, logs or snapshots. +- No output target escapes registered roots. +- No debug routes or seed credentials. +- No generated placeholder art used as actual UI. +- Existing v3 parser/resolver/executor regression tests remain green. + +- [ ] **Step 7: Commit** + +```powershell +git add . +git commit -m "feat: add full AutoAnime Web console" +``` + +--- + +## Execution handoff + +Implement tasks in order. Tasks 1-9 establish the safe backend and API; Tasks 10-12 implement the accepted interface; Task 13 performs packaging and complete functional and visual verification. Every production behavior follows RED, verified RED, GREEN, verified GREEN, then refactor. + diff --git a/docs/superpowers/plans/2026-08-02-webui-release-readiness.md b/docs/superpowers/plans/2026-08-02-webui-release-readiness.md new file mode 100644 index 0000000..b8eb242 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-webui-release-readiness.md @@ -0,0 +1,114 @@ +# AutoAnime WebUI Release Readiness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the current `codex/webui-full-console` working tree into a committed, security-audited, Windows-tested release candidate and publish it as a pull request to `main`. + +**Architecture:** Preserve the existing FastAPI/SQLite worker architecture and React SPA. Make only release-blocking changes: update the vulnerable router dependency, make Windows Playwright teardown reliable, align documented runtime requirements with actual dependencies, and publish the complete existing WebUI working tree. + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy, SQLite, React, TypeScript, Vite, pnpm, Vitest, Playwright, WinSW, Caddy. + +--- + +### Task 1: Close production dependency advisories + +**Files:** +- Modify: `webui/package.json` +- Modify: `webui/pnpm-lock.yaml` + +- [x] **Step 1: Preserve the failing security check** + +Run: `pnpm --dir webui audit --prod --audit-level high` + +Expected: non-zero exit with the React Router XSS/open-redirect advisory. + +- [x] **Step 2: Upgrade the router dependency** + +Upgrade `react-router-dom` to the latest compatible v6 patch that removes high/critical advisories and regenerate `pnpm-lock.yaml` through pnpm. Current published releases cannot remove every moderate advisory without introducing an unavailable or RSC-affected major line, so the release gate rejects high and critical findings. + +- [x] **Step 3: Verify security and compatibility** + +Run: + +```powershell +pnpm --dir webui audit --prod --audit-level high +pnpm --dir webui test --run +pnpm --dir webui build +``` + +Expected: audit reports no high or critical production vulnerabilities; tests and build exit 0. + +### Task 2: Make Windows E2E teardown reliable + +**Files:** +- Modify: `webui/e2e/console-flow.spec.ts` + +- [x] **Step 1: Reproduce the teardown failure** + +Run: `pnpm --dir webui e2e console-flow.spec.ts --grep "library title correction"` + +Expected before the fix: business assertions complete, then teardown fails with `EPERM` at temporary-root deletion. + +- [x] **Step 2: Implement bounded asynchronous cleanup** + +Close child-process streams after the Web process exits and replace the synchronous deletion with an explicit bounded retry loop for Windows `EPERM`, `EBUSY`, and `ENOTEMPTY`. Re-throw all other errors and re-throw the final retry failure. + +- [x] **Step 3: Verify the regression and full flow** + +Run: + +```powershell +pnpm --dir webui e2e console-flow.spec.ts --grep "library title correction" +pnpm --dir webui e2e console-flow.spec.ts +``` + +Expected: focused and complete suites exit 0. + +### Task 3: Align release documentation + +**Files:** +- Modify: `README.md` +- Modify: `README_en.md` +- Modify: `webui/package.json` + +- [x] **Step 1: Correct runtime requirements** + +Document Python 3.11 as the production runtime and Node.js 20+ with pnpm 10+ as the frontend build environment. Add matching `engines` metadata to `webui/package.json`. + +- [x] **Step 2: Correct the Playwright command** + +Use the package script form `pnpm --dir webui e2e` in both READMEs so Windows resolves the local Playwright binary consistently. + +- [x] **Step 3: Verify documentation commands** + +Run the documented unit-test, build, and E2E commands from the repository root and confirm they exit 0. + +### Task 4: Verify and publish the complete branch + +**Files:** +- Stage the complete intended working tree, including Web/API, worker, frontend, deployment templates, tests, documentation, and this plan. + +- [x] **Step 1: Run release gates** + +Run: + +```powershell +python -m unittest discover -s tests -p "test_v3_*.py" -v +python -m compileall -q AutoAnimeMv3.py AutoAnimeWeb.py AutoAnimeWorker.py autoanime_v3 +python -m pip check +pnpm --dir webui test --run +pnpm --dir webui build +pnpm --dir webui e2e console-flow.spec.ts +pnpm --dir webui audit --prod --audit-level high +git diff --check +``` + +Expected: all commands exit 0, with only the documented Windows symlink-permission test skipped. + +- [ ] **Step 2: Commit and push** + +Stage the complete intended WebUI release candidate, commit with a terse release description, and push `codex/webui-full-console` with upstream tracking. + +- [ ] **Step 3: Open a draft PR** + +Open a draft pull request targeting `main`. The body must summarize the V3 replacement, authenticated Web console, worker/automation, security controls, deployment templates, release fixes, and exact validation results. diff --git a/install-autostart.bat b/install-autostart.bat new file mode 100644 index 0000000..5b86ed3 --- /dev/null +++ b/install-autostart.bat @@ -0,0 +1,15 @@ +@echo off +setlocal +cd /d "%~dp0" +title AutoAnime Install Autostart +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-autostart.ps1" %* +set ERR=%ERRORLEVEL% +if not "%ERR%"=="0" ( + echo. + echo [AutoAnime] 注册自启失败,错误码 %ERR% + pause + exit /b %ERR% +) +echo. +pause +exit /b 0 diff --git a/install-autostart.ps1 b/install-autostart.ps1 new file mode 100644 index 0000000..977662f --- /dev/null +++ b/install-autostart.ps1 @@ -0,0 +1,73 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + 注册当前用户登录时自动启动 AutoAnime Web + Worker。 +.DESCRIPTION + 使用「当前用户」计划任务(一般无需管理员)。 + 同时在开始菜单「启动」文件夹放一份快捷方式作为备份。 +#> +[CmdletBinding()] +param( + [string]$DataDir = "C:\ProgramData\AutoAnime", + [string]$HostAddress = "0.0.0.0", + [int]$Port = 8765, + [string]$TaskName = "AutoAnime WebUI" +) + +$ErrorActionPreference = "Stop" +$ProjectRoot = (Resolve-Path $PSScriptRoot).Path +$StartScript = Join-Path $ProjectRoot "start-autoanime.ps1" +$StartBat = Join-Path $ProjectRoot "start-autoanime.bat" + +if (-not (Test-Path $StartScript)) { + throw "找不到 $StartScript" +} + +$argument = @( + "-NoProfile" + "-ExecutionPolicy", "Bypass" + "-WindowStyle", "Hidden" + "-File", "`"$StartScript`"" + "-DataDir", "`"$DataDir`"" + "-HostAddress", "`"$HostAddress`"" + "-Port", "$Port" +) -join " " + +$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $argument -WorkingDirectory $ProjectRoot +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME +$trigger.Delay = "PT20S" +$settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -StartWhenAvailable ` + -ExecutionTimeLimit ([TimeSpan]::Zero) ` + -RestartCount 3 ` + -RestartInterval (New-TimeSpan -Minutes 1) +$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited + +Register-ScheduledTask ` + -TaskName $TaskName ` + -Action $action ` + -Trigger $trigger ` + -Settings $settings ` + -Principal $principal ` + -Force | Out-Null + +$startup = [Environment]::GetFolderPath("Startup") +$shortcutPath = Join-Path $startup "AutoAnime WebUI.lnk" +$wsh = New-Object -ComObject WScript.Shell +$shortcut = $wsh.CreateShortcut($shortcutPath) +$shortcut.TargetPath = $StartBat +$shortcut.WorkingDirectory = $ProjectRoot +$shortcut.WindowStyle = 7 +$shortcut.Description = "Start AutoAnime Web console and Worker" +$shortcut.Save() + +Write-Host "[AutoAnime] 已注册开机/登录自启" -ForegroundColor Cyan +Write-Host " 计划任务: $TaskName" -ForegroundColor Green +Write-Host " 启动项: $shortcutPath" -ForegroundColor Green +Write-Host " 数据目录: $DataDir" -ForegroundColor Green +Write-Host " 控制台: http://127.0.0.1:$Port" -ForegroundColor Green +Write-Host "" +Write-Host "取消自启请运行: .\uninstall-autostart.bat" -ForegroundColor DarkGray +Write-Host "也可手动: 任务计划程序 删除「$TaskName」;启动文件夹删除快捷方式" -ForegroundColor DarkGray diff --git a/requirements.txt b/requirements.txt index 4920694..933a166 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,14 @@ -# Runtime dependencies for AutoAnimeMv -# Keep this file limited to project runtime dependencies only. -requests -zhconv +# Core filename normalization. +zhconv==1.4.3 + +# Web console backend and persistence. +SQLAlchemy==2.0.41 +alembic==1.16.4 +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +argon2-cffi==25.1.0 +cryptography==45.0.5 +watchdog==6.0.0 +tzdata==2025.2 +httpx==0.28.1 +pydantic-settings==2.10.1 diff --git a/scripts/cache_doctor.py b/scripts/cache_doctor.py deleted file mode 100644 index 6dd1aee..0000000 --- a/scripts/cache_doctor.py +++ /dev/null @@ -1,696 +0,0 @@ -# -*- coding: utf-8 -*- -""" -缓存诊断与灾难恢复:inspect / 审计导出 / 按 audit 撤销别名 / 从 organization 重建 titles / -手工白名单写入 / 修改识别用中文名 / 按 episode_last_dst 计划或执行重命名(与 Sorting 命名一致)。 - -用法见 `autoanime/cache/README.md` §8、`autoanime/cache/cache_doctor_重命名与剧名纠偏_使用说明.md` 与 `docs/10_缓存Schema_v2设计.md`。 -""" - -from __future__ import annotations - -import argparse -import json -import sys -from datetime import datetime -from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional, Tuple - -_ROOT = Path(__file__).resolve().parent.parent -if str(_ROOT) not in sys.path: - sys.path.insert(0, str(_ROOT)) - - -def _cache_base(path: Optional[str]) -> Path: - if path and str(path).strip(): - p = Path(path) - return p if p.is_absolute() else (_ROOT / p) - return _ROOT / ".cache" - - -def _read_json(p: Path) -> dict: - if not p.is_file(): - return {} - try: - with open(p, "r", encoding="utf-8") as f: - o = json.load(f) - return o if type(o) is dict else {} - except Exception: - return {} - - -def _iter_audit_lines(audit_path: Path) -> Iterator[dict]: - if not audit_path.is_file(): - return - with open(audit_path, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - o = json.loads(line) - if type(o) is dict: - yield o - except Exception: - continue - - -def cmd_inspect(cache_dir: Path) -> int: - from autoanime.cache.trust import ALIAS_KEY_MAX_LEN - from autoanime.cache.v2_data import Auxiliary_Sha256File, V2_VERSION - - meta = _read_json(cache_dir / "cache_meta.json") - if not meta.get("schema_version"): - print("未找到有效的 cache_meta.json(可能仍为旧版单文件 api_cache.json 布局)。") - mono = cache_dir / "api_cache.json" - if mono.is_file(): - print(f"检测到单文件缓存: {mono} 大小={mono.stat().st_size} 字节") - return 1 - - subfiles = [ - ("organization.json", "整理进度"), - ("titles.json", "主名与别名"), - ("api_responses.json", "API 响应(TTL)"), - ("pollution_audit.jsonl", "审计(仅追加)"), - ] - print(f"schema_version={meta.get('schema_version')} cache_dir={cache_dir}") - if meta.get("legacy_archive"): - print(f"legacy_archive={meta.get('legacy_archive')}") - for name, desc in subfiles: - p = cache_dir / name - if not p.is_file(): - print(f" [{desc}] {name}: 缺失") - continue - st = p.stat() - h = Auxiliary_Sha256File(p) - extra = "" - if name == "titles.json": - tj = _read_json(p) - als = tj.get("aliases") or {} - cans = tj.get("canonicals") or {} - long_keys = [k for k in als if len(str(k)) > ALIAS_KEY_MAX_LEN] - low_trust = sum( - 1 - for v in als.values() - if type(v) is dict and int(v.get("trust_level", 0) or 0) < 50 - ) - extra = f" canonicals={len(cans)} aliases={len(als)} len>{ALIAS_KEY_MAX_LEN}别名键={len(long_keys)} trust<50={low_trust}" - elif name == "organization.json": - oj = _read_json(p) - recs = oj.get("records") or {} - extra = f" records={len(recs)}" - elif name == "api_responses.json": - aj = _read_json(p) - n = 0 - tmdb = aj.get("tmdb") or {} - if type(tmdb) is dict: - for _k, bkt in tmdb.items(): - if type(bkt) is dict: - n += len(bkt) - bg = (aj.get("bangumi") or {}).get("titles") or {} - if type(bg) is dict: - n += len(bg) - oa = ((aj.get("openai_identify") or {}).get("file_info")) or {} - if type(oa) is dict: - n += len(oa) - ext = aj.get("ext") or {} - if type(ext) is dict: - for bkt in ext.values(): - if type(bkt) is dict: - n += len(bkt) - extra = f" 约 {n} 条缓存条目(估算)" - elif name.endswith(".jsonl"): - try: - lines = sum(1 for _ in open(p, "r", encoding="utf-8")) - except Exception: - lines = -1 - extra = f" 行数≈{lines}" - print(f" [{desc}] {name}: {st.st_size} bytes sha256={h[:16]}...{extra}") - _ = V2_VERSION - return 0 - - -def _parse_since(s: str) -> float: - dt = datetime.strptime(s[:10], "%Y-%m-%d") - return dt.timestamp() - - -def cmd_export_audit(cache_dir: Path, since: str) -> int: - ap = cache_dir / "pollution_audit.jsonl" - t0 = _parse_since(since) - n = 0 - for ev in _iter_audit_lines(ap): - ts = float(ev.get("ts", 0) or 0) - if ts >= t0: - print(json.dumps(ev, ensure_ascii=False)) - n += 1 - print(f"# exported {n} events since {since}", file=sys.stderr) - return 0 - - -def cmd_revert(cache_dir: Path, audit_id: str) -> int: - from autoanime.cache.v2_data import Auxiliary_AtomicWriteJson, Auxiliary_Sha256File, Auxiliary_WriteV2CacheMeta - from autoanime.cache.v2_data import EMPTY_TITLES - - ap = cache_dir / "pollution_audit.jsonl" - target: Optional[dict] = None - for ev in _iter_audit_lines(ap): - if str(ev.get("audit_id", "")) == str(audit_id): - target = ev - break - if target is None: - print(f"未在 {ap} 中找到 audit_id={audit_id}", file=sys.stderr) - return 1 - et = str(target.get("type", "")) - if et != "alias_written": - print(f"该事件 type={et} 不可撤销(仅支持 alias_written)", file=sys.stderr) - return 1 - ak = str(target.get("alias_key", "") or "") - if not ak: - print("记录缺少 alias_key", file=sys.stderr) - return 1 - tp = cache_dir / "titles.json" - data = _read_json(tp) - if not data: - data = json.loads(json.dumps(EMPTY_TITLES)) - als = data.get("aliases") - if type(als) is not dict or ak not in als: - print(f"titles.json 中不存在别名键: {ak!r}(可能已删除)", file=sys.stderr) - return 0 - del als[ak] - data["aliases"] = als - meta = data.get("__meta__") - if type(meta) is dict: - meta["updated_at"] = datetime.now().replace(microsecond=0).isoformat() - Auxiliary_AtomicWriteJson(tp, data) - nc = len((data.get("canonicals") or {})) - na = len((data.get("aliases") or {})) - Auxiliary_WriteV2CacheMeta( - { - "titles.json": { - "sha256": Auxiliary_Sha256File(tp), - "canonicals": nc, - "aliases": na, - "updated_at": datetime.now().replace(microsecond=0).isoformat(), - } - } - ) - print(f"已移除别名 {ak!r},请重启主程序或自行重载内存缓存。") - return 0 - - -def cmd_rebuild(cache_dir: Path) -> int: - from autoanime.cache.trust import ALIAS_KEY_MAX_LEN - from autoanime.cache.v2_data import Auxiliary_AtomicWriteJson, Auxiliary_Sha256File, Auxiliary_WriteV2CacheMeta, V2_VERSION - from autoanime.text_utils import Auxiliary_NormalizeAliasKey, Auxiliary_NormalizeApiTitle, Auxiliary_NormalizeDisplayTitle - - op = cache_dir / "organization.json" - org = _read_json(op) - recs = org.get("records") or {} - if type(recs) is not dict or not recs: - print("organization.json 无 records,放弃重建。", file=sys.stderr) - return 1 - root: Dict[str, Any] = { - "__meta__": { - "schema_version": V2_VERSION, - "updated_at": datetime.now().replace(microsecond=0).isoformat(), - }, - "canonicals": {}, - "aliases": {}, - } - for cid, rec in recs.items(): - if type(rec) is not dict: - continue - c = str(rec.get("canonical_id") or cid) - zh = Auxiliary_NormalizeApiTitle(str(rec.get("title_zh", ""))) - en = Auxiliary_NormalizeDisplayTitle(str(rec.get("title_en", ""))) - rj = Auxiliary_NormalizeDisplayTitle(str(rec.get("title_romaji", ""))) - root["canonicals"][c] = { - "zh": zh, - "en": en, - "romaji": rj, - "source": "rebuild_from_organization", - "confidence": 90, - "locked": False, - "created_at": str(rec.get("first_organized_at", "")), - "last_updated": datetime.now().replace(microsecond=0).isoformat(), - } - for piece, src in ((zh, "zh"), (en, "en"), (rj, "romaji")): - if piece in [None, ""]: - continue - ak = Auxiliary_NormalizeAliasKey(piece) - if not ak or len(ak) > ALIAS_KEY_MAX_LEN: - continue - root["aliases"][ak] = { - "canonical_id": c, - "trust_level": 85, - "source": f"rebuild:{src}", - "added_at": datetime.now().replace(microsecond=0).isoformat(), - } - tp = cache_dir / "titles.json" - Auxiliary_AtomicWriteJson(tp, root) - nc = len(root["canonicals"]) - na = len(root["aliases"]) - Auxiliary_WriteV2CacheMeta( - { - "titles.json": { - "sha256": Auxiliary_Sha256File(tp), - "canonicals": nc, - "aliases": na, - "updated_at": datetime.now().replace(microsecond=0).isoformat(), - } - } - ) - print(f"已写入 {tp}:canonicals={nc} aliases={na}(请备份后使用;会覆盖现有 titles.json)") - return 0 - - -def _now_iso() -> str: - return datetime.now().replace(microsecond=0).isoformat() - - -def _find_org_record( - org: Dict[str, Any], canonical_id: Optional[str], old_title_zh: Optional[str] -) -> Tuple[Optional[str], Optional[dict]]: - from autoanime.text_utils import Auxiliary_NormalizeApiTitle - - recs = org.get("records") or {} - if type(recs) is not dict: - return None, None - cid = str(canonical_id or "").strip() - if cid != "": - if cid in recs and type(recs[cid]) is dict: - return cid, recs[cid] - for k, v in recs.items(): - if type(v) is dict and str(v.get("canonical_id", "") or "") == cid: - return str(k), v - return None, None - o = Auxiliary_NormalizeApiTitle(str(old_title_zh or "")) - if o in [None, ""]: - return None, None - matches: List[Tuple[str, dict]] = [] - for k, v in recs.items(): - if type(v) is not dict: - continue - if Auxiliary_NormalizeApiTitle(str(v.get("title_zh", ""))) == o: - matches.append((str(k), v)) - if len(matches) == 1: - return matches[0] - return None, None - - -def _write_org_titles_meta( - cache_dir: Path, - org_data: dict, - titles_data: dict, -) -> None: - from autoanime.cache.v2_data import ( - Auxiliary_AtomicWriteJson, - Auxiliary_Sha256File, - Auxiliary_WriteV2CacheMeta, - ) - - op = cache_dir / "organization.json" - tp = cache_dir / "titles.json" - meta_o = org_data.get("__meta__") - if type(meta_o) is dict: - meta_o["updated_at"] = _now_iso() - meta_t = titles_data.get("__meta__") - if type(meta_t) is dict: - meta_t["updated_at"] = _now_iso() - Auxiliary_AtomicWriteJson(op, org_data) - Auxiliary_AtomicWriteJson(tp, titles_data) - nr = len((org_data.get("records") or {})) if type(org_data.get("records")) is dict else 0 - nc = len((titles_data.get("canonicals") or {})) if type(titles_data.get("canonicals")) is dict else 0 - na = len((titles_data.get("aliases") or {})) if type(titles_data.get("aliases")) is dict else 0 - Auxiliary_WriteV2CacheMeta( - { - "organization.json": { - "sha256": Auxiliary_Sha256File(op), - "records": nr, - "updated_at": _now_iso(), - }, - "titles.json": { - "sha256": Auxiliary_Sha256File(tp), - "canonicals": nc, - "aliases": na, - "updated_at": _now_iso(), - }, - } - ) - - -def _patch_canonical_zh(titles_data: dict, canonical_id: str, new_zh: str) -> None: - cans = titles_data.get("canonicals") - if type(cans) is not dict: - cans = {} - titles_data["canonicals"] = cans - cid = str(canonical_id) - rec = cans.get(cid) - if type(rec) is not dict: - cans[cid] = { - "zh": new_zh, - "en": "", - "romaji": "", - "source": "cache_doctor", - "confidence": 90, - "locked": False, - "last_updated": _now_iso(), - } - else: - rec["zh"] = new_zh - rec["last_updated"] = _now_iso() - - -def cmd_set_whitelist( - cache_dir: Path, - alias: str, - title_zh: str, - apply_rename: bool, - canonical_id: str, - old_title_zh: str, - naming_style: str, - use_title_to_ep: bool, -) -> int: - from autoanime.cache.v2_data import EMPTY_TITLES - from autoanime.episode_dst_rename import ( - ApplyEpisodeDstRenames, - EpisodeDstRenameParams, - PatchOrganizationRecordPaths, - PlanEpisodeDstRenames, - ) - from autoanime.text_utils import Auxiliary_NormalizeAliasKey, Auxiliary_NormalizeApiTitle - - ak = Auxiliary_NormalizeAliasKey(alias) - tv = Auxiliary_NormalizeApiTitle(title_zh) - if ak in [None, ""] or tv in [None, ""]: - print("别名或中文剧名归一后为空,已放弃。", file=sys.stderr) - return 2 - wpath = cache_dir / "manual_title_whitelist.json" - wdata = _read_json(wpath) - if not wdata and wpath.is_file() is False: - wdata = {} - wdata[str(ak)] = tv - with open(wpath, "w", encoding="utf-8") as f: - json.dump(wdata, f, ensure_ascii=False, indent=2) - print(f"已写入白名单: {wpath!s} {ak!r} -> {tv!r}") - - if not apply_rename: - print("已跳过磁盘重命名(未传 --apply-rename)。") - return 0 - - org_data = _read_json(cache_dir / "organization.json") - if type(org_data) is not dict: - org_data = {} - if not org_data.get("__meta__"): - org_data["__meta__"] = {"schema_version": 2, "updated_at": _now_iso()} - tpath = cache_dir / "titles.json" - titles_data = _read_json(tpath) - if not titles_data or type(titles_data.get("canonicals")) is not dict: - titles_data = json.loads(json.dumps(EMPTY_TITLES)) - rkey, rec = _find_org_record(org_data, canonical_id or None, old_title_zh or None) - if rec is None or rkey is None: - print( - "无法解析待重命名条目:请传 --canonical-id,或传能在 organization.records 中唯一命中的 --old-title-zh。", - file=sys.stderr, - ) - return 1 - params = EpisodeDstRenameParams( - naming_style=naming_style, - use_title_to_ep=use_title_to_ep, - ) - moves, errs = PlanEpisodeDstRenames(rec, tv, params) - for e in errs: - print(f"[plan] {e}", file=sys.stderr) - if errs: - return 1 - ok, log_lines = ApplyEpisodeDstRenames(moves, apply=apply_rename) - for line in log_lines: - print(line) - if not ok: - return 1 - if apply_rename and moves: - PatchOrganizationRecordPaths(rec, moves) - rec["title_zh"] = tv - recs = org_data.get("records") or {} - if type(recs) is not dict: - recs = {} - recs[rkey] = rec - org_data["records"] = recs - _patch_canonical_zh(titles_data, str(rec.get("canonical_id", rkey)), tv) - _write_org_titles_meta(cache_dir, org_data, titles_data) - print("已同步 organization.json 与 titles.json 中的中文主名。请重启主程序以加载新缓存。") - return 0 - - -def cmd_set_title_zh( - cache_dir: Path, - canonical_id: str, - title_zh: str, - apply_rename: bool, - naming_style: str, - use_title_to_ep: bool, -) -> int: - from autoanime.cache.v2_data import EMPTY_TITLES - from autoanime.episode_dst_rename import ( - ApplyEpisodeDstRenames, - EpisodeDstRenameParams, - PatchOrganizationRecordPaths, - PlanEpisodeDstRenames, - ) - from autoanime.text_utils import Auxiliary_NormalizeApiTitle - - cid = str(canonical_id or "").strip() - if cid == "": - print("需要 --canonical-id", file=sys.stderr) - return 2 - new_zh = Auxiliary_NormalizeApiTitle(title_zh) - if new_zh in [None, ""]: - print("新标题归一后为空", file=sys.stderr) - return 2 - org_data = _read_json(cache_dir / "organization.json") - if type(org_data) is not dict: - org_data = {} - if type(org_data.get("__meta__")) is not dict: - org_data["__meta__"] = {"schema_version": 2, "updated_at": _now_iso()} - titles_data = _read_json(cache_dir / "titles.json") - if not titles_data or type(titles_data.get("canonicals")) is not dict: - titles_data = json.loads(json.dumps(EMPTY_TITLES)) - rkey, rec = _find_org_record(org_data, cid, None) - if rec is None or rkey is None: - print(f"未找到 canonical_id / 记录键: {cid!r}", file=sys.stderr) - return 1 - if apply_rename: - params = EpisodeDstRenameParams( - naming_style=naming_style, - use_title_to_ep=use_title_to_ep, - ) - moves, errs = PlanEpisodeDstRenames(rec, new_zh, params) - for e in errs: - print(f"[plan] {e}", file=sys.stderr) - if errs: - return 1 - ok, log_lines = ApplyEpisodeDstRenames(moves, apply=True) - for line in log_lines: - print(line) - if not ok: - return 1 - if moves: - PatchOrganizationRecordPaths(rec, moves) - rec["title_zh"] = new_zh - recs = org_data.get("records") or {} - if type(recs) is not dict: - recs = {} - recs[rkey] = rec - org_data["records"] = recs - c_for_title = str(rec.get("canonical_id", rkey)) - _patch_canonical_zh(titles_data, c_for_title, new_zh) - _write_org_titles_meta(cache_dir, org_data, titles_data) - print("已更新 titles.json 与 organization.json。请重启主程序以加载新缓存。") - return 0 - - -def cmd_rename_episodes( - cache_dir: Path, - canonical_id: str, - old_title_zh: str, - title_zh: str, - apply_rename: bool, - naming_style: str, - use_title_to_ep: bool, -) -> int: - """ - 仅使用 organization 的 episode_last_dst 做与 Sorting 一致的路径计算: - 默认只打印 move 计划、不写任何 JSON、不 move;加 --apply-rename 时执行 move 并回写 organization / titles。 - """ - from autoanime.cache.v2_data import EMPTY_TITLES - from autoanime.episode_dst_rename import ( - ApplyEpisodeDstRenames, - EpisodeDstRenameParams, - PatchOrganizationRecordPaths, - PlanEpisodeDstRenames, - ) - from autoanime.text_utils import Auxiliary_NormalizeApiTitle - - new_zh = Auxiliary_NormalizeApiTitle(title_zh) - if new_zh in [None, ""]: - print("--zh 归一后为空", file=sys.stderr) - return 2 - org_data = _read_json(cache_dir / "organization.json") - if type(org_data) is not dict: - org_data = {} - rkey, rec = _find_org_record(org_data, canonical_id or None, old_title_zh or None) - if rec is None or rkey is None: - print( - "未找到记录:请传 --canonical-id,或传能在 organization 中唯一条的 --old-title-zh。", - file=sys.stderr, - ) - return 1 - params = EpisodeDstRenameParams( - naming_style=naming_style, - use_title_to_ep=use_title_to_ep, - ) - moves, errs = PlanEpisodeDstRenames(rec, new_zh, params) - for e in errs: - print(f"[plan] {e}", file=sys.stderr) - if errs: - return 1 - ok, log_lines = ApplyEpisodeDstRenames(moves, apply=bool(apply_rename)) - for line in log_lines: - print(line) - if not ok: - return 1 - if not apply_rename: - print("# 以上为预览;未加 --apply-rename,未修改 organization / titles / 磁盘。") - return 0 - titles_data = _read_json(cache_dir / "titles.json") - if not titles_data or type(titles_data.get("canonicals")) is not dict: - titles_data = json.loads(json.dumps(EMPTY_TITLES)) - if type(org_data.get("__meta__")) is not dict: - org_data["__meta__"] = {"schema_version": 2, "updated_at": _now_iso()} - if moves: - PatchOrganizationRecordPaths(rec, moves) - rec["title_zh"] = new_zh - recs = org_data.get("records") or {} - if type(recs) is not dict: - recs = {} - recs[rkey] = rec - org_data["records"] = recs - c_for_title = str(rec.get("canonical_id", rkey)) - _patch_canonical_zh(titles_data, c_for_title, new_zh) - _write_org_titles_meta(cache_dir, org_data, titles_data) - print("已重命名并同步 organization / titles。请重启主程序以加载新缓存。") - return 0 - - -def main(argv: Optional[List[str]] = None) -> int: - p = argparse.ArgumentParser(description="AutoAnime Schema v2 缓存诊断") - p.add_argument("--cache-dir", default="", help="缓存目录(默认项目下 .cache)") - sub = p.add_mutually_exclusive_group(required=True) - sub.add_argument("--inspect", action="store_true", help="子文件体积、sha256、条目与污染嫌疑统计") - sub.add_argument("--export-audit", action="store_true", help="导出审计(需配合 --since)") - sub.add_argument("--revert", action="store_true", help="按 audit 撤销(需配合 --audit-id)") - sub.add_argument("--rebuild-from-organization", action="store_true", help="由 organization.json 重建 titles.json") - sub.add_argument("--set-whitelist", action="store_true", help="写入 manual_title_whitelist(需 --alias 与 --zh)") - sub.add_argument("--set-title-zh", action="store_true", help="更新识别用中文主名到 titles+organization(需 --canonical-id 与 --zh)") - sub.add_argument( - "--rename-episodes", - action="store_true", - help="仅按 episode_last_dst 做重命名计划或执行(需 --zh 与 --canonical-id 或 --old-title-zh;默认只预览,不加本开关不移动)", - ) - p.add_argument("--since", default="", help="仅 --export-audit:YYYY-MM-DD 起") - p.add_argument("--audit-id", default="", help="仅 --revert:pollution_audit.jsonl 中的 audit_id") - p.add_argument("--alias", default="", help="仅 --set-whitelist:别名字符串") - p.add_argument( - "--zh", - default="", - help="set-whitelist / set-title-zh 的中文;rename-episodes 的目标中文主名(新目录/新文件名用)", - ) - p.add_argument( - "--canonical-id", - default="", - help="canonical id;--set-title-zh 必填;--rename-episodes 与 --set-whitelist+--apply-rename 时可与 --old-title-zh 二选一以定位记录", - ) - p.add_argument( - "--old-title-zh", - default="", - help="以归一后的 title_zh 在 organization.records 中唯一条;用于 set-whitelist+apply 或 --rename-episodes", - ) - p.add_argument( - "--apply-rename", - action="store_true", - help="实际执行 shutil.move 并回写 organization/titles;未加时:--rename-episodes 仅打印计划;--set-title-zh 只改 JSON;--set-whitelist 只写白名单", - ) - p.add_argument("--naming-style", default="default", choices=["default", "emby"], help="与主程序 NAMING_STYLE 一致") - p.add_argument( - "--no-use-title-to-ep", - action="store_true", - help="对应 USETITLTOEP=False 的文件名(SxxExx 不拼剧名)", - ) - args = p.parse_args(argv) - base = _cache_base(args.cache_dir or None) - use_title_to_ep = not bool(args.no_use_title_to_ep) - - if args.inspect: - return cmd_inspect(base) - if args.export_audit: - if not args.since: - print("需要 --since YYYY-MM-DD", file=sys.stderr) - return 2 - return cmd_export_audit(base, args.since) - if args.revert: - if not args.audit_id: - print("需要 --audit-id", file=sys.stderr) - return 2 - return cmd_revert(base, args.audit_id) - if args.rebuild_from_organization: - return cmd_rebuild(base) - if args.set_whitelist: - if not str(args.alias or "").strip() or not str(args.zh or "").strip(): - print("--set-whitelist 需要同时指定 --alias 与 --zh", file=sys.stderr) - return 2 - if args.apply_rename and not str(args.canonical_id or "").strip() and not str(args.old_title_zh or "").strip(): - print("--set-whitelist 与 --apply-rename 时需要 --canonical-id 或 --old-title-zh 之一", file=sys.stderr) - return 2 - return cmd_set_whitelist( - base, - str(args.alias).strip(), - str(args.zh).strip(), - bool(args.apply_rename), - str(args.canonical_id or "").strip(), - str(args.old_title_zh or "").strip(), - str(args.naming_style or "default").strip().lower(), - use_title_to_ep, - ) - if args.set_title_zh: - if not str(args.canonical_id or "").strip() or not str(args.zh or "").strip(): - print("--set-title-zh 需要同时指定 --canonical-id 与 --zh", file=sys.stderr) - return 2 - return cmd_set_title_zh( - base, - str(args.canonical_id or "").strip(), - str(args.zh).strip(), - bool(args.apply_rename), - str(args.naming_style or "default").strip().lower(), - use_title_to_ep, - ) - if args.rename_episodes: - if not str(args.zh or "").strip(): - print("--rename-episodes 需要 --zh(目标中文主名)", file=sys.stderr) - return 2 - if not str(args.canonical_id or "").strip() and not str(args.old_title_zh or "").strip(): - print("--rename-episodes 需要 --canonical-id 或 --old-title-zh 之一", file=sys.stderr) - return 2 - return cmd_rename_episodes( - base, - str(args.canonical_id or "").strip(), - str(args.old_title_zh or "").strip(), - str(args.zh).strip(), - bool(args.apply_rename), - str(args.naming_style or "default").strip().lower(), - use_title_to_ep, - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/normalize_api_cache_cn_punct.py b/scripts/normalize_api_cache_cn_punct.py deleted file mode 100644 index dc830d8..0000000 --- a/scripts/normalize_api_cache_cn_punct.py +++ /dev/null @@ -1,161 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Normalize ASCII punctuation to full-width Chinese punctuation in api_cache.json -for strings that contain CJK, excluding URL-like values and filename keys. - -Run: python scripts/normalize_api_cache_cn_punct.py -""" -from __future__ import annotations - -import json -import re -from typing import Any, Dict, Tuple - -CACHE_PATH = r"C:\Users\17645\Desktop\AutoAnime\.cache\api_cache.json" - -# Basic CJK blocks (titles; not exhaustive CJK ext) -_HAN = re.compile(r"[\u4e00-\u9fff]") -_URL = re.compile(r"^https?://", re.I) -_VIDEO_EXT = re.compile( - r"\.(mp4|mkv|ass|srt|avi|webm|flv|mov|m4v|mpg|mpeg|wmv)(\[[^\]]*\])?$", re.I -) - - -def has_han(s: str) -> bool: - return _HAN.search(s) is not None - - -def is_filename_like_key(s: str) -> bool: - if not isinstance(s, str) or s == "": - return False - sl = s.lower() - if _VIDEO_EXT.search(sl): - return True - if "[" in s and "]" in s: - return True - if re.search(r"\.(mp4|mkv|ass|srt)\b", sl): - return True - return False - - -def ascii_double_to_curly(s: str) -> str: - if '"' not in s: - return s - parts = s.split('"') - out = [parts[0]] - for i in range(1, len(parts)): - q = "\u201c" if (i % 2 == 1) else "\u201d" - out.append(q + parts[i]) - return "".join(out) - - -def ascii_single_to_curly(s: str) -> str: - if "'" not in s: - return s - parts = s.split("'") - out = [parts[0]] - for i in range(1, len(parts)): - q = "\u2018" if (i % 2 == 1) else "\u2019" - out.append(q + parts[i]) - return "".join(out) - - -def normalize_cn_punct_text(s: str) -> str: - if not s or not has_han(s): - return s - if _URL.match(s.strip()): - return s - - t = s - # Colon between CJK (not Re: style: Latin before colon) - t = re.sub(r"(?<=[\u4e00-\u9fff\u3000-\u303f\uff01-\uff60]):(?=[\u4e00-\u9fff])", ":", t) - # Comma between CJK - t = re.sub(r"(?<=[\u4e00-\u9fff]),(?=[\u4e00-\u9fff])", ",", t) - t = re.sub(r"(?<=[\u4e00-\u9fff]),(\s+)(?=[\u4e00-\u9fff])", r",\1", t) - # Semicolon between CJK - t = re.sub(r"(?<=[\u4e00-\u9fff]);(?=[\u4e00-\u9fff])", ";", t) - # Exclamation / question adjacent to CJK - t = re.sub(r"(?<=[\u4e00-\u9fff])!", "!", t) - t = re.sub(r"!(?=[\u4e00-\u9fff])", "!", t) - t = re.sub(r"(?<=[\u4e00-\u9fff])\?", "?", t) - t = re.sub(r"\?(?=[\u4e00-\u9fff])", "?", t) - # Slash between CJK (e.g. 怀玉/涩谷) - t = re.sub(r"(?<=[\u4e00-\u9fff])/(?=[\u4e00-\u9fff])", "/", t) - # Parentheses touching CJK - t = re.sub(r"(?<=[\u4e00-\u9fff])\(", "(", t) - t = re.sub(r"\((?=[\u4e00-\u9fff])", "(", t) - t = re.sub(r"(?<=[\u4e00-\u9fff])\)", ")", t) - t = re.sub(r"\)(?=[\u4e00-\u9fff])", ")", t) - - t = ascii_double_to_curly(t) - t = ascii_single_to_curly(t) - - # Trailing period after CJK (line or string end) - t = re.sub(r"(?<=[\u4e00-\u9fff])\.(?=\s*$)", "。", t) - - return t - - -def merge_nodes(a: Any, b: Any) -> Any: - if isinstance(a, dict) and isinstance(b, dict): - if "ts" in a and "ts" in b: - ta, tb = float(a.get("ts", 0)), float(b.get("ts", 0)) - pick = a if ta >= tb else b - out = dict(pick) - out["ts"] = max(ta, tb) - return out - if "value" in a and "value" in b: - va, vb = a["value"], b["value"] - if isinstance(va, str) and isinstance(vb, str): - out = dict(b) - out["ts"] = max(float(a.get("ts", 0)), float(b.get("ts", 0))) - return out - return b - - -def normalize_key(k: str) -> str: - if is_filename_like_key(k): - return k - if not has_han(k): - return k - return normalize_cn_punct_text(k) - - -def normalize_tree(obj: Any) -> Any: - if isinstance(obj, dict): - out: Dict[Any, Any] = {} - for k, v in obj.items(): - nk = normalize_key(k) if isinstance(k, str) else k - nv = normalize_tree(v) - if nk in out: - out[nk] = merge_nodes(out[nk], nv) - else: - out[nk] = nv - return out - if isinstance(obj, list): - return [normalize_tree(x) for x in obj] - if isinstance(obj, str): - return normalize_cn_punct_text(obj) - return obj - - -def main() -> Tuple[int, int]: - with open(CACHE_PATH, encoding="utf-8") as f: - raw = f.read() - before = json.loads(raw) - after = normalize_tree(before) - bs = json.dumps(before, ensure_ascii=False, sort_keys=True) - as_ = json.dumps(after, ensure_ascii=False, sort_keys=True) - changed = 0 if bs == as_ else 1 - with open(CACHE_PATH, "w", encoding="utf-8", newline="\n") as f: - json.dump(after, f, ensure_ascii=False, indent=2) - f.write("\n") - # count string diffs roughly - n_diff = sum(1 for x, y in zip(bs.splitlines(), as_.splitlines()) if x != y) if changed else 0 - print("cache updated:", CACHE_PATH) - print("structural change:", bool(changed), "line_diff_approx:", n_diff) - return changed, n_diff - - -if __name__ == "__main__": - main() diff --git a/scripts/verify_refactor_with_real_data.py b/scripts/verify_refactor_with_real_data.py deleted file mode 100644 index 01590aa..0000000 --- a/scripts/verify_refactor_with_real_data.py +++ /dev/null @@ -1,421 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -用真实日志 + api_cache 驱动 autoanime 包的集成验证脚本。 - -数据源: -- logs/AutoAnime_operations_20260421_204030.json —— 696 条 already_organized_show_cache 的真实文件名 -- .cache/api_cache.json —— 真实的 ShowOrganizationIndex + Canonical 索引 - -覆盖验证项: -1. ShowIndex 自愈逻辑(不修改原缓存,克隆到临时缓存目录跑) - - 场景A:老数据无 episode_last_dst -> 跳过(向后兼容) - - 场景B:episode_last_dst 指向的目标文件真实存在 -> 跳过 - - 场景C:episode_last_dst 指向的路径缺失 -> 自愈 tag,重新整理 -2. CLI 单文件模式 - - 输入完整文件路径 -> parent + basename 自动拆分 - - 目录 + --file 显式 -3. OpenAI 识别失败回退 - - 模拟 AI 返回 None(匹配真实日志中 missing_api_key 场景),fallback 成功 -4. 主流水线端到端(dry-run) - - 用真实文件名构造 dry-run 目录 + 被 mock 的识别链路,验证三条路径都能稳定跑完 -""" - -import json -import os -import shutil -import sys -import tempfile -from pathlib import Path -from unittest.mock import patch - -ROOT = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(ROOT)) - -# 关闭控制台日志刷屏,但留下 state.LogData 供最后校验 -os.environ.setdefault('AUTOANIME_VERIFY_QUIET', '1') - -LOG_FILE = ROOT / 'logs' / 'AutoAnime_operations_20260421_204030.json' -CACHE_FILE = ROOT / '.cache' / 'api_cache.json' - - -class VerifyReport: - def __init__(self): - self.rows = [] - self.passed = 0 - self.failed = 0 - - def add(self, name, ok, detail=''): - self.rows.append((name, ok, detail)) - if ok: - self.passed += 1 - else: - self.failed += 1 - - def render(self): - width = max(len(r[0]) for r in self.rows) + 2 - print('\n' + '=' * 80) - print('autoanime 包集成验证 —— 真实数据驱动') - print('=' * 80) - for name, ok, detail in self.rows: - mark = '[OK] ' if ok else '[FAIL]' - print(f'{mark} {name.ljust(width)} {detail}') - print('-' * 80) - print(f'通过 {self.passed} / 总 {self.passed + self.failed}') - print('=' * 80) - return self.failed == 0 - - -def extract_real_samples(limit=6): - """从真实日志里取一些被跳过的 Jujutsu Kaisen 文件名作为测试样本。""" - with open(LOG_FILE, 'r', encoding='utf-8') as fh: - data = json.load(fh) - samples = [] - for rec in data.get('records', []): - if rec.get('message') != 'already_organized_show_cache': - continue - src = rec.get('src', '') - basename = os.path.basename(src) - if basename == '' or basename in [x['basename'] for x in samples]: - continue - samples.append({'src': src, 'basename': basename}) - if len(samples) >= limit: - break - return samples - - -def extract_real_jjk_record(): - with open(CACHE_FILE, 'r', encoding='utf-8') as fh: - cache = json.load(fh) - si = cache.get('ShowOrganizationIndex', {}) - for cid, entry in si.items(): - val = entry.get('value', {}) - if val.get('title_zh', '').startswith('咒术回战'): - return cid, val - raise RuntimeError('api_cache.json 未找到 咒术回战 的 ShowOrganizationIndex') - - -def setup_clone_cache(workdir: Path): - """克隆 .cache 到一个临时目录,避免污染真实缓存。""" - clone_cache = workdir / '.cache' - clone_cache.mkdir(parents=True, exist_ok=True) - shutil.copy2(CACHE_FILE, clone_cache / 'api_cache.json') - manual = ROOT / '.cache' / 'manual_title_whitelist.json' - if manual.exists(): - shutil.copy2(manual, clone_cache / 'manual_title_whitelist.json') - return clone_cache - - -def bootstrap_autoanime_with_clone(clone_cache: Path): - """初始化 autoanime.state,使其指向克隆目录。""" - from autoanime import state - from autoanime.cache.persistent import Auxiliary_LoadPersistentCache - - state.init_defaults() - state.PRINTLOGFLAG = False - state.CACHE_DIR = str(clone_cache) - state.PyPath = str(clone_cache.parent) - state.filepath = str(clone_cache.parent) - from autoanime.config_loader import Auxiliary_InitRuntimeContext - - Auxiliary_InitRuntimeContext() - Auxiliary_LoadPersistentCache() - return state - - -def verify_show_index_self_heal(report: VerifyReport, samples, jjk_cid, jjk_record): - """Scene A/B/C 三种自愈情形,基于真实 canonical + 真实 basename 验证。""" - from autoanime.cache.show_index import ( - Auxiliary_ShowHasOrganizedEpisode, - Auxiliary_ShowSetEpisodeExpectedDst, - Auxiliary_ShowMarkOrganizedEpisode, - Auxiliary_ShowClearOrganizedEpisode, - Auxiliary_GetShowOrganizationRecord, - ) - - rec = Auxiliary_GetShowOrganizationRecord(jjk_cid) - eps = rec.get('organized_episodes', []) if rec else [] - report.add( - '加载真实 ShowOrganizationIndex', - len(eps) >= 10 and 'S01E48' in eps, - f'canonical_id={jjk_cid!r}, 已标记 {len(eps)} 集,含 S01E48={"S01E48" in eps}', - ) - - # 场景A:老数据无 expected_dst(当前真实数据全是此态) - has_tag, dst = Auxiliary_ShowHasOrganizedEpisode(jjk_cid, '01', '48') - report.add( - '场景A 老数据无 expected_dst => has_tag=True, dst=None', - has_tag is True and dst is None, - f'has_tag={has_tag}, dst={dst}', - ) - - # 场景B:写一个真实存在的临时目标路径 - tmp_fd, tmp_name = tempfile.mkstemp(suffix='.mkv', prefix='jjk_e48_') - os.close(tmp_fd) - tmp_dst = Path(tmp_name) - tmp_dst.write_bytes(b'\x00') - try: - Auxiliary_ShowSetEpisodeExpectedDst(jjk_cid, '01', '48', str(tmp_dst)) - has_tag_b, dst_b = Auxiliary_ShowHasOrganizedEpisode(jjk_cid, '01', '48') - scene_b_ok = has_tag_b is True and dst_b is not None and dst_b.exists() - report.add( - '场景B expected_dst 指向真实存在的文件 => dst.exists()=True', - scene_b_ok, - f'dst={dst_b}, exists={dst_b.exists() if dst_b else None}', - ) - finally: - tmp_dst.unlink(missing_ok=True) - - # 场景C:此时 expected_dst 已经不存在 => 上层应自愈剔除 - has_tag_c, dst_c = Auxiliary_ShowHasOrganizedEpisode(jjk_cid, '01', '48') - should_self_heal = has_tag_c is True and dst_c is not None and not dst_c.exists() - report.add( - '场景C expected_dst 缺失 => pipeline 可识别自愈条件', - should_self_heal, - f'has_tag={has_tag_c}, dst={dst_c}, exists={dst_c.exists() if dst_c else None}', - ) - - changed = Auxiliary_ShowClearOrganizedEpisode(jjk_cid, '01', '48') - has_tag_after, _ = Auxiliary_ShowHasOrganizedEpisode(jjk_cid, '01', '48') - report.add( - '场景C 调用 ShowClearOrganizedEpisode 自愈后 tag 被剔除', - changed is True and has_tag_after is False, - f'cleared={changed}, now has_tag={has_tag_after}', - ) - - # 复位:把 S01E48 重新写回并给一个 dst,模拟重新整理完成 - Auxiliary_ShowMarkOrganizedEpisode(jjk_cid, '咒术回战', 'Jujutsu Kaisen', '', '01', '48', DstPath=str(tmp_dst)) - has_tag_restore, dst_restore = Auxiliary_ShowHasOrganizedEpisode(jjk_cid, '01', '48') - report.add( - '场景C 重新整理完成 => ShowMarkOrganizedEpisode 再次标记 tag + expected_dst', - has_tag_restore is True and dst_restore is not None, - f'mark ok, dst={dst_restore}', - ) - - -def verify_cli_single_file(report: VerifyReport, samples): - """CLI 自动拆单文件、同目录字幕附属匹配。""" - from autoanime import state - from autoanime import cli as cli_mod - from autoanime.scanning import NormalizeSingleFileInput - - with tempfile.TemporaryDirectory() as td: - base = Path(td) - # 用真实日志里的 basename 构造假视频 + 同集字幕 - real_basename = samples[0]['basename'] - stem = Path(real_basename).stem - video_path = base / real_basename - video_path.write_bytes(b'\x00') - sub_chs = base / f'{stem}.chs.ass' - sub_chs.write_bytes(b'\x00') - sub_cht = base / f'{stem}.cht.srt' - sub_cht.write_bytes(b'\x00') - # 同目录另一个不相关番剧 - unrelated = base / 'Unrelated Show - 01.mkv' - unrelated.write_bytes(b'\x00') - - eff_dir, names, single = NormalizeSingleFileInput(str(video_path)) - report.add( - 'CLI: 单文件完整路径 => parent + basename + 同集字幕', - single is True and Path(eff_dir) == base and names[0] == real_basename and - set(names[1:]) == {sub_chs.name, sub_cht.name}, - f'single={single}, dir={eff_dir}, names={names}', - ) - - # Start_GetArgv 路径 1:传单文件 - argv = ['AutoAnimeMv2.py', str(video_path)] - state.init_defaults() - with patch.object(cli_mod, 'argv', argv), patch.object(sys, 'argv', argv), \ - patch('autoanime.cli.Auxiliary_InitRuntimeContext'): - result = cli_mod.Start_GetArgv() - report.add( - 'CLI: Start_GetArgv 单文件 => (dir, basename, "1")', - state.SingleFileMode is True - and state.SingleFileVideoName == real_basename - and set(state.SingleFileSubtitles) == {sub_chs.name, sub_cht.name} - and result == (str(base), real_basename, '1'), - f'result={result}, subs={state.SingleFileSubtitles}', - ) - - # Start_GetArgv 路径 2:目录 + --file - argv2 = ['AutoAnimeMv2.py', str(base), '--file', real_basename] - state.init_defaults() - with patch.object(cli_mod, 'argv', argv2), patch.object(sys, 'argv', argv2), \ - patch('autoanime.cli.Auxiliary_InitRuntimeContext'): - result2 = cli_mod.Start_GetArgv() - report.add( - 'CLI: --file 显式指定 => number=1', - state.SingleFileMode is True - and state.SingleFileVideoName == real_basename - and result2 == (str(base), real_basename, '1'), - f'result={result2}', - ) - - -def verify_openai_fallback(report: VerifyReport, samples): - """OpenAI 返回 None 时走回退链路,并且熔断器正确累积。""" - from autoanime import state - from autoanime.identification import Processing_Identification - from autoanime.identification import local_fallback - - state.init_defaults() - state.PRINTLOGFLAG = False - state.USEOPENAIAPI = True - state.OPENAI_IDENTIFY_ALL = True - state.OPENAI_FALLBACK_ON_FAILURE = True - Auxiliary_ResetBreaker = local_fallback.Auxiliary_ResetOpenAIBreaker - Auxiliary_ResetBreaker() - - info5 = ('01', '48', '1', '48', '咒术回战') - meta = { - 'NameEN': 'Jujutsu Kaisen', - 'NameRomaji': 'Jujutsu Kaisen', - 'CanonicalID': 'jujutsukaisen_test', - 'CanonicalZh': '咒术回战', - 'Source': 'local_rules+traditional_api', - } - - # 用日志中真实出现过的文件名 - real_basename = samples[0]['basename'] - with patch( - 'autoanime.identification.openai_identify.Auxiliary_OpenAIIdentifyFileInfo', - return_value=None, - ), patch( - 'autoanime.identification.Auxiliary_ResolveFileInfoWithFallback', - return_value=(info5, meta), - ): - result = Processing_Identification(real_basename) - - report.add( - 'OpenAI 返回 None + 回退成功 => Processing_Identification 返回 (SE,EP,...) 且 LastIdentificationFromAI=False', - result == info5 and state.LastIdentificationFromAI is False, - f'result={result}, from_ai={state.LastIdentificationFromAI}', - ) - report.add( - 'LastOpenAIFileInfoMeta 由回退链路写入', - state.LastOpenAIFileInfoMeta.get('CanonicalID') == 'jujutsukaisen_test' - and state.LastOpenAIFileInfoMeta.get('CanonicalZh') == '咒术回战', - f'meta={state.LastOpenAIFileInfoMeta}', - ) - - # 熔断器:连续多次 missing_api_key 触发跳过 OpenAI - Auxiliary_ResetBreaker() - for _ in range(5): - local_fallback.Auxiliary_NoteOpenAIBreakerEvent({'reason': 'missing_api_key'}) - tripped = local_fallback.Auxiliary_ShouldTripOpenAIBreaker() - report.add( - '熔断器:连续 missing_api_key 累积 >= 阈值 => ShouldTripOpenAIBreaker()=True', - tripped is True, - f'tripped={tripped}', - ) - - -def verify_pipeline_dryrun_with_real_data(report: VerifyReport, samples, jjk_cid): - """用真实文件名 + dry-run 跑主流水线,验证三种路径(跳过/自愈/正常整理)。""" - from autoanime import state - from autoanime.pipeline.main import Processing_Main - from autoanime.cache.show_index import ( - Auxiliary_ShowMarkOrganizedEpisode, - Auxiliary_GetShowOrganizationRecord, - Auxiliary_ShowHasOrganizedEpisode, - ) - - with tempfile.TemporaryDirectory() as td: - base = Path(td) - state.init_defaults() - state.PRINTLOGFLAG = False - state.DRY_RUN = True - state.USEOPENAIAPI = True - state.OPENAI_IDENTIFY_ALL = True - state.OPENAI_FALLBACK_ON_FAILURE = True - state.filepath = str(base) - state.OUTPUT_PATH = str(base / 'out') - from autoanime.config_loader import Auxiliary_InitRuntimeContext - Auxiliary_InitRuntimeContext() - - # 给前 3 个真实样本落成临时物理文件 - chosen = samples[:3] - rel_files = [] - for s in chosen: - fp = base / s['basename'] - fp.write_bytes(b'\x00') - rel_files.append(s['basename']) - - # 构造 Processing_Identification 的 patch:让每个文件都返回一个合法识别结果 - def fake_identification(File): - # 用文件名里的集数回填 EP - from re import search as _search - m = _search(r'-\s*(\d{1,3})\s*(?:\[|\.|$)', File) - ep = m.group(1).zfill(2) if m else '01' - state.LastOpenAIFileInfoMeta = { - 'NameEN': 'Jujutsu Kaisen', - 'NameRomaji': 'Jujutsu Kaisen', - 'CanonicalID': jjk_cid, - 'CanonicalZh': '咒术回战', - } - state.LastIdentificationFromAI = True - return ('01', ep, '1', ep, '咒术回战') - - # 场景1:ShowIndex 里存在但无 expected_dst => 盲跳(与历史一致) - ep1_tag = '01' - fn1_ep = chosen[0]['basename'] - rec_before = Auxiliary_GetShowOrganizationRecord(jjk_cid) - if rec_before is None: - # 如果未命中(clone 情形),手动注入一条 - Auxiliary_ShowMarkOrganizedEpisode(jjk_cid, '咒术回战', 'Jujutsu Kaisen', '', '01', '48') - - with patch('autoanime.pipeline.main.Processing_Identification', side_effect=fake_identification), \ - patch('autoanime.pipeline.main.Auxiliary_UpsertCanonicalTitle', return_value=(jjk_cid, '咒术回战')), \ - patch('autoanime.pipeline.main.Sorting_Mv', return_value={'status': 'dry-run', 'dst': str(base / 'out' / 'dry.mkv')}): - state.Runtime.operation_records = [] - state.LogData = '' - Processing_Main(rel_files) - - ops = state.Runtime.operation_records - msgs = [r.get('message', '') for r in ops] - report.add( - '端到端 dry-run:存在 already_organized tag 但无 expected_dst => 记录 already_organized_show_cache', - msgs.count('already_organized_show_cache') >= 1, - f'msg counts={dict((m, msgs.count(m)) for m in set(msgs))}', - ) - - -def main(): - report = VerifyReport() - - if not LOG_FILE.exists(): - print(f'缺少日志文件:{LOG_FILE}') - return 1 - if not CACHE_FILE.exists(): - print(f'缺少缓存文件:{CACHE_FILE}') - return 1 - - samples = extract_real_samples(limit=6) - report.add( - '从真实日志抽取样本', - len(samples) >= 3, - f'抽到 {len(samples)} 个 basename,首条={samples[0]["basename"] if samples else "-"}', - ) - - jjk_cid, jjk_record = extract_real_jjk_record() - report.add( - '从真实 api_cache 抽取 咒术回战 记录', - jjk_record is not None, - f'canonical_id={jjk_cid!r}, title_zh={jjk_record.get("title_zh")}', - ) - - with tempfile.TemporaryDirectory() as clone_workdir: - clone_cache = setup_clone_cache(Path(clone_workdir)) - bootstrap_autoanime_with_clone(clone_cache) - - verify_show_index_self_heal(report, samples, jjk_cid, jjk_record) - verify_cli_single_file(report, samples) - verify_openai_fallback(report, samples) - verify_pipeline_dryrun_with_real_data(report, samples, jjk_cid) - - ok = report.render() - return 0 if ok else 1 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/start-autoanime.bat b/start-autoanime.bat new file mode 100644 index 0000000..0a2b845 --- /dev/null +++ b/start-autoanime.bat @@ -0,0 +1,16 @@ +@echo off +setlocal +cd /d "%~dp0" +title AutoAnime Start +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-autoanime.ps1" %* +set ERR=%ERRORLEVEL% +if not "%ERR%"=="0" ( + echo. + echo [AutoAnime] 启动失败,错误码 %ERR% + pause + exit /b %ERR% +) +echo. +echo 窗口可关闭;服务已在后台运行。 +timeout /t 5 >nul +exit /b 0 diff --git a/start-autoanime.ps1 b/start-autoanime.ps1 new file mode 100644 index 0000000..d86c5f7 --- /dev/null +++ b/start-autoanime.ps1 @@ -0,0 +1,214 @@ +#Requires -Version 5.1 +param( + [string]$DataDir = "C:\ProgramData\AutoAnime", + [string]$HostAddress = "0.0.0.0", + [int]$Port = 8765, + [switch]$SecureCookies, + [switch]$NoBuild +) + +$ErrorActionPreference = "Stop" +$ProjectRoot = (Resolve-Path $PSScriptRoot).Path +Set-Location $ProjectRoot + +function Write-Info([string]$Message) { Write-Host "[AutoAnime] $Message" -ForegroundColor Cyan } +function Write-Warn([string]$Message) { Write-Host "[AutoAnime] $Message" -ForegroundColor Yellow } +function Write-Err([string]$Message) { Write-Host "[AutoAnime] $Message" -ForegroundColor Red } + +function Find-Python { + foreach ($rel in @(".venv\Scripts\python.exe", "venv\Scripts\python.exe")) { + $candidate = Join-Path $ProjectRoot $rel + if (Test-Path $candidate) { return $candidate } + } + $cmd = Get-Command python -ErrorAction SilentlyContinue + if ($cmd -and $cmd.Source -notmatch "WindowsApps") { return $cmd.Source } + $pyLauncher = Join-Path $env:WINDIR "py.exe" + if (Test-Path $pyLauncher) { return $pyLauncher } + $py = Get-Command py -ErrorAction SilentlyContinue + if ($py) { return $py.Source } + return $null +} + +function Get-PythonInvocation([string]$PythonPath) { + if ((Split-Path $PythonPath -Leaf) -ieq "py.exe") { + return @{ Exe = $PythonPath; Prefix = @("-3") } + } + return @{ Exe = $PythonPath; Prefix = @() } +} + +function Invoke-Python { + param( + [hashtable]$Python, + [string[]]$Args + ) + $all = @() + $all += $Python.Prefix + $all += $Args + & $Python.Exe @all + return $LASTEXITCODE +} + +function Ensure-PythonDeps([hashtable]$Python) { + $marker = Join-Path $ProjectRoot ".venv\.autoanime-deps.ok" + $requirements = Join-Path $ProjectRoot "requirements.txt" + if ((Test-Path $marker) -and (Test-Path $requirements)) { + if ((Get-Item $marker).LastWriteTimeUtc -ge (Get-Item $requirements).LastWriteTimeUtc) { + return + } + } + Write-Info "Installing Python dependencies..." + $code = Invoke-Python -Python $Python -Args @("-m", "pip", "install", "-r", "requirements.txt") + if ($code -ne 0) { throw "pip install -r requirements.txt failed (exit=$code)" } + "ok" | Set-Content -Path $marker -Encoding ascii +} + +function Test-ProcessRunning([string]$ScriptName) { + $pattern = [regex]::Escape($ScriptName) + $procs = Get-CimInstance Win32_Process -Filter "Name = 'python.exe' OR Name = 'pythonw.exe' OR Name = 'py.exe'" -ErrorAction SilentlyContinue + foreach ($proc in $procs) { + if ($proc.CommandLine -and ($proc.CommandLine -match $pattern)) { + return $true + } + } + return $false +} + +function Ensure-Frontend { + $distIndex = Join-Path $ProjectRoot "webui\dist\index.html" + if (Test-Path $distIndex) { + Write-Info "Frontend build found: webui\dist" + return + } + if ($NoBuild) { + throw "Missing webui\dist. Run: pnpm --dir webui install && pnpm --dir webui build" + } + $pnpm = Get-Command pnpm -ErrorAction SilentlyContinue + if (-not $pnpm) { + throw "pnpm not found and webui\dist missing. Install Node.js 20+ / pnpm 10+, then build frontend." + } + Write-Info "Building frontend for the first time..." + & pnpm --dir (Join-Path $ProjectRoot "webui") install + if ($LASTEXITCODE -ne 0) { throw "pnpm install failed" } + & pnpm --dir (Join-Path $ProjectRoot "webui") build + if ($LASTEXITCODE -ne 0) { throw "pnpm build failed" } + if (-not (Test-Path $distIndex)) { + throw "Build finished but webui\dist\index.html is still missing" + } +} + +function Start-AutoAnimeProcess { + param( + [string]$Name, + [hashtable]$Python, + [string]$ScriptPath, + [string[]]$ScriptArgs, + [string]$StdOutLog, + [string]$StdErrLog + ) + + $leaf = Split-Path $ScriptPath -Leaf + if (Test-ProcessRunning $leaf) { + Write-Warn "$Name is already running; skip" + return + } + + $argList = @() + $argList += $Python.Prefix + $argList += $ScriptPath + $argList += $ScriptArgs + + Write-Info "Starting $Name ..." + Write-Info (" cmd: {0} {1}" -f $Python.Exe, ($argList -join " ")) + Write-Info " log: $StdOutLog" + + $process = Start-Process ` + -FilePath $Python.Exe ` + -ArgumentList $argList ` + -WorkingDirectory $ProjectRoot ` + -RedirectStandardOutput $StdOutLog ` + -RedirectStandardError $StdErrLog ` + -WindowStyle Hidden ` + -PassThru + + Start-Sleep -Seconds 1 + if ($process.HasExited) { + $errTail = "" + if (Test-Path $StdErrLog) { + $errTail = ((Get-Content $StdErrLog -Tail 30 -ErrorAction SilentlyContinue) -join "`n") + } + if (-not $errTail -and (Test-Path $StdOutLog)) { + $errTail = ((Get-Content $StdOutLog -Tail 30 -ErrorAction SilentlyContinue) -join "`n") + } + throw ("{0} exited immediately (exit={1}). {2}" -f $Name, $process.ExitCode, $errTail) + } + Write-Info ("{0} started (PID {1})" -f $Name, $process.Id) +} + +try { + Write-Info "Project: $ProjectRoot" + Write-Info "DataDir: $DataDir" + + $pythonPath = Find-Python + if (-not $pythonPath) { + throw "Python not found. Install Python 3.11+ or create .venv and pip install -r requirements.txt" + } + $python = Get-PythonInvocation $pythonPath + Write-Info "Python: $($python.Exe)" + + $logDir = Join-Path $DataDir "logs" + $dataDbDir = Join-Path $DataDir "data" + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + New-Item -ItemType Directory -Path $dataDbDir -Force | Out-Null + + Ensure-PythonDeps $python + Ensure-Frontend + + $webScript = Join-Path $ProjectRoot "AutoAnimeWeb.py" + $workerScript = Join-Path $ProjectRoot "AutoAnimeWorker.py" + if (-not (Test-Path $webScript)) { throw "Missing $webScript" } + if (-not (Test-Path $workerScript)) { throw "Missing $workerScript" } + + $webArgs = @( + "--data-dir", $DataDir, + "--host", $HostAddress, + "--port", "$Port" + ) + if (-not $SecureCookies) { + $webArgs += "--insecure-http" + } + + $stamp = Get-Date -Format "yyyyMMdd" + Start-AutoAnimeProcess ` + -Name "Web" ` + -Python $python ` + -ScriptPath $webScript ` + -ScriptArgs $webArgs ` + -StdOutLog (Join-Path $logDir "web-$stamp.log") ` + -StdErrLog (Join-Path $logDir "web-$stamp.err.log") + + Start-AutoAnimeProcess ` + -Name "Worker" ` + -Python $python ` + -ScriptPath $workerScript ` + -ScriptArgs @("--data-dir", $DataDir) ` + -StdOutLog (Join-Path $logDir "worker-$stamp.log") ` + -StdErrLog (Join-Path $logDir "worker-$stamp.err.log") + + if ($HostAddress -eq "0.0.0.0") { + $url = "http://127.0.0.1:$Port" + } else { + $url = "http://${HostAddress}:$Port" + } + + Write-Host "" + Write-Info "Startup complete" + Write-Info "Console: $url" + Write-Info "Default admin: admin / AutoAnime-Admin-ChangeMe!" + Write-Info "Local loopback defaults to passwordless login (toggle in Settings)" + Write-Info "Stop with: .\stop-autoanime.bat" + Write-Host "" +} +catch { + Write-Err $_.Exception.Message + exit 1 +} diff --git a/stop-autoanime.bat b/stop-autoanime.bat new file mode 100644 index 0000000..775fbeb --- /dev/null +++ b/stop-autoanime.bat @@ -0,0 +1,7 @@ +@echo off +setlocal +cd /d "%~dp0" +title AutoAnime Stop +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0stop-autoanime.ps1" %* +echo. +pause diff --git a/stop-autoanime.ps1 b/stop-autoanime.ps1 new file mode 100644 index 0000000..f8e1a5f --- /dev/null +++ b/stop-autoanime.ps1 @@ -0,0 +1,29 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + 停止由 start-autoanime 启动的 Web / Worker 进程。 +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = "Continue" + +function Stop-MatchingPython([string]$ScriptLeaf) { + $pattern = [regex]::Escape($ScriptLeaf) + $procs = Get-CimInstance Win32_Process -Filter "Name = 'python.exe' OR Name = 'pythonw.exe' OR Name = 'py.exe'" -ErrorAction SilentlyContinue + $stopped = 0 + foreach ($proc in $procs) { + if ($proc.CommandLine -and ($proc.CommandLine -match $pattern)) { + Write-Host "[AutoAnime] 停止 PID $($proc.ProcessId): $ScriptLeaf" -ForegroundColor Yellow + Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue + $stopped++ + } + } + if ($stopped -eq 0) { + Write-Host "[AutoAnime] 未发现运行中的 $ScriptLeaf" -ForegroundColor DarkGray + } +} + +Stop-MatchingPython "AutoAnimeWeb.py" +Stop-MatchingPython "AutoAnimeWorker.py" +Write-Host "[AutoAnime] 已完成停止" -ForegroundColor Cyan diff --git a/tests/fixtures/sample_filenames.json b/tests/fixtures/sample_filenames.json deleted file mode 100644 index cf3875b..0000000 --- a/tests/fixtures/sample_filenames.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "video_samples": [ - "[LoliHouse] 葬送的芙莉莲 - 03 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv", - "药屋少女的呢喃.S01E12.1080p.WEB-DL.mkv", - "孤独摇滚! 第2季 第01集.mkv" - ], - "subtitle_samples": [ - "葬送的芙莉莲-S01-03.简体.ass", - "葬送的芙莉莲-S01-03.繁体.ass", - "葬送的芙莉莲-S01-03.jp.ass", - "药屋少女的呢喃-S01-12.chs.srt" - ] -} diff --git a/tests/test_autoanime_package.py b/tests/test_autoanime_package.py deleted file mode 100644 index 8077166..0000000 --- a/tests/test_autoanime_package.py +++ /dev/null @@ -1,145 +0,0 @@ -# -*- coding: utf-8 -*- -"""autoanime 包专项测试:单文件 CLI、ShowIndex 自愈、AI 失败回退、字幕汇总。""" - -import importlib.resources -import json -import sys -import unittest -from pathlib import Path -from tempfile import TemporaryDirectory -from unittest.mock import patch - -import zhconv.zhconv as zhconv_module - -from autoanime import state -from autoanime.cache import show_index -from autoanime.naming import Auxiliary_IDEASS -from autoanime.zhconv_safe import Auxiliary_InitZhconvDictionarySafely - - -class TestAutoanimePackage(unittest.TestCase): - def setUp(self): - state.init_defaults() - state.PRINTLOGFLAG = False - # 预加载 zhconv 词典,避免 naming 首次 import 时第三方懒加载触发 ResourceWarning - Auxiliary_InitZhconvDictionarySafely() - - def test_cli_start_getargv_single_file_mode(self): - with TemporaryDirectory() as tmp: - base = Path(tmp) - video = base / "Episode_01_test.mkv" - video.write_bytes(b"\x00") - argv = [str(Path("AutoAnimeMv2.py")), str(video)] - with patch.object(sys, "argv", argv), patch("autoanime.cli.Auxiliary_InitRuntimeContext"): - from autoanime import cli - - out = cli.Start_GetArgv() - self.assertTrue(state.SingleFileMode) - self.assertEqual(state.SingleFileVideoName, "Episode_01_test.mkv") - self.assertEqual(out, (str(base), "Episode_01_test.mkv", "1")) - self.assertEqual(state.filepath, str(base)) - self.assertEqual(state.number, "1") - - def test_show_index_self_heal_clear(self): - cid = "test_canonical_self_heal" - show_index.Auxiliary_SetShowOrganizationRecord( - cid, - { - "canonical_id": cid, - "organized_episodes": ["S01E01"], - "episode_last_dst": {"S01E01": "Z:/phantom/removed/01.mkv"}, - "title_zh": "测试", - "title_en": "", - "title_romaji": "", - "v": 1, - }, - ) - has_tag, dst = show_index.Auxiliary_ShowHasOrganizedEpisode(cid, "01", "01") - self.assertTrue(has_tag) - self.assertIsNotNone(dst) - self.assertTrue(show_index.Auxiliary_ShowClearOrganizedEpisode(cid, "01", "01")) - has2, _dst2 = show_index.Auxiliary_ShowHasOrganizedEpisode(cid, "01", "01") - self.assertFalse(has2) - - def test_processing_identification_openai_fails_uses_fallback(self): - from autoanime.identification import Processing_Identification - - state.USEOPENAIAPI = True - state.OPENAI_IDENTIFY_ALL = True - state.OPENAI_FALLBACK_ON_FAILURE = True - info5 = ("01", "01", "1", "01", "回退剧名") - meta = { - "NameEN": "", - "NameRomaji": "", - "CanonicalID": "fb1", - "CanonicalZh": "回退剧名", - "Source": "local_rules+traditional_api", - } - with patch( - "autoanime.identification.openai_identify.Auxiliary_OpenAIIdentifyFileInfo", - return_value=None, - ), patch( - "autoanime.identification.Auxiliary_ResolveFileInfoWithFallback", - return_value=(info5, meta), - ): - r = Processing_Identification("S01E01.SomeTitle.mkv") - self.assertEqual(r, info5) - self.assertFalse(state.LastIdentificationFromAI) - - def test_ideass_single_summary_for_unparseable_subs(self): - state.LogData = "" - state.PRINTLOGFLAG = False - rel = Auxiliary_IDEASS( - "MainVideo.mkv", - "01", - "01", - ["a.ass", "b.ass", "c.srt"], - ) - self.assertIsNone(rel) - self.assertEqual(state.LogData.count("字幕文件无法提取剧集"), 1) - self.assertIn("跳过 3 个", state.LogData) - - def test_zhconv_safe_uses_resource_context_manager(self): - """优先走 importlib.resources 的 with 句柄;失败时仍须关闭 get_module_res 回退流。""" - fake_dict = { - "SIMPONLY": ["测"], - "TRADONLY": ["測"], - "zh2Hans": {}, - "zh2CN": {}, - "zh2Hant": {}, - "zh2TW": {}, - "zh2HK": {}, - "zh2SG": {}, - } - stream_payload = json.dumps(fake_dict, ensure_ascii=False).encode("utf-8") - - class _Dummy: - def __init__(self, payload): - self._payload = payload - self.closed = False - - def read(self): - return self._payload - - def close(self): - self.closed = True - - stream = _Dummy(stream_payload) - with patch.object(zhconv_module, "zhcdicts", None), patch.object( - zhconv_module, "DICTIONARY", "zhcdict.json" - ), patch.object(zhconv_module, "_DEFAULT_DICT", "zhcdict.json"), patch.object( - zhconv_module, "get_module_res", return_value=stream - ), patch.object( - importlib.resources, - "files", - side_effect=OSError("force legacy reader"), - create=True, - ): - Auxiliary_InitZhconvDictionarySafely() - self.assertTrue(stream.closed) - self.assertIsNotNone(zhconv_module.zhcdicts) - self.assertIsInstance(zhconv_module.zhcdicts.get("SIMPONLY"), frozenset) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_cache_schema_v2.py b/tests/test_cache_schema_v2.py deleted file mode 100644 index c3c8739..0000000 --- a/tests/test_cache_schema_v2.py +++ /dev/null @@ -1,290 +0,0 @@ -# -*- coding: utf-8 -*- -"""Schema v2 缓存:子文件路由、信任校验、原子写、迁移、兼容层、cache_doctor。""" - -import json -import os -import subprocess -import sys -import unittest -from pathlib import Path -from tempfile import TemporaryDirectory - -from autoanime import state -from autoanime.cache import persistent -from autoanime.cache.canonical import Auxiliary_LinkAliasToCanonical, Auxiliary_UpsertCanonicalTitle -from autoanime.cache.migrate import Auxiliary_MigrateCacheToV2IfNeeded -from autoanime.cache.trust import Auxiliary_ValidateAliasWrite -from autoanime.cache.v2_data import Auxiliary_AtomicWriteJson, Auxiliary_GetV2DataDir -from autoanime.config_loader import Auxiliary_InitRuntimeContext - - -def _init_cache_in_tmp(base: Path) -> None: - state.init_defaults() - state.PRINTLOGFLAG = False - cdir = base / ".cache" - cdir.mkdir(parents=True, exist_ok=True) - state.CACHE_DIR = str(cdir) - Auxiliary_InitRuntimeContext() - state.PersistentApiCache = {} - state.PersistentApiCacheDirty = False - state.CacheSubfileDirty = { - "organization": False, - "titles": False, - "api_responses": False, - } - state.TitleAliasIndexDataCache = {} - state.CanonicalTitleIndexDataCache = {} - state.ShowOrganizationIndexDataCache = {} - - -def _load_save(): - persistent.Auxiliary_LoadPersistentCache() - return persistent.Auxiliary_SavePersistentCache - - -def _read_json(p: Path) -> dict: - with open(p, "r", encoding="utf-8") as f: - return json.load(f) - - -class TestCacheV2Route(unittest.TestCase): - """1. Set/Get 路由到正确子文件""" - - def test_groups_map_to_subfiles(self): - with TemporaryDirectory() as tmp: - base = Path(tmp) - _init_cache_in_tmp(base) - Auxiliary_MigrateCacheToV2IfNeeded() - _load_save() - persistent.Auxiliary_SetPersistentCache("TMDB", "q1", "标题A") - persistent.Auxiliary_SetPersistentCache("ShowOrganizationIndex", "c1", {"canonical_id": "c1", "title_zh": "番"}) - persistent.Auxiliary_SavePersistentCache(force=True) - ap = Auxiliary_GetV2DataDir() / "api_responses.json" - op = Auxiliary_GetV2DataDir() / "organization.json" - self.assertTrue(ap.is_file()) - self.assertTrue(op.is_file()) - aj = _read_json(ap) - oj = _read_json(op) - tmdb_titles = (aj.get("tmdb") or {}).get("titles", {}) - self.assertIn("q1", tmdb_titles) - self.assertEqual(tmdb_titles["q1"].get("value"), "标题A") - self.assertIn("c1", oj.get("records", {})) - self.assertEqual(persistent.Auxiliary_GetPersistentCache("TMDB", "q1"), "标题A") - r = persistent.Auxiliary_GetPersistentCache("ShowOrganizationIndex", "c1") - self.assertIsInstance(r, dict) - self.assertEqual(r.get("title_zh"), "番") - - -class TestCacheV2Trust(unittest.TestCase): - """2–4. 拒绝原因与信任:长键 / 低 trust / locked""" - - def test_alias_key_too_long_rejected(self): - with TemporaryDirectory() as tmp: - base = Path(tmp) - _init_cache_in_tmp(base) - _load_save() - cid, _ = Auxiliary_UpsertCanonicalTitle("测试剧名", "En", "", "TMDB", []) - self.assertIsNotNone(cid) - long_key = "x" * 101 - ok, reason = Auxiliary_ValidateAliasWrite(long_key, str(cid), 80, new_source="TMDB") - self.assertFalse(ok) - self.assertEqual(reason, "alias_key_too_long") - - def test_lower_trust_does_not_override(self): - with TemporaryDirectory() as tmp: - base = Path(tmp) - _init_cache_in_tmp(base) - _load_save() - cid, _ = Auxiliary_UpsertCanonicalTitle("低信任测", "L", "", "TMDB", []) - self.assertIsNotNone(cid) - cid2, _ = Auxiliary_UpsertCanonicalTitle("另一部", "O", "", "TMDB", []) - self.assertIsNotNone(cid2) - Auxiliary_LinkAliasToCanonical("onealias", cid, "TMDB") - Auxiliary_LinkAliasToCanonical("onealias", cid2, "openai_identify") - v = persistent.Auxiliary_GetPersistentCache("TitleAliasIndex", "onealias") - self.assertEqual(str(v), str(cid)) - - def test_locked_canonical_rejects_auto_alias(self): - with TemporaryDirectory() as tmp: - base = Path(tmp) - _init_cache_in_tmp(base) - _load_save() - cid, _ = Auxiliary_UpsertCanonicalTitle("锁定测", "E", "", "TMDB", []) - rec = { - "zh": "锁定测", - "en": "E", - "romaji": "", - "source": "TMDB", - "confidence": 80, - "locked": True, - } - persistent.Auxiliary_SetPersistentCache("CanonicalTitleIndex", cid, rec) - state.CanonicalTitleIndexDataCache[cid] = rec - persistent.Auxiliary_SavePersistentCache(force=True) - Auxiliary_LinkAliasToCanonical("新别名键", cid, "TMDB") - self.assertIsNone(persistent.Auxiliary_GetPersistentCache("TitleAliasIndex", "新别名键")) - - -class TestCacheV2Atomic(unittest.TestCase): - """5. 原子写:未 replace 前主文件内容保持""" - - def test_atomic_write_old_unchanged_if_tmp_incomplete(self): - with TemporaryDirectory() as tmp: - p = Path(tmp) / "sample.json" - p.write_text('{"a": 1}\n', encoding="utf-8") - t = p.with_suffix(p.suffix + ".tmp") - t.write_text("partial", encoding="utf-8") - self.assertEqual(_read_json(p), {"a": 1}) - t.unlink() - Auxiliary_AtomicWriteJson(p, {"b": 2}) - self.assertEqual(_read_json(p), {"b": 2}) - - -class TestCacheV2IncrementalFlush(unittest.TestCase): - """6. 只刷 organization 时 titles 文件不被覆盖(mtime)""" - - def test_only_organization_subfile_touched(self): - with TemporaryDirectory() as tmp: - base = Path(tmp) - _init_cache_in_tmp(base) - Auxiliary_MigrateCacheToV2IfNeeded() - _load_save() - persistent.Auxiliary_SetPersistentCache("TMDB", "init", "v") - persistent.Auxiliary_SavePersistentCache(force=True) - titles = Auxiliary_GetV2DataDir() / "titles.json" - t0 = os.path.getmtime(titles) - persistent.Auxiliary_SetPersistentCache( - "ShowOrganizationIndex", - "o1", - {"canonical_id": "o1", "title_zh": "仅进度"}, - ) - persistent.Auxiliary_SavePersistentCache(force=False) - t1 = os.path.getmtime(titles) - self.assertEqual(t0, t1) - - -class TestCacheV2Migrate(unittest.TestCase): - """7. 迁移:旧 monolithic 归档 + v2 空表""" - - def test_legacy_api_cache_archived_and_v2_init(self): - with TemporaryDirectory() as tmp: - base = Path(tmp) - cdir = base / ".cache" - cdir.mkdir(parents=True, exist_ok=True) - legacy = cdir / "api_cache.json" - legacy.write_text("{}", encoding="utf-8") - state.init_defaults() - state.CACHE_DIR = str(cdir) - state.PRINTLOGFLAG = False - Auxiliary_InitRuntimeContext() - Auxiliary_MigrateCacheToV2IfNeeded() - self.assertTrue((cdir / "cache_meta.json").is_file()) - self.assertFalse(legacy.is_file()) - backs = list((cdir / "backups").glob("api_cache_legacy_*.json")) - self.assertEqual(len(backs), 1) - oj = _read_json(cdir / "organization.json") - self.assertEqual(oj.get("records"), {}) - - -class TestCacheV2ApiCompatible(unittest.TestCase): - """8. 对外 API 签名不变:Get/Set 行为""" - - def test_get_set_public_surface(self): - self.assertTrue(callable(persistent.Auxiliary_GetPersistentCache)) - self.assertTrue(callable(persistent.Auxiliary_SetPersistentCache)) - self.assertTrue(callable(persistent.Auxiliary_MaybeFlushPersistentCache)) - with TemporaryDirectory() as tmp: - _init_cache_in_tmp(Path(tmp)) - Auxiliary_MigrateCacheToV2IfNeeded() - _load_save() - persistent.Auxiliary_SetPersistentCache("Bangumi", "k", {"name": 1}) - v = persistent.Auxiliary_GetPersistentCache("Bangumi", "k") - self.assertEqual(v, {"name": 1}) - - -class TestCacheDoctorInspect(unittest.TestCase): - """9. cache_doctor --inspect 能跑通并返回码""" - - def test_inspect_subprocess(self): - with TemporaryDirectory() as tmp: - base = Path(tmp) - cdir = base / ".cache" - cdir.mkdir(parents=True, exist_ok=True) - from autoanime.cache.v2_data import ( - EMPTY_API_RESPONSES, - EMPTY_ORGANIZATION, - EMPTY_TITLES, - ) - - Auxiliary_AtomicWriteJson(cdir / "organization.json", dict(EMPTY_ORGANIZATION)) - Auxiliary_AtomicWriteJson(cdir / "titles.json", dict(EMPTY_TITLES)) - Auxiliary_AtomicWriteJson(cdir / "api_responses.json", dict(EMPTY_API_RESPONSES)) - Auxiliary_AtomicWriteJson( - cdir / "cache_meta.json", - {"schema_version": 2, "subfiles": {}, "created_at": "x"}, - ) - root = Path(__file__).resolve().parent.parent - script = root / "scripts" / "cache_doctor.py" - r = subprocess.run( - [sys.executable, str(script), "--inspect", "--cache-dir", str(cdir)], - capture_output=True, - text=True, - ) - self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) - self.assertIn("schema_version=2", r.stdout) - self.assertIn("organization.json", r.stdout) - - -class TestCacheV2Composite(unittest.TestCase): - """10. 综合:revert 与 doctor export(依赖 audit 行)""" - - def test_revert_and_export_audit(self): - with TemporaryDirectory() as tmp: - base = Path(tmp) - cdir = base / ".cache" - cdir.mkdir(parents=True, exist_ok=True) - from autoanime.cache.v2_data import ( - EMPTY_TITLES, - ) - - Auxiliary_AtomicWriteJson( - cdir / "cache_meta.json", - {"schema_version": 2, "subfiles": {}}, - ) - tdata = dict(EMPTY_TITLES) - tdata["aliases"]["akey"] = { - "canonical_id": "c1", - "trust_level": 80, - "source": "T", - "added_at": "t", - } - Auxiliary_AtomicWriteJson(cdir / "titles.json", tdata) - ev = { - "audit_id": "test-audit-001", - "ts": 1e9, - "type": "alias_written", - "alias_key": "akey", - "canonical_id": "c1", - } - with open(cdir / "pollution_audit.jsonl", "w", encoding="utf-8") as f: - f.write(json.dumps(ev, ensure_ascii=False) + "\n") - root = Path(__file__).resolve().parent.parent - script = root / "scripts" / "cache_doctor.py" - r0 = subprocess.run( - [sys.executable, str(script), "--export-audit", "--since", "2000-01-01", "--cache-dir", str(cdir)], - capture_output=True, - text=True, - ) - self.assertEqual(r0.returncode, 0, msg=r0.stderr) - r1 = subprocess.run( - [sys.executable, str(script), "--revert", "--audit-id", "test-audit-001", "--cache-dir", str(cdir)], - capture_output=True, - text=True, - ) - self.assertEqual(r1.returncode, 0, msg=r1.stderr) - t2 = _read_json(cdir / "titles.json") - self.assertNotIn("akey", t2.get("aliases", {})) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_episode_dst_rename.py b/tests/test_episode_dst_rename.py deleted file mode 100644 index c655df8..0000000 --- a/tests/test_episode_dst_rename.py +++ /dev/null @@ -1,69 +0,0 @@ -# -*- coding: utf-8 -*- -import unittest -from pathlib import Path -from unittest import mock - -from autoanime.episode_dst_rename import ( - Auxiliary_ParseOrganizedTag, - BuildSortingDestPath, - EpisodeDstRenameParams, - PlanEpisodeDstRenames, -) - - -class TestEpisodeDstRename(unittest.TestCase): - def test_parse_tag(self) -> None: - self.assertEqual(Auxiliary_ParseOrganizedTag("S05E01"), ("05", "01")) - self.assertEqual(Auxiliary_ParseOrganizedTag("s1e2"), ("1", "2")) - self.assertIsNone(Auxiliary_ParseOrganizedTag("xx")) - - def test_build_sorting_dest_default(self) -> None: - p = Path("F:/库/老剧名/Season01/S01E01.老剧名.mp4") - dst = BuildSortingDestPath( - p, - "01", - "01", - "新剧名", - EpisodeDstRenameParams( - naming_style="default", - use_title_to_ep=True, - ), - ) - self.assertIn("新剧名", str(dst).replace("\\", "/")) - self.assertTrue(str(dst).replace("\\", "/").endswith("S01E01.新剧名.mp4")) - - def test_build_sorting_dest_emby(self) -> None: - p = Path("F:/库/老剧/Season 01/Show - S01E01.mkv") - dst = BuildSortingDestPath( - p, - "1", - "1", - "新", - EpisodeDstRenameParams( - naming_style="emby", - use_title_to_ep=False, - ), - ) - self.assertIn("新 - S01E01", str(dst).replace("\\", "/")) - - def test_plan_mismatch_roots(self) -> None: - rec = { - "episode_last_dst": { - "S01E01": "F:/a/Show1/Season1/a.mp4", - "S01E02": "F:/b/Show2/Season1/b.mp4", - } - } - with mock.patch("pathlib.Path.is_file", return_value=True): - moves, errs = PlanEpisodeDstRenames(rec, "X", EpisodeDstRenameParams()) - self.assertIn("不同剧集根", "\n".join(errs)) - self.assertTrue(errs) - - def test_plan_empty(self) -> None: - rec = {"episode_last_dst": {}} - moves, errs = PlanEpisodeDstRenames(rec, "Z", EpisodeDstRenameParams()) - self.assertEqual(moves, []) - self.assertEqual(errs, []) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_identification.py b/tests/test_identification.py deleted file mode 100644 index e8063e6..0000000 --- a/tests/test_identification.py +++ /dev/null @@ -1,42 +0,0 @@ -from unittest import TestCase -from unittest.mock import patch - -import AutoAnimeMv as aam - -from tests.test_refactor_features import _reset_aam_caches - - -class TestIdentification(TestCase): - def setUp(self): - with patch.object(aam, "Auxiliary_READConfig", return_value=None), patch.object( - aam, "Auxiliary_LoadModule", return_value=None - ): - aam.Start_PATH() - _reset_aam_caches(aam) - aam.PRINTLOGFLAG = False - aam.USELINK = False - aam.MANDATORYCOVER = True - aam.CategoryName = "" - - def test_processing_identification_extract_episode_and_name(self): - """当前 `Processing_Identification` 要求 OpenAI 识别成功,mock 以解耦网络。""" - file_name = "[LoliHouse] 葬送的芙莉莲 - 03 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv" - with patch.object( - aam, - "Auxiliary_OpenAIIdentifyFileInfo", - return_value=("01", "03", "", "03", "葬送的芙莉莲"), - ): - result = aam.Processing_Identification(file_name) - self.assertIsNotNone(result) - - se, ep, raw_se, raw_ep, raw_name = result - self.assertEqual(se, "01") - self.assertEqual(ep, "03") - self.assertEqual(raw_ep, "03") - self.assertEqual(raw_se, "") - self.assertIn("葬送的芙莉莲", raw_name) - - def test_ass_language_classify(self): - self.assertEqual(aam.Auxiliary_ASSFileCA("foo.简体.ass"), ".chs") - self.assertEqual(aam.Auxiliary_ASSFileCA("foo.繁体.ass"), ".cht") - self.assertEqual(aam.Auxiliary_ASSFileCA("foo.jp.ass"), ".jp") diff --git a/tests/test_refactor_features.py b/tests/test_refactor_features.py deleted file mode 100644 index c0194dd..0000000 --- a/tests/test_refactor_features.py +++ /dev/null @@ -1,962 +0,0 @@ -import json -import os -import time -from pathlib import Path -from tempfile import TemporaryDirectory -from unittest import TestCase -from unittest.mock import patch - -import AutoAnimeMv as aam - - -def _reset_aam_caches(aam_module): - """清空 aam 的所有内存缓存,避免受真实 api_cache.json 历史数据(如污染的 TitleAliasIndex)干扰。 - - 必须在每个测试 setUp 里调用,因为 aam.Start_PATH() 会从默认 .cache/api_cache.json 自动加载。 - """ - aam_module.PersistentApiCache = {} - aam_module.PersistentApiCacheDirty = False - aam_module.TitleAliasIndexDataCache = {} - aam_module.CanonicalTitleIndexDataCache = {} - aam_module.ShowOrganizationIndexDataCache = {} - aam_module.OpenAIIdentifyFileMemoryCache = {} - aam_module.OpenAIAPIDataCache = {} - aam_module.BgmAPIDataCache = {} - aam_module.TMDBAPIDataCache = {} - aam_module.BangumiAPIDataCache = {} - aam_module.EpisodeDecisionDataCache = {} - aam_module.ManualTitleWhitelistDataCache = {} - aam_module.ManualTitleWhitelistMTime = 0.0 - aam_module.TMDBTvSeasonLayoutMemoryCache = {} - aam_module.TMDBTvSeriesIdMemoryCache = {} - aam_module.LastOpenAIFileInfoMeta = {} - aam_module.LastOpenAIIdentifyFailure = None - aam_module.LastIdentificationFromAI = False - - -class TestRefactorFeatures(TestCase): - def setUp(self): - self.tmp = TemporaryDirectory() - self.tmp_path = Path(self.tmp.name) - with patch.object(aam, "Auxiliary_READConfig", return_value=None), patch.object( - aam, "Auxiliary_LoadModule", return_value=None - ): - aam.Start_PATH() - _reset_aam_caches(aam) - aam.filepath = str(self.tmp_path) - aam.Path = str(self.tmp_path) - aam.CategoryName = "" - aam.categoryname = "" - aam.USELINK = False - aam.MANDATORYCOVER = True - aam.PRINTLOGFLAG = False - aam.NAMING_STYLE = "default" - aam.DRY_RUN = False - aam.CACHE_DIR = str(self.tmp_path / ".cache") - aam.Auxiliary_InitRuntimeContext() - - def tearDown(self): - self.tmp.cleanup() - - def test_zhconv_safe_init_closes_resource_stream(self): - class DummyStream: - def __init__(self, payload): - self.payload = payload - self.closed = False - - def read(self): - return self.payload - - def close(self): - self.closed = True - - fake_dict = { - "SIMPONLY": ["测"], - "TRADONLY": ["測"], - "zh2Hans": {}, - "zh2CN": {}, - "zh2Hant": {}, - "zh2TW": {}, - "zh2HK": {}, - "zh2SG": {}, - } - stream = DummyStream(json.dumps(fake_dict, ensure_ascii=False).encode("utf-8")) - - with patch.object(aam.zhconv_module, "zhcdicts", None), patch.object( - aam.zhconv_module, "DICTIONARY", "zhcdict.json" - ), patch.object( - aam.zhconv_module, "_DEFAULT_DICT", "zhcdict.json" - ), patch.object( - aam.zhconv_module, "get_module_res", return_value=stream - ): - aam.Auxiliary_InitZhconvDictionarySafely() - self.assertTrue(stream.closed) - self.assertIsNotNone(aam.zhconv_module.zhcdicts) - self.assertIsInstance(aam.zhconv_module.zhcdicts.get("SIMPONLY"), frozenset) - self.assertIsInstance(aam.zhconv_module.zhcdicts.get("TRADONLY"), frozenset) - - def test_processing_main_tuple_only_iterates_videos(self): - with patch.object( - aam, - "Processing_Identification", - return_value=("01", "01", "S01", "01", "TestAnime"), - ) as mocked_ident, patch.object( - aam, "Auxiliary_IDEASS", return_value=["sub1.ass"] - ) as mocked_ideass, patch.object( - aam, "Auxiliary_Api", return_value="TestAnime" - ), patch.object( - aam, "Sorting_Mv" - ) as mocked_sort: - aam.Processing_Main((["video1.mkv"], ["sub1.ass", "sub2.ass"])) - - self.assertEqual(mocked_ident.call_count, 1) - self.assertEqual(mocked_ideass.call_count, 1) - self.assertEqual(mocked_sort.call_count, 1) - self.assertEqual(mocked_sort.call_args[0][0], "video1.mkv") - - def test_processing_main_skips_incomplete_download_file(self): - partial_file = "[Comicat&kisssub][Sousou no Frieren S2][08][1080P][GB][MP4].mp4.!qB" - with patch.object(aam, "Processing_Identification") as mocked_ident, patch.object( - aam, "Sorting_Mv" - ) as mocked_sort: - aam.Processing_Main([partial_file]) - - mocked_ident.assert_not_called() - mocked_sort.assert_not_called() - - def test_processing_mode_refreshes_runtime_context_from_cli_like_globals(self): - video = self.tmp_path / "video1.mkv" - video.write_text("video", encoding="utf-8") - output_root = self.tmp_path / "organized" - - aam.filepath = str(self.tmp_path) - aam.categoryname = "动漫" - aam.NAMING_STYLE = "emby" - aam.DRY_RUN = True - aam.STRICT_MODE = False - aam.OUTPUT_PATH = str(output_root) - - aam.Processing_Mode(str(self.tmp_path)) - - self.assertEqual(aam.Runtime.source_path, self.tmp_path) - self.assertEqual(aam.Runtime.output_path, output_root) - self.assertEqual(aam.Runtime.category_name, "动漫") - self.assertEqual(aam.Runtime.config.naming_style, "emby") - self.assertTrue(aam.Runtime.config.dry_run) - self.assertFalse(aam.Runtime.config.strict_mode) - - def test_processing_mode_qb_callback_skips_incomplete_download_file(self): - partial_file = "[Comicat&kisssub][Sousou no Frieren S2][08][1080P][GB][MP4].mp4.!qB" - (self.tmp_path / partial_file).write_text("partial", encoding="utf-8") - - result = aam.Processing_Mode((str(self.tmp_path), partial_file, "1")) - - self.assertEqual(result, []) - - def test_processing_identification_uses_openai_full_info_when_enabled(self): - class DummyResponse: - status_code = 200 - - def json(self): - return { - "choices": [ - { - "message": { - "content": '{"anime_name":"咒术回战","season":"2","episode":"48","special":false}' - } - } - ] - } - - aam.OPENAI_IDENTIFY_ALL = True - aam.OPENAI_API_KEY = "test-key" - with patch.object(aam, "post", return_value=DummyResponse()) as mocked_post, patch.object( - aam, "Auxiliary_IDEEP", side_effect=Exception("should_not_call") - ), patch.object( - aam, - "Auxiliary_RemappedJujutsuKaisenSeasonEpisode", - return_value=("3", "1", "03", "01"), - ): - result = aam.Processing_Identification("Jujutsu Kaisen [WEB] 48.mkv") - - self.assertEqual(result, ("03", "01", "3", "1", "咒术回战")) - self.assertTrue(aam.LastIdentificationFromAI) - self.assertIn("简体中文", mocked_post.call_args.kwargs["json"]["messages"][0]["content"]) - - def test_openai_full_info_cache_hit_repairs_title_with_standard_cache(self): - file_name = "[BeanSub&FZSD&LoliHouse] Jujutsu Kaisen - 59.mkv" - aam.USEOPENAIAPI = True - aam.OPENAI_IDENTIFY_ALL = True - aam.Auxiliary_UpsertCanonicalTitle( - "咒术回战", "Jujutsu Kaisen", "Jujutsu Kaisen", "Bangumi", [file_name, "Jujutsu Kaisen"] - ) - aam.OpenAIIdentifyFileMemoryCache[file_name] = { - "SE": "01", - "EP": "59", - "RAWSE": "1", - "RAWEP": "59", - "RAWName": "Jujutsu Kaisen", - "NameEN": "Jujutsu Kaisen", - "NameRomaji": "Jujutsu Kaisen", - "CanonicalID": "", - } - - with patch.object( - aam, - "Auxiliary_RemappedJujutsuKaisenSeasonEpisode", - return_value=("3", "12", "03", "12"), - ): - result = aam.Auxiliary_OpenAIIdentifyFileInfo(file_name) - - self.assertEqual(result, ("03", "12", "3", "12", "咒术回战")) - repaired = aam.OpenAIIdentifyFileMemoryCache.get(file_name) - self.assertIsNotNone(repaired) - self.assertEqual(repaired["RAWName"], "咒术回战") - self.assertEqual(repaired["SE"], "03") - self.assertEqual(repaired["EP"], "12") - - def test_processing_main_identifies_with_basename_for_nested_source(self): - with patch.object( - aam, "Processing_Identification", return_value=("01", "01", "S01", "01", "TestAnime") - ) as mocked_ident, patch.object( - aam, "Auxiliary_IDEASS", return_value=None - ), patch.object( - aam, "Auxiliary_Api", return_value="TestAnime" - ), patch.object( - aam, "Sorting_Mv" - ) as mocked_sort: - aam.Processing_Main((["pack\\video1.mkv"], ["pack\\video1.chs.ass"])) - - self.assertEqual(mocked_ident.call_args[0][0], "video1.mkv") - self.assertEqual(mocked_sort.call_args.kwargs.get("SourceFilePath"), "pack\\video1.mkv") - - def test_scan_dir_recursively_finds_nested_video_and_subtitle(self): - nested = self.tmp_path / "pack" - nested.mkdir(parents=True, exist_ok=True) - (nested / "Jujutsu Kaisen =48=.mkv").write_text("video", encoding="utf-8") - (nested / "Jujutsu Kaisen S01 =48= 简体.ass").write_text("sub", encoding="utf-8") - - result = aam.Auxiliary_ScanDIR(str(self.tmp_path)) - self.assertIsInstance(result, tuple) - videos, subtitles = result - self.assertTrue(any(x.endswith("Jujutsu Kaisen =48=.mkv") for x in videos)) - self.assertTrue(any(x.endswith("Jujutsu Kaisen S01 =48= 简体.ass") for x in subtitles)) - - def test_sorting_moves_nested_source_into_parent_output(self): - nested = self.tmp_path / "pack" - nested.mkdir(parents=True, exist_ok=True) - source_file = nested / "Jujutsu Kaisen =48=.mkv" - source_file.write_text("video", encoding="utf-8") - - aam.DRY_RUN = False - aam.NAMING_STYLE = "emby" - aam.Auxiliary_InitRuntimeContext() - aam.Sorting_Mv( - "Jujutsu Kaisen =48=.mkv", - "Jujutsu Kaisen", - "01", - "48", - None, - "咒术回战", - SourceFilePath=str(source_file.relative_to(self.tmp_path)), - ) - - expected_target = self.tmp_path / "咒术回战" / "Season 01" / "咒术回战 - S01E48.mkv" - self.assertTrue(expected_target.exists()) - self.assertFalse(source_file.exists()) - - def test_sorting_with_output_path_moves_to_custom_target_root(self): - nested = self.tmp_path / "pack" - nested.mkdir(parents=True, exist_ok=True) - source_file = nested / "Jujutsu Kaisen =49=.mkv" - source_file.write_text("video", encoding="utf-8") - output_root = self.tmp_path / "organized" - - aam.DRY_RUN = False - aam.NAMING_STYLE = "emby" - aam.OUTPUT_PATH = str(output_root) - aam.Auxiliary_InitRuntimeContext() - aam.Sorting_Mv( - "Jujutsu Kaisen =49=.mkv", - "Jujutsu Kaisen", - "01", - "49", - None, - "咒术回战", - SourceFilePath=str(source_file.relative_to(self.tmp_path)), - ) - - expected_target = output_root / "咒术回战" / "Season 01" / "咒术回战 - S01E49.mkv" - self.assertTrue(expected_target.exists()) - self.assertFalse(source_file.exists()) - - def test_sorting_with_output_path_skips_same_hardlinked_target_on_rerun(self): - nested = self.tmp_path / "pack" - nested.mkdir(parents=True, exist_ok=True) - source_file = nested / "Jujutsu Kaisen =50=.mkv" - source_file.write_text("video", encoding="utf-8") - output_root = self.tmp_path / "organized" - - aam.DRY_RUN = False - aam.NAMING_STYLE = "emby" - aam.OUTPUT_PATH = str(output_root) - aam.USELINK = True - aam.Auxiliary_InitRuntimeContext() - - sorting_kwargs = dict( - FileName="Jujutsu Kaisen =50=.mkv", - RAWName="Jujutsu Kaisen", - SE="01", - EP="50", - ASSList=None, - ApiName="咒术回战", - SourceFilePath=str(source_file.relative_to(self.tmp_path)), - ) - - aam.Sorting_Mv(**sorting_kwargs) - expected_target = output_root / "咒术回战" / "Season 01" / "咒术回战 - S01E50.mkv" - self.assertTrue(expected_target.exists()) - self.assertTrue(source_file.exists()) - self.assertTrue(aam.Auxiliary_IsSamePhysicalFile(source_file, expected_target)) - - aam.Sorting_Mv(**sorting_kwargs) - - self.assertEqual(aam.Runtime.operation_records[-1]["status"], "skipped") - self.assertEqual(aam.Runtime.operation_records[-1]["message"], "same_file") - self.assertEqual(list(output_root.rglob("*.aam.bak.*")), []) - - def test_link_mode_keeps_existing_target_instead_of_replacing_duplicate(self): - source_old = self.tmp_path / "old_source.mkv" - source_new = self.tmp_path / "new_source.mkv" - source_old.write_text("old", encoding="utf-8") - source_new.write_text("new", encoding="utf-8") - output_root = self.tmp_path / "organized" - - aam.DRY_RUN = False - aam.NAMING_STYLE = "emby" - aam.OUTPUT_PATH = str(output_root) - aam.USELINK = True - aam.MANDATORYCOVER = True - aam.Auxiliary_InitRuntimeContext() - - aam.Sorting_Mv("old_source.mkv", "Frieren", "02", "08", None, "葬送的芙莉莲", SourceFilePath="old_source.mkv") - expected_target = output_root / "葬送的芙莉莲" / "Season 02" / "葬送的芙莉莲 - S02E08.mkv" - self.assertTrue(expected_target.exists()) - self.assertTrue(aam.Auxiliary_IsSamePhysicalFile(source_old, expected_target)) - - aam.Sorting_Mv("new_source.mkv", "Sousou no Frieren", "02", "08", None, "葬送的芙莉莲", SourceFilePath="new_source.mkv") - - self.assertTrue(source_new.exists()) - self.assertTrue(aam.Auxiliary_IsSamePhysicalFile(source_old, expected_target)) - self.assertFalse(aam.Auxiliary_IsSamePhysicalFile(source_new, expected_target)) - self.assertEqual(aam.Runtime.operation_records[-1]["status"], "skipped") - self.assertEqual(aam.Runtime.operation_records[-1]["message"], "existing_link_kept") - self.assertEqual(list(output_root.rglob("*.aam.bak.*")), []) - - def test_duplicate_skip_still_caches_resolved_file_info(self): - original_file = self.tmp_path / "original.mkv" - duplicate_name = "[Comicat&kisssub][Sousou no Frieren S2][08][1080P][GB][MP4].mp4" - duplicate_file = self.tmp_path / duplicate_name - original_file.write_text("origin", encoding="utf-8") - duplicate_file.write_text("dup", encoding="utf-8") - output_root = self.tmp_path / "organized" - - aam.DRY_RUN = False - aam.NAMING_STYLE = "emby" - aam.OUTPUT_PATH = str(output_root) - aam.USELINK = True - aam.MANDATORYCOVER = True - aam.Auxiliary_InitRuntimeContext() - - aam.Sorting_Mv("original.mkv", "Frieren", "02", "08", None, "葬送的芙莉莲", SourceFilePath="original.mkv") - expected_target = output_root / "葬送的芙莉莲" / "Season 02" / "葬送的芙莉莲 - S02E08.mkv" - pre_hint = aam.Auxiliary_PreDetectEpisodeHint(duplicate_name) - self.assertIsNotNone(pre_hint) - aam.EpisodeDecisionDataCache[pre_hint["EpisodeKey"]] = { - "source_mtime": 0.0, - "src": str(original_file), - "dst": str(expected_target), - "resolved": { - "SE": "02", - "EP": "08", - "RAWSE": "2", - "RAWEP": "08", - "RAWName": "Sousou no Frieren S2", - "ApiName": "葬送的芙莉莲", - "NameEN": "Sousou no Frieren", - "NameRomaji": "Sousou no Frieren", - "CanonicalID": "", - }, - } - - with patch.object( - aam, "Processing_Identification", return_value=("02", "08", "2", "08", "Sousou no Frieren S2") - ) as mocked_ident, patch.object( - aam, "Auxiliary_Api", return_value="葬送的芙莉莲" - ) as mocked_api: - aam.Processing_Main([duplicate_name]) - - mocked_ident.assert_not_called() - mocked_api.assert_not_called() - self.assertEqual(aam.Runtime.operation_records[-1]["message"], "newer_duplicate_kept_oldest") - - with patch.object(aam, "Processing_Identification") as mocked_ident_again, patch.object( - aam, "Auxiliary_Api" - ) as mocked_api_again: - aam.Processing_Main([duplicate_name]) - - mocked_ident_again.assert_not_called() - mocked_api_again.assert_not_called() - - def test_strict_mode_prevents_move_fallback_after_link_failure(self): - source_file = self.tmp_path / "src.mkv" - target_file = self.tmp_path / "dst.mkv" - source_file.write_text("video", encoding="utf-8") - - aam.USELINK = True - aam.STRICT_MODE = True - aam.LINKFAILSUSEMOVEFLAGS = True - aam.Auxiliary_InitRuntimeContext() - with patch.object(aam, "link", side_effect=OSError("[WinError 1] not supported")): - aam.Auxiliary_ExecuteFileOperation(source_file, target_file) - - self.assertTrue(source_file.exists()) - self.assertFalse(target_file.exists()) - - def test_non_strict_mode_can_move_when_link_fails(self): - source_file = self.tmp_path / "src2.mkv" - target_file = self.tmp_path / "dst2.mkv" - source_file.write_text("video", encoding="utf-8") - - aam.USELINK = True - aam.STRICT_MODE = False - aam.LINKFAILSUSEMOVEFLAGS = True - aam.Auxiliary_InitRuntimeContext() - with patch.object(aam, "link", side_effect=OSError("[WinError 1] not supported")): - aam.Auxiliary_ExecuteFileOperation(source_file, target_file) - - self.assertFalse(source_file.exists()) - self.assertTrue(target_file.exists()) - - def test_filename_sanitizer_handles_windows_reserved_and_symbols(self): - cleaned = aam.Auxiliary_SanitizePathComponent('CON<>:"/\\|?* .', 24) - self.assertNotIn("<", cleaned) - self.assertNotIn(">", cleaned) - self.assertNotIn(":", cleaned) - self.assertFalse(cleaned.endswith(" ")) - self.assertFalse(cleaned.endswith(".")) - self.assertNotEqual(cleaned.upper(), "CON") - - def test_emby_naming_and_dry_run_records_operations(self): - video = "Frieren - 03.mkv" - sub = "Frieren - 03.简体.ass" - (self.tmp_path / video).write_text("video", encoding="utf-8") - (self.tmp_path / sub).write_text("sub", encoding="utf-8") - - aam.NAMING_STYLE = "emby" - aam.DRY_RUN = True - aam.Auxiliary_InitRuntimeContext() - aam.Sorting_Mv(video, "Frieren", "01", "03", [sub], "葬送的芙莉莲") - - records = aam.Runtime.operation_records - self.assertGreaterEqual(len(records), 2) - dst_values = [x["dst"] for x in records] - self.assertTrue(any("Season 01" in x for x in dst_values)) - self.assertTrue(any("S01E03" in x for x in dst_values)) - self.assertTrue(any(".zh-CN.ass" in x for x in dst_values)) - self.assertTrue(all(x["status"] == "dry-run" for x in records)) - - def test_persistent_cache_ttl(self): - aam.Runtime.config.cache_ttl_seconds = 1 - aam.Auxiliary_SetPersistentCache("BGM", "k1", "v1") - self.assertEqual(aam.Auxiliary_GetPersistentCache("BGM", "k1"), "v1") - aam.PersistentApiCache["BGM"]["k1"]["ts"] = time.time() - 3 - self.assertIsNone(aam.Auxiliary_GetPersistentCache("BGM", "k1")) - - def test_rollback_from_log_moves_file_back(self): - src = self.tmp_path / "src.mkv" - dst = self.tmp_path / "dst.mkv" - src.write_text("demo", encoding="utf-8") - src.rename(dst) - - log_file = self.tmp_path / "ops.json" - payload = { - "records": [ - { - "timestamp": "2026-04-06 00:00:00", - "action": "move", - "src": str(src), - "dst": str(dst), - "status": "success", - "message": "", - "backup": "", - } - ] - } - log_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") - aam.Auxiliary_RollbackFromLog(str(log_file)) - - self.assertTrue(src.exists()) - self.assertFalse(dst.exists()) - - def test_start_getargv_rollback_refreshes_runtime_and_skips_operation_log(self): - rollback_log = self.tmp_path / "rollback.json" - rollback_log.write_text('{"records":[]}', encoding="utf-8") - - with patch.object(aam, "argv", ["AutoAnimeMv.py", "rollback", "--log", str(rollback_log)]): - result = aam.Start_GetArgv() - - self.assertEqual(result, str(rollback_log)) - self.assertEqual(aam.RUN_COMMAND, "rollback") - self.assertEqual(aam.Runtime.rollback_log_path, rollback_log) - - aam.Runtime.operation_log_path = self.tmp_path / "logs" / "should_not_exist.json" - aam.Auxiliary_WriteOperationLog() - self.assertFalse(aam.Runtime.operation_log_path.exists()) - - def test_auxiliary_api_uses_json_dict_without_literal_eval(self): - aam.USEOPENAIAPI = False - aam.USEBANGUMIAPI = True - aam.USETMDBAPI = False - with patch.object( - aam, - "Auxiliary_Http", - return_value={"list": [{"name_cn": "葬送的芙莉莲", "name": "Frieren"}]}, - ): - result = aam.Auxiliary_Api("Frieren") - self.assertEqual(result, "葬送的芙莉莲") - - def test_alias_key_normalizes_punctuation_variants_to_same_canonical_title(self): - aam.Auxiliary_UpsertCanonicalTitle( - "命运:奇异赝品", - "Fate/Strange Fake", - "Fate strange Fake", - "Bangumi", - ["Fate:Strange Fake"], - ) - canonical_zh, canonical_id, _ = aam.Auxiliary_ResolveCanonicalTitleByAliases("Fate:Strange Fake") - self.assertEqual(canonical_zh, "命运:奇异赝品") - self.assertIsNotNone(canonical_id) - self.assertEqual( - aam.Auxiliary_NormalizeAliasKey("Fate:Strange Fake"), - aam.Auxiliary_NormalizeAliasKey("Fate/Strange Fake"), - ) - - def test_alias_key_normalizes_ordinal_season_variant(self): - self.assertEqual( - aam.Auxiliary_NormalizeAliasKey("Medalist 2nd Season"), - aam.Auxiliary_NormalizeAliasKey("Medalist Season 2"), - ) - - def test_openai_identify_user_message_strips_leading_bracket_release_tag(self): - class DummyResponse: - status_code = 200 - - def json(self): - return { - "choices": [ - { - "message": { - "content": json.dumps( - { - "anime_name_zh": "测", - "anime_name_en": "Otaku ni Yasashii Gal wa Inai", - "anime_name_romaji": "X", - "season": "1", - "episode": "3", - "special": False, - }, - ensure_ascii=False, - ) - } - } - ] - } - - file_name = "[Tsukigakirei] Otaku ni Yasashii Gal wa Inai - 03.mp4" - aam.OPENAI_IDENTIFY_ALL = True - aam.OPENAI_API_KEY = "test-key" - with patch.object(aam, "post", return_value=DummyResponse()) as mock_post: - aam.Auxiliary_OpenAIIdentifyFileInfo(file_name) - payload = mock_post.call_args[1]["json"] - user_content = payload["messages"][-1]["content"] - self.assertTrue( - user_content.startswith("Otaku"), - msg=f"expected stripped prompt, got: {user_content!r}", - ) - self.assertNotIn("[Tsukigakirei]", user_content) - - def test_openai_full_info_reuses_history_chinese_by_english_or_romaji(self): - class DummyResponse: - status_code = 200 - - def json(self): - return { - "choices": [ - { - "message": { - "content": json.dumps( - { - "anime_name_zh": "", - "anime_name_en": "Sousou no Frieren", - "anime_name_romaji": "Sousou no Frieren", - "season": "2", - "episode": "8", - "special": False, - }, - ensure_ascii=False, - ) - } - } - ] - } - - aam.OPENAI_IDENTIFY_ALL = True - aam.OPENAI_API_KEY = "test-key" - aam.Auxiliary_UpsertCanonicalTitle( - "葬送的芙莉莲", - "Sousou no Frieren", - "Sousou no Frieren", - "Bangumi", - ["Frieren"], - ) - with patch.object(aam, "post", return_value=DummyResponse()): - result = aam.Auxiliary_OpenAIIdentifyFileInfo("[Test] Sousou no Frieren - 08.mkv") - self.assertEqual(result, ("02", "08", "2", "8", "葬送的芙莉莲")) - self.assertEqual(aam.LastOpenAIFileInfoMeta.get("CanonicalZh"), "葬送的芙莉莲") - self.assertEqual(aam.LastOpenAIFileInfoMeta.get("NameEN"), "Sousou no Frieren") - - def test_openai_full_info_prefers_tmdb_chinese_over_ai_chinese(self): - class DummyResponse: - status_code = 200 - - def json(self): - return { - "choices": [ - { - "message": { - "content": json.dumps( - { - "anime_name_zh": "葬送的芙莉莲(旧译名)", - "anime_name_en": "Sousou no Frieren", - "anime_name_romaji": "Sousou no Frieren", - "season": "1", - "episode": "8", - "special": False, - }, - ensure_ascii=False, - ) - } - } - ] - } - - aam.OPENAI_IDENTIFY_ALL = True - aam.OPENAI_API_KEY = "test-key" - with patch.object(aam, "post", return_value=DummyResponse()), patch.object( - aam, "Auxiliary_QueryBangumiChineseTitle", return_value=None - ), patch.object( - aam, "Auxiliary_QueryTMDBChineseTitle", return_value="葬送的芙莉莲" - ): - result = aam.Auxiliary_OpenAIIdentifyFileInfo("[Test] Sousou no Frieren - 08.mkv") - self.assertEqual(result, ("01", "08", "1", "8", "葬送的芙莉莲")) - - def test_openai_full_info_uses_ai_chinese_when_tmdb_not_hit(self): - class DummyResponse: - status_code = 200 - - def json(self): - return { - "choices": [ - { - "message": { - "content": json.dumps( - { - "anime_name_zh": "葬送的芙莉莲", - "anime_name_en": "Sousou no Frieren", - "anime_name_romaji": "Sousou no Frieren", - "season": "1", - "episode": "9", - "special": False, - }, - ensure_ascii=False, - ) - } - } - ] - } - - aam.OPENAI_IDENTIFY_ALL = True - aam.OPENAI_API_KEY = "test-key" - with patch.object(aam, "post", return_value=DummyResponse()), patch.object( - aam, "Auxiliary_QueryBangumiChineseTitle", return_value=None - ), patch.object( - aam, "Auxiliary_QueryTMDBChineseTitle", return_value=None - ): - result = aam.Auxiliary_OpenAIIdentifyFileInfo("[Test] Sousou no Frieren - 09.mkv") - self.assertEqual(result, ("01", "09", "1", "9", "葬送的芙莉莲")) - - def test_openai_full_info_uses_bangumi_chinese_when_ai_chinese_empty(self): - class DummyResponse: - status_code = 200 - - def json(self): - return { - "choices": [ - { - "message": { - "content": json.dumps( - { - "anime_name_zh": "", - "anime_name_en": "Gnosia", - "anime_name_romaji": "Gnosia", - "season": "1", - "episode": "5", - "special": False, - }, - ensure_ascii=False, - ) - } - } - ] - } - - aam.OPENAI_IDENTIFY_ALL = True - aam.OPENAI_API_KEY = "test-key" - with patch.object(aam, "post", return_value=DummyResponse()), patch.object( - aam, "Auxiliary_QueryBangumiChineseTitle", return_value="诺希亚" - ) as mocked_bangumi, patch.object( - aam, "Auxiliary_QueryTMDBChineseTitle", return_value=None - ): - result = aam.Auxiliary_OpenAIIdentifyFileInfo("[LoliHouse] GNOSIA - 05.mkv") - self.assertEqual(result[0:4], ("01", "05", "1", "5")) - self.assertTrue(aam.Auxiliary_HasChineseText(result[4])) - mocked_bangumi.assert_called() - - def test_openai_identify_normalizes_decimal_episode_without_forcing_special(self): - class DummyResponse: - status_code = 200 - - def json(self): - return { - "choices": [ - { - "message": { - "content": json.dumps( - { - "anime_name_zh": "地底奴隶的驯兽师", - "anime_name_en": "Mato Seihei no Slave 2", - "anime_name_romaji": "Mato Seihei no Slave 2", - "season": "2", - "episode": "6.0", - "special": False, - }, - ensure_ascii=False, - ) - } - } - ] - } - - aam.OPENAI_IDENTIFY_ALL = True - aam.OPENAI_API_KEY = "test-key" - with patch.object(aam, "post", return_value=DummyResponse()), patch.object( - aam, "Auxiliary_QueryBangumiChineseTitle", return_value=None - ), patch.object( - aam, "Auxiliary_QueryTMDBChineseTitle", return_value=None - ): - result = aam.Auxiliary_OpenAIIdentifyFileInfo("[LoliHouse] Mato Seihei no Slave 2 - 06.mkv") - self.assertEqual(result[0:4], ("02", "06", "2", "6")) - self.assertTrue(result[4] not in [None, ""]) - - def test_openai_identify_prefers_hint_canonical_when_single_episode_drift(self): - class DummyResponse: - status_code = 200 - - def json(self): - return { - "choices": [ - { - "message": { - "content": json.dumps( - { - "anime_name_zh": "弹丸论破:希望的学园与绝望高中生", - "anime_name_en": "Danganronpa: The Animation", - "anime_name_romaji": "Danganronpa: The Animation", - "season": "1", - "episode": "6", - "special": False, - }, - ensure_ascii=False, - ) - } - } - ] - } - - aam.OPENAI_IDENTIFY_ALL = True - aam.OPENAI_API_KEY = "test-key" - gnosia_cid, _ = aam.Auxiliary_UpsertCanonicalTitle( - "吉诺西亚", - "GNOSIA", - "Gnosia", - "legacy", - ["Gnosia"], - ) - file_name = "[LoliHouse] GNOSIA - 06 [WebRip].mkv" - aam.OpenAIIdentifyFileMemoryCache.pop(file_name, None) - with patch.object(aam, "post", return_value=DummyResponse()), patch.object( - aam, "Auxiliary_QueryBangumiChineseTitle", return_value=None - ), patch.object( - aam, "Auxiliary_QueryTMDBChineseTitle", return_value=None - ), patch.object( - aam, - "Auxiliary_PreDetectEpisodeHint", - return_value={"CanonicalID": gnosia_cid, "EpisodeKey": "stub"}, - ): - result = aam.Auxiliary_OpenAIIdentifyFileInfo(file_name) - self.assertEqual(result[0:4], ("01", "06", "1", "6")) - self.assertNotIn("弹丸", result[4]) - self.assertTrue(aam.Auxiliary_HasChineseText(result[4])) - - def test_processing_main_openai_non_chinese_title_keeps_current_name_without_api_fallback(self): - file_name = "GNOSIA - 01.mkv" - - def fake_ident(_): - aam.LastIdentificationFromAI = True - aam.LastOpenAIFileInfoMeta = { - "NameEN": "GNOSIA", - "NameRomaji": "Gnosia", - "CanonicalID": "gnosia_test_id", - "CanonicalZh": "GNOSIA", - } - return ("01", "01", "1", "1", "GNOSIA") - - with patch.object(aam, "Processing_Identification", side_effect=fake_ident), patch.object( - aam, "Auxiliary_Api", return_value="诺希亚" - ) as mocked_api, patch.object( - aam, "Sorting_Mv", return_value={"status": "success", "message": "", "dst": "dst", "src": "src"} - ) as mocked_sort: - aam.Processing_Main([file_name]) - - mocked_api.assert_not_called() - self.assertEqual(mocked_sort.call_count, 1) - - def test_processing_identification_skips_and_logs_when_openai_identify_fails(self): - warn_path = self.tmp_path / "logs" / "AutoAnime_openai_identify_warnings.json" - with patch.object(aam, "Auxiliary_OpenAIIdentifyFileInfo", return_value=None): - result = aam.Processing_Identification("Fallback Anime - 01.mkv") - self.assertIsNone(result) - self.assertTrue(warn_path.is_file()) - payload = json.loads(warn_path.read_text(encoding="utf-8")) - self.assertIn("records", payload) - self.assertTrue(len(payload["records"]) >= 1) - last = payload["records"][-1] - self.assertIn(last.get("reason", ""), ("openai_identify_returned_none",)) - - def test_openai_coalesce_episode_accepts_integer_zero(self): - self.assertEqual(aam.Auxiliary_CoalesceEpisodeFromParsed({"episode": 0}), "0") - self.assertEqual(aam.Auxiliary_CoalesceEpisodeFromParsed({"ep": 0}), "0") - self.assertEqual(aam.Auxiliary_CoalesceSeasonFromParsed({"season": 0}), "0") - - def test_processing_main_keeps_oldest_file_for_same_episode(self): - old_name = "TestAnime =01= old.mkv" - new_name = "TestAnime =01= new.mkv" - old_file = self.tmp_path / old_name - new_file = self.tmp_path / new_name - old_file.write_text("old", encoding="utf-8") - new_file.write_text("new", encoding="utf-8") - now = time.time() - os.utime(old_file, (now - 200, now - 200)) - os.utime(new_file, (now - 10, now - 10)) - - aam.DRY_RUN = False - aam.NAMING_STYLE = "emby" - aam.USELINK = False - aam.MANDATORYCOVER = True - aam.Auxiliary_InitRuntimeContext() - - def fake_ident(path): - aam.LastIdentificationFromAI = True - aam.LastOpenAIFileInfoMeta = { - "NameEN": "", - "NameRomaji": "", - "CanonicalID": "testanime_id", - "CanonicalZh": "测试番剧", - } - return ("01", "01", "1", "1", "TestAnime") - - with patch.object(aam, "Processing_Identification", side_effect=fake_ident) as mocked_ident: - aam.Processing_Main([new_name, old_name]) - - expected_target = self.tmp_path / "测试番剧" / "Season 01" / "测试番剧 - S01E01.mkv" - self.assertTrue(expected_target.exists()) - self.assertFalse(old_file.exists()) - self.assertTrue(new_file.exists()) - self.assertEqual(mocked_ident.call_count, 2) - self.assertEqual(aam.Runtime.operation_records[-1]["status"], "skipped") - self.assertEqual(aam.Runtime.operation_records[-1]["message"], "already_organized_show_cache") - - def test_jujutsu_openai_cache_record_contracts_title_and_absolute_episode(self): - record = { - "SE": "01", - "EP": "57", - "RAWSE": "1", - "RAWEP": "57", - "RAWName": "咒术回战 怀玉・玉折 / 涩谷事变", - "NameEN": "Jujutsu Kaisen", - "NameRomaji": "Jujutsu Kaisen", - "CanonicalID": "", - } - with patch.object( - aam, - "Auxiliary_RemappedJujutsuKaisenSeasonEpisode", - return_value=("3", "10", "03", "10"), - ): - fixed, updated = aam.Auxiliary_ApplyStandardTitleCacheToFileInfoRecord(record.copy()) - self.assertTrue(updated) - self.assertEqual(fixed["RAWName"], "咒术回战") - self.assertEqual(fixed["RAWSE"], "3") - self.assertEqual(fixed["RAWEP"], "10") - self.assertEqual(fixed["SE"], "03") - self.assertEqual(fixed["EP"], "10") - - def test_normalize_display_title_uses_fullwidth_question_mark(self): - self.assertIn("?", aam.Auxiliary_NormalizeDisplayTitle("abc?")) - - def test_sanitize_path_does_not_turn_question_into_trailing_underscore(self): - name = aam.Auxiliary_SanitizePathComponent("多闻君现在是哪边?") - self.assertIn("?", name) - self.assertFalse(name.endswith("_")) - - def test_sanitize_path_preserves_fullwidth_quotes_in_chinese_title(self): - name = aam.Auxiliary_SanitizePathComponent("公主殿下,“拷问”的时间到了") - self.assertIn("\u201c", name) - self.assertIn("\u201d", name) - self.assertNotIn("公主殿下,_", name) - - def test_tmdb_season_layout_parse_and_absolute_map(self): - details = { - "seasons": [ - {"season_number": 0, "episode_count": 3}, - {"season_number": 1, "episode_count": 24}, - {"season_number": 2, "episode_count": 23}, - {"season_number": 3, "episode_count": 12}, - ] - } - pairs = aam.Auxiliary_ParseTMDBTvDetailsSeasonLayout(details) - self.assertEqual(pairs, [(1, 24), (2, 23), (3, 12)]) - self.assertEqual(aam.Auxiliary_MapAbsoluteEpisodeUsingTMDBSeasonLayout(24, pairs), (1, 24)) - self.assertEqual(aam.Auxiliary_MapAbsoluteEpisodeUsingTMDBSeasonLayout(25, pairs), (2, 1)) - self.assertEqual(aam.Auxiliary_MapAbsoluteEpisodeUsingTMDBSeasonLayout(57, pairs), (3, 10)) - self.assertEqual(aam.Auxiliary_MapAbsoluteEpisodeUsingTMDBSeasonLayout(70, pairs), (3, 23)) - - def test_jujutsu_remap_uses_tmdb_layout_when_available(self): - layout = [(1, 10), (2, 5)] - with patch.object(aam, "Auxiliary_ResolveTMDBTvIdForJujutsuKaisen", return_value=999), patch.object( - aam, "Auxiliary_GetTMDBTvSeasonLayoutBySeriesId", return_value=layout - ): - out = aam.Auxiliary_RemappedJujutsuKaisenSeasonEpisode( - "1", "12", "01", "12", "Jujutsu Kaisen", "Jujutsu Kaisen", "咒术回战" - ) - self.assertEqual(out[0:2], ("2", "2")) diff --git a/tests/test_subtitle_matching.py b/tests/test_subtitle_matching.py deleted file mode 100644 index 29b7029..0000000 --- a/tests/test_subtitle_matching.py +++ /dev/null @@ -1,15 +0,0 @@ -from unittest import TestCase - -import AutoAnimeMv as aam - - -class TestSubtitleMatching(TestCase): - def test_ideass_matches_current_episode_subtitle(self): - ass_list = [ - "Frieren.S01.=03=.chs.ass", - "Frieren.S01.=04=.chs.ass", - "OtherAnime.S01.=03=.chs.ass", - ] - - matched = aam.Auxiliary_IDEASS("Frieren", "S01", "03", ass_list) - self.assertEqual(matched, ["Frieren.S01.=03=.chs.ass"]) diff --git a/tests/test_v3_active_rules.py b/tests/test_v3_active_rules.py new file mode 100644 index 0000000..3cf6c03 --- /dev/null +++ b/tests/test_v3_active_rules.py @@ -0,0 +1,544 @@ +import hashlib +import json +import sqlite3 +import tempfile +import threading +import unittest +from pathlib import Path +from unittest.mock import patch + + +class ActiveRuleIntegrationTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.database = self.root / "web.sqlite3" + self.source = self.root / "downloads" + self.library = self.root / "library" + self.source.mkdir() + self.library.mkdir() + + from autoanime_v3.domain.entities import CreateProfile + from autoanime_v3.services.profiles import ProfileService + from autoanime_v3.services.roots import RootService + + roots = RootService(self.database) + source_root = roots.create_root("source", self.source) + library_root = roots.create_root("library", self.library) + self.profile = ProfileService(self.database).create_profile( + CreateProfile( + name="活动规则", + source_root_id=source_root.id, + library_root_id=library_root.id, + mode="link", + ) + ) + + def tearDown(self): + self.temporary_directory.cleanup() + + def activate(self, rule_set, document): + from autoanime_v3.services.rules import RuleService + + service = RuleService(self.database) + revision = service.create_revision(rule_set.id, document) + return service.activate(service.validate(revision.id).id) + + def set_active_revision_without_staling_plans(self, rule_set_id, revision_id): + connection = sqlite3.connect(str(self.database)) + try: + connection.execute( + "UPDATE rule_sets SET active_revision_id = ? WHERE id = ?", + (revision_id, rule_set_id), + ) + connection.commit() + finally: + connection.close() + + def set_execution_policy_without_revision(self, execution_policy): + connection = sqlite3.connect(str(self.database)) + try: + connection.execute( + "UPDATE scan_profiles SET execution_policy = ? WHERE id = ?", + (execution_policy, self.profile.id), + ) + connection.commit() + finally: + connection.close() + + def execute_job_count(self): + connection = sqlite3.connect(str(self.database)) + try: + return int( + connection.execute( + "SELECT COUNT(*) FROM jobs WHERE job_type = 'execute_plan'" + ).fetchone()[0] + ) + finally: + connection.close() + + def latest_identification(self): + connection = sqlite3.connect(str(self.database)) + connection.row_factory = sqlite3.Row + try: + return connection.execute( + """ + SELECT title, accepted, decision_fingerprint, rule_version + FROM identification_results ORDER BY id DESC LIMIT 1 + """ + ).fetchone() + finally: + connection.close() + + def test_multiple_active_sets_merge_by_set_id_with_later_values_winning(self): + from autoanime_v3.services.rules import RuleService, canonical_document + + service = RuleService(self.database) + first_set = service.create_set("第一组") + second_set = service.create_set("第二组") + first = self.activate( + first_set, + { + "aliases": {"Shared": "第一标题", "First Only": "第一独有"}, + "season_layouts": {"共同标题": [12]}, + "episode_defaults": {"Shared PV": [0, 1]}, + "season_defaults": {"Shared": 1}, + }, + ) + second = self.activate( + second_set, + { + "aliases": {"Shared": "第二标题", "Second Only": "第二独有"}, + "season_layouts": {"共同标题": [12, 12]}, + "episode_defaults": {"Shared PV": [0, 2]}, + "season_defaults": {"Shared": 2}, + }, + ) + + active = service.get_active() + + self.assertEqual(active.revision_ids, (first.id, second.id)) + self.assertEqual(active.document["aliases"]["Shared"], "第二标题") + self.assertEqual(active.document["aliases"]["First Only"], "第一独有") + self.assertEqual(active.document["aliases"]["Second Only"], "第二独有") + self.assertEqual(active.document["season_layouts"]["共同标题"], [12, 12]) + self.assertEqual(active.document["episode_defaults"]["Shared PV"], [0, 2]) + self.assertEqual(active.document["season_defaults"]["Shared"], 2) + self.assertEqual( + active.content_hash, + hashlib.sha256(canonical_document(active.document).encode("utf-8")).hexdigest(), + ) + self.assertEqual(service.get_active(), active) + + def test_no_active_rules_keep_builtin_aliases_with_a_deterministic_version(self): + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import ScanService + + (self.source / "Sousou no Frieren S01E01.mkv").write_bytes(b"builtin-alias") + active = RuleService(self.database).get_active() + outcome = ScanService(self.database).run(self.profile.id) + result = self.latest_identification() + + self.assertEqual(active.revision_ids, ()) + self.assertEqual(result["title"], "葬送的芙莉莲") + self.assertEqual(result["accepted"], 1) + self.assertEqual(result["rule_version"], active.content_hash) + self.assertEqual(PlanService(self.database).get(outcome.plan_id).rule_version, active.content_hash) + + def test_activating_alias_changes_real_scan_resolution_and_decision_version(self): + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import ScanService + + (self.source / "Runtime Alias S01E03.mkv").write_bytes(b"runtime-alias") + scanner = ScanService(self.database) + scanner.run(self.profile.id) + before = self.latest_identification() + + service = RuleService(self.database) + rule_set = service.create_set("扫描别名") + self.activate(rule_set, {"aliases": {"Runtime Alias": "运行时番剧"}}) + active = service.get_active() + outcome = scanner.run(self.profile.id) + after = self.latest_identification() + + self.assertEqual(after["title"], "运行时番剧") + self.assertEqual(after["accepted"], 1) + self.assertEqual(after["rule_version"], active.content_hash) + self.assertEqual(outcome.review_count, 0) + self.assertNotEqual(after["decision_fingerprint"], before["decision_fingerprint"]) + from autoanime_v3.services.plans import PlanService + + self.assertEqual(PlanService(self.database).get(outcome.plan_id).rule_version, active.content_hash) + + def test_rule_switch_after_analysis_persists_a_stale_plan_and_matching_outcome(self): + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import CoreScanAdapter, ScanService + + (self.source / "测试番 S01E01.mkv").write_bytes(b"rule-race") + rules = RuleService(self.database) + rule_set = rules.create_set("扫描竞态规则") + self.activate(rule_set, {"aliases": {"Versioned": "旧标题"}}) + analyzed_version = rules.get_active().content_hash + delegate = CoreScanAdapter(self.database) + test_case = self + + class RuleChangingAdapter: + def analyze(inner_self, source, library, min_confidence): + result = delegate.analyze(source, library, min_confidence) + test_case.activate(rule_set, {"aliases": {"Versioned": "新标题"}}) + return result + + outcome = ScanService(self.database, adapter=RuleChangingAdapter()).run(self.profile.id) + plan = PlanService(self.database).get(outcome.plan_id) + + self.assertEqual(plan.rule_version, analyzed_version) + self.assertNotEqual(plan.rule_version, rules.get_active().content_hash) + self.assertEqual(plan.status, "stale") + self.assertEqual(outcome.plan_status, plan.status) + + def test_rule_switch_after_analysis_is_not_auto_applied_and_reports_db_status(self): + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import CoreScanAdapter, ScanService + + self.set_execution_policy_without_revision("auto_apply_safe") + (self.source / "测试番 S01E01.mkv").write_bytes(b"rule-race-auto") + rules = RuleService(self.database) + rule_set = rules.create_set("自动应用竞态规则") + self.activate(rule_set, {"aliases": {"Versioned": "旧标题"}}) + delegate = CoreScanAdapter(self.database) + test_case = self + + class RuleChangingAdapter: + def analyze(inner_self, source, library, min_confidence): + result = delegate.analyze(source, library, min_confidence) + test_case.activate(rule_set, {"aliases": {"Versioned": "新标题"}}) + return result + + outcome = ScanService(self.database, adapter=RuleChangingAdapter()).run(self.profile.id) + plan = PlanService(self.database).get(outcome.plan_id) + + self.assertEqual(plan.status, "stale") + self.assertEqual(outcome.plan_status, plan.status) + self.assertEqual(self.execute_job_count(), 0) + + def test_explicit_wrong_rule_version_stales_approve_even_when_db_matches(self): + from autoanime_v3.domain.errors import StalePlanError + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.scans import ScanService + + (self.source / "测试番 S01E01.mkv").write_bytes(b"approve-caller-version") + outcome = ScanService(self.database).run(self.profile.id) + plans = PlanService(self.database) + + with self.assertRaises(StalePlanError): + plans.approve( + outcome.plan_id, + current_rule_version="caller-supplied-wrong-version", + ) + + self.assertEqual(plans.get(outcome.plan_id).status, "stale") + + def test_explicit_wrong_rule_version_stales_approve_and_enqueue_even_when_db_matches(self): + from autoanime_v3.domain.errors import StalePlanError + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import ScanService + + (self.source / "测试番 S01E01.mkv").write_bytes(b"caller-version") + outcome = ScanService(self.database).run(self.profile.id) + plans = PlanService(self.database) + plan = plans.get(outcome.plan_id) + self.assertEqual(plan.rule_version, RuleService(self.database).get_active().content_hash) + + with self.assertRaises(StalePlanError): + plans.approve_and_enqueue( + plan.id, + current_rule_version="caller-supplied-wrong-version", + ) + + self.assertEqual(plans.get(plan.id).status, "stale") + + def test_explicit_wrong_rule_version_stales_auto_apply_safe_even_when_db_matches(self): + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import ScanService + + (self.source / "测试番 S01E01.mkv").write_bytes(b"automatic-caller-version") + outcome = ScanService(self.database).run(self.profile.id) + self.set_execution_policy_without_revision("auto_apply_safe") + plans = PlanService(self.database) + plan = plans.get(outcome.plan_id) + self.assertEqual(plan.rule_version, RuleService(self.database).get_active().content_hash) + + automatic = plans.auto_apply_safe( + plan.id, + current_rule_version="caller-supplied-wrong-version", + ) + + self.assertIsNone(automatic) + self.assertEqual(plans.get(plan.id).status, "stale") + + def test_correct_caller_version_still_stales_approval_when_db_active_rules_changed(self): + from autoanime_v3.domain.errors import StalePlanError + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import ScanService + + rules = RuleService(self.database) + rule_set = rules.create_set("数据库版本优先") + self.activate(rule_set, {"aliases": {"Versioned": "旧标题"}}) + (self.source / "测试番 S01E01.mkv").write_bytes(b"database-version") + outcome = ScanService(self.database).run(self.profile.id) + plans = PlanService(self.database) + plan = plans.get(outcome.plan_id) + newer = rules.create_revision(rule_set.id, {"aliases": {"Versioned": "新标题"}}) + newer = rules.validate(newer.id) + self.set_active_revision_without_staling_plans(rule_set.id, newer.id) + self.assertEqual(plans.get(plan.id).status, "ready") + + with self.assertRaises(StalePlanError): + plans.approve(plan.id, current_rule_version=plan.rule_version) + + self.assertEqual(plans.get(plan.id).status, "stale") + + def test_correct_caller_version_still_stales_enqueue_when_db_active_rules_changed(self): + from autoanime_v3.domain.errors import StalePlanError + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import ScanService + + rules = RuleService(self.database) + rule_set = rules.create_set("入队数据库版本优先") + self.activate(rule_set, {"aliases": {"Versioned": "旧标题"}}) + (self.source / "测试番 S01E01.mkv").write_bytes(b"enqueue-database-version") + outcome = ScanService(self.database).run(self.profile.id) + plans = PlanService(self.database) + plan = plans.get(outcome.plan_id) + newer = rules.validate( + rules.create_revision(rule_set.id, {"aliases": {"Versioned": "新标题"}}).id + ) + self.set_active_revision_without_staling_plans(rule_set.id, newer.id) + + with self.assertRaises(StalePlanError): + plans.approve_and_enqueue( + plan.id, + current_rule_version=plan.rule_version, + ) + + self.assertEqual(plans.get(plan.id).status, "stale") + + def test_correct_caller_version_still_stales_auto_apply_when_db_active_rules_changed(self): + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import ScanService + + rules = RuleService(self.database) + rule_set = rules.create_set("自动应用数据库版本优先") + self.activate(rule_set, {"aliases": {"Versioned": "旧标题"}}) + (self.source / "测试番 S01E01.mkv").write_bytes(b"auto-database-version") + outcome = ScanService(self.database).run(self.profile.id) + self.set_execution_policy_without_revision("auto_apply_safe") + plans = PlanService(self.database) + plan = plans.get(outcome.plan_id) + newer = rules.validate( + rules.create_revision(rule_set.id, {"aliases": {"Versioned": "新标题"}}).id + ) + self.set_active_revision_without_staling_plans(rule_set.id, newer.id) + + automatic = plans.auto_apply_safe( + plan.id, + current_rule_version=plan.rule_version, + ) + + self.assertIsNone(automatic) + self.assertEqual(plans.get(plan.id).status, "stale") + self.assertEqual(self.execute_job_count(), 0) + + def test_activating_new_revision_marks_old_ready_plan_stale_and_approval_fails(self): + from autoanime_v3.domain.errors import StalePlanError + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import ScanService + + (self.source / "测试番 S01E01.mkv").write_bytes(b"ready-plan") + outcome = ScanService(self.database).run(self.profile.id) + plans = PlanService(self.database) + self.assertEqual(plans.get(outcome.plan_id).status, "ready") + + rules = RuleService(self.database) + rule_set = rules.create_set("计划规则") + self.activate(rule_set, {"aliases": {"unused": "未使用"}}) + + self.assertEqual(plans.get(outcome.plan_id).status, "stale") + with self.assertRaises(StalePlanError): + plans.approve(outcome.plan_id) + self.assertEqual(plans.get(outcome.plan_id).status, "stale") + + def test_rule_switch_after_approval_prevents_execution_without_file_changes(self): + from autoanime_v3.domain.errors import StalePlanError + from autoanime_v3.services.operations import OperationService + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import ScanService + + (self.source / "测试番 S01E01.mkv").write_bytes(b"approved-plan") + rules = RuleService(self.database) + rule_set = rules.create_set("执行规则") + self.activate(rule_set, {"aliases": {"Versioned": "旧标题"}}) + outcome = ScanService(self.database).run(self.profile.id) + approved = PlanService(self.database).approve(outcome.plan_id) + destination = Path(approved.items[0].destination_path) + + self.activate(rule_set, {"aliases": {"Versioned": "新标题"}}) + + with self.assertRaises(StalePlanError): + OperationService(self.database).execute(approved.id) + self.assertFalse(destination.exists()) + self.assertEqual(PlanService(self.database).get(approved.id).status, "stale") + + def test_rule_activation_does_not_rewrite_completed_plan_history(self): + from autoanime_v3.services.operations import OperationService + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import ScanService + + (self.source / "测试番 S01E01.mkv").write_bytes(b"completed-plan") + outcome = ScanService(self.database).run(self.profile.id) + approved = PlanService(self.database).approve(outcome.plan_id) + OperationService(self.database).execute(approved.id) + + rules = RuleService(self.database) + rule_set = rules.create_set("历史规则") + self.activate(rule_set, {"aliases": {"unused": "不会改历史"}}) + + self.assertEqual(PlanService(self.database).get(approved.id).status, "completed") + + def test_rule_activation_during_execution_keeps_completed_batch_but_plan_stale(self): + from autoanime_v3.services import operations as operations_module + from autoanime_v3.services.operations import OperationService + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import ScanService + + (self.source / "测试番 S01E01.mkv").write_bytes(b"concurrent-complete") + rules = RuleService(self.database) + rule_set = rules.create_set("并发成功规则") + self.activate(rule_set, {"aliases": {"Versioned": "旧标题"}}) + outcome = ScanService(self.database).run(self.profile.id) + approved = PlanService(self.database).approve(outcome.plan_id) + destination = Path(approved.items[0].destination_path) + claimed = threading.Event() + release = threading.Event() + result = {} + original_execute_plan = operations_module.execute_plan + + def paused_execute_plan(*args, **kwargs): + claimed.set() + if not release.wait(5): + raise TimeoutError("test did not release execution") + return original_execute_plan(*args, **kwargs) + + def execute(): + try: + result["batch"] = OperationService(self.database).execute(approved.id) + except Exception as error: + result["error"] = error + + with patch.object(operations_module, "execute_plan", side_effect=paused_execute_plan): + worker = threading.Thread(target=execute) + worker.start() + self.assertTrue(claimed.wait(5)) + self.assertEqual(PlanService(self.database).get(approved.id).status, "executing") + self.activate(rule_set, {"aliases": {"Versioned": "新标题"}}) + self.assertEqual(PlanService(self.database).get(approved.id).status, "stale") + release.set() + worker.join(10) + + self.assertFalse(worker.is_alive()) + self.assertNotIn("error", result) + self.assertEqual(result["batch"].status, "completed") + self.assertEqual(PlanService(self.database).get(approved.id).status, "stale") + self.assertTrue(destination.exists()) + + def test_rule_activation_during_failed_execution_preserves_stale_plan(self): + from autoanime_v3.services import operations as operations_module + from autoanime_v3.services.operations import OperationService + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import ScanService + + (self.source / "测试番 S01E01.mkv").write_bytes(b"concurrent-failure") + rules = RuleService(self.database) + rule_set = rules.create_set("并发失败规则") + self.activate(rule_set, {"aliases": {"Versioned": "旧标题"}}) + outcome = ScanService(self.database).run(self.profile.id) + approved = PlanService(self.database).approve(outcome.plan_id) + claimed = threading.Event() + release = threading.Event() + result = {} + + def failed_execute_plan(*args, **kwargs): + claimed.set() + if not release.wait(5): + raise TimeoutError("test did not release execution") + raise RuntimeError("controlled execution failure") + + def execute(): + try: + OperationService(self.database).execute(approved.id) + except Exception as error: + result["error"] = error + + with patch.object(operations_module, "execute_plan", side_effect=failed_execute_plan): + worker = threading.Thread(target=execute) + worker.start() + self.assertTrue(claimed.wait(5)) + self.activate(rule_set, {"aliases": {"Versioned": "新标题"}}) + self.assertEqual(PlanService(self.database).get(approved.id).status, "stale") + release.set() + worker.join(10) + + self.assertFalse(worker.is_alive()) + self.assertIsInstance(result.get("error"), RuntimeError) + connection = sqlite3.connect(str(self.database)) + try: + batch_status = connection.execute( + "SELECT status FROM operation_batches ORDER BY id DESC LIMIT 1" + ).fetchone()[0] + finally: + connection.close() + self.assertEqual(batch_status, "failed_rolled_back") + self.assertEqual(PlanService(self.database).get(approved.id).status, "stale") + + def test_rollback_restores_previous_rule_version_for_new_scans(self): + from autoanime_v3.services.rules import RuleService + from autoanime_v3.services.scans import ScanService + + (self.source / "Rollback Alias S01E02.mkv").write_bytes(b"rollback-alias") + rules = RuleService(self.database) + rule_set = rules.create_set("回滚规则") + first = self.activate(rule_set, {"aliases": {"Rollback Alias": "旧版标题"}}) + scanner = ScanService(self.database) + scanner.run(self.profile.id) + old_result = self.latest_identification() + + self.activate(rule_set, {"aliases": {"Rollback Alias": "新版标题"}}) + scanner.run(self.profile.id) + new_result = self.latest_identification() + + rules.rollback(rule_set.id, first.id) + scanner.run(self.profile.id) + restored_result = self.latest_identification() + + self.assertEqual(old_result["title"], "旧版标题") + self.assertEqual(new_result["title"], "新版标题") + self.assertEqual(restored_result["title"], "旧版标题") + self.assertEqual(restored_result["rule_version"], old_result["rule_version"]) + self.assertNotEqual(restored_result["rule_version"], new_result["rule_version"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_api.py b/tests/test_v3_api.py new file mode 100644 index 0000000..672f94b --- /dev/null +++ b/tests/test_v3_api.py @@ -0,0 +1,160 @@ +import tempfile +import unittest +from pathlib import Path + +from fastapi.testclient import TestClient + +from autoanime_v3.services.auth import DEFAULT_ADMIN_PASSWORD, DEFAULT_ADMIN_USERNAME + + +class ApiTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + root = Path(self.temporary_directory.name) + from autoanime_v3.api.app import ServerSettings, create_app + + self.settings = ServerSettings( + database_path=root / "web.sqlite3", + data_directory=root, + secure_cookies=False, + ) + self.app = create_app(self.settings) + self.client = TestClient(self.app, client=("127.0.0.1", 50000)) + + def tearDown(self): + self.client.close() + self.temporary_directory.cleanup() + + def login_default(self): + response = self.client.post( + "/api/v1/auth/login", + json={"username": DEFAULT_ADMIN_USERNAME, "password": DEFAULT_ADMIN_PASSWORD}, + ) + self.assertEqual(response.status_code, 200) + return response.json()["csrf_token"] + + def test_health_default_login_me_and_logout(self): + self.assertEqual(self.client.get("/health/live").json()["status"], "live") + self.assertEqual(self.client.get("/health/ready").json()["status"], "ready") + status = self.client.get("/api/v1/auth/bootstrap-status").json() + self.assertTrue(status["configured"]) + self.assertTrue(status["local_bypass"]) + self.assertTrue(status["can_local_login"]) + csrf = self.login_default() + cookie = self.client.cookies.get("autoanime_session") + self.assertTrue(cookie) + me = self.client.get("/api/v1/auth/me") + self.assertEqual(me.json()["username"], DEFAULT_ADMIN_USERNAME) + logout = self.client.post("/api/v1/auth/logout", headers={"X-CSRF-Token": csrf}) + self.assertEqual(logout.status_code, 204) + self.assertEqual(self.client.get("/api/v1/auth/me").status_code, 401) + + def test_local_session_on_loopback(self): + response = self.client.post("/api/v1/auth/local-session") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["user"]["username"], DEFAULT_ADMIN_USERNAME) + me = self.client.get("/api/v1/auth/me") + self.assertEqual(me.status_code, 200) + + def test_remote_client_cannot_use_local_session(self): + with TestClient(self.app, client=("203.0.113.10", 50000)) as remote: + response = remote.post("/api/v1/auth/local-session") + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["code"], "local_only") + remote_status = remote.get("/api/v1/auth/bootstrap-status").json() + self.assertTrue(remote_status["configured"]) + self.assertFalse(remote_status["local_client"]) + self.assertFalse(remote_status["can_local_login"]) + + def test_local_bypass_can_be_disabled(self): + csrf = self.login_default() + settings = self.client.get("/api/v1/settings").json() + revision = settings["security"]["local_bypass_revision"] + disabled = self.client.patch( + "/api/v1/settings", + json={"key": "auth.local_bypass", "value": False, "revision": revision}, + headers={"X-CSRF-Token": csrf}, + ) + self.assertEqual(disabled.status_code, 200) + self.client.cookies.clear() + blocked = self.client.post("/api/v1/auth/local-session") + self.assertEqual(blocked.status_code, 401) + status = self.client.get("/api/v1/auth/bootstrap-status").json() + self.assertFalse(status["can_local_login"]) + + def test_authenticated_root_creation_and_listing(self): + csrf = self.login_default() + source = Path(self.temporary_directory.name) / "source" + source.mkdir() + created = self.client.post( + "/api/v1/roots", + json={"kind": "source", "path": str(source)}, + headers={"X-CSRF-Token": csrf}, + ) + self.assertEqual(created.status_code, 201) + roots = self.client.get("/api/v1/roots") + self.assertEqual(len(roots.json()["items"]), 1) + + def test_local_hook_requires_loopback_and_enabled_profile(self): + csrf = self.login_default() + source = Path(self.temporary_directory.name) / "hook-source" + library = Path(self.temporary_directory.name) / "hook-library" + source.mkdir() + library.mkdir() + source_id = self.client.post( + "/api/v1/roots", + json={"kind": "source", "path": str(source)}, + headers={"X-CSRF-Token": csrf}, + ).json()["id"] + library_id = self.client.post( + "/api/v1/roots", + json={"kind": "library", "path": str(library)}, + headers={"X-CSRF-Token": csrf}, + ).json()["id"] + self.client.post( + "/api/v1/profiles", + json={ + "name": "hook-profile", + "source_root_id": source_id, + "library_root_id": library_id, + }, + headers={"X-CSRF-Token": csrf}, + ) + media = source / "show S01E01.mkv" + media.write_bytes(b"x" * 1024) + accepted = self.client.post( + "/api/v1/hooks/local", + json={"path": str(media)}, + ) + self.assertEqual(accepted.status_code, 202) + with TestClient(self.app, client=("203.0.113.10", 50000)) as remote: + rejected = remote.post("/api/v1/hooks/local", json={"path": str(media)}) + self.assertEqual(rejected.status_code, 403) + self.assertEqual(rejected.json()["code"], "local_only") + + def test_remote_client_cannot_claim_first_administrator_when_empty(self): + empty_root = Path(self.temporary_directory.name) / "empty" + empty_root.mkdir() + from autoanime_v3.api.app import ServerSettings, ServiceContainer, create_app + + settings = ServerSettings( + database_path=empty_root / "web.sqlite3", + data_directory=empty_root, + secure_cookies=False, + secret_provider="file", + ) + services = ServiceContainer.build(settings) + import sqlite3 + + connection = sqlite3.connect(str(settings.database_path)) + connection.execute("DELETE FROM users") + connection.commit() + connection.close() + app = create_app(settings, services=services) + with TestClient(app, client=("203.0.113.10", 50000)) as remote: + response = remote.post( + "/api/v1/auth/bootstrap", + json={"username": "attacker", "password": "Correct Horse Battery Staple!42"}, + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["code"], "bootstrap_local_only") diff --git a/tests/test_v3_api_management.py b/tests/test_v3_api_management.py new file mode 100644 index 0000000..bb24fb1 --- /dev/null +++ b/tests/test_v3_api_management.py @@ -0,0 +1,349 @@ +import tempfile +import unittest +from pathlib import Path + +from fastapi.testclient import TestClient + + +class ApiManagementTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + from autoanime_v3.api.app import ServerSettings, create_app + + self.settings = ServerSettings( + database_path=self.root / "web.sqlite3", + data_directory=self.root, + secure_cookies=False, + ) + self.client = TestClient( + create_app(self.settings), client=("127.0.0.1", 50000) + ) + + def tearDown(self): + self.client.close() + self.temporary_directory.cleanup() + + def login(self): + from autoanime_v3.services.auth import DEFAULT_ADMIN_PASSWORD, DEFAULT_ADMIN_USERNAME + + response = self.client.post( + "/api/v1/auth/login", + json={"username": DEFAULT_ADMIN_USERNAME, "password": DEFAULT_ADMIN_PASSWORD}, + ) + self.assertEqual(response.status_code, 200) + return {"X-CSRF-Token": response.json()["csrf_token"]} + + def test_bootstrap_status_distinguishes_first_run_from_logged_out(self): + status = self.client.get("/api/v1/auth/bootstrap-status").json() + self.assertTrue(status["configured"]) + self.assertTrue(status["local_bypass"]) + self.assertTrue(status["local_client"]) + self.assertTrue(status["can_local_login"]) + self.login() + status = self.client.get("/api/v1/auth/bootstrap-status").json() + self.assertTrue(status["configured"]) + + def test_settings_update_uses_revisions_and_returns_json_values(self): + headers = self.login() + created = self.client.patch( + "/api/v1/settings", + json={"key": "backup.retention_days", "value": 14, "revision": 0}, + headers=headers, + ) + self.assertEqual(created.status_code, 200) + self.assertEqual(created.json()["value"], 14) + self.assertEqual(created.json()["revision"], 1) + + conflict = self.client.patch( + "/api/v1/settings", + json={"key": "backup.retention_days", "value": 30, "revision": 0}, + headers=headers, + ) + self.assertEqual(conflict.status_code, 409) + listed = { + item["key"]: item["value"] + for item in self.client.get("/api/v1/settings").json()["items"] + } + self.assertEqual(listed["backup.retention_days"], 14) + self.assertIn("auth.local_bypass", listed) + self.assertTrue(listed["auth.local_bypass"]) + + def test_schedule_and_webhook_management_and_anonymous_downloader_hook(self): + headers = self.login() + source = self.root / "automation-source" + library = self.root / "automation-library" + source.mkdir() + library.mkdir() + source_id = self.client.post( + "/api/v1/roots", json={"kind": "source", "path": str(source)}, headers=headers + ).json()["id"] + library_id = self.client.post( + "/api/v1/roots", json={"kind": "library", "path": str(library)}, headers=headers + ).json()["id"] + profile = self.client.post( + "/api/v1/profiles", + json={"name": "自动化", "source_root_id": source_id, "library_root_id": library_id}, + headers=headers, + ).json() + + missing_csrf = self.client.post( + "/api/v1/schedules", + json={"profile_id": profile["id"], "kind": "interval", "schedule": {"interval_minutes": 5}, "timezone": "UTC"}, + ) + self.assertEqual(missing_csrf.status_code, 403) + schedule = self.client.post( + "/api/v1/schedules", + json={"profile_id": profile["id"], "kind": "interval", "schedule": {"interval_minutes": 5}, "timezone": "UTC"}, + headers=headers, + ) + self.assertEqual(schedule.status_code, 201) + self.assertEqual(self.client.get("/api/v1/schedules").json()["items"][0]["revision"], 1) + + created = self.client.post( + "/api/v1/webhook-sources", + json={"name": "qBittorrent", "downloader": "qbittorrent", "profile_id": profile["id"]}, + headers=headers, + ) + self.assertEqual(created.status_code, 201) + token = created.json()["token"] + listing = self.client.get("/api/v1/webhook-sources").json()["items"][0] + self.assertNotIn("token", listing) + self.assertNotIn("token_hash", listing) + + target = source / "completed.mkv" + target.write_bytes(b"complete") + accepted = self.client.post( + "/api/v1/hooks/downloaders/%s" % token, + json={"path": str(target)}, + ) + self.assertEqual(accepted.status_code, 202) + self.assertEqual(accepted.json()["payload"]["paths"], [str(target.resolve())]) + + disabled = self.client.patch( + "/api/v1/webhook-sources/%s" % created.json()["id"], + json={"revision": created.json()["revision"], "patch": {"enabled": False}}, + headers=headers, + ) + self.assertEqual(disabled.status_code, 200) + rejected = self.client.post( + "/api/v1/hooks/downloaders/%s" % token, + json={"paths": [str(target)]}, + ) + self.assertEqual(rejected.status_code, 404) + + def test_storage_root_can_be_disabled_and_revalidated(self): + headers = self.login() + source = self.root / "source" + source.mkdir() + created = self.client.post( + "/api/v1/roots", + json={"kind": "source", "path": str(source)}, + headers=headers, + ).json() + updated = self.client.patch( + "/api/v1/roots/%s" % created["id"], + json={"patch": {"enabled": False}}, + headers=headers, + ) + self.assertEqual(updated.status_code, 200) + self.assertFalse(updated.json()["enabled"]) + health = self.client.post( + "/api/v1/roots/%s/validate" % created["id"], headers=headers + ) + self.assertEqual(health.json()["health_status"], "healthy") + + def test_reenabling_root_rechecks_source_library_overlap(self): + headers = self.login() + source = self.root / "overlap-source" + library = source / "library" + library.mkdir(parents=True) + source_root = self.client.post( + "/api/v1/roots", json={"kind": "source", "path": str(source)}, headers=headers + ).json() + self.client.patch( + "/api/v1/roots/%s" % source_root["id"], + json={"patch": {"enabled": False}}, + headers=headers, + ) + created_library = self.client.post( + "/api/v1/roots", json={"kind": "library", "path": str(library)}, headers=headers + ) + self.assertEqual(created_library.status_code, 201) + unsafe = self.client.patch( + "/api/v1/roots/%s" % source_root["id"], + json={"patch": {"enabled": True}}, + headers=headers, + ) + self.assertEqual(unsafe.status_code, 409) + self.assertEqual(unsafe.json()["code"], "unsafe_root") + wrong_type = self.client.patch( + "/api/v1/roots/%s" % source_root["id"], + json={"patch": {"enabled": "false"}}, + headers=headers, + ) + self.assertEqual(wrong_type.status_code, 422) + + def test_profile_patch_rejects_invalid_modes_instead_of_persisting_them(self): + headers = self.login() + source = self.root / "profile-source" + library = self.root / "profile-library" + source.mkdir() + library.mkdir() + source_id = self.client.post( + "/api/v1/roots", json={"kind": "source", "path": str(source)}, headers=headers + ).json()["id"] + library_id = self.client.post( + "/api/v1/roots", json={"kind": "library", "path": str(library)}, headers=headers + ).json()["id"] + profile = self.client.post( + "/api/v1/profiles", + json={"name": "默认", "source_root_id": source_id, "library_root_id": library_id}, + headers=headers, + ).json() + invalid = self.client.patch( + "/api/v1/profiles/%s" % profile["id"], + json={"revision": profile["revision"], "patch": {"mode": "overwrite"}}, + headers=headers, + ) + self.assertEqual(invalid.status_code, 422) + invalid_number = self.client.patch( + "/api/v1/profiles/%s" % profile["id"], + json={"revision": profile["revision"], "patch": {"min_confidence": "high"}}, + headers=headers, + ) + self.assertEqual(invalid_number.status_code, 422) + persisted = self.client.get("/api/v1/profiles").json()["items"][0] + self.assertEqual(persisted["mode"], "link") + + def test_rule_revision_lifecycle_is_available_through_api(self): + headers = self.login() + rule_set = self.client.post( + "/api/v1/rules", + json={"name": "默认别名"}, + headers=headers, + ) + self.assertEqual(rule_set.status_code, 201) + revision = self.client.post( + "/api/v1/rules/revisions", + json={ + "rule_set_id": rule_set.json()["id"], + "document": {"aliases": {"Frieren": "葬送的芙莉莲"}}, + }, + headers=headers, + ) + self.assertEqual(revision.status_code, 201) + validated = self.client.post( + "/api/v1/rules/revisions/%s/validate" % revision.json()["id"], + headers=headers, + ) + self.assertEqual(validated.json()["status"], "validated") + active = self.client.post( + "/api/v1/rules/revisions/%s/activate" % revision.json()["id"], + headers=headers, + ) + self.assertEqual(active.json()["status"], "active") + listing = self.client.get("/api/v1/rules").json()["items"] + self.assertEqual(listing[0]["revisions"][0]["document"]["aliases"]["Frieren"], "葬送的芙莉莲") + + def test_show_correction_can_be_previewed_and_applied_through_api(self): + headers = self.login() + from autoanime_v3.services.changes import ChangeService + + show = ChangeService(self.settings.database_path).create_show("旧标题") + preview = self.client.post( + "/api/v1/library/changes/preview", + json={ + "show_id": show.id, + "base_revision": show.revision, + "patch": {"canonical_title": "新标题", "title_locked": True}, + "reason": "人工纠正", + }, + headers=headers, + ) + self.assertEqual(preview.status_code, 201) + applied = self.client.post( + "/api/v1/library/changes/%s/approve" % preview.json()["id"], + headers=headers, + ) + self.assertEqual(applied.json()["canonical_title"], "新标题") + self.assertTrue(applied.json()["title_locked"]) + + def test_static_frontend_falls_back_to_index_for_client_routes(self): + self.client.close() + frontend = self.root / "frontend" + frontend.mkdir() + (frontend / "index.html").write_text("AutoAnime", encoding="utf-8") + from autoanime_v3.api.app import ServerSettings, create_app + + settings = ServerSettings( + database_path=self.root / "spa.sqlite3", + data_directory=self.root / "spa-data", + secure_cookies=False, + frontend_directory=frontend, + ) + self.client = TestClient( + create_app(settings), client=("127.0.0.1", 50000) + ) + response = self.client.get("/profiles") + self.assertEqual(response.status_code, 200) + self.assertIn("AutoAnime", response.text) + + def test_review_resolution_validation_has_stable_422_envelope(self): + headers = self.login() + source = self.root / "review-source" + library = self.root / "review-library" + source.mkdir() + library.mkdir() + from autoanime_v3.domain.entities import CreateProfile + from autoanime_v3.services.profiles import ProfileService + from autoanime_v3.services.reviews import ReviewService + from autoanime_v3.services.roots import RootService + from autoanime_v3.services.scans import ScanService + + roots = RootService(self.settings.database_path) + source_root = roots.create_root("source", source) + library_root = roots.create_root("library", library) + profile = ProfileService(self.settings.database_path).create_profile( + CreateProfile( + name="审核验证", + source_root_id=source_root.id, + library_root_id=library_root.id, + ) + ) + (source / "Unknown Show - 02.mkv").write_bytes(b"review-media") + ScanService(self.settings.database_path).run(profile.id) + review = ReviewService(self.settings.database_path).list_open()[0] + + response = self.client.post( + "/api/v1/reviews/%s/resolve" % review.id, + json={"resolution": {"title": "番剧", "media_type": "episode", "episode": 2}}, + headers=headers, + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual(response.json()["code"], "validation_error") + self.assertEqual(response.json()["details"]["field"], "season") + + for invalid_resolution in ([], "not-an-object", None): + with self.subTest(resolution=invalid_resolution): + response = self.client.post( + "/api/v1/reviews/%s/resolve" % review.id, + json={"resolution": invalid_resolution}, + headers={**headers, "X-Trace-ID": "review-resolution-validation"}, + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json(), + { + "code": "validation_error", + "message": "Resolution must be an object", + "details": {"field": "resolution"}, + "trace_id": "review-resolution-validation", + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_api_security.py b/tests/test_v3_api_security.py new file mode 100644 index 0000000..e091fa8 --- /dev/null +++ b/tests/test_v3_api_security.py @@ -0,0 +1,55 @@ +import tempfile +import unittest +from pathlib import Path + +from fastapi.testclient import TestClient + + +class ApiSecurityTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + root = Path(self.temporary_directory.name) + from autoanime_v3.api.app import ServerSettings, create_app + from autoanime_v3.services.auth import DEFAULT_ADMIN_PASSWORD, DEFAULT_ADMIN_USERNAME + + self.client = TestClient( + create_app( + ServerSettings(root / "web.sqlite3", root, secure_cookies=False) + ), + client=("127.0.0.1", 50000), + ) + login = self.client.post( + "/api/v1/auth/login", + json={"username": DEFAULT_ADMIN_USERNAME, "password": DEFAULT_ADMIN_PASSWORD}, + ) + self.csrf = login.json()["csrf_token"] + + def tearDown(self): + self.client.close() + self.temporary_directory.cleanup() + + def test_error_envelope_and_csrf_rejection(self): + response = self.client.post( + "/api/v1/roots", json={"kind": "source", "path": "C:/missing-csrf"} + ) + self.assertEqual(response.status_code, 403) + body = response.json() + self.assertEqual(body["code"], "csrf_validation_failed") + self.assertTrue(body["trace_id"]) + + def test_secret_update_returns_status_only(self): + response = self.client.put( + "/api/v1/settings/secrets/metadata.api_key", + json={"value": "never-return-this-value"}, + headers={"X-CSRF-Token": self.csrf}, + ) + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertTrue(body["configured"]) + serialized = response.text + self.assertNotIn("never-return-this-value", serialized) + self.assertNotIn("ciphertext", serialized) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_auth_security.py b/tests/test_v3_auth_security.py new file mode 100644 index 0000000..d63712f --- /dev/null +++ b/tests/test_v3_auth_security.py @@ -0,0 +1,133 @@ +import sqlite3 +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +class MutableClock: + def __init__(self): + self.value = datetime(2026, 7, 25, 8, 0, tzinfo=timezone.utc) + + def __call__(self): + return self.value + + def advance(self, **kwargs): + self.value += timedelta(**kwargs) + + +class AuthSecurityTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.database = self.root / "web.sqlite3" + self.clock = MutableClock() + + def tearDown(self): + self.temporary_directory.cleanup() + + def auth_service(self, ttl_seconds=3600): + from autoanime_v3.services.auth import AuthService + + return AuthService(self.database, clock=self.clock, session_ttl_seconds=ttl_seconds) + + def test_bootstrap_creates_exactly_one_administrator(self): + from autoanime_v3.domain.errors import AlreadyBootstrappedError + + service = self.auth_service() + admin = service.bootstrap_admin("admin", "Correct Horse Battery Staple!42") + self.assertEqual(admin.username, "admin") + self.assertTrue(admin.is_active) + self.assertFalse(hasattr(admin, "password_hash")) + with self.assertRaises(AlreadyBootstrappedError): + service.bootstrap_admin("second", "Another Strong Password!42") + + def test_login_returns_random_session_and_never_exposes_hashes(self): + service = self.auth_service() + service.bootstrap_admin("admin", "Correct Horse Battery Staple!42") + + first = service.login("admin", "Correct Horse Battery Staple!42", "127.0.0.1", "test") + second = service.login("admin", "Correct Horse Battery Staple!42", "127.0.0.1", "test") + + self.assertNotEqual(first.session_token, second.session_token) + self.assertNotEqual(first.csrf_token, second.csrf_token) + self.assertFalse(hasattr(first.user, "password_hash")) + connection = sqlite3.connect(str(self.database)) + try: + stored = connection.execute( + "SELECT token_hash, csrf_hash FROM user_sessions ORDER BY id" + ).fetchall() + finally: + connection.close() + self.assertNotIn(first.session_token, {row[0] for row in stored}) + self.assertNotIn(first.csrf_token, {row[1] for row in stored}) + + def test_expired_or_revoked_session_is_rejected(self): + from autoanime_v3.domain.errors import AuthenticationError + + service = self.auth_service(ttl_seconds=30) + service.bootstrap_admin("admin", "Correct Horse Battery Staple!42") + expired = service.login("admin", "Correct Horse Battery Staple!42") + self.clock.advance(seconds=31) + with self.assertRaises(AuthenticationError): + service.authenticate(expired.session_token) + + current = service.login("admin", "Correct Horse Battery Staple!42") + service.logout(current.session_token) + with self.assertRaises(AuthenticationError): + service.authenticate(current.session_token) + + def test_state_changing_request_requires_matching_csrf_token(self): + from autoanime_v3.domain.errors import CsrfValidationError + + service = self.auth_service() + service.bootstrap_admin("admin", "Correct Horse Battery Staple!42") + session = service.login("admin", "Correct Horse Battery Staple!42") + + authenticated = service.require_csrf(session.session_token, session.csrf_token) + self.assertEqual(authenticated.username, "admin") + with self.assertRaises(CsrfValidationError): + service.require_csrf(session.session_token, "wrong-token") + + def test_repeated_login_failures_are_temporarily_throttled(self): + from autoanime_v3.domain.errors import AuthenticationError, LoginThrottledError + + service = self.auth_service() + service.bootstrap_admin("admin", "Correct Horse Battery Staple!42") + for unused in range(5): + with self.assertRaises(AuthenticationError): + service.login("admin", "wrong", "192.168.1.8", "browser") + with self.assertRaises(LoginThrottledError): + service.login("admin", "Correct Horse Battery Staple!42", "192.168.1.8", "browser") + self.clock.advance(minutes=16) + session = service.login( + "admin", "Correct Horse Battery Staple!42", "192.168.1.8", "browser" + ) + self.assertEqual(session.user.username, "admin") + + def test_secret_status_never_returns_plaintext_or_ciphertext(self): + from autoanime_v3.security.secrets import EncryptedFileSecretStore + from autoanime_v3.services.auth import SecretService + + store = EncryptedFileSecretStore(self.root / "secret-store") + service = SecretService(self.database, store) + status = service.set_secret("metadata.api_key", "top-secret-value") + + self.assertTrue(status.configured) + self.assertEqual(status.key, "metadata.api_key") + self.assertFalse(hasattr(status, "value")) + self.assertFalse(hasattr(status, "ciphertext")) + connection = sqlite3.connect(str(self.database)) + try: + ciphertext = connection.execute( + "SELECT ciphertext FROM secret_settings WHERE key = ?", + ("metadata.api_key",), + ).fetchone()[0] + finally: + connection.close() + self.assertNotIn(b"top-secret-value", bytes(ciphertext)) + self.assertEqual(store.unprotect(bytes(ciphertext)), "top-secret-value") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_automation.py b/tests/test_v3_automation.py new file mode 100644 index 0000000..b3c1139 --- /dev/null +++ b/tests/test_v3_automation.py @@ -0,0 +1,200 @@ +import hashlib +import json +import sqlite3 +import tempfile +import time +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +class MutableClock: + def __init__(self): + self.value = datetime(2026, 7, 25, 10, 0, tzinfo=timezone.utc) + + def __call__(self): + return self.value + + def advance(self, **kwargs): + self.value += timedelta(**kwargs) + + +class AutomationTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.database = self.root / "web.sqlite3" + self.source = self.root / "source" + self.library = self.root / "library" + self.source.mkdir() + self.library.mkdir() + + from autoanime_v3.domain.entities import CreateProfile + from autoanime_v3.services.profiles import ProfileService + from autoanime_v3.services.roots import RootService + + roots = RootService(self.database) + source_root = roots.create_root("source", self.source) + library_root = roots.create_root("library", self.library) + self.profile = ProfileService(self.database).create_profile( + CreateProfile( + "automation", + source_root.id, + library_root.id, + stability_seconds=1, + watch_enabled=True, + ) + ) + + def tearDown(self): + self.temporary_directory.cleanup() + + def jobs(self): + connection = sqlite3.connect(str(self.database)) + connection.row_factory = sqlite3.Row + try: + return [dict(row) for row in connection.execute("SELECT * FROM jobs ORDER BY id")] + finally: + connection.close() + + def test_watcher_debounces_and_requires_stable_file(self): + from autoanime_v3.jobs.watcher import StableFileBuffer + + clock = MutableClock() + buffer = StableFileBuffer(clock=clock, debounce_seconds=2, stability_seconds=5) + path = Path("C:/Downloads/show.mkv") + buffer.record(path, 100, 1) + buffer.record(path, 120, 2) + self.assertEqual(buffer.ready(), ()) + clock.advance(seconds=4) + self.assertEqual(buffer.ready(), ()) + clock.advance(seconds=2) + self.assertEqual(buffer.ready(), (path,)) + buffer.record(Path("C:/Downloads/show.!qB"), 1, 1) + clock.advance(seconds=10) + self.assertEqual(buffer.ready(), ()) + + def test_interval_schedule_tick_is_atomic_and_restart_safe(self): + from autoanime_v3.services.automation import AutomationRuntime, ScheduleService + + clock = MutableClock() + schedule = ScheduleService(self.database, clock=clock).create( + self.profile.id, + "interval", + {"interval_minutes": 5}, + "UTC", + ) + self.assertEqual(schedule.next_run_at, "2026-07-25T10:05:00+00:00") + + clock.advance(minutes=5) + first_runtime = AutomationRuntime(self.database, clock=clock, watch_enabled=False) + first_runtime.tick() + second_runtime = AutomationRuntime(self.database, clock=clock, watch_enabled=False) + second_runtime.tick() + + jobs = self.jobs() + self.assertEqual(len(jobs), 1) + self.assertEqual(jobs[0]["idempotency_key"], "schedule:%s:2026-07-25T10:05:00+00:00" % schedule.id) + refreshed = ScheduleService(self.database, clock=clock).get(schedule.id) + self.assertEqual(refreshed.last_run_at, "2026-07-25T10:05:00+00:00") + self.assertEqual(refreshed.next_run_at, "2026-07-25T10:10:00+00:00") + + def test_daily_schedule_respects_timezone_and_revision(self): + from autoanime_v3.domain.errors import RevisionConflictError + from autoanime_v3.services.automation import ScheduleService + + clock = MutableClock() + service = ScheduleService(self.database, clock=clock) + schedule = service.create( + self.profile.id, + "daily", + {"time": "18:30"}, + "Asia/Shanghai", + ) + self.assertEqual(schedule.next_run_at, "2026-07-25T10:30:00+00:00") + updated = service.update(schedule.id, schedule.revision, {"enabled": False}) + self.assertFalse(updated.enabled) + self.assertIsNone(updated.next_run_at) + with self.assertRaises(RevisionConflictError): + service.update(schedule.id, schedule.revision, {"enabled": True}) + + def test_webhook_token_is_hashed_shown_once_and_enforces_scope_and_enabled(self): + from autoanime_v3.domain.errors import NotFoundError, PathOutsideRootError + from autoanime_v3.services.automation import WebhookSourceService + + service = WebhookSourceService(self.database) + created = service.create("qBittorrent", "qbittorrent", self.profile.id) + self.assertTrue(created.token) + self.assertNotIn("token", service.list()[0].__dict__) + + connection = sqlite3.connect(str(self.database)) + try: + stored = connection.execute( + "SELECT token_hash FROM webhook_sources WHERE id = ?", (created.id,) + ).fetchone()[0] + finally: + connection.close() + self.assertEqual(stored, hashlib.sha256(created.token.encode("utf-8")).hexdigest()) + self.assertNotEqual(stored, created.token) + + target = self.source / "completed.mkv" + target.write_bytes(b"media") + job = service.submit_token(created.token, [target]) + self.assertEqual(job.payload["paths"], [str(target.resolve())]) + with self.assertRaises(PathOutsideRootError): + service.submit_token(created.token, [self.root / "outside.mkv"]) + + updated = service.update(created.id, created.revision, {"enabled": False}) + self.assertFalse(updated.enabled) + with self.assertRaises(NotFoundError): + service.submit_token(created.token, [target]) + + def test_real_observer_enqueues_stable_target_and_ignores_temporary_suffix(self): + from autoanime_v3.services.automation import AutomationRuntime + + runtime = AutomationRuntime( + self.database, + watch_poll_seconds=0.05, + observer_reload_seconds=0.05, + ) + runtime.start() + try: + temporary = self.source / "ignored.mkv.!qB" + temporary.write_bytes(b"partial") + target = self.source / "ready.mkv" + target.write_bytes(b"complete") + deadline = time.monotonic() + 4 + while time.monotonic() < deadline and not self.jobs(): + runtime.tick() + time.sleep(0.05) + jobs = self.jobs() + self.assertEqual(len(jobs), 1) + payload = json.loads(jobs[0]["payload_json"]) + self.assertEqual(payload["paths"], [str(target.resolve())]) + finally: + runtime.stop() + self.assertFalse(runtime.is_running) + + def test_targeted_scan_does_not_include_sibling_and_rejects_outside_scope(self): + from autoanime_v3.domain.errors import PathOutsideRootError + from autoanime_v3.services.scans import ScanService + + target = self.source / "Target Show S01E01.mkv" + sibling = self.source / "Sibling Show S01E02.mkv" + target.write_bytes(b"target") + sibling.write_bytes(b"sibling") + + outcome = ScanService(self.database).run(self.profile.id, [target]) + self.assertEqual(outcome.discovered_count, 1) + connection = sqlite3.connect(str(self.database)) + try: + scanned = [row[0] for row in connection.execute("SELECT path FROM scan_items")] + finally: + connection.close() + self.assertEqual(scanned, [str(target.resolve())]) + with self.assertRaises(PathOutsideRootError): + ScanService(self.database).run(self.profile.id, [self.root / "outside"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_backups.py b/tests/test_v3_backups.py new file mode 100644 index 0000000..147e61e --- /dev/null +++ b/tests/test_v3_backups.py @@ -0,0 +1,49 @@ +import sqlite3 +import tempfile +import unittest +from pathlib import Path + + +class BackupTests(unittest.TestCase): + def test_online_backup_has_checksum_and_restore_verifies_schema(self): + from autoanime_v3.db.migrations import run_migrations + from autoanime_v3.services.backups import BackupService + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + database = root / "web.sqlite3" + run_migrations(database) + connection = sqlite3.connect(str(database)) + try: + connection.execute( + "INSERT INTO app_settings(key, value_json, revision) VALUES ('marker', '1', 1)" + ) + connection.commit() + finally: + connection.close() + service = BackupService(database, root / "backups") + record = service.create() + self.assertTrue(Path(record.path).is_file()) + self.assertEqual(len(record.sha256), 64) + + connection = sqlite3.connect(str(database)) + try: + connection.execute("UPDATE app_settings SET value_json = '2' WHERE key = 'marker'") + connection.commit() + finally: + connection.close() + service.restore(record.id, maintenance_mode=True) + connection = sqlite3.connect(str(database)) + try: + value = connection.execute( + "SELECT value_json FROM app_settings WHERE key = 'marker'" + ).fetchone()[0] + integrity = connection.execute("PRAGMA integrity_check").fetchone()[0] + finally: + connection.close() + self.assertEqual(value, "1") + self.assertEqual(integrity, "ok") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_boundaries.py b/tests/test_v3_boundaries.py new file mode 100644 index 0000000..c9faf21 --- /dev/null +++ b/tests/test_v3_boundaries.py @@ -0,0 +1,103 @@ +import ast +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from autoanime_v3 import PARSER_VERSION, __version__ +from autoanime_v3.config import AppConfig +from autoanime_v3.models import MediaFile, ParsedName +from autoanime_v3.normalize import safe_component +from autoanime_v3.parser import parse_name +from autoanime_v3.remote import OpenAIResolverAgent + + +class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps(self._payload).encode("utf-8") + + +class BoundaryRegressionTests(unittest.TestCase): + def test_runtime_sources_remain_python_38_syntax_compatible(self): + project_root = Path(__file__).resolve().parent.parent + for source in (project_root / "autoanime_v3").glob("*.py"): + with self.subTest(source=source.name): + tree = ast.parse( + source.read_text(encoding="utf-8"), + filename=str(source), + feature_version=(3, 8), + ) + pep604_unions = [ + node for node in ast.walk(tree) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr) + ] + self.assertEqual(pep604_unions, [], "PEP 604 unions require Python 3.10") + + def test_parser_behavior_change_bumps_cache_version(self): + self.assertEqual(PARSER_VERSION, "3.1.1") + self.assertEqual(__version__, "3.1.1") + + def test_trailing_episode_is_removed_from_chinese_title(self): + parsed = parse_name(Path("测试动画 03 [1080p].mkv")) + + self.assertEqual(parsed.raw_title, "测试动画") + self.assertEqual((parsed.season, parsed.episode), (1, 3)) + + def test_windows_reserved_name_with_extension_is_prefixed(self): + self.assertEqual(safe_component("CON.txt"), "_CON.txt") + self.assertEqual(safe_component("aux.release"), "_aux.release") + + def test_remote_agent_rejects_non_boolean_movie_flag(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + media_path = root / "example.mkv" + media_path.write_bytes(b"video") + stat = media_path.stat() + media = MediaFile( + media_path, + root, + "下载", + media_path.name, + stat.st_size, + stat.st_mtime_ns, + ) + parsed = ParsedName("Example", 1, 1) + config = AppConfig( + database_path=root / "library.sqlite3", + alias_file=root / "aliases.json", + openai_enabled=True, + openai_api_key="x", + ) + content = json.dumps( + { + "title_zh": "测试电影", + "season": 1, + "episode": 1, + "is_movie": "false", + "confidence": 0.9, + "reason": "fixture", + }, + ensure_ascii=False, + ) + response = _FakeResponse( + {"choices": [{"message": {"content": content}}]} + ) + + with patch("autoanime_v3.remote.urllib.request.urlopen", return_value=response): + result = OpenAIResolverAgent(config).resolve(media, parsed) + + self.assertIsNone(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_cache_lifecycle.py b/tests/test_v3_cache_lifecycle.py new file mode 100644 index 0000000..b4a57f9 --- /dev/null +++ b/tests/test_v3_cache_lifecycle.py @@ -0,0 +1,179 @@ +import sqlite3 +import tempfile +import unittest +from contextlib import closing +from pathlib import Path + +from autoanime_v3.cache import ResolutionCache, fingerprint +from autoanime_v3.models import MediaFile, Resolution + + +class ResolutionCacheLifecycleTests(unittest.TestCase): + def _media(self, root: Path) -> MediaFile: + path = root / "Example.S01E01.mkv" + path.write_bytes(b"video") + stat = path.stat() + return MediaFile(path, root, "bundle", path.name, stat.st_size, stat.st_mtime_ns) + + def _resolution(self, media: MediaFile, title: str, decision_version: str) -> Resolution: + return Resolution( + media=media, + canonical_title=title, + season=1, + episode=1, + confidence=0.99, + accepted=True, + fingerprint=fingerprint(media, decision_version), + ) + + def test_rule_change_replaces_current_fact_for_same_source(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + media = self._media(root) + old_resolution = self._resolution(media, "\u65e7\u6807\u9898", "rules-v1") + new_resolution = self._resolution(media, "\u65b0\u6807\u9898", "rules-v2") + + with ResolutionCache(root / "library.sqlite3") as cache: + cache.put(old_resolution) + cache.put(new_resolution) + + rows = cache.connection.execute( + "SELECT fingerprint, episode_id FROM media_files" + ).fetchall() + self.assertEqual(1, len(rows)) + self.assertEqual(new_resolution.fingerprint, rows[0]["fingerprint"]) + + progress = cache.list_show_progress() + self.assertEqual(["\u65b0\u6807\u9898"], [row["canonical_title"] for row in progress]) + old_show_id = cache.connection.execute( + "SELECT id FROM shows WHERE canonical_title='\u65e7\u6807\u9898'" + ).fetchone()[0] + self.assertEqual([], cache.show_detail(old_show_id)["episodes"]) + + cached = cache.get(media, "rules-v2") + self.assertIsNotNone(cached) + self.assertEqual("\u65b0\u6807\u9898", cached.canonical_title) + + def test_reused_download_path_does_not_inherit_old_organized_state(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + media = self._media(root) + old_resolution = self._resolution(media, "\u65e7\u6807\u9898", "rules-v1") + organized_path = root / "library" / "\u65e7\u6807\u9898" / "S01E01.mkv" + + with ResolutionCache(root / "library.sqlite3") as cache: + cache.put(old_resolution) + cache.mark_organized(old_resolution, organized_path) + + media.path.unlink() + media.path.write_bytes(b"a completely new download") + new_stat = media.path.stat() + replacement_media = MediaFile( + media.path, + root, + "bundle", + media.path.name, + new_stat.st_size, + new_stat.st_mtime_ns, + ) + replacement = self._resolution(replacement_media, "\u65b0\u6807\u9898", "rules-v2") + cache.put(replacement) + + row = cache.connection.execute( + "SELECT current_path, status FROM media_files" + ).fetchone() + + self.assertEqual(str(media.path), row["current_path"]) + self.assertEqual("identified", row["status"]) + + def test_v1_database_is_migrated_without_losing_audit_history(self): + with tempfile.TemporaryDirectory() as directory: + database = Path(directory) / "library.sqlite3" + source = str(Path(directory) / "Example.S01E01.mkv") + organized_destination = str(Path(directory) / "library" / "S01E01.mkv") + with closing(sqlite3.connect(str(database))) as connection: + connection.executescript( + """ + CREATE TABLE media_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fingerprint TEXT NOT NULL UNIQUE, + episode_id INTEGER, + original_path TEXT NOT NULL, + current_path TEXT NOT NULL, + size INTEGER NOT NULL, + mtime_ns INTEGER NOT NULL, + release_tag TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'identified', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + action TEXT NOT NULL, + source TEXT NOT NULL, + destination TEXT NOT NULL, + status TEXT NOT NULL, + error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE corrections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, + entity_id INTEGER NOT NULL, + field_name TEXT NOT NULL, + old_value TEXT NOT NULL, + new_value TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'draft', + migration_plan_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + applied_at TEXT + ); + """ + ) + connection.execute( + "INSERT INTO media_files(fingerprint, original_path, current_path, size, mtime_ns, status) " + "VALUES('old-fingerprint', ?, ?, 5, 1, 'organized')", + (source, organized_destination), + ) + connection.execute( + "INSERT INTO media_files(fingerprint, original_path, current_path, size, mtime_ns) " + "VALUES('new-fingerprint', ?, ?, 5, 1)", + (source, source), + ) + connection.execute( + "INSERT INTO operations(run_id, action, source, destination, status) " + "VALUES('run-1', 'move', ?, 'destination', 'done')", + (source,), + ) + connection.execute( + "INSERT INTO corrections(entity_type, entity_id, field_name, old_value, new_value) " + "VALUES('show', 1, 'canonical_title', '\u65e7\u6807\u9898', '\u65b0\u6807\u9898')" + ) + connection.commit() + + with ResolutionCache(database) as cache: + columns = { + row["name"] for row in cache.connection.execute("PRAGMA table_info(media_files)") + } + self.assertIn("source_key", columns) + rows = cache.connection.execute( + "SELECT fingerprint, source_key, current_path, status FROM media_files" + ).fetchall() + self.assertEqual(1, len(rows)) + self.assertEqual("new-fingerprint", rows[0]["fingerprint"]) + self.assertTrue(rows[0]["source_key"]) + self.assertEqual(organized_destination, rows[0]["current_path"]) + self.assertEqual("organized", rows[0]["status"]) + self.assertEqual( + "2", + cache.connection.execute( + "SELECT value FROM meta WHERE key='schema_version'" + ).fetchone()[0], + ) + self.assertEqual(1, cache.connection.execute("SELECT COUNT(*) FROM operations").fetchone()[0]) + self.assertEqual(1, cache.connection.execute("SELECT COUNT(*) FROM corrections").fetchone()[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_execution_policy.py b/tests/test_v3_execution_policy.py new file mode 100644 index 0000000..ec4b679 --- /dev/null +++ b/tests/test_v3_execution_policy.py @@ -0,0 +1,668 @@ +import os +import sqlite3 +import subprocess +import tempfile +import threading +import unittest +from pathlib import Path +from unittest import mock + +from fastapi.testclient import TestClient + + +class ExecutionPolicyTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.database = self.root / "web.sqlite3" + self.source = self.root / "downloads" + self.library = self.root / "library" + self.source.mkdir() + self.library.mkdir() + + def tearDown(self): + self.temporary_directory.cleanup() + + def create_profile(self, execution_policy): + from autoanime_v3.domain.entities import CreateProfile + from autoanime_v3.services.profiles import ProfileService + from autoanime_v3.services.roots import RootService + + roots = RootService(self.database) + source_root = roots.create_root("source", self.source) + library_root = roots.create_root("library", self.library) + return ProfileService(self.database).create_profile( + CreateProfile( + name="execution-policy-test", + source_root_id=source_root.id, + library_root_id=library_root.id, + execution_policy=execution_policy, + min_confidence=86, + ) + ) + + def scan_safe_file(self, execution_policy): + from autoanime_v3.services.scans import ScanService + + profile = self.create_profile(execution_policy) + media = self.source / "测试番 S01E01.mkv" + media.write_bytes(b"safe-media-content") + return profile, media, ScanService(self.database).run(profile.id) + + def login(self, client): + from autoanime_v3.services.auth import DEFAULT_ADMIN_PASSWORD, DEFAULT_ADMIN_USERNAME + + response = client.post( + "/api/v1/auth/login", + json={"username": DEFAULT_ADMIN_USERNAME, "password": DEFAULT_ADMIN_PASSWORD}, + ) + self.assertEqual(response.status_code, 200) + return {"X-CSRF-Token": response.json()["csrf_token"]} + + def execute_jobs(self): + connection = sqlite3.connect(str(self.database)) + try: + return connection.execute( + "SELECT id, status, payload_json FROM jobs WHERE job_type = 'execute_plan' ORDER BY id" + ).fetchall() + finally: + connection.close() + + def create_directory_link(self, link, target, junction=False): + target.mkdir(parents=True, exist_ok=True) + if junction: + if os.name != "nt": + self.skipTest("Windows junction test") + result = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(link), str(target)], + capture_output=True, + ) + if result.returncode != 0: + self.skipTest( + "Cannot create Windows junction: %s" + % result.stderr.decode(errors="replace").strip() + ) + return + try: + os.symlink(str(target), str(link), target_is_directory=True) + except (OSError, NotImplementedError) as error: + self.skipTest("Cannot create directory symlink: %s" % error) + + def remove_directory_link(self, link): + if not os.path.lexists(str(link)): + return + if link.is_symlink(): + link.unlink() + else: + os.rmdir(str(link)) + + def test_dry_run_api_approval_is_rejected_without_enqueuing_or_touching_files(self): + from autoanime_v3.api.app import ServerSettings, create_app + + unused_profile, media, outcome = self.scan_safe_file("dry_run") + original = media.read_bytes() + client = TestClient( + create_app( + ServerSettings( + database_path=self.database, + data_directory=self.root, + secure_cookies=False, + ) + ), + client=("127.0.0.1", 50000), + ) + try: + response = client.post( + "/api/v1/plans/%s/approve" % outcome.plan_id, + headers=self.login(client), + ) + finally: + client.close() + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["code"], "execution_policy_forbidden") + self.assertEqual(self.execute_jobs(), []) + self.assertEqual(media.read_bytes(), original) + self.assertEqual(list(self.library.rglob("*")), []) + + def test_dry_run_operation_service_rejects_even_an_already_approved_plan(self): + from autoanime_v3.domain.errors import ExecutionPolicyError + from autoanime_v3.services.operations import OperationService + + unused_profile, media, outcome = self.scan_safe_file("dry_run") + connection = sqlite3.connect(str(self.database)) + try: + connection.execute("UPDATE plans SET status = 'approved' WHERE id = ?", (outcome.plan_id,)) + connection.commit() + finally: + connection.close() + + with self.assertRaises(ExecutionPolicyError) as raised: + OperationService(self.database, self.root / "operations").execute(outcome.plan_id) + self.assertEqual(raised.exception.code, "execution_policy_forbidden") + self.assertTrue(media.exists()) + self.assertEqual(list(self.library.rglob("*")), []) + + def test_worker_execution_rejects_profile_revision_changed_after_approval(self): + from autoanime_v3.domain.errors import StalePlanError + from autoanime_v3.services.operations import OperationService + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.profiles import ProfileService + + profile, media, outcome = self.scan_safe_file("review_all") + plan, job = PlanService(self.database).approve_and_enqueue(outcome.plan_id) + destination = Path(plan.items[0].destination_path) + self.assertEqual(job.status, "queued") + ProfileService(self.database).update_profile( + profile.id, + profile.revision, + {"min_confidence": 85}, + ) + + with self.assertRaises(StalePlanError): + OperationService(self.database, self.root / "operations").execute(plan.id) + + self.assertTrue(media.exists()) + self.assertFalse(destination.exists()) + self.assertFalse(any(path.is_file() for path in self.library.rglob("*"))) + self.assertEqual(PlanService(self.database).get(plan.id).status, "stale") + + def test_auto_approval_rejects_a_destination_parent_symlink_outside_library(self): + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.scans import CoreScanAdapter, ScanService + + profile = self.create_profile("auto_apply_safe") + (self.source / "测试番 S01E01.mkv").write_bytes(b"safe-media-content") + unused_rule, unused_resolutions, entries = CoreScanAdapter(self.database).analyze( + self.source, self.library, 0.86 + ) + first_component = entries[0].destination.relative_to(self.library).parts[0] + link = self.library / first_component + outside = self.root / "outside-symlink" + self.create_directory_link(link, outside) + try: + outcome = ScanService(self.database).run(profile.id) + plan = PlanService(self.database).get(outcome.plan_id) + self.assertNotEqual(plan.status, "approved") + self.assertEqual(self.execute_jobs(), []) + finally: + self.remove_directory_link(link) + self.assertFalse(any(path.is_file() for path in outside.rglob("*"))) + + def test_auto_approval_rejects_a_windows_junction_outside_library(self): + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.scans import CoreScanAdapter, ScanService + + profile = self.create_profile("auto_apply_safe") + (self.source / "测试番 S01E01.mkv").write_bytes(b"safe-media-content") + unused_rule, unused_resolutions, entries = CoreScanAdapter(self.database).analyze( + self.source, self.library, 0.86 + ) + first_component = entries[0].destination.relative_to(self.library).parts[0] + link = self.library / first_component + outside = self.root / "outside-auto-junction" + self.create_directory_link(link, outside, junction=True) + try: + outcome = ScanService(self.database).run(profile.id) + plan = PlanService(self.database).get(outcome.plan_id) + self.assertNotEqual(plan.status, "approved") + self.assertEqual(self.execute_jobs(), []) + finally: + self.remove_directory_link(link) + self.assertFalse(any(path.is_file() for path in outside.rglob("*"))) + + def test_execution_rejects_a_windows_junction_inserted_after_approval(self): + from autoanime_v3.domain.errors import PlanConflictError + from autoanime_v3.services.operations import OperationService + from autoanime_v3.services.plans import PlanService + + unused_profile, media, outcome = self.scan_safe_file("review_all") + plan, unused_job = PlanService(self.database).approve_and_enqueue(outcome.plan_id) + destination = Path(plan.items[0].destination_path) + relative = destination.relative_to(self.library) + link = self.library / relative.parts[0] + outside = self.root / "outside-junction" + self.create_directory_link(link, outside, junction=True) + try: + with self.assertRaises(PlanConflictError): + OperationService(self.database, self.root / "operations").execute(plan.id) + finally: + self.remove_directory_link(link) + self.assertTrue(media.exists()) + self.assertFalse(any(path.is_file() for path in outside.rglob("*"))) + + def test_executor_rechecks_destination_after_claim_for_all_file_modes(self): + from autoanime_v3.domain.entities import CreateProfile + from autoanime_v3.executor import ExecutionError + from autoanime_v3.services.operations import OperationService + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.profiles import ProfileService + from autoanime_v3.services.roots import RootService + from autoanime_v3.services.scans import ScanService + + for mode in ("link", "copy", "move"): + with self.subTest(mode=mode): + case_root = self.root / ("claim-race-" + mode) + source = case_root / "source" + library = case_root / "library" + outside = case_root / "outside" + source.mkdir(parents=True) + library.mkdir() + database = case_root / "web.sqlite3" + roots = RootService(database) + source_root = roots.create_root("source", source) + library_root = roots.create_root("library", library) + profile = ProfileService(database).create_profile( + CreateProfile( + name="claim-race-" + mode, + source_root_id=source_root.id, + library_root_id=library_root.id, + mode=mode, + execution_policy="review_all", + ) + ) + media = source / "测试番 S01E01.mkv" + media.write_bytes(b"safe-media-content") + outcome = ScanService(database).run(profile.id) + plan, unused_job = PlanService(database).approve_and_enqueue(outcome.plan_id) + destination = Path(plan.items[0].destination_path) + relative = destination.relative_to(library) + link = library / relative.parts[0] + + outer = self + + class JunctionAfterClaimOperationService(OperationService): + def _claim_execution(inner_self, plan_id, requested_by, rows): + batch_id = super()._claim_execution(plan_id, requested_by, rows) + outer.create_directory_link( + link, + outside, + junction=os.name == "nt", + ) + return batch_id + + try: + with self.assertRaises(ExecutionError): + JunctionAfterClaimOperationService( + database, case_root / "operations" + ).execute(plan.id) + finally: + self.remove_directory_link(link) + + self.assertTrue(media.exists()) + self.assertFalse(any(path.is_file() for path in outside.rglob("*"))) + connection = sqlite3.connect(str(database)) + try: + batch = connection.execute( + "SELECT status FROM operation_batches WHERE plan_id = ?", + (plan.id,), + ).fetchone() + self.assertEqual(batch[0], "failed_rolled_back") + finally: + connection.close() + + def test_execution_claim_rechecks_policy_after_preflight(self): + from autoanime_v3.domain.errors import ExecutionPolicyError + from autoanime_v3.services.operations import OperationService + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.profiles import ProfileService + + profile, media, outcome = self.scan_safe_file("review_all") + plan, unused_job = PlanService(self.database).approve_and_enqueue(outcome.plan_id) + destination = Path(plan.items[0].destination_path) + + class PolicyChangingOperationService(OperationService): + def _preflight(inner_self, checked_plan, rows): + prepared = super()._preflight(checked_plan, rows) + ProfileService(self.database).update_profile( + profile.id, + profile.revision, + {"execution_policy": "dry_run"}, + ) + return prepared + + with self.assertRaises(ExecutionPolicyError): + PolicyChangingOperationService( + self.database, self.root / "operations" + ).execute(plan.id) + self.assertTrue(media.exists()) + self.assertFalse(destination.exists()) + connection = sqlite3.connect(str(self.database)) + try: + self.assertEqual( + connection.execute("SELECT COUNT(*) FROM operation_batches").fetchone()[0], + 0, + ) + finally: + connection.close() + + def test_concurrent_execute_calls_only_one_claims_the_approved_plan(self): + from autoanime_v3.domain.errors import InvalidStateError + from autoanime_v3.services.operations import OperationService + from autoanime_v3.services.plans import PlanService + + unused_profile, unused_media, outcome = self.scan_safe_file("review_all") + plan, unused_job = PlanService(self.database).approve_and_enqueue(outcome.plan_id) + original_preflight = OperationService._preflight + barrier = threading.Barrier(2) + results = [] + errors = [] + + def synchronized_preflight(service, checked_plan, rows): + prepared = original_preflight(service, checked_plan, rows) + barrier.wait(timeout=10) + return prepared + + def execute_once(): + try: + results.append( + OperationService(self.database, self.root / "operations").execute(plan.id) + ) + except Exception as error: + errors.append(error) + + with mock.patch.object(OperationService, "_preflight", new=synchronized_preflight): + threads = [threading.Thread(target=execute_once) for unused in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=20) + + self.assertTrue(all(not thread.is_alive() for thread in threads)) + self.assertEqual(len(results), 1) + self.assertEqual(len(errors), 1) + self.assertIsInstance(errors[0], InvalidStateError) + connection = sqlite3.connect(str(self.database)) + try: + self.assertEqual( + connection.execute("SELECT COUNT(*) FROM operation_batches").fetchone()[0], + 1, + ) + finally: + connection.close() + + def test_rollback_api_validates_eligibility_before_enqueuing(self): + from autoanime_v3.api.app import ServerSettings, create_app + from autoanime_v3.db.migrations import run_migrations + + log_path = self.root / "rollback-source.jsonl" + log_path.write_text("", encoding="utf-8") + missing_log = self.root / "missing-operation-log.jsonl" + run_migrations(self.database) + connection = sqlite3.connect(str(self.database)) + try: + running_id = connection.execute( + "INSERT INTO operation_batches(kind, status, summary_json) VALUES ('execute', 'running', '{}')" + ).lastrowid + missing_log_id = connection.execute( + "INSERT INTO operation_batches(kind, status, summary_json) VALUES ('execute', 'completed', ?)", + ('{"log_path": "%s"}' % str(missing_log).replace("\\", "\\\\"),), + ).lastrowid + rolled_back_id = connection.execute( + "INSERT INTO operation_batches(kind, status, summary_json) VALUES ('execute', 'completed', ?)", + ('{"log_path": "%s"}' % str(log_path).replace("\\", "\\\\"),), + ).lastrowid + connection.execute( + """ + INSERT INTO operation_batches(parent_batch_id, kind, status, summary_json) + VALUES (?, 'manual_rollback', 'completed', '{}') + """, + (rolled_back_id,), + ) + eligible_id = connection.execute( + "INSERT INTO operation_batches(kind, status, summary_json) VALUES ('execute', 'completed', ?)", + ('{"log_path": "%s"}' % str(log_path).replace("\\", "\\\\"),), + ).lastrowid + connection.commit() + finally: + connection.close() + + client = TestClient( + create_app( + ServerSettings( + database_path=self.database, + data_directory=self.root, + secure_cookies=False, + ) + ), + client=("127.0.0.1", 50000), + ) + try: + headers = self.login(client) + missing = client.post("/api/v1/operations/999999/rollback", headers=headers) + running = client.post( + "/api/v1/operations/%s/rollback" % running_id, headers=headers + ) + no_log = client.post( + "/api/v1/operations/%s/rollback" % missing_log_id, headers=headers + ) + rolled_back = client.post( + "/api/v1/operations/%s/rollback" % rolled_back_id, headers=headers + ) + eligible = client.post( + "/api/v1/operations/%s/rollback" % eligible_id, headers=headers + ) + finally: + client.close() + + self.assertEqual(missing.status_code, 404) + self.assertEqual(missing.json()["code"], "not_found") + self.assertEqual(running.status_code, 409) + self.assertEqual(running.json()["code"], "invalid_state") + self.assertEqual(no_log.status_code, 404) + self.assertEqual(no_log.json()["code"], "not_found") + self.assertEqual(rolled_back.status_code, 409) + self.assertEqual(rolled_back.json()["code"], "invalid_state") + self.assertEqual(eligible.status_code, 202) + self.assertEqual(eligible.json()["job_type"], "rollback_operation") + connection = sqlite3.connect(str(self.database)) + try: + self.assertEqual( + connection.execute( + "SELECT COUNT(*) FROM jobs WHERE job_type = 'rollback_operation'" + ).fetchone()[0], + 1, + ) + finally: + connection.close() + + def test_concurrent_rollback_workers_only_one_claims_the_completed_batch(self): + from autoanime_v3.domain.errors import InvalidStateError + from autoanime_v3.services.operations import OperationService + + unused_profile, unused_media, outcome = self.scan_safe_file("review_all") + from autoanime_v3.services.plans import PlanService + + plan, unused_job = PlanService(self.database).approve_and_enqueue(outcome.plan_id) + batch = OperationService(self.database, self.root / "operations").execute(plan.id) + barrier = threading.Barrier(2) + results = [] + errors = [] + + class SynchronizedRollbackService(OperationService): + def validate_rollback(inner_self, batch_id): + original = super().validate_rollback(batch_id) + barrier.wait(timeout=10) + return original + + def rollback_once(): + try: + results.append( + SynchronizedRollbackService( + self.database, self.root / "operations" + ).rollback(batch.id) + ) + except Exception as error: + errors.append(error) + + threads = [threading.Thread(target=rollback_once) for unused in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=20) + + self.assertTrue(all(not thread.is_alive() for thread in threads)) + self.assertEqual(len(results), 1) + self.assertEqual(len(errors), 1) + self.assertIsInstance(errors[0], InvalidStateError) + connection = sqlite3.connect(str(self.database)) + try: + children = connection.execute( + """ + SELECT status FROM operation_batches + WHERE parent_batch_id = ? AND kind = 'manual_rollback' + """, + (batch.id,), + ).fetchall() + self.assertEqual(children, [("completed",)]) + finally: + connection.close() + + def test_review_all_waits_for_api_approval_then_enqueues_once(self): + from autoanime_v3.api.app import ServerSettings, create_app + + unused_profile, unused_media, outcome = self.scan_safe_file("review_all") + self.assertEqual(self.execute_jobs(), []) + client = TestClient( + create_app( + ServerSettings( + database_path=self.database, + data_directory=self.root, + secure_cookies=False, + ) + ), + client=("127.0.0.1", 50000), + ) + try: + headers = self.login(client) + first = client.post("/api/v1/plans/%s/approve" % outcome.plan_id, headers=headers) + second = client.post("/api/v1/plans/%s/approve" % outcome.plan_id, headers=headers) + finally: + client.close() + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(first.json()["job"]["id"], second.json()["job"]["id"]) + self.assertEqual(len(self.execute_jobs()), 1) + + def test_public_plan_approve_cannot_create_an_approved_plan_without_a_job(self): + from autoanime_v3.services.plans import PlanService + + unused_profile, unused_media, outcome = self.scan_safe_file("review_all") + + plan = PlanService(self.database).approve(outcome.plan_id) + + self.assertEqual(plan.status, "approved") + self.assertEqual(len(self.execute_jobs()), 1) + + def test_auto_apply_safe_approves_and_enqueues_a_ready_safe_plan(self): + from autoanime_v3.services.plans import PlanService + + unused_profile, unused_media, outcome = self.scan_safe_file("auto_apply_safe") + + plan = PlanService(self.database).get(outcome.plan_id) + self.assertEqual(outcome.plan_status, "approved") + self.assertEqual(plan.status, "approved") + self.assertTrue(plan.items) + self.assertTrue(all(item.risk_level == "normal" for item in plan.items)) + self.assertTrue(any(item.action not in {"skip", "conflict"} for item in plan.items)) + jobs = self.execute_jobs() + self.assertEqual(len(jobs), 1) + self.assertIn('"plan_id": %s' % outcome.plan_id, jobs[0][2]) + + def test_auto_apply_safe_does_not_enqueue_an_empty_ready_plan(self): + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.scans import ScanService + + profile = self.create_profile("auto_apply_safe") + + outcome = ScanService(self.database).run(profile.id) + + self.assertEqual(PlanService(self.database).get(outcome.plan_id).status, "ready") + self.assertEqual(self.execute_jobs(), []) + + def test_auto_apply_safe_leaves_a_review_plan_unapproved_and_unqueued(self): + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.scans import ScanService + + profile = self.create_profile("auto_apply_safe") + (self.source / "Unknown Show - 02.mkv").write_bytes(b"needs-review") + + outcome = ScanService(self.database).run(profile.id) + + self.assertEqual(PlanService(self.database).get(outcome.plan_id).status, "draft") + self.assertEqual(self.execute_jobs(), []) + + def test_resolving_the_last_review_auto_applies_the_new_safe_plan(self): + from autoanime_v3.services.reviews import ReviewService + from autoanime_v3.services.scans import ScanService + + profile = self.create_profile("auto_apply_safe") + media = self.source / "Unknown Show - 02.mkv" + media.write_bytes(b"needs-review") + ScanService(self.database).run(profile.id) + reviews = ReviewService(self.database) + + plan = reviews.resolve( + reviews.list_open()[0].id, + {"title": "人工确认番剧", "season": 1, "episode": 2, "is_movie": False}, + ) + + self.assertEqual(plan.status, "approved") + self.assertEqual(len(self.execute_jobs()), 1) + self.assertTrue(media.exists()) + self.assertFalse(any(path.is_file() for path in self.library.rglob("*"))) + + def test_auto_apply_safe_leaves_a_conflicting_plan_unapproved_and_unqueued(self): + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.scans import CoreScanAdapter, ScanService + + profile = self.create_profile("auto_apply_safe") + (self.source / "测试番 S01E01.mkv").write_bytes(b"safe-media-content") + unused_rule_version, unused_resolutions, entries = CoreScanAdapter( + self.database + ).analyze(self.source, self.library, 0.86) + destination = entries[0].destination + destination.parent.mkdir(parents=True) + destination.write_bytes(b"occupied") + + outcome = ScanService(self.database).run(profile.id) + + self.assertNotEqual(PlanService(self.database).get(outcome.plan_id).status, "approved") + self.assertEqual(self.execute_jobs(), []) + self.assertEqual(destination.read_bytes(), b"occupied") + + def test_auto_apply_safe_does_not_approve_a_plan_staled_during_analysis(self): + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.profiles import ProfileService + from autoanime_v3.services.scans import CoreScanAdapter, ScanService + + profile = self.create_profile("auto_apply_safe") + (self.source / "测试番 S01E01.mkv").write_bytes(b"safe-media-content") + delegate = CoreScanAdapter(self.database) + + class ProfileChangingAdapter: + def analyze(inner_self, source, library, min_confidence): + ProfileService(self.database).update_profile( + profile.id, + profile.revision, + {"min_confidence": 85}, + ) + return delegate.analyze(source, library, min_confidence) + + outcome = ScanService(self.database, adapter=ProfileChangingAdapter()).run(profile.id) + + self.assertEqual(PlanService(self.database).get(outcome.plan_id).status, "stale") + self.assertEqual(self.execute_jobs(), []) + + def test_approve_and_enqueue_preserves_not_found_for_a_missing_plan(self): + from autoanime_v3.domain.errors import NotFoundError + from autoanime_v3.services.plans import PlanService + + with self.assertRaises(NotFoundError): + PlanService(self.database).approve_and_enqueue(999999) + self.assertEqual(self.execute_jobs(), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_executor_safety.py b/tests/test_v3_executor_safety.py new file mode 100644 index 0000000..ae8abfe --- /dev/null +++ b/tests/test_v3_executor_safety.py @@ -0,0 +1,314 @@ +import errno +import hashlib +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import autoanime_v3.executor as executor +from autoanime_v3.cache import ResolutionCache +from autoanime_v3.executor import ExecutionError, ExecutionFailure, execute_plan, rollback +from autoanime_v3.models import MediaFile, PlanEntry, Resolution + + +class ExecutorSafetyTests(unittest.TestCase): + def _entry(self, root, content=b"original-media", filename="episode.mkv"): + source = root / "incoming" / filename + source.parent.mkdir(parents=True, exist_ok=True) + source.write_bytes(content) + stat = source.stat() + media = MediaFile( + source, + source.parent, + "incoming", + source.name, + int(stat.st_size), + int(stat.st_mtime_ns), + ) + resolution = Resolution(media, "Test Show", 1, 1, False, 1.0, True) + destination = root / "library" / "Test Show" / filename + return PlanEntry(source, destination, "organize", resolution) + + def _execute(self, root, entry, mode): + cache_path = root / "library.sqlite3" + with ResolutionCache(cache_path) as cache: + cache.put(entry.resolution) + return execute_plan([entry], mode, True, cache, root / "logs") + + def test_partial_automatic_rollback_exposes_recovery_metadata(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + first = self._entry(root, b"first-media") + second = self._entry(root, b"second-media", "episode-2.mkv") + + cache_path = root / "library.sqlite3" + with ResolutionCache(cache_path) as cache: + cache.put(first.resolution) + cache.put(second.resolution) + calls = {"count": 0} + + def apply_then_fail(entry, unused_mode): + calls["count"] += 1 + if calls["count"] == 2: + raise OSError("apply failed") + entry.destination.parent.mkdir(parents=True, exist_ok=True) + entry.destination.write_bytes(entry.source.read_bytes()) + + with mock.patch.object( + executor, + "_apply_one", + side_effect=apply_then_fail, + ), mock.patch.object( + executor, + "_rollback_one", + side_effect=OSError("rollback failed"), + ): + with self.assertRaises(ExecutionFailure) as raised: + execute_plan([first, second], "copy", True, cache, root / "logs") + + failure = raised.exception + self.assertTrue(failure.partial_rollback) + self.assertTrue(failure.log_path.is_file()) + self.assertEqual(failure.rollback_errors, ("rollback failed",)) + self.assertEqual(len(failure.applied_records), 1) + self.assertEqual(failure.applied_records[0]["source"], str(first.source)) + + def test_move_rejects_source_changed_since_scan(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + entry = self._entry(root) + entry.source.write_bytes(b"changed-after-scan-and-longer") + + with self.assertRaises(ExecutionError): + self._execute(root, entry, "move") + + self.assertTrue(entry.source.exists()) + self.assertFalse(entry.destination.exists()) + + def test_failed_copy_cleanup_uses_the_created_file_identity(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "source.mkv" + destination = root / "destination.mkv" + source.write_bytes(b"media") + + def fail_after_partial_copy(source_handle, destination_handle, length): + destination_handle.write(b"x") + raise OSError("simulated copy failure") + + with mock.patch.object( + executor, + "_remove_created_destination", + wraps=executor._remove_created_destination, + ) as cleanup: + with mock.patch.object( + executor.shutil, + "copyfileobj", + side_effect=fail_after_partial_copy, + ): + with self.assertRaises(OSError): + executor._copy_exclusive(source, destination) + + cleanup.assert_called_once() + self.assertIsNotNone(cleanup.call_args.args[1]) + self.assertFalse(destination.exists()) + + def test_cross_volume_move_restores_source_and_discards_copy_if_staging_changes(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + entry = self._entry(root) + original_copyfileobj = executor.shutil.copyfileobj + original_link = executor.os.link + + def copy_then_change_source(source_handle, destination_handle, length): + original_copyfileobj(source_handle, destination_handle, length) + Path(source_handle.name).write_bytes(b"source-changed-during-copy") + + def fail_destination_link(source, destination, *args, **kwargs): + if Path(destination) == entry.destination: + raise OSError(errno.EXDEV, "cross-device link") + return original_link(source, destination, *args, **kwargs) + + with mock.patch.object(executor.os, "link", side_effect=fail_destination_link): + with mock.patch.object(executor.shutil, "copyfileobj", side_effect=copy_then_change_source): + with self.assertRaises(ExecutionError): + self._execute(root, entry, "move") + + self.assertTrue(entry.source.exists()) + self.assertEqual(entry.source.read_bytes(), b"source-changed-during-copy") + self.assertFalse(entry.destination.exists()) + + def test_move_never_unlinks_a_file_recreated_at_the_original_source_path(self): + for cross_volume in (False, True): + with self.subTest(cross_volume=cross_volume): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + entry = self._entry(root) + original_unlink = Path.unlink + recreated_content = b"new-download-at-original-path" + injected = {"done": False} + + def recreate_source_before_cleanup(path, *args, **kwargs): + if not injected["done"]: + preserved = entry.source.with_name(entry.source.name + ".preserved") + if entry.source.exists(): + entry.source.rename(preserved) + entry.source.write_bytes(recreated_content) + injected["done"] = True + return original_unlink(path, *args, **kwargs) + + link_patch = ( + mock.patch.object(executor.os, "link", side_effect=OSError(errno.EXDEV, "cross-device link")) + if cross_volume + else mock.patch.object(executor.os, "link", wraps=executor.os.link) + ) + with link_patch: + with mock.patch.object(Path, "unlink", new=recreate_source_before_cleanup): + self._execute(root, entry, "move") + + self.assertTrue(injected["done"]) + self.assertTrue(entry.source.exists()) + self.assertEqual(entry.source.read_bytes(), recreated_content) + self.assertTrue(entry.destination.exists()) + + def test_subtitle_move_uses_staging_and_preserves_recreated_source_path(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + video_entry = self._entry(root) + subtitle = root / "incoming" / "episode.ass" + subtitle.write_bytes(b"old-subtitle") + subtitle_destination = root / "library" / "Test Show" / "episode.ass" + entry = PlanEntry( + subtitle, + subtitle_destination, + "organize", + video_entry.resolution, + "subtitle", + str(video_entry.source), + ) + original_unlink = Path.unlink + recreated_content = b"new-subtitle-download" + injected = {"done": False} + + def recreate_source_before_cleanup(path, *args, **kwargs): + if not injected["done"]: + subtitle.write_bytes(recreated_content) + injected["done"] = True + return original_unlink(path, *args, **kwargs) + + with mock.patch.object(Path, "unlink", new=recreate_source_before_cleanup): + self._execute(root, entry, "move") + + self.assertTrue(injected["done"]) + self.assertEqual(subtitle.read_bytes(), recreated_content) + self.assertEqual(subtitle_destination.read_bytes(), b"old-subtitle") + + def test_failed_move_preserves_staging_when_original_path_is_recreated(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + entry = self._entry(root) + original_copyfileobj = executor.shutil.copyfileobj + original_link = executor.os.link + recreated_content = b"new-download-occupies-source" + changed_staging_content = b"staging-changed-during-copy" + + def copy_then_change_staging(source_handle, destination_handle, length): + original_copyfileobj(source_handle, destination_handle, length) + Path(source_handle.name).write_bytes(changed_staging_content) + entry.source.write_bytes(recreated_content) + + def fail_destination_link(source, destination, *args, **kwargs): + if Path(destination) == entry.destination: + raise OSError(errno.EXDEV, "cross-device link") + return original_link(source, destination, *args, **kwargs) + + with mock.patch.object(executor.os, "link", side_effect=fail_destination_link): + with mock.patch.object(executor.shutil, "copyfileobj", side_effect=copy_then_change_staging): + with self.assertRaisesRegex(ExecutionError, "待恢复文件保留在.*partial"): + self._execute(root, entry, "move") + + staging_files = list(entry.source.parent.glob(".*.partial")) + self.assertEqual(entry.source.read_bytes(), recreated_content) + self.assertEqual(len(staging_files), 1) + self.assertEqual(staging_files[0].read_bytes(), changed_staging_content) + self.assertFalse(entry.destination.exists()) + + def test_success_log_records_sha256_digest(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + entry = self._entry(root) + + log_path = self._execute(root, entry, "copy") + + record = json.loads(log_path.read_text(encoding="utf-8").strip()) + self.assertEqual( + record.get("result_sha256"), + hashlib.sha256(entry.destination.read_bytes()).hexdigest(), + ) + + def test_copy_rollback_rejects_same_size_replacement_with_restored_mtime(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + entry = self._entry(root, b"original-content") + log_path = self._execute(root, entry, "copy") + record = json.loads(log_path.read_text(encoding="utf-8").strip()) + replacement = b"tampered-content" + self.assertEqual(len(replacement), len(b"original-content")) + entry.destination.write_bytes(replacement) + os.utime( + str(entry.destination), + ns=(int(record["result_mtime_ns"]), int(record["result_mtime_ns"])), + ) + + with self.assertRaisesRegex(ExecutionError, "摘要|变化"): + rollback(log_path) + + self.assertTrue(entry.destination.exists()) + self.assertEqual(entry.destination.read_bytes(), replacement) + + def test_copy_rollback_refuses_legacy_log_without_digest(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + entry = self._entry(root) + log_path = self._execute(root, entry, "copy") + record = json.loads(log_path.read_text(encoding="utf-8").strip()) + record.pop("result_sha256", None) + log_path.write_text(json.dumps(record, ensure_ascii=False) + "\n", encoding="utf-8") + + with self.assertRaisesRegex(ExecutionError, "摘要"): + rollback(log_path) + + self.assertTrue(entry.destination.exists()) + + def test_rollback_syncs_database_after_resolution_fingerprint_changes(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + entry = self._entry(root) + with ResolutionCache(root / "library.sqlite3") as cache: + cache.put(entry.resolution) + log_path = execute_plan([entry], "copy", True, cache, root / "logs") + revised = Resolution( + entry.resolution.media, + "Renamed Test Show", + 1, + 1, + False, + 1.0, + True, + fingerprint="new-decision-fingerprint", + ) + cache.put(revised) + + self.assertEqual(rollback(log_path, cache), 1) + row = cache.connection.execute( + "SELECT current_path, status FROM media_files WHERE source_key IS NOT NULL" + ).fetchone() + + self.assertEqual(row["current_path"], str(entry.source)) + self.assertEqual(row["status"], "identified") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_file_facts.py b/tests/test_v3_file_facts.py new file mode 100644 index 0000000..2b2412b --- /dev/null +++ b/tests/test_v3_file_facts.py @@ -0,0 +1,71 @@ +import os +import tempfile +import unittest +from pathlib import Path + + +class FileFactsTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.database = self.root / "web.sqlite3" + self.source = self.root / "source" + self.library = self.root / "library" + self.source.mkdir() + self.library.mkdir() + + from autoanime_v3.services.roots import RootService + + roots = RootService(self.database) + self.source_root = roots.create_root("source", self.source) + self.library_root = roots.create_root("library", self.library) + + def tearDown(self): + self.temporary_directory.cleanup() + + def repository(self): + from autoanime_v3.db.repositories.library import LibraryRepository + + return LibraryRepository(self.database) + + def test_one_media_file_can_have_source_and_library_hardlink_locations(self): + source_path = self.source / "[Group] Test Show - 01.mkv" + source_path.write_bytes(b"real-media-fact" * 128) + library_path = self.library / "Test Show" / "Season 01" / "E01.mkv" + library_path.parent.mkdir(parents=True) + os.link(str(source_path), str(library_path)) + + repository = self.repository() + source_media = repository.observe_path( + self.source_root.id, source_path, "source", "video" + ) + library_media = repository.observe_path( + self.library_root.id, library_path, "library", "video" + ) + + self.assertEqual(source_media.id, library_media.id) + refreshed = repository.get_media(source_media.id) + self.assertEqual( + {(location.role, location.state) for location in refreshed.locations}, + {("source", "present"), ("library", "present")}, + ) + + def test_reused_path_creates_new_generation_and_replaces_old_location(self): + path = self.source / "reused.mkv" + path.write_bytes(b"generation-one") + repository = self.repository() + first = repository.observe_path(self.source_root.id, path, "source", "video") + + path.unlink() + path.write_bytes(b"generation-two-is-different") + second = repository.observe_path(self.source_root.id, path, "source", "video") + + self.assertNotEqual(first.id, second.id) + old = repository.get_media(first.id) + current = repository.get_media(second.id) + self.assertEqual(old.locations[0].state, "replaced") + self.assertEqual(current.locations[0].state, "present") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_jobs.py b/tests/test_v3_jobs.py new file mode 100644 index 0000000..918c570 --- /dev/null +++ b/tests/test_v3_jobs.py @@ -0,0 +1,125 @@ +import tempfile +import threading +import time +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +class MutableClock: + def __init__(self): + self.value = datetime(2026, 7, 25, 9, 0, tzinfo=timezone.utc) + + def __call__(self): + return self.value + + def advance(self, **kwargs): + self.value += timedelta(**kwargs) + + +class PersistentJobQueueTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.database = Path(self.temporary_directory.name) / "jobs.sqlite3" + self.clock = MutableClock() + + from autoanime_v3.jobs.queue import JobQueue + + self.queue = JobQueue(self.database, clock=self.clock) + + def tearDown(self): + self.temporary_directory.cleanup() + + def test_enqueue_is_idempotent(self): + first = self.queue.enqueue("scan", {"profile_id": 1}, "scan-profile-1") + second = self.queue.enqueue("scan", {"profile_id": 1}, "scan-profile-1") + self.assertEqual(first.id, second.id) + self.assertEqual(second.status, "queued") + + def test_only_one_worker_can_lease_and_heartbeat_requires_owner(self): + from autoanime_v3.domain.errors import LeaseConflictError + + job = self.queue.enqueue("scan", {}, "one-lease") + leased = self.queue.lease_next("worker-a", 60) + self.assertEqual(leased.id, job.id) + self.assertEqual(leased.lease_owner, "worker-a") + self.assertIsNone(self.queue.lease_next("worker-b", 60)) + renewed = self.queue.heartbeat(job.id, "worker-a", 120) + self.assertEqual(renewed.lease_owner, "worker-a") + with self.assertRaises(LeaseConflictError): + self.queue.heartbeat(job.id, "worker-b", 120) + + def test_expired_file_changing_lease_becomes_interrupted_not_requeued(self): + job = self.queue.enqueue("execute_plan", {"plan_id": 8}, "execute-8") + self.queue.lease_next("worker-a", 30) + self.queue.start(job.id, "worker-a") + self.clock.advance(seconds=31) + + self.assertIsNone(self.queue.lease_next("worker-b", 30)) + recovered = self.queue.get(job.id) + self.assertEqual(recovered.status, "interrupted") + self.assertEqual(recovered.error_code, "lease_expired") + + def test_events_have_strictly_increasing_sequences(self): + job = self.queue.enqueue("scan", {}, "events") + first = self.queue.append_event(job.id, "phase", {"name": "discover"}, "开始扫描") + second = self.queue.append_event(job.id, "progress", {"current": 1}, "发现文件") + self.assertEqual((first.sequence, second.sequence), (1, 2)) + self.assertEqual([event.sequence for event in self.queue.events(job.id)], [1, 2]) + + def test_running_cancellation_waits_for_safe_boundary(self): + job = self.queue.enqueue("execute_plan", {}, "cancel") + self.queue.lease_next("worker-a", 60) + self.queue.start(job.id, "worker-a") + requested = self.queue.request_cancel(job.id) + self.assertEqual(requested.status, "running") + self.assertTrue(requested.cancel_requested) + cancelled = self.queue.cancel_at_safe_boundary(job.id, "worker-a") + self.assertEqual(cancelled.status, "cancelled") + self.assertIsNone(cancelled.lease_owner) + + def test_worker_crash_does_not_silently_repeat_unknown_file_operation(self): + from autoanime_v3.jobs.worker import Worker + + job = self.queue.enqueue("execute_plan", {"plan_id": 99}, "crash-recovery") + worker = Worker("worker-a", self.queue, {"execute_plan": lambda unused: None}) + leased = worker.acquire(lease_seconds=10) + self.assertEqual(leased.id, job.id) + self.queue.start(job.id, "worker-a") + self.clock.advance(seconds=11) + + replacement = Worker("worker-b", self.queue, {"execute_plan": lambda unused: None}) + self.assertIsNone(replacement.acquire(lease_seconds=10)) + self.assertEqual(self.queue.get(job.id).status, "interrupted") + + def test_worker_renews_lease_for_entire_handler_lifetime(self): + from autoanime_v3.jobs.queue import JobQueue + from autoanime_v3.jobs.worker import Worker + + queue = JobQueue(self.database) + job = queue.enqueue("scan", {}, "long-handler") + started = threading.Event() + release = threading.Event() + + def long_handler(unused_job): + started.set() + self.assertTrue(release.wait(3)) + + worker = Worker("worker-a", queue, {"scan": long_handler}) + thread = threading.Thread(target=worker.run_once, kwargs={"lease_seconds": 0.3}) + thread.start() + self.assertTrue(started.wait(1)) + time.sleep(0.5) + + replacement = Worker("worker-b", queue, {"scan": lambda unused: None}) + self.assertIsNone(replacement.acquire(lease_seconds=0.3)) + self.assertEqual(queue.get(job.id).lease_owner, "worker-a") + + release.set() + thread.join(3) + self.assertFalse(thread.is_alive()) + self.assertEqual(queue.get(job.id).status, "succeeded") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_library_service.py b/tests/test_v3_library_service.py new file mode 100644 index 0000000..d4eca45 --- /dev/null +++ b/tests/test_v3_library_service.py @@ -0,0 +1,57 @@ +import tempfile +import unittest +from pathlib import Path + +from autoanime_v3.cache import ResolutionCache +from autoanime_v3.library_service import LibraryService +from autoanime_v3.models import MediaFile, Resolution + + +class LibraryServiceTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + + def tearDown(self): + self.temporary_directory.cleanup() + + def media(self, name, content=b"video"): + source = self.root / name + source.write_bytes(content) + stat = source.stat() + return MediaFile(source, self.root, "bundle", source.name, stat.st_size, stat.st_mtime_ns) + + def test_library_progress_and_title_correction_preview(self): + media = self.media("Show.S01E01.mkv") + resolution = Resolution(media, "旧番名", 1, 1, False, 0.99, True, "Baha", fingerprint="fixture") + with ResolutionCache(self.root / "library.sqlite3") as repository: + repository.put(resolution) + repository.mark_organized(resolution, self.root / "library" / "旧番名" / "Season 01" / "S01E01 - 旧番名.mkv") + service = LibraryService(repository, self.root / "library") + shows = service.list_shows() + self.assertEqual(shows[0]["organized_episodes"], 1) + preview = service.preview_show_title_change(shows[0]["show_id"], "新番名", "测试纠正") + self.assertEqual(preview["status"], "draft") + self.assertIn("S01E01 - 新番名.mkv", preview["moves"][0]["destination"]) + self.assertEqual(preview["conflicts"], 0) + + def test_title_correction_preserves_version_suffix_and_ignores_unorganized_files(self): + organized_media = self.media("organized-source.mkv") + organized = Resolution(organized_media, "旧番名", 1, 1, False, 0.99, True, "Baha", fingerprint="organized") + pending_media = self.media("pending.mkv") + pending = Resolution(pending_media, "旧番名", 1, 2, False, 0.99, True, "", fingerprint="pending") + + with ResolutionCache(self.root / "library.sqlite3") as repository: + repository.put(organized) + repository.put(pending) + current = self.root / "library" / "旧番名" / "Season 01" / "S01E01 - 旧番名 [Baha-abcd1234].mkv" + repository.mark_organized(organized, current) + service = LibraryService(repository, self.root / "library") + show_id = service.list_shows()[0]["show_id"] + preview = service.preview_show_title_change(show_id, "新番名") + self.assertEqual(len(preview["moves"]), 1) + self.assertTrue(preview["moves"][0]["destination"].endswith("S01E01 - 新番名 [Baha-abcd1234].mkv")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_metadata.py b/tests/test_v3_metadata.py new file mode 100644 index 0000000..79be25f --- /dev/null +++ b/tests/test_v3_metadata.py @@ -0,0 +1,18 @@ +import unittest + + +class MetadataBoundaryTests(unittest.TestCase): + def test_provider_failure_returns_unavailable_without_raising(self): + from autoanime_v3.integrations.metadata import SafeMetadataAdapter + + def failing_provider(unused_title): + raise TimeoutError("provider offline") + + result = SafeMetadataAdapter(failing_provider).fetch("测试番") + self.assertFalse(result.available) + self.assertEqual(result.status, "unavailable") + self.assertIsNone(result.poster_url) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_operation_service.py b/tests/test_v3_operation_service.py new file mode 100644 index 0000000..cd42302 --- /dev/null +++ b/tests/test_v3_operation_service.py @@ -0,0 +1,121 @@ +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +class OperationServiceTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.database = self.root / "web.sqlite3" + self.source = self.root / "downloads" + self.library = self.root / "library" + self.source.mkdir() + self.library.mkdir() + + from autoanime_v3.domain.entities import CreateProfile + from autoanime_v3.services.profiles import ProfileService + from autoanime_v3.services.roots import RootService + + roots = RootService(self.database) + source_root = roots.create_root("source", self.source) + library_root = roots.create_root("library", self.library) + self.profile = ProfileService(self.database).create_profile( + CreateProfile( + name="真实执行", + source_root_id=source_root.id, + library_root_id=library_root.id, + mode="link", + ) + ) + + def tearDown(self): + self.temporary_directory.cleanup() + + def prepare_plan(self, names): + for index, name in enumerate(names): + (self.source / name).write_bytes(("real-file-%s" % index).encode("utf-8") * 1024) + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.scans import ScanService + + outcome = ScanService(self.database).run(self.profile.id) + return PlanService(self.database).approve(outcome.plan_id) + + def test_preflight_checks_entire_batch_before_any_file_change(self): + from autoanime_v3.domain.errors import StalePlanError + from autoanime_v3.services.operations import OperationService + + plan = self.prepare_plan(["测试番 S01E01.mkv", "测试番 S01E02.mkv"]) + Path(plan.items[1].source_path).write_bytes(b"changed-after-approval") + + with self.assertRaises(StalePlanError): + OperationService(self.database).execute(plan.id) + self.assertFalse(any(path.is_file() for path in self.library.rglob("*"))) + + def test_real_hardlink_execution_and_manual_rollback_are_recorded(self): + from autoanime_v3.services.operations import OperationService + + plan = self.prepare_plan(["测试番 S01E01.mkv"]) + operations = OperationService(self.database) + batch = operations.execute(plan.id) + destination = Path(plan.items[0].destination_path) + + self.assertEqual(batch.status, "completed") + self.assertTrue(destination.is_file()) + self.assertTrue(os.path.samefile(plan.items[0].source_path, destination)) + self.assertEqual(len(batch.items), 1) + self.assertEqual(batch.items[0].status, "success") + + rollback_batch = operations.rollback(batch.id) + self.assertEqual(rollback_batch.status, "completed") + self.assertFalse(destination.exists()) + self.assertTrue(Path(plan.items[0].source_path).exists()) + + def test_partial_automatic_rollback_is_recorded_for_recovery(self): + from autoanime_v3.executor import ExecutionFailure + from autoanime_v3.services import operations as operations_module + from autoanime_v3.services.operations import OperationService + + plan = self.prepare_plan(["测试番 S01E01.mkv"]) + log_path = self.root / "operations" / "partial.jsonl" + log_path.parent.mkdir() + log_path.write_text("{}\n", encoding="utf-8") + applied = { + "source": plan.items[0].source_path, + "destination": plan.items[0].destination_path, + "applied": True, + "result_sha256": "a" * 64, + } + failure = ExecutionFailure( + "controlled partial rollback", + log_path=log_path, + applied_records=[applied], + rollback_results=[ + { + "source": plan.items[0].source_path, + "destination": plan.items[0].destination_path, + "status": "failed", + "error": "destination could not be removed", + } + ], + rollback_errors=["destination could not be removed"], + ) + + with mock.patch.object(operations_module, "execute_plan", side_effect=failure): + with self.assertRaises(ExecutionFailure): + OperationService(self.database).execute(plan.id) + + batch = OperationService(self.database).get(1) + self.assertEqual(batch.status, "failed_partial_rollback") + self.assertEqual(batch.summary["log_path"], str(log_path)) + self.assertEqual(batch.summary["rollback_errors"], ["destination could not be removed"]) + self.assertEqual(batch.summary["applied_items"], [applied]) + self.assertEqual(len(batch.items), 1) + self.assertEqual(batch.items[0].status, "applied") + self.assertEqual(batch.items[0].compensation_status, "failed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_parser.py b/tests/test_v3_parser.py new file mode 100644 index 0000000..9b4d032 --- /dev/null +++ b/tests/test_v3_parser.py @@ -0,0 +1,55 @@ +import tempfile +import unittest +from pathlib import Path + +from autoanime_v3.parser import parse_name +from autoanime_v3.scanner import scan_media + + +class ParserTests(unittest.TestCase): + def test_parses_ubweb_folder_and_episode(self): + parsed = parse_name( + Path("[斗球女弹子].Dodge.Danko.2026.S01E03.1080p.WEB-DL.mkv"), + "[斗球女弹子].Dodge.Danko.2026.S01.Complete.1080p.WEB-DL", + ) + self.assertEqual(parsed.raw_title, "斗球女弹子") + self.assertEqual((parsed.season, parsed.episode), (1, 3)) + + def test_parses_multilingual_bracket_title(self): + parsed = parse_name( + Path("【今晚月色真美】[没有辣妹会对阿宅温柔!? / オタクに優しいギャルはいない!? / Otaku ni Yasashii Gal wa Inai!?][11][1080P].mkv") + ) + self.assertEqual(parsed.raw_title, "没有辣妹会对阿宅温柔!?") + self.assertEqual(parsed.episode, 11) + + def test_prefers_local_episode_over_absolute_parenthetical(self): + parsed = parse_name(Path("[BeanSub&LoliHouse] Tensei Shitara Slime Datta Ken 4th Season - 14(86) [1080p].mkv")) + self.assertEqual((parsed.season, parsed.episode), (4, 14)) + + def test_single_chinese_file(self): + parsed = parse_name(Path("[ANi] 骸骨騎士大人異世界冒險中 第二季 - 03 [1080P][Baha].mp4")) + self.assertEqual(parsed.raw_title, "骸骨骑士大人异世界冒险中 第二季") + self.assertEqual((parsed.season, parsed.episode), (2, 3)) + + def test_metadata_bracket_is_not_used_as_title(self): + parsed = parse_name(Path("[ANi] 从后面来的神威先生 [年龄限制版] - 03 [1080P].mp4")) + self.assertEqual(parsed.raw_title, "从后面来的神威先生") + self.assertEqual(parsed.episode, 3) + + def test_language_bracket_is_not_used_as_title(self): + parsed = parse_name(Path("[Group] 葬送的芙莉莲 第二季 Sousou no Frieren S2 [10][简体双语][1080p].mp4")) + self.assertNotIn("简体双语", parsed.raw_title) + self.assertEqual((parsed.season, parsed.episode), (2, 10)) + + def test_scanner_does_not_drop_source_when_output_is_an_ancestor(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "downloads" + source.mkdir() + video = source / "Show.S01E01.mkv" + video.write_bytes(b"video") + self.assertEqual([item.path for item in scan_media(source, root)], [video]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_planner_executor.py b/tests/test_v3_planner_executor.py new file mode 100644 index 0000000..6f93cb5 --- /dev/null +++ b/tests/test_v3_planner_executor.py @@ -0,0 +1,115 @@ +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from autoanime_v3.cache import ResolutionCache, fingerprint +from autoanime_v3.executor import ExecutionError, execute_plan, rollback +from autoanime_v3.models import MediaFile, Resolution +from autoanime_v3.planner import build_plan + + +class PlannerExecutorTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + + def tearDown(self): + self.temporary_directory.cleanup() + + def resolution(self, name, tag): + path = self.root / name + path.write_bytes(name.encode("utf-8")) + stat = path.stat() + media = MediaFile(path, self.root, "bundle", name, stat.st_size, stat.st_mtime_ns) + return Resolution(media, "测试番剧", 1, 3, False, 0.99, True, tag) + + def test_duplicate_platform_versions_do_not_overwrite(self): + left = self.resolution("Show.S01E03.Baha.mkv", "Baha") + right = self.resolution("Show.S01E03.friDay.mkv", "friDay") + plan = build_plan([left, right], self.root / "library") + destinations = [entry.destination for entry in plan if entry.action == "organize"] + self.assertEqual(len(destinations), 2) + self.assertEqual(len(set(destinations)), 2) + + def test_move_and_rollback(self): + resolution = self.resolution("Show.S01E03.mkv", "") + plan = build_plan([resolution], self.root / "library") + with ResolutionCache(self.root / "library.sqlite3") as cache: + cache.put(resolution) + log = execute_plan(plan, "move", True, cache, self.root / "logs") + destination = plan[0].destination + self.assertTrue(destination and destination.exists() and not resolution.media.path.exists()) + self.assertEqual(rollback(log, cache), 1) + row = cache.connection.execute( + "SELECT current_path, status FROM media_files WHERE source_key IS NOT NULL" + ).fetchone() + self.assertEqual(row["current_path"], str(resolution.media.path)) + self.assertEqual(row["status"], "identified") + self.assertTrue(resolution.media.path.exists()) + self.assertFalse(destination.exists()) + + def test_subtitle_language_suffix_is_preserved_case_insensitively(self): + resolution = self.resolution("Show.S01E03.mkv", "") + subtitle = self.root / "SHOW.S01E03.CHS.ass" + subtitle.write_text("subtitle", encoding="utf-8") + plan = build_plan([resolution], self.root / "library") + subtitle_entries = [entry for entry in plan if entry.companion_of] + self.assertEqual(len(subtitle_entries), 1) + self.assertTrue(subtitle_entries[0].destination.name.endswith(".CHS.ass")) + + def test_failed_batch_auto_rolls_back_completed_moves(self): + first = self.resolution("Show.S01E03.mkv", "") + second = self.resolution("Show.S01E04.mkv", "") + second.episode = 4 + plan = build_plan([first, second], self.root / "library") + second.media.path.unlink() + with ResolutionCache(self.root / "library.sqlite3") as cache: + with self.assertRaises(ExecutionError): + execute_plan(plan, "move", True, cache, self.root / "logs") + self.assertTrue(first.media.path.exists()) + self.assertFalse(plan[0].destination.exists()) + + def test_failed_copy_removes_partial_destination(self): + resolution = self.resolution("Show.S01E03.mkv", "") + plan = build_plan([resolution], self.root / "library") + + def fail_after_partial_copy(source_handle, destination_handle, length): + destination_handle.write(source_handle.read(1)) + raise OSError("simulated copy failure") + + with mock.patch("autoanime_v3.executor.shutil.copyfileobj", side_effect=fail_after_partial_copy): + with ResolutionCache(self.root / "library.sqlite3") as cache: + cache.put(resolution) + with self.assertRaises(ExecutionError): + execute_plan(plan, "copy", True, cache, self.root / "logs") + self.assertTrue(resolution.media.path.exists()) + self.assertFalse(plan[0].destination.exists()) + + def test_manual_rollback_refuses_to_delete_changed_copy(self): + resolution = self.resolution("Show.S01E03.mkv", "") + plan = build_plan([resolution], self.root / "library") + with ResolutionCache(self.root / "library.sqlite3") as cache: + cache.put(resolution) + log = execute_plan(plan, "copy", True, cache, self.root / "logs") + plan[0].destination.write_bytes(b"changed after organization") + with self.assertRaisesRegex(ExecutionError, "已变化|摘要"): + rollback(log, cache) + self.assertTrue(resolution.media.path.exists()) + self.assertTrue(plan[0].destination.exists()) + + def test_hard_link_and_rollback_keep_original_source(self): + resolution = self.resolution("Show.S01E03.mkv", "") + plan = build_plan([resolution], self.root / "library") + with ResolutionCache(self.root / "library.sqlite3") as cache: + cache.put(resolution) + log = execute_plan(plan, "link", True, cache, self.root / "logs") + destination = plan[0].destination + self.assertTrue(destination.exists() and destination.samefile(resolution.media.path)) + self.assertEqual(rollback(log, cache), 1) + self.assertTrue(resolution.media.path.exists()) + self.assertFalse(destination.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_planner_incremental.py b/tests/test_v3_planner_incremental.py new file mode 100644 index 0000000..ba2d405 --- /dev/null +++ b/tests/test_v3_planner_incremental.py @@ -0,0 +1,198 @@ +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from autoanime_v3.models import Evidence, MediaFile, Resolution +from autoanime_v3.planner import build_plan + + +class PlannerIncrementalTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.output_root = self.root / "library" + + def tearDown(self): + self.temporary_directory.cleanup() + + def resolution(self, relative_path, release_tag="", episode=3): + path = self.root / "input" / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(str(relative_path).encode("utf-8")) + stat = path.stat() + media = MediaFile( + path, + self.root / "input", + "bundle", + Path(relative_path).as_posix(), + stat.st_size, + stat.st_mtime_ns, + ) + return Resolution(media, "测试番剧", 1, episode, False, 0.99, True, release_tag) + + @staticmethod + def video_entry(plan): + return next(entry for entry in plan if not entry.companion_of) + + def test_incremental_platform_versions_keep_distinct_stable_destinations(self): + baha = self.resolution("baha/Show.S01E03.Baha.mkv", "Baha") + friday = self.resolution("friday/Show.S01E03.friDay.mkv", "friDay") + + first = self.video_entry(build_plan([baha], self.output_root)) + self.assertIn("[Baha]", first.destination.name) + first.destination.parent.mkdir(parents=True, exist_ok=True) + first.destination.write_bytes(b"already organized Baha release") + + second = self.video_entry(build_plan([friday], self.output_root)) + self.assertEqual("organize", second.action) + self.assertIn("[friDay]", second.destination.name) + self.assertNotEqual(first.destination, second.destination) + + def test_single_file_intrinsic_metadata_gets_a_stable_version_label(self): + cases = ( + ("[BeanSub] Show.S01E03.mkv", "", "BeanSub"), + ("[ANi] Show.S01E03.mkv", "", "ANi"), + ("[Studio GreenTea] Show.S01E03.mkv", "", "Studio GreenTea"), + ("[BeanSub&LoliHouse] Show.S01E03.mkv", "", "BeanSub&LoliHouse"), + ("Show.S01E03.uncensored.mkv", "", "Uncensored"), + ("Show.S01E03.Mandarin.mkv", "", "zh-dub"), + ) + for index, (name, release_tag, expected_label) in enumerate(cases): + with self.subTest(name=name): + resolution = self.resolution("case%d/%s" % (index, name), release_tag) + entry = self.video_entry(build_plan([resolution], self.output_root)) + self.assertIn("[%s]" % expected_label, entry.destination.name) + + def test_version_label_uses_the_actual_v_number(self): + for version in (3, 4): + with self.subTest(version=version): + resolution = self.resolution("v%d/Show.S01E03.V%d.mkv" % (version, version)) + entry = self.video_entry(build_plan([resolution], self.output_root)) + self.assertIn("[V%d]" % version, entry.destination.name) + self.assertNotIn("[V2]", entry.destination.name) + + def test_equal_intrinsic_labels_use_order_independent_relative_path_digests(self): + left = self.resolution("left/Show.S01E03.Baha.mkv", "Baha") + right = self.resolution("right/Show.S01E03.Baha.mkv", "Baha") + + forward = { + entry.source: entry.destination + for entry in build_plan([left, right], self.output_root) + if not entry.companion_of + } + reverse = { + entry.source: entry.destination + for entry in build_plan([right, left], self.output_root) + if not entry.companion_of + } + + self.assertEqual(forward, reverse) + self.assertEqual(2, len(set(forward.values()))) + for destination in forward.values(): + self.assertRegex(destination.name, r"\[Baha-[0-9a-f]{8}\]") + + def test_incremental_plain_versions_get_distinct_stable_path_keys(self): + first_resolution = self.resolution("plain-a/Show.S01E03.mkv") + second_resolution = self.resolution("plain-b/Show.S01E03.mkv") + + first = self.video_entry(build_plan([first_resolution], self.output_root)) + self.assertRegex(first.destination.name, r"\[version-[0-9a-f]{8}\]") + first.destination.parent.mkdir(parents=True, exist_ok=True) + first.destination.write_bytes(b"already organized plain release") + + second = self.video_entry(build_plan([second_resolution], self.output_root)) + self.assertEqual("organize", second.action) + self.assertRegex(second.destination.name, r"\[version-[0-9a-f]{8}\]") + self.assertNotEqual(first.destination, second.destination) + + def test_plain_versions_with_same_relative_path_in_different_roots_get_distinct_keys(self): + resolutions = [] + for root_name in ("input-a", "input-b"): + input_root = self.root / root_name + path = input_root / "Show.S01E03.mkv" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(root_name.encode("utf-8")) + stat = path.stat() + media = MediaFile( + path, + input_root, + "bundle", + "Show.S01E03.mkv", + stat.st_size, + stat.st_mtime_ns, + ) + resolutions.append(Resolution(media, "测试番剧", 1, 3, False, 0.99, True)) + + destinations = [ + self.video_entry(build_plan([resolution], self.output_root)).destination + for resolution in resolutions + ] + + self.assertNotEqual(destinations[0], destinations[1]) + + def test_stable_version_key_does_not_require_path_resolution_access(self): + resolution = self.resolution("restricted/Show.S01E03.mkv") + + with mock.patch.object(Path, "resolve", side_effect=PermissionError("denied")): + entry = self.video_entry(build_plan([resolution], self.output_root)) + + self.assertRegex(entry.destination.name, r"\[version-[0-9a-f]{8}\]") + + def test_title_bracket_is_not_misclassified_as_release_group(self): + resolution = self.resolution("title/[测试番剧] Show.S01E03.mkv") + entry = self.video_entry(build_plan([resolution], self.output_root)) + self.assertNotIn("[测试番剧]", entry.destination.name) + + def test_catalog_alias_bracket_is_not_misclassified_as_release_group(self): + resolution = self.resolution("title-alias/[Grand Blue] Show.S01E03.mkv") + resolution.canonical_title = "碧蓝之海" + resolution.evidence.append(Evidence("catalog", "碧蓝之海", 0.99, "alias=Grand Blue")) + + entry = self.video_entry(build_plan([resolution], self.output_root)) + + self.assertNotIn("[Grand Blue]", entry.destination.name) + self.assertRegex(entry.destination.name, r"\[version-[0-9a-f]{8}\]") + + def test_already_linked_video_still_plans_a_new_matching_subtitle(self): + resolution = self.resolution("linked/Show.S01E03.mkv") + initial = self.video_entry(build_plan([resolution], self.output_root)) + initial.destination.parent.mkdir(parents=True, exist_ok=True) + os.link(str(resolution.media.path), str(initial.destination)) + subtitle = resolution.media.path.with_name("Show.S01E03.CHS.ass") + subtitle.write_text("new subtitle", encoding="utf-8") + + plan = build_plan([resolution], self.output_root) + video = self.video_entry(plan) + subtitle_entries = [entry for entry in plan if entry.companion_of] + + self.assertEqual("skip", video.action) + self.assertEqual("already_linked", video.reason) + self.assertEqual(1, len(subtitle_entries)) + self.assertEqual("organize", subtitle_entries[0].action) + self.assertEqual(subtitle, subtitle_entries[0].source) + self.assertTrue(subtitle_entries[0].destination.name.endswith(".CHS.ass")) + + def test_already_linked_video_keeps_subtitle_destination_collision_safe(self): + resolution = self.resolution("collision/Show.S01E04.mkv", episode=4) + initial = self.video_entry(build_plan([resolution], self.output_root)) + initial.destination.parent.mkdir(parents=True, exist_ok=True) + os.link(str(resolution.media.path), str(initial.destination)) + subtitle = resolution.media.path.with_name("Show.S01E04.CHS.ass") + subtitle.write_text("new subtitle", encoding="utf-8") + subtitle_destination = initial.destination.with_suffix("").with_name( + initial.destination.stem + ".CHS.ass" + ) + subtitle_destination.write_text("unrelated existing subtitle", encoding="utf-8") + + plan = build_plan([resolution], self.output_root) + subtitle_entries = [entry for entry in plan if entry.companion_of] + + self.assertEqual(1, len(subtitle_entries)) + self.assertEqual("conflict", subtitle_entries[0].action) + self.assertEqual("subtitle_destination_exists", subtitle_entries[0].reason) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_resolver.py b/tests/test_v3_resolver.py new file mode 100644 index 0000000..38fc601 --- /dev/null +++ b/tests/test_v3_resolver.py @@ -0,0 +1,95 @@ +import os +import tempfile +import unittest +from pathlib import Path + +from autoanime_v3.cache import ResolutionCache, fingerprint +from autoanime_v3.catalog import TitleCatalog +from autoanime_v3.config import AppConfig +from autoanime_v3.models import MediaFile +from autoanime_v3.resolver import Resolver + + +class ResolverTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + + def tearDown(self): + self.temporary_directory.cleanup() + + def media(self, name, context="下载"): + path = self.root / name + path.write_bytes(b"video") + stat = path.stat() + return MediaFile(path, self.root, context, name, stat.st_size, stat.st_mtime_ns) + + def config(self): + return AppConfig(self.root / "library.sqlite3", self.root / "aliases.json") + + def test_catalog_resolution_is_accepted(self): + catalog = TitleCatalog({"sousounofrieren": "葬送的芙莉莲"}, {"葬送的芙莉莲": [28, 28]}) + with ResolutionCache(self.config().cache_path) as cache: + result = Resolver(catalog, self.config(), cache).resolve(self.media("Sousou no Frieren - 38.mkv")) + self.assertTrue(result.accepted) + self.assertEqual((result.canonical_title, result.season, result.episode), ("葬送的芙莉莲", 2, 10)) + + def test_unknown_english_title_requires_review(self): + with ResolutionCache(self.config().cache_path) as cache: + result = Resolver(TitleCatalog({}, {}), self.config(), cache).resolve(self.media("Unknown Anime S01E03.mkv")) + self.assertFalse(result.accepted) + self.assertIn("unverified_non_chinese_title", result.warnings) + + def test_chinese_title_is_accepted_without_remote_api(self): + with ResolutionCache(self.config().cache_path) as cache: + result = Resolver(TitleCatalog({}, {}), self.config(), cache).resolve(self.media("[ANi] 摩緒 - 03 [1080P].mp4")) + self.assertTrue(result.accepted) + self.assertEqual(result.canonical_title, "摩绪") + + def test_catalog_can_define_single_file_special_default(self): + catalog = TitleCatalog({"somepv": "某动画 PV"}, {}, {"somepv": (0, 1)}) + with ResolutionCache(self.config().cache_path) as cache: + result = Resolver(catalog, self.config(), cache).resolve(self.media("Some PV.mkv")) + self.assertTrue(result.accepted) + self.assertEqual((result.season, result.episode), (0, 1)) + + def test_explicit_season_absolute_episode_is_remapped(self): + catalog = TitleCatalog({"slime": "史莱姆"}, {"史莱姆": [24, 24, 24, 24]}) + with ResolutionCache(self.config().cache_path) as cache: + result = Resolver(catalog, self.config(), cache).resolve(self.media("Slime 4th Season - 87.mkv")) + self.assertEqual((result.season, result.episode), (4, 15)) + + def test_romanized_alias_uses_canonical_title_season_layout(self): + catalog = TitleCatalog( + {"himesamagoumonnojikandesu": "公主殿下,“拷问”的时间到了"}, + {"公主殿下,“拷问”的时间到了": [12, 12]}, + ) + with ResolutionCache(self.config().cache_path) as cache: + result = Resolver(catalog, self.config(), cache).resolve(self.media("Hime-sama Goumon no Jikan desu [23].mkv")) + self.assertEqual(result.canonical_title, "公主殿下,“拷问”的时间到了") + self.assertEqual((result.season, result.episode), (2, 11)) + + def test_catalog_change_invalidates_cached_decision(self): + media = self.media("Example S01E01.mkv") + with ResolutionCache(self.config().cache_path) as cache: + first = Resolver(TitleCatalog({"example": "旧标题"}, {}), self.config(), cache).resolve(media) + second = Resolver(TitleCatalog({"example": "新标题"}, {}), self.config(), cache).resolve(media) + self.assertEqual(first.canonical_title, "旧标题") + self.assertEqual(second.canonical_title, "新标题") + + def test_same_named_files_in_different_subdirectories_do_not_share_cache(self): + left_path = self.root / "season" / "left" / "Episode01.mkv" + right_path = self.root / "season" / "right" / "Episode01.mkv" + left_path.parent.mkdir(parents=True) + right_path.parent.mkdir(parents=True) + left_path.write_bytes(b"same") + right_path.write_bytes(b"same") + shared_mtime = left_path.stat().st_mtime_ns + os.utime(str(right_path), ns=(shared_mtime, shared_mtime)) + left = MediaFile(left_path, self.root, "season", "season/left/Episode01.mkv", 4, shared_mtime) + right = MediaFile(right_path, self.root, "season", "season/right/Episode01.mkv", 4, shared_mtime) + self.assertNotEqual(fingerprint(left, "rules"), fingerprint(right, "rules")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_review_plan_service.py b/tests/test_v3_review_plan_service.py new file mode 100644 index 0000000..6a5ba99 --- /dev/null +++ b/tests/test_v3_review_plan_service.py @@ -0,0 +1,470 @@ +import concurrent.futures +import threading +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from autoanime_v3.domain.errors import ValidationError + + +class ReviewAndPlanServiceTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.database = self.root / "web.sqlite3" + self.source = self.root / "downloads" + self.library = self.root / "library" + self.source.mkdir() + self.library.mkdir() + + from autoanime_v3.domain.entities import CreateProfile + from autoanime_v3.services.profiles import ProfileService + from autoanime_v3.services.roots import RootService + + roots = RootService(self.database) + self.source_root = roots.create_root("source", self.source) + self.library_root = roots.create_root("library", self.library) + self.profiles = ProfileService(self.database) + self.profile = self.profiles.create_profile( + CreateProfile( + name="默认整理", + source_root_id=self.source_root.id, + library_root_id=self.library_root.id, + ) + ) + + def tearDown(self): + self.temporary_directory.cleanup() + + def scan_safe(self): + self.media = self.source / "测试番 S01E01.mkv" + self.media.write_bytes(b"safe-media-content") + from autoanime_v3.services.scans import ScanService + + return ScanService(self.database).run(self.profile.id) + + def scan_review(self, filename="Unknown Show - 02.mkv"): + (self.source / filename).write_bytes(b"review-media") + from autoanime_v3.services.reviews import ReviewService + from autoanime_v3.services.scans import ScanService + + ScanService(self.database).run(self.profile.id) + return ReviewService(self.database).list_open()[0] + + def test_changed_source_identity_makes_plan_stale(self): + from autoanime_v3.domain.errors import StalePlanError + from autoanime_v3.services.plans import PlanService + + outcome = self.scan_safe() + self.media.write_bytes(b"changed-after-preview") + with self.assertRaises(StalePlanError): + PlanService(self.database).approve(outcome.plan_id) + self.assertEqual(PlanService(self.database).get(outcome.plan_id).status, "stale") + + def test_changed_profile_revision_makes_plan_stale(self): + from autoanime_v3.domain.errors import StalePlanError + from autoanime_v3.services.plans import PlanService + + outcome = self.scan_safe() + self.profiles.update_profile(self.profile.id, self.profile.revision, {"min_confidence": 92}) + with self.assertRaises(StalePlanError): + PlanService(self.database).approve(outcome.plan_id) + + def test_destination_created_after_preview_prevents_approval(self): + from autoanime_v3.domain.errors import PlanConflictError + from autoanime_v3.services.plans import PlanService + + outcome = self.scan_safe() + plan = PlanService(self.database).get(outcome.plan_id) + destination = Path(plan.items[0].destination_path) + destination.parent.mkdir(parents=True) + destination.write_bytes(b"occupied") + with self.assertRaises(PlanConflictError): + PlanService(self.database).approve(outcome.plan_id) + + def test_resolving_review_creates_new_plan_revision_without_mutating_old(self): + (self.source / "Unknown Show - 02.mkv").write_bytes(b"review-media") + from autoanime_v3.db.engine import connect_sqlite + from autoanime_v3.services.plans import PlanService + from autoanime_v3.services.reviews import ReviewService + from autoanime_v3.services.scans import ScanService + + outcome = ScanService(self.database).run(self.profile.id) + reviews = ReviewService(self.database).list_open() + connection = connect_sqlite(self.database) + try: + source_location = connection.execute( + """ + SELECT fl.*, mf.file_index, mf.size, mf.mtime_ns + FROM file_locations fl + JOIN media_files mf ON mf.id = fl.media_file_id + WHERE fl.media_file_id = ? AND fl.role = 'source' AND fl.state = 'present' + """, + (reviews[0].media_file_id,), + ).fetchone() + source_location_id, source_file_index, source_size, source_mtime_ns = ( + source_location[0], + source_location[-3], + source_location[-2], + source_location[-1], + ) + connection.execute( + """ + INSERT INTO plan_items( + plan_id, source_location_id, destination_root_id, + destination_relative_path, action, reason, risk_level, + source_file_index, source_size, source_mtime_ns, + identification_snapshot_json, execution_status + ) VALUES (?, ?, ?, ?, 'link', 'legacy_review_item', 'normal', ?, ?, ?, '{}', 'pending') + """, + ( + outcome.plan_id, + source_location_id, + self.library_root.id, + "legacy/Unknown Show - 02.mkv", + source_file_index, + source_size, + source_mtime_ns, + ), + ) + connection.commit() + finally: + connection.close() + + old_plan_before = PlanService(self.database).get(outcome.plan_id) + new_plan = ReviewService(self.database).resolve( + reviews[0].id, + {"title": "人工确认番剧", "season": 1, "episode": 2, "is_movie": False}, + ) + old_plan = PlanService(self.database).get(outcome.plan_id) + + self.assertEqual(old_plan.revision, 1) + self.assertEqual(new_plan.revision, 2) + self.assertNotEqual(old_plan.id, new_plan.id) + self.assertEqual(old_plan, old_plan_before) + self.assertEqual( + sum(item.source_location_id == source_location_id for item in old_plan.items), + 1, + ) + self.assertEqual( + sum(item.source_location_id == source_location_id for item in new_plan.items), + 1, + ) + self.assertEqual(ReviewService(self.database).get(reviews[0].id).status, "resolved") + + def test_resolving_review_persists_video_and_observed_subtitle_entries(self): + video = self.source / "Unknown Show - 02.mkv" + subtitle = self.source / "Unknown Show - 02.CHS.ass" + video.write_bytes(b"review-video") + subtitle.write_text("subtitle", encoding="utf-8") + from autoanime_v3.db.repositories.library import LibraryRepository + from autoanime_v3.services.reviews import ReviewService + from autoanime_v3.services.scans import ScanService + + ScanService(self.database).run(self.profile.id) + subtitle_media = LibraryRepository(self.database).observe_path( + self.source_root.id, subtitle, "source", "subtitle" + ) + review = ReviewService(self.database).list_open()[0] + + new_plan = ReviewService(self.database).resolve( + review.id, + {"title": "人工确认番剧", "media_type": "episode", "season": 1, "episode": 2}, + ) + + self.assertEqual({Path(item.source_path) for item in new_plan.items}, {video, subtitle}) + subtitle_item = next(item for item in new_plan.items if Path(item.source_path) == subtitle) + self.assertEqual(subtitle_item.source_location_id, subtitle_media.locations[0].id) + self.assertEqual(subtitle_item.reason, "subtitle") + + def test_resolving_review_reports_copied_destination_conflict_without_new_revision(self): + review_source = self.source / "Unknown Show - 02.mkv" + review_source.write_bytes(b"review-source") + from autoanime_v3.db.repositories.library import LibraryRepository + from autoanime_v3.domain.errors import PlanConflictError + from autoanime_v3.services.reviews import ReviewService + from autoanime_v3.services.scans import ScanService + + outcome = ScanService(self.database).run(self.profile.id) + service = ReviewService(self.database) + review = service.list_open()[0] + copied_source = self.source / "copied-plan-source.mkv" + copied_source.write_bytes(b"copied-plan-source") + copied_media = LibraryRepository(self.database).observe_path( + self.source_root.id, copied_source, "source", "video" + ) + copied_location = copied_media.locations[0] + from autoanime_v3.models import MediaFile as CoreMediaFile, Resolution + from autoanime_v3.planner import build_plan + + observed_review_source = Path(review.payload["source"]) + review_stat = observed_review_source.stat() + accepted = Resolution( + CoreMediaFile( + path=observed_review_source, + input_root=observed_review_source.parent, + context_name=self.source.name, + relative_path=review.payload["relative_path"], + size=review_stat.st_size, + mtime_ns=review_stat.st_mtime_ns, + ), + "测试番", + 1, + 1, + False, + 1.0, + True, + media_type="episode", + ) + conflicting_destination = str( + build_plan([accepted], self.library)[0].destination.relative_to(self.library) + ) + from autoanime_v3.db.engine import connect_sqlite + + connection = connect_sqlite(self.database) + try: + connection.execute( + """ + INSERT INTO plan_items( + plan_id, source_location_id, destination_root_id, + destination_relative_path, action, reason, risk_level, + source_file_index, source_size, source_mtime_ns, + identification_snapshot_json, execution_status + ) VALUES (?, ?, ?, ?, 'link', 'copied_item', 'normal', ?, ?, ?, '{}', 'pending') + """, + ( + outcome.plan_id, + copied_location.id, + self.library_root.id, + conflicting_destination, + copied_media.file_index, + copied_media.size, + copied_media.mtime_ns, + ), + ) + connection.commit() + finally: + connection.close() + + with self.assertRaises(PlanConflictError) as raised: + service.resolve( + review.id, + {"title": "测试番", "media_type": "episode", "season": 1, "episode": 1}, + ) + + self.assertEqual(raised.exception.details["field"], "destination") + self.assertEqual(service.get(review.id).status, "open") + + connection = connect_sqlite(self.database) + try: + revisions = connection.execute( + "SELECT revision FROM plans WHERE scan_run_id = ? ORDER BY revision", + (outcome.scan_run_id,), + ).fetchall() + finally: + connection.close() + self.assertEqual(revisions, [(1,)]) + + def test_concurrent_resolve_atomically_claims_review_once(self): + (self.source / "Unknown Show - 02.mkv").write_bytes(b"review-source") + from autoanime_v3.db.engine import connect_sqlite + from autoanime_v3.domain.errors import InvalidStateError + from autoanime_v3.services import reviews as reviews_module + from autoanime_v3.services.reviews import ReviewService + from autoanime_v3.services.scans import ScanService + + outcome = ScanService(self.database).run(self.profile.id) + review = ReviewService(self.database).list_open()[0] + connection = connect_sqlite(self.database) + try: + first_user = connection.execute( + "INSERT INTO users(username, password_hash) VALUES ('resolver-one', 'unused')" + ).lastrowid + second_user = connection.execute( + "INSERT INTO users(username, password_hash) VALUES ('resolver-two', 'unused')" + ).lastrowid + connection.commit() + finally: + connection.close() + + barrier = threading.Barrier(2) + original_normalize = reviews_module.normalize_resolution + + def synchronized_normalize(value): + normalized = original_normalize(value) + barrier.wait(timeout=5) + return normalized + + def resolve(user_id): + try: + plan = ReviewService(self.database).resolve( + review.id, + { + "title": "人工确认番剧", + "media_type": "episode", + "season": 1, + "episode": 2, + }, + user_id, + ) + return ("resolved", user_id, plan.id) + except Exception as error: + return ("error", user_id, error) + + with patch.object(reviews_module, "normalize_resolution", side_effect=synchronized_normalize): + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(resolve, (first_user, second_user))) + + resolved = [result for result in results if result[0] == "resolved"] + errors = [result for result in results if result[0] == "error"] + self.assertEqual(len(resolved), 1) + self.assertEqual(len(errors), 1) + self.assertIsInstance(errors[0][2], InvalidStateError) + + connection = connect_sqlite(self.database) + try: + stored = connection.execute( + "SELECT status, resolved_by FROM review_items WHERE id = ?", (review.id,) + ).fetchone() + revisions = connection.execute( + "SELECT revision FROM plans WHERE scan_run_id = ? ORDER BY revision", + (outcome.scan_run_id,), + ).fetchall() + finally: + connection.close() + self.assertEqual(stored, ("resolved", resolved[0][1])) + self.assertEqual(revisions, [(1,), (2,)]) + + def test_resolving_episode_normalizes_s02e12_and_release_tag(self): + from autoanime_v3.services.reviews import ReviewService + + review = self.scan_review("Unknown Show S02E12 WEB.mkv") + new_plan = ReviewService(self.database).resolve( + review.id, + { + "title": "人工确认番剧", + "media_type": "episode", + "season": "2", + "episode": "12", + "release_tag": "WEB-DL", + "manual_lock": True, + }, + ) + + resolved = ReviewService(self.database).get(review.id) + self.assertEqual( + resolved.resolution, + { + "title": "人工确认番剧", + "media_type": "episode", + "season": 2, + "episode": 12, + "is_movie": False, + "release_tag": "WEB-DL", + "manual_lock": True, + }, + ) + self.assertIn("Season 02", new_plan.items[-1].destination_path) + self.assertIn("S02E12 - 人工确认番剧 [WEB-DL]", new_plan.items[-1].destination_path) + + def test_resolving_movie_omits_episode_fields_and_builds_movie_plan(self): + from autoanime_v3.services.reviews import ReviewService + + review = self.scan_review("Unknown Movie.mkv") + new_plan = ReviewService(self.database).resolve( + review.id, + { + "title": "人工确认电影", + "media_type": "movie", + "release_tag": "BDRip", + "manual_lock": True, + }, + ) + + resolution = ReviewService(self.database).get(review.id).resolution + self.assertNotIn("season", resolution) + self.assertNotIn("episode", resolution) + self.assertTrue(resolution["is_movie"]) + self.assertEqual(resolution["media_type"], "movie") + self.assertIn("人工确认电影 [BDRip].mkv", new_plan.items[-1].destination_path) + + def test_resolving_special_preserves_sp_episode_and_builds_executable_plan(self): + from autoanime_v3.services.reviews import ReviewService + + review = self.scan_review("Unknown Show SP03.mkv") + new_plan = ReviewService(self.database).resolve( + review.id, + { + "title": "人工确认番剧", + "media_type": "special", + "season": 0, + "episode": "SP03", + "release_tag": "", + "manual_lock": True, + }, + ) + + resolved = ReviewService(self.database).get(review.id) + self.assertEqual(resolved.resolution["season"], 0) + self.assertEqual(resolved.resolution["episode"], "SP03") + self.assertEqual(new_plan.items[-1].action, "link") + self.assertIn("Specials", new_plan.items[-1].destination_path) + self.assertIn("SP03 - 人工确认番剧", new_plan.items[-1].destination_path) + self.assertTrue(new_plan.items[-1].destination_path.endswith(".mkv")) + + def test_resolving_decimal_episode_does_not_truncate_value(self): + from autoanime_v3.services.reviews import ReviewService + + review = self.scan_review("Unknown Show - 12.5.mkv") + new_plan = ReviewService(self.database).resolve( + review.id, + {"title": "分段番剧", "media_type": "episode", "season": 1, "episode": "12.5"}, + ) + + self.assertEqual(ReviewService(self.database).get(review.id).resolution["episode"], 12.5) + self.assertIn("S01E12.5 - 分段番剧", new_plan.items[-1].destination_path) + + def test_invalid_structured_resolutions_are_rejected_without_resolving_review(self): + from autoanime_v3.services.reviews import ReviewService + + review = self.scan_review() + service = ReviewService(self.database) + invalid_values = ( + ({"media_type": "episode", "season": 1, "episode": 2}, "title"), + ({"title": "番剧", "media_type": "episode", "episode": 2}, "season"), + ({"title": "番剧", "media_type": "episode", "season": 1}, "episode"), + ({"title": "电影", "media_type": "movie", "season": 1}, "season"), + ({"title": "番剧", "media_type": "ova", "season": 1, "episode": 1}, "media_type"), + ({"title": "番剧", "media_type": [], "season": 1, "episode": 1}, "media_type"), + ({"title": "番剧", "media_type": "episode", "season": -1, "episode": 1}, "season"), + ({"title": "番剧", "media_type": "episode", "season": 1, "episode": True}, "episode"), + ({"title": "番剧", "media_type": "special", "episode": "../SP01"}, "episode"), + ) + + for resolution, field in invalid_values: + with self.subTest(resolution=resolution): + with self.assertRaises(ValidationError) as raised: + service.resolve(review.id, resolution) + self.assertEqual(raised.exception.details["field"], field) + self.assertEqual(service.get(review.id).status, "open") + + def test_unknown_resolution_fields_are_rejected_with_stable_field(self): + from autoanime_v3.services.reviews import normalize_resolution + + for field in ("unexpected", "manual_lcok"): + with self.subTest(field=field): + resolution = { + "title": "番剧", + "media_type": "episode", + "season": 1, + "episode": 1, + field: True, + } + with self.assertRaises(ValidationError) as raised: + normalize_resolution(resolution) + self.assertEqual(raised.exception.details["field"], field) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_roots_profiles.py b/tests/test_v3_roots_profiles.py new file mode 100644 index 0000000..74aa2a9 --- /dev/null +++ b/tests/test_v3_roots_profiles.py @@ -0,0 +1,95 @@ +import tempfile +import unittest +from pathlib import Path + + +class RootsAndProfilesTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.database = self.root / "web.sqlite3" + self.source = self.root / "Downloads" + self.library = self.root / "Library" + self.source.mkdir() + self.library.mkdir() + + def tearDown(self): + self.temporary_directory.cleanup() + + def services(self): + from autoanime_v3.services.profiles import ProfileService + from autoanime_v3.services.roots import RootService + + return RootService(self.database), ProfileService(self.database) + + def test_windows_paths_are_case_insensitive_and_duplicate_roots_are_rejected(self): + from autoanime_v3.domain.errors import DuplicateRootError + + roots, unused_profiles = self.services() + created = roots.create_root("source", self.source) + + self.assertEqual(created.normalized_path, str(self.source.resolve()).casefold()) + with self.assertRaises(DuplicateRootError): + roots.create_root("source", Path(str(self.source).upper())) + + def test_library_equal_to_or_below_source_is_rejected(self): + from autoanime_v3.domain.errors import UnsafeRootError + + roots, unused_profiles = self.services() + roots.create_root("source", self.source) + + with self.assertRaises(UnsafeRootError): + roots.create_root("library", self.source) + nested = self.source / "organized" + nested.mkdir() + with self.assertRaises(UnsafeRootError): + roots.create_root("library", nested) + + def test_operation_targets_cannot_escape_registered_root(self): + from autoanime_v3.domain.errors import PathOutsideRootError + + roots, unused_profiles = self.services() + library = roots.create_root("library", self.library) + + target = roots.resolve_target(library.id, Path("Show") / "Season 01" / "E01.mkv") + self.assertTrue(str(target).casefold().startswith(str(self.library).casefold())) + with self.assertRaises(PathOutsideRootError): + roots.resolve_target(library.id, Path("..") / "escape.mkv") + with self.assertRaises(PathOutsideRootError): + roots.resolve_target(library.id, self.source / "absolute.mkv") + + def test_profile_updates_require_current_revision(self): + from autoanime_v3.domain.entities import CreateProfile + from autoanime_v3.domain.errors import RevisionConflictError + + roots, profiles = self.services() + source = roots.create_root("source", self.source) + library = roots.create_root("library", self.library) + profile = profiles.create_profile( + CreateProfile( + name="新番自动整理", + source_root_id=source.id, + library_root_id=library.id, + mode="link", + execution_policy="review_all", + min_confidence=85, + stability_seconds=45, + watch_enabled=True, + ) + ) + + changed = profiles.update_profile( + profile.id, + profile.revision, + {"min_confidence": 90, "watch_enabled": False}, + ) + self.assertEqual(changed.revision, 2) + self.assertEqual(changed.min_confidence, 90) + self.assertFalse(changed.watch_enabled) + with self.assertRaises(RevisionConflictError): + profiles.update_profile(profile.id, profile.revision, {"min_confidence": 95}) + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_v3_rules_changes.py b/tests/test_v3_rules_changes.py new file mode 100644 index 0000000..91512d7 --- /dev/null +++ b/tests/test_v3_rules_changes.py @@ -0,0 +1,54 @@ +import tempfile +import unittest +from pathlib import Path + + +class RulesAndChangesTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.database = Path(self.temporary_directory.name) / "web.sqlite3" + + def tearDown(self): + self.temporary_directory.cleanup() + + def test_rule_revisions_validate_activate_and_rollback_immutably(self): + from autoanime_v3.domain.errors import ValidationError + from autoanime_v3.services.rules import RuleService + + service = RuleService(self.database) + rule_set = service.create_set("默认规则") + invalid = service.create_revision(rule_set.id, {"aliases": []}) + with self.assertRaises(ValidationError): + service.validate(invalid.id) + + first = service.create_revision(rule_set.id, {"aliases": {"Frieren": "葬送的芙莉莲"}}) + validated = service.validate(first.id) + active = service.activate(validated.id) + second = service.create_revision(rule_set.id, {"aliases": {"Frieren": "芙莉莲"}}) + service.validate(second.id) + newer = service.activate(second.id) + rolled_back = service.rollback(rule_set.id, active.id) + + self.assertNotEqual(active.content_hash, newer.content_hash) + self.assertEqual(rolled_back.id, active.id) + self.assertEqual(service.get_set(rule_set.id).active_revision_id, active.id) + + def test_show_change_uses_base_revision_and_preserves_old_new_values(self): + from autoanime_v3.domain.errors import RevisionConflictError + from autoanime_v3.services.changes import ChangeService + + service = ChangeService(self.database) + show = service.create_show("旧标题") + request = service.preview_show_change( + show.id, show.revision, {"canonical_title": "新标题", "title_locked": True}, "人工纠正" + ) + applied = service.apply(request.id) + self.assertEqual(applied.canonical_title, "新标题") + self.assertTrue(applied.title_locked) + with self.assertRaises(RevisionConflictError): + service.preview_show_change(show.id, show.revision, {"canonical_title": "过期修改"}, "冲突") + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_v3_scan_service.py b/tests/test_v3_scan_service.py new file mode 100644 index 0000000..b7ea852 --- /dev/null +++ b/tests/test_v3_scan_service.py @@ -0,0 +1,73 @@ +import sqlite3 +import tempfile +import unittest +from pathlib import Path + + +class ScanServiceTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.database = self.root / "web.sqlite3" + self.source = self.root / "downloads" + self.library = self.root / "library" + self.source.mkdir() + self.library.mkdir() + + from autoanime_v3.domain.entities import CreateProfile + from autoanime_v3.services.profiles import ProfileService + from autoanime_v3.services.roots import RootService + + roots = RootService(self.database) + self.source_root = roots.create_root("source", self.source) + self.library_root = roots.create_root("library", self.library) + self.profile = ProfileService(self.database).create_profile( + CreateProfile( + name="默认整理", + source_root_id=self.source_root.id, + library_root_id=self.library_root.id, + min_confidence=86, + ) + ) + + def tearDown(self): + self.temporary_directory.cleanup() + + def test_scan_records_facts_reviews_and_draft_plan_without_touching_library(self): + (self.source / "测试番 S01E01.mkv").write_bytes(b"safe-media") + (self.source / "Unknown Show - 02.mkv").write_bytes(b"needs-review") + + from autoanime_v3.services.scans import ScanService + + outcome = ScanService(self.database).run(self.profile.id) + + self.assertEqual(outcome.discovered_count, 2) + self.assertEqual(outcome.review_count, 1) + self.assertEqual(outcome.plan_status, "draft") + self.assertEqual(list(self.library.rglob("*")), []) + connection = sqlite3.connect(str(self.database)) + try: + counts = { + name: connection.execute("SELECT COUNT(*) FROM %s" % name).fetchone()[0] + for name in [ + "scan_runs", + "scan_items", + "media_files", + "identification_results", + "review_items", + "plans", + ] + } + finally: + connection.close() + self.assertEqual(counts["scan_runs"], 1) + self.assertEqual(counts["scan_items"], 2) + self.assertEqual(counts["media_files"], 2) + self.assertEqual(counts["identification_results"], 2) + self.assertEqual(counts["review_items"], 1) + self.assertEqual(counts["plans"], 1) + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_v3_web_schema.py b/tests/test_v3_web_schema.py new file mode 100644 index 0000000..3946736 --- /dev/null +++ b/tests/test_v3_web_schema.py @@ -0,0 +1,111 @@ +import sqlite3 +import tempfile +import unittest +from pathlib import Path + + +class WebSchemaTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.database = self.root / "library.sqlite3" + + def tearDown(self): + self.temporary_directory.cleanup() + + def migration_module(self): + try: + from autoanime_v3.db import migrations + except ModuleNotFoundError as error: + self.fail("Web schema migrations are not implemented: %s" % error) + return migrations + + def table_names(self): + connection = sqlite3.connect(str(self.database)) + try: + return { + str(row[0]) + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + finally: + connection.close() + + def test_migration_creates_complete_web_console_schema(self): + migrations = self.migration_module() + + migrations.run_migrations(self.database) + + expected = { + "schema_migrations", + "users", + "user_sessions", + "app_settings", + "secret_settings", + "audit_events", + "storage_roots", + "scan_profiles", + "profile_rules", + "schedules", + "webhook_sources", + "resource_leases", + "shows", + "seasons", + "episodes", + "media_files", + "file_locations", + "media_assignments", + "identification_results", + "identification_evidence", + "metadata_records", + "jobs", + "job_events", + "scan_runs", + "scan_items", + "review_items", + "plans", + "plan_items", + "operation_batches", + "operation_items", + "change_requests", + "rule_sets", + "rule_revisions", + "backup_records", + } + self.assertTrue(expected.issubset(self.table_names())) + + def test_migration_is_idempotent_and_records_schema_version(self): + migrations = self.migration_module() + + migrations.run_migrations(self.database) + migrations.run_migrations(self.database) + + connection = sqlite3.connect(str(self.database)) + try: + rows = connection.execute( + "SELECT version FROM schema_migrations ORDER BY version" + ).fetchall() + finally: + connection.close() + self.assertEqual(rows, [(3,)]) + + def test_database_connections_enable_foreign_keys_wal_and_busy_timeout(self): + migrations = self.migration_module() + + migrations.run_migrations(self.database) + connection = migrations.connect_database(self.database) + try: + foreign_keys = connection.execute("PRAGMA foreign_keys").fetchone()[0] + journal_mode = connection.execute("PRAGMA journal_mode").fetchone()[0] + busy_timeout = connection.execute("PRAGMA busy_timeout").fetchone()[0] + finally: + connection.close() + + self.assertEqual(foreign_keys, 1) + self.assertEqual(str(journal_mode).casefold(), "wal") + self.assertGreaterEqual(int(busy_timeout), 5000) + + +if __name__ == "__main__": + unittest.main() diff --git a/uninstall-autostart.bat b/uninstall-autostart.bat new file mode 100644 index 0000000..66decc6 --- /dev/null +++ b/uninstall-autostart.bat @@ -0,0 +1,7 @@ +@echo off +setlocal +cd /d "%~dp0" +title AutoAnime Uninstall Autostart +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0uninstall-autostart.ps1" %* +echo. +pause diff --git a/uninstall-autostart.ps1 b/uninstall-autostart.ps1 new file mode 100644 index 0000000..d188a97 --- /dev/null +++ b/uninstall-autostart.ps1 @@ -0,0 +1,25 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param( + [string]$TaskName = "AutoAnime WebUI" +) + +$ErrorActionPreference = "Continue" + +try { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction Stop + Write-Host "[AutoAnime] 已删除计划任务: $TaskName" -ForegroundColor Cyan +} catch { + Write-Host "[AutoAnime] 计划任务不存在或无法删除: $TaskName" -ForegroundColor DarkGray +} + +$startup = [Environment]::GetFolderPath("Startup") +$shortcutPath = Join-Path $startup "AutoAnime WebUI.lnk" +if (Test-Path $shortcutPath) { + Remove-Item $shortcutPath -Force + Write-Host "[AutoAnime] 已删除启动项: $shortcutPath" -ForegroundColor Cyan +} else { + Write-Host "[AutoAnime] 启动项不存在: $shortcutPath" -ForegroundColor DarkGray +} + +Write-Host "[AutoAnime] 取消自启完成" -ForegroundColor Green diff --git a/webui/.gitignore b/webui/.gitignore new file mode 100644 index 0000000..0303d16 --- /dev/null +++ b/webui/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +test-results/ +playwright-report/ +*.tsbuildinfo +vite.config.js +vite.config.d.ts diff --git a/webui/e2e/console-flow.spec.ts b/webui/e2e/console-flow.spec.ts new file mode 100644 index 0000000..598a280 --- /dev/null +++ b/webui/e2e/console-flow.spec.ts @@ -0,0 +1,161 @@ +import { expect, test, type Page } from '@playwright/test' +import { execFileSync, spawn, ChildProcess } from 'node:child_process' +import { mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +let server: ChildProcess +let root: string +const python = resolve('../.venv/Scripts/python.exe') +const password = 'AutoAnime-Admin-ChangeMe!' + +async function ensureLoggedIn(page: Page) { + await page.goto('/') + const overview = page.getByRole('link', { name: /概览/ }) + try { + await expect(overview).toBeVisible({ timeout: 8000 }) + return + } catch { + // fall through to password login when local bypass is off or slow + } + await page.getByLabel('密码').fill(password) + await page.getByRole('button', { name: '登录' }).click() + await expect(overview).toBeVisible() +} + +async function loginExisting(page: Page) { + await ensureLoggedIn(page) +} + +function countFiles(directory: string): number { + return readdirSync(directory, { withFileTypes: true }).reduce((count, entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return count + countFiles(path) + return count + (statSync(path).isFile() ? 1 : 0) + }, 0) +} + +async function removeTestRoot(directory: string): Promise { + let lastError: NodeJS.ErrnoException | undefined + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + rmSync(directory, { recursive: true, force: true }) + return + } catch (error) { + const candidate = error as NodeJS.ErrnoException + if (!['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(candidate.code ?? '')) throw error + lastError = candidate + await new Promise(resolveWait => setTimeout(resolveWait, 250 + attempt * 50)) + } + } + throw lastError +} + +test.beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), 'autoanime-web-e2e-')) + server = spawn(python, [resolve('../AutoAnimeWeb.py'), '--data-dir', root, '--insecure-http', '--port', '8765'], { stdio: 'ignore' }) + for (let attempt = 0; attempt < 60; attempt += 1) { + try { if ((await fetch('http://127.0.0.1:8765/health/live')).ok) return } catch { /* wait */ } + await new Promise(resolveWait => setTimeout(resolveWait, 250)) + } + throw new Error('AutoAnimeWeb did not become ready') +}) + +test.afterAll(async () => { + if (server && server.exitCode === null) { + server.kill() + await new Promise(resolveExit => server.once('exit', () => resolveExit())) + } + server?.unref() + await removeTestRoot(root) +}) + +test('login, configure, scan, approve, execute and rollback real file', async ({ page }) => { + const source = join(root, 'downloads'); const library = join(root, 'library') + mkdirSync(source); mkdirSync(library) + writeFileSync(join(source, '测试番 S01E01.mkv'), Buffer.alloc(1024 * 32, 7)) + + await ensureLoggedIn(page) + + await page.getByRole('link', { name: /扫描配置/ }).click() + await page.getByLabel('目录路径').fill(source) + await page.getByRole('button', { name: '添加' }).click() + await page.getByLabel('目录类型').selectOption('library') + await page.getByLabel('目录路径').fill(library) + await page.getByRole('button', { name: '添加' }).click() + await page.getByLabel('配置名称').fill('E2E 真实整理') + await page.getByLabel('下载源').selectOption({ label: source }) + await page.getByLabel('媒体库').selectOption({ label: library }) + await page.getByRole('button', { name: '创建扫描配置' }).click() + await page.getByRole('button', { name: '编辑' }).click() + await page.getByLabel('最低置信度').fill('90') + await page.getByRole('button', { name: '保存配置' }).click() + await expect(page.getByText(/阈值 90%/)).toBeVisible() + await page.getByRole('button', { name: '手动扫描' }).click() + + execFileSync(python, [resolve('../AutoAnimeWorker.py'), '--data-dir', root, '--once']) + await page.getByRole('link', { name: /任务中心/ }).click() + await page.locator('tbody tr').first().click() + await expect(page.getByText('扫描完成')).toBeVisible() + await page.getByRole('link', { name: /整理计划/ }).click() + await expect(page.getByRole('button', { name: '批准并执行' })).toBeEnabled() + await page.getByRole('button', { name: '批准并执行' }).click() + execFileSync(python, [resolve('../AutoAnimeWorker.py'), '--data-dir', root, '--once']) + + await page.getByRole('link', { name: /操作历史/ }).click() + await expect(page.getByText('completed').first()).toBeVisible() + await page.getByRole('button', { name: '回滚' }).click() + await expect.poll(async () => { + const response = await page.request.get('/api/v1/jobs') + const jobs = (await response.json()).items as Array<{ job_type: string }> + return jobs[0]?.job_type + }).toBe('rollback_operation') + expect(countFiles(library)).toBe(1) + execFileSync(python, [resolve('../AutoAnimeWorker.py'), '--data-dir', root, '--once']) + await expect.poll(() => countFiles(library)).toBe(0) +}) + +test('existing installation shows login on fresh browser storage', async ({ page }) => { + await page.request.post('/api/v1/auth/bootstrap', { data: { username: 'admin', password } }) + await page.goto('/') + await expect(page.getByRole('heading', { name: '管理员登录' })).toBeVisible({ timeout: 5_000 }) + await expect(page.getByRole('heading', { name: '创建管理员账号' })).toHaveCount(0) +}) + +test('rules and ordinary settings can be created and activated', async ({ page }) => { + await loginExisting(page) + await page.getByRole('link', { name: /规则与别名/ }).click() + await page.getByLabel('规则集名称').fill('E2E 别名规则') + await page.getByRole('button', { name: '新建规则集' }).click() + await page.getByLabel('规则 JSON').fill('{"aliases":{"Frieren":"葬送的芙莉莲"}}') + await page.getByRole('button', { name: '保存草稿' }).click() + await page.getByRole('button', { name: '校验' }).click() + await expect(page.getByText('validated').first()).toBeVisible() + await page.getByRole('button', { name: '激活' }).click() + await expect(page.getByText('active').first()).toBeVisible() + + await page.getByRole('link', { name: /系统设置/ }).click() + await page.getByLabel('设置键').fill('backup.retention_days') + await page.getByLabel('设置值(JSON)').fill('14') + await page.getByRole('button', { name: '保存设置' }).click() + await expect(page.getByText('backup.retention_days')).toBeVisible() +}) + +test('library title correction is previewed before approval', async ({ page }) => { + const database = join(root, 'data', 'library.sqlite3') + execFileSync( + python, + ['-c', 'import sys; from autoanime_v3.services.changes import ChangeService; ChangeService(sys.argv[1]).create_show("待纠正标题")', database], + { cwd: resolve('..') }, + ) + await loginExisting(page) + await page.getByRole('link', { name: /资料库/ }).click() + await page.getByText('待纠正标题').click() + await page.getByLabel('新规范标题').fill('已纠正标题') + await page.getByLabel('修改原因').fill('E2E 人工纠正') + await page.getByRole('button', { name: '预览修改' }).click() + await expect(page.getByText(/待纠正标题/).last()).toBeVisible() + await expect(page.getByText(/已纠正标题/).last()).toBeVisible() + await page.getByRole('button', { name: '批准修改' }).click() + await expect(page.getByText('已纠正标题').first()).toBeVisible() +}) diff --git a/webui/e2e/real-file-modes.spec.ts b/webui/e2e/real-file-modes.spec.ts new file mode 100644 index 0000000..66f17fa --- /dev/null +++ b/webui/e2e/real-file-modes.spec.ts @@ -0,0 +1,109 @@ +import { expect, test, type Page } from '@playwright/test' +import { execFileSync, spawn, type ChildProcess } from 'node:child_process' +import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const validationRoot = process.env.AUTOANIME_REAL_TEST_ROOT +const sample = process.env.AUTOANIME_REAL_SAMPLE +const python = resolve('../.venv/Scripts/python.exe') +const password = 'AutoAnime-Admin-ChangeMe!' +let server: ChildProcess + +test.skip(!validationRoot || !sample, 'Set AUTOANIME_REAL_TEST_ROOT and AUTOANIME_REAL_SAMPLE for isolated real-file validation') +test.setTimeout(240_000) + +function filesIn(directory: string): string[] { + if (!existsSync(directory)) return [] + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const path = join(directory, entry.name) + return entry.isDirectory() ? filesIn(path) : [path] + }) +} + +async function login(page: Page) { + await page.goto('/') + const overview = page.getByRole('link', { name: /概览/ }) + try { + await expect(overview).toBeVisible({ timeout: 8000 }) + return + } catch { + // fall through + } + await page.getByLabel('密码').fill(password) + await page.getByRole('button', { name: '登录' }).click() + await expect(overview).toBeVisible() +} + +test.beforeAll(async () => { + mkdirSync(validationRoot!, { recursive: true }) + server = spawn(python, [resolve('../AutoAnimeWeb.py'), '--data-dir', validationRoot!, '--insecure-http', '--port', '8765'], { stdio: 'pipe' }) + for (let attempt = 0; attempt < 80; attempt += 1) { + try { if ((await fetch('http://127.0.0.1:8765/health/live')).ok) return } catch { /* wait */ } + await new Promise(resolveWait => setTimeout(resolveWait, 250)) + } + throw new Error('AutoAnimeWeb did not become ready for real-file validation') +}) + +test.afterAll(async () => { + if (server && server.exitCode === null) { + server.kill() + await new Promise(resolveExit => server.once('exit', () => resolveExit())) + } +}) + +test('link, copy and move execute and rollback with a real media payload', async ({ page }) => { + await login(page) + for (const mode of ['link', 'copy', 'move']) { + const source = join(validationRoot!, `source-${mode}`) + const library = join(validationRoot!, `library-${mode}`) + mkdirSync(source); mkdirSync(library) + const original = join(source, `真实${mode}测试 S01E01.mp4`) + copyFileSync(sample!, original) + const originalSize = statSync(original).size + + await page.getByRole('link', { name: /扫描配置/ }).click() + await page.getByLabel('目录类型').selectOption('source') + await page.getByLabel('目录路径').fill(source) + await page.getByRole('button', { name: '添加' }).click() + await page.getByLabel('目录类型').selectOption('library') + await page.getByLabel('目录路径').fill(library) + await page.getByRole('button', { name: '添加' }).click() + await page.getByLabel('配置名称').fill(`真实 ${mode} 验证`) + await page.getByLabel('下载源').selectOption({ label: source }) + await page.getByLabel('媒体库').selectOption({ label: library }) + await page.getByLabel('文件模式').selectOption(mode) + await page.getByRole('button', { name: '创建扫描配置' }).click() + const profile = page.locator('.profile-row').filter({ hasText: `真实 ${mode} 验证` }) + await profile.getByRole('button', { name: '手动扫描' }).click() + + execFileSync(python, [resolve('../AutoAnimeWorker.py'), '--data-dir', validationRoot!, '--once'], { cwd: resolve('..') }) + await page.getByRole('link', { name: /整理计划/ }).click() + await expect(page.getByRole('button', { name: '批准并执行' })).toBeEnabled() + await page.getByRole('button', { name: '批准并执行' }).click() + execFileSync(python, [resolve('../AutoAnimeWorker.py'), '--data-dir', validationRoot!, '--once'], { cwd: resolve('..') }) + + await page.getByRole('link', { name: /操作历史/ }).click() + await expect(page.locator('tbody tr').first().getByText('completed')).toBeVisible() + await expect.poll(() => filesIn(library).length).toBe(1) + const destination = filesIn(library)[0] + expect(statSync(destination).size).toBe(originalSize) + if (mode === 'move') expect(existsSync(original)).toBe(false) + else expect(existsSync(original)).toBe(true) + if (mode === 'link') { + const sameFile = execFileSync(python, ['-c', 'import os,sys; print(os.path.samefile(sys.argv[1], sys.argv[2]))', original, destination], { encoding: 'utf8' }).trim() + expect(sameFile).toBe('True') + } + + await page.locator('tbody tr').first().getByRole('button', { name: '回滚' }).click() + await expect.poll(async () => { + const response = await page.request.get('/api/v1/jobs') + const jobs = (await response.json()).items as Array<{ job_type: string }> + return jobs[0]?.job_type + }).toBe('rollback_operation') + expect(filesIn(library).length).toBe(1) + execFileSync(python, [resolve('../AutoAnimeWorker.py'), '--data-dir', validationRoot!, '--once'], { cwd: resolve('..') }) + await expect.poll(() => filesIn(library).length).toBe(0) + expect(existsSync(original)).toBe(true) + expect(statSync(original).size).toBe(originalSize) + } +}) diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 0000000..cbbb24b --- /dev/null +++ b/webui/index.html @@ -0,0 +1,13 @@ + + + + + + + AutoAnime 管理控制台 + + +
+ + + diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..4255296 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,37 @@ +{ + "name": "autoanime-webui", + "private": true, + "version": "3.0.0", + "type": "module", + "packageManager": "pnpm@11.9.0", + "engines": { + "node": ">=20", + "pnpm": ">=10" + }, + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "test": "vitest", + "e2e": "playwright test" + }, + "dependencies": { + "@tanstack/react-query": "5.83.0", + "lucide-react": "0.468.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "6.30.4" + }, + "devDependencies": { + "@playwright/test": "1.54.1", + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.1.0", + "@testing-library/user-event": "14.5.2", + "@types/react": "18.3.18", + "@types/react-dom": "18.3.5", + "@vitejs/plugin-react": "4.3.4", + "jsdom": "26.0.0", + "typescript": "5.7.3", + "vite": "6.1.0", + "vitest": "3.0.5" + } +} diff --git a/webui/playwright.config.ts b/webui/playwright.config.ts new file mode 100644 index 0000000..a25c8b5 --- /dev/null +++ b/webui/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + timeout: 90_000, + workers: 1, + use: { + baseURL: 'http://127.0.0.1:8765', + viewport: { width: 1536, height: 1024 }, + actionTimeout: 10_000, + trace: 'retain-on-failure', + }, +}) diff --git a/webui/pnpm-lock.yaml b/webui/pnpm-lock.yaml new file mode 100644 index 0000000..75aa659 --- /dev/null +++ b/webui/pnpm-lock.yaml @@ -0,0 +1,2218 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: 5.83.0 + version: 5.83.0(react@18.3.1) + lucide-react: + specifier: 0.468.0 + version: 0.468.0(react@18.3.1) + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: 6.30.4 + version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + devDependencies: + '@playwright/test': + specifier: 1.54.1 + version: 1.54.1 + '@testing-library/jest-dom': + specifier: 6.6.3 + version: 6.6.3 + '@testing-library/react': + specifier: 16.1.0 + version: 16.1.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: 14.5.2 + version: 14.5.2(@testing-library/dom@10.4.1) + '@types/react': + specifier: 18.3.18 + version: 18.3.18 + '@types/react-dom': + specifier: 18.3.5 + version: 18.3.5(@types/react@18.3.18) + '@vitejs/plugin-react': + specifier: 4.3.4 + version: 4.3.4(vite@6.1.0) + jsdom: + specifier: 26.0.0 + version: 26.0.0 + typescript: + specifier: 5.7.3 + version: 5.7.3 + vite: + specifier: 6.1.0 + version: 6.1.0 + vitest: + specifier: 3.0.5 + version: 3.0.5(jsdom@26.0.0) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.24.2': + resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.24.2': + resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.24.2': + resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.24.2': + resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.24.2': + resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.24.2': + resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.24.2': + resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.24.2': + resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.24.2': + resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.24.2': + resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.24.2': + resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.24.2': + resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.24.2': + resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.24.2': + resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.24.2': + resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.24.2': + resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.24.2': + resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.24.2': + resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.24.2': + resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.24.2': + resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.24.2': + resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.24.2': + resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.24.2': + resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.24.2': + resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.24.2': + resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@playwright/test@1.54.1': + resolution: {integrity: sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==} + engines: {node: '>=18'} + hasBin: true + + '@remix-run/router@1.23.3': + resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==} + engines: {node: '>=14.0.0'} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@tanstack/query-core@5.83.0': + resolution: {integrity: sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==} + + '@tanstack/react-query@5.83.0': + resolution: {integrity: sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==} + peerDependencies: + react: ^18 || ^19 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.6.3': + resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.1.0': + resolution: {integrity: sha512-Q2ToPvg0KsVL0ohND9A3zLJWcOXXcO8IDu3fj11KhNt0UlCWyFyvnCIBkd12tidB2lkiVRG8VFqdhcqhqnAQtg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.5.2': + resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.5': + resolution: {integrity: sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.18': + resolution: {integrity: sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==} + + '@vitejs/plugin-react@4.3.4': + resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 + + '@vitest/expect@3.0.5': + resolution: {integrity: sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==} + + '@vitest/mocker@3.0.5': + resolution: {integrity: sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.0.5': + resolution: {integrity: sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==} + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.0.5': + resolution: {integrity: sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==} + + '@vitest/snapshot@3.0.5': + resolution: {integrity: sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==} + + '@vitest/spy@3.0.5': + resolution: {integrity: sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==} + + '@vitest/utils@3.0.5': + resolution: {integrity: sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + baseline-browser-mapping@2.11.1: + resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.395: + resolution: {integrity: sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.24.2: + resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@26.0.0: + resolution: {integrity: sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.468.0: + resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + playwright-core@1.54.1: + resolution: {integrity: sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.54.1: + resolution: {integrity: sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.22: + resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + react-router-dom@6.30.4: + resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.4: + resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + typescript@5.7.3: + resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite-node@3.0.5: + resolution: {integrity: sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@6.1.0: + resolution: {integrity: sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.0.5: + resolution: {integrity: sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.0.5 + '@vitest/ui': 3.0.5 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.24.2': + optional: true + + '@esbuild/android-arm64@0.24.2': + optional: true + + '@esbuild/android-arm@0.24.2': + optional: true + + '@esbuild/android-x64@0.24.2': + optional: true + + '@esbuild/darwin-arm64@0.24.2': + optional: true + + '@esbuild/darwin-x64@0.24.2': + optional: true + + '@esbuild/freebsd-arm64@0.24.2': + optional: true + + '@esbuild/freebsd-x64@0.24.2': + optional: true + + '@esbuild/linux-arm64@0.24.2': + optional: true + + '@esbuild/linux-arm@0.24.2': + optional: true + + '@esbuild/linux-ia32@0.24.2': + optional: true + + '@esbuild/linux-loong64@0.24.2': + optional: true + + '@esbuild/linux-mips64el@0.24.2': + optional: true + + '@esbuild/linux-ppc64@0.24.2': + optional: true + + '@esbuild/linux-riscv64@0.24.2': + optional: true + + '@esbuild/linux-s390x@0.24.2': + optional: true + + '@esbuild/linux-x64@0.24.2': + optional: true + + '@esbuild/netbsd-arm64@0.24.2': + optional: true + + '@esbuild/netbsd-x64@0.24.2': + optional: true + + '@esbuild/openbsd-arm64@0.24.2': + optional: true + + '@esbuild/openbsd-x64@0.24.2': + optional: true + + '@esbuild/sunos-x64@0.24.2': + optional: true + + '@esbuild/win32-arm64@0.24.2': + optional: true + + '@esbuild/win32-ia32@0.24.2': + optional: true + + '@esbuild/win32-x64@0.24.2': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@playwright/test@1.54.1': + dependencies: + playwright: 1.54.1 + + '@remix-run/router@1.23.3': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@tanstack/query-core@5.83.0': {} + + '@tanstack/react-query@5.83.0(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.83.0 + react: 18.3.1 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.6.3': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.18.1 + redent: 3.0.0 + + '@testing-library/react@16.1.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.9': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.5(@types/react@18.3.18)': + dependencies: + '@types/react': 18.3.18 + + '@types/react@18.3.18': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@vitejs/plugin-react@4.3.4(vite@6.1.0)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@types/babel__core': 7.20.5 + react-refresh: 0.14.2 + vite: 6.1.0 + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.0.5': + dependencies: + '@vitest/spy': 3.0.5 + '@vitest/utils': 3.0.5 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.0.5(vite@6.1.0)': + dependencies: + '@vitest/spy': 3.0.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.1.0 + + '@vitest/pretty-format@3.0.5': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.0.5': + dependencies: + '@vitest/utils': 3.0.5 + pathe: 2.0.3 + + '@vitest/snapshot@3.0.5': + dependencies: + '@vitest/pretty-format': 3.0.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.0.5': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@3.0.5': + dependencies: + '@vitest/pretty-format': 3.0.5 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + baseline-browser-mapping@2.11.1: {} + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.1 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.395 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + caniuse-lite@1.0.30001806: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.395: {} + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.24.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.2 + '@esbuild/android-arm': 0.24.2 + '@esbuild/android-arm64': 0.24.2 + '@esbuild/android-x64': 0.24.2 + '@esbuild/darwin-arm64': 0.24.2 + '@esbuild/darwin-x64': 0.24.2 + '@esbuild/freebsd-arm64': 0.24.2 + '@esbuild/freebsd-x64': 0.24.2 + '@esbuild/linux-arm': 0.24.2 + '@esbuild/linux-arm64': 0.24.2 + '@esbuild/linux-ia32': 0.24.2 + '@esbuild/linux-loong64': 0.24.2 + '@esbuild/linux-mips64el': 0.24.2 + '@esbuild/linux-ppc64': 0.24.2 + '@esbuild/linux-riscv64': 0.24.2 + '@esbuild/linux-s390x': 0.24.2 + '@esbuild/linux-x64': 0.24.2 + '@esbuild/netbsd-arm64': 0.24.2 + '@esbuild/netbsd-x64': 0.24.2 + '@esbuild/openbsd-arm64': 0.24.2 + '@esbuild/openbsd-x64': 0.24.2 + '@esbuild/sunos-x64': 0.24.2 + '@esbuild/win32-arm64': 0.24.2 + '@esbuild/win32-ia32': 0.24.2 + '@esbuild/win32-x64': 0.24.2 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + indent-string@4.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + js-tokens@4.0.0: {} + + jsdom@26.0.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lodash@4.18.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.468.0(react@18.3.1): + dependencies: + react: 18.3.1 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + min-indent@1.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + node-releases@2.0.51: {} + + nwsapi@2.2.24: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + playwright-core@1.54.1: {} + + playwright@1.54.1: + dependencies: + playwright-core: 1.54.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.22: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@17.0.2: {} + + react-refresh@0.14.2: {} + + react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.4(react@18.3.1) + + react-router@6.30.4(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.3 + react: 18.3.1 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + typescript@5.7.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite-node@3.0.5: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.1.0 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@6.1.0: + dependencies: + esbuild: 0.24.2 + postcss: 8.5.22 + rollup: 4.62.2 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.0.5(jsdom@26.0.0): + dependencies: + '@vitest/expect': 3.0.5 + '@vitest/mocker': 3.0.5(vite@6.1.0) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.0.5 + '@vitest/snapshot': 3.0.5 + '@vitest/spy': 3.0.5 + '@vitest/utils': 3.0.5 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.1.0 + vite-node: 3.0.5 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 26.0.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} diff --git a/webui/pnpm-workspace.yaml b/webui/pnpm-workspace.yaml new file mode 100644 index 0000000..03cbbe5 --- /dev/null +++ b/webui/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - . + +allowBuilds: + esbuild: true diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts new file mode 100644 index 0000000..87b6b8d --- /dev/null +++ b/webui/src/api/client.ts @@ -0,0 +1,40 @@ +const CSRF_KEY = 'autoanime.csrf' + +export class ApiError extends Error { + constructor(public status: number, public code: string, message: string, public details: unknown = null) { + super(message) + } +} + +export function setCsrfToken(token: string | null) { + if (token) sessionStorage.setItem(CSRF_KEY, token) + else sessionStorage.removeItem(CSRF_KEY) +} + +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers) + if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json') + const csrf = sessionStorage.getItem(CSRF_KEY) + if (csrf && init.method && init.method !== 'GET') headers.set('X-CSRF-Token', csrf) + const response = await fetch(`/api/v1${path}`, { ...init, headers, credentials: 'same-origin' }) + if (!response.ok) { + const body = await response.json().catch(() => ({})) + throw new ApiError(response.status, body.code || 'http_error', body.message || response.statusText, body.details) + } + if (response.status === 204) return undefined as T + return response.json() as Promise +} + +export async function apiText(path: string): Promise { + const response = await fetch(`/api/v1${path}`, { credentials: 'same-origin' }) + if (!response.ok) throw new ApiError(response.status, 'http_error', response.statusText) + return response.text() +} + +export const api = { + get: (path: string) => apiFetch(path), + post: (path: string, body?: unknown, headers?: HeadersInit) => apiFetch(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body), headers }), + put: (path: string, body: unknown) => apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }), + patch: (path: string, body: unknown) => apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }), + text: (path: string) => apiText(path), +} diff --git a/webui/src/api/events.test.ts b/webui/src/api/events.test.ts new file mode 100644 index 0000000..276a57e --- /dev/null +++ b/webui/src/api/events.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { parseEventStream } from './events' + +describe('parseEventStream', () => { + it('returns ordered typed job events from SSE text', () => { + const events = parseEventStream([ + 'id: 1', + 'event: phase', + 'data: {"sequence":1,"message":"开始扫描","payload":{"name":"scan"}}', + '', + 'id: 2', + 'event: scan_completed', + 'data: {"sequence":2,"message":"扫描完成","payload":{"plan_id":7}}', + '', + ].join('\n')) + + expect(events).toEqual([ + { id: 1, type: 'phase', sequence: 1, message: '开始扫描', payload: { name: 'scan' } }, + { id: 2, type: 'scan_completed', sequence: 2, message: '扫描完成', payload: { plan_id: 7 } }, + ]) + }) +}) diff --git a/webui/src/api/events.ts b/webui/src/api/events.ts new file mode 100644 index 0000000..a8cc381 --- /dev/null +++ b/webui/src/api/events.ts @@ -0,0 +1,31 @@ +export type JobEvent = { + id: number + type: string + sequence: number + message: string + payload: Record +} + +export function parseEventStream(text: string): JobEvent[] { + return text + .split(/\r?\n\r?\n/) + .filter(Boolean) + .map(block => { + const lines = block.split(/\r?\n/) + const id = Number(lines.find(line => line.startsWith('id:'))?.slice(3).trim() || 0) + const type = lines.find(line => line.startsWith('event:'))?.slice(6).trim() || 'message' + const data = lines + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trimStart()) + .join('\n') + const parsed = JSON.parse(data || '{}') as Partial + return { + id, + type, + sequence: Number(parsed.sequence ?? id), + message: String(parsed.message ?? ''), + payload: (parsed.payload ?? {}) as Record, + } + }) + .sort((left, right) => left.sequence - right.sequence) +} diff --git a/webui/src/app/App.tsx b/webui/src/app/App.tsx new file mode 100644 index 0000000..e1ddeb7 --- /dev/null +++ b/webui/src/app/App.tsx @@ -0,0 +1,165 @@ +import { FormEvent, useEffect, useState } from 'react' +import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from '@tanstack/react-query' +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { api, ApiError, setCsrfToken } from '../api/client' +import { AppShell } from '../components/AppShell' +import { DashboardPage, JobsPage, LibraryPage, OperationsPage, PlansPage, ProfilesPage, ReviewsPage, RulesPage, SettingsPage } from '../pages/ConsolePages' + +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 2000 } } }) + +type BootstrapStatus = { + configured: boolean + local_bypass?: boolean + local_client?: boolean + can_local_login?: boolean +} + +function LoginPage({ firstRun, canLocalLogin }: { firstRun: boolean; canLocalLogin: boolean }) { + const [username, setUsername] = useState('admin') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const client = useQueryClient() + + async function finishLogin(csrfToken: string) { + setCsrfToken(csrfToken) + await client.invalidateQueries({ queryKey: ['me'] }) + await client.invalidateQueries({ queryKey: ['bootstrap-status'] }) + } + + async function submit(event: FormEvent) { + event.preventDefault() + setError('') + setBusy(true) + try { + if (firstRun) { + await api.post('/auth/bootstrap', { username, password }) + await client.invalidateQueries({ queryKey: ['bootstrap-status'] }) + } + const result = await api.post<{ csrf_token: string }>('/auth/login', { username, password }) + await finishLogin(result.csrf_token) + } catch (reason) { + setError(reason instanceof Error ? reason.message : '登录失败') + } finally { + setBusy(false) + } + } + + async function localLogin() { + setError('') + setBusy(true) + try { + const result = await api.post<{ csrf_token: string }>('/auth/local-session') + await finishLogin(result.csrf_token) + } catch (reason) { + setError(reason instanceof Error ? reason.message : '本机免密登录失败') + } finally { + setBusy(false) + } + } + + return ( +
+
+
+ A +
+ AutoAnime + 管理员控制台 +
+
+

{firstRun ? '创建管理员账号' : '管理员登录'}

+

+ {firstRun + ? '首次管理员只能在服务器本机通过 127.0.0.1 创建。默认账号 admin / AutoAnime-Admin-ChangeMe! 会在首次启动时自动创建。' + : canLocalLogin + ? '本机 loopback 已启用免密登录;局域网访问仍需账号密码。' + : '登录后可查看并修改所有整理配置。'} +

+ {canLocalLogin ? ( + + ) : null} + + + {error ?
{error}
: null} + +
+
+ ) +} + +function AuthenticatedApp() { + const me = useQuery({ queryKey: ['me'], queryFn: () => api.get('/auth/me'), retry: false }) + const bootstrap = useQuery({ + queryKey: ['bootstrap-status'], + queryFn: () => api.get('/auth/bootstrap-status'), + retry: false, + }) + const [localTried, setLocalTried] = useState(false) + const client = useQueryClient() + + useEffect(() => { + if (localTried || me.isLoading || bootstrap.isLoading || me.data) return + if (!bootstrap.data?.can_local_login) return + let cancelled = false + setLocalTried(true) + ;(async () => { + try { + const result = await api.post<{ csrf_token: string }>('/auth/local-session') + if (cancelled) return + setCsrfToken(result.csrf_token) + await client.invalidateQueries({ queryKey: ['me'] }) + } catch { + // Fall back to the password form when local session is rejected. + } + })() + return () => { + cancelled = true + } + }, [bootstrap.data, bootstrap.isLoading, client, localTried, me.data, me.isLoading]) + + if (me.isLoading || bootstrap.isLoading || (bootstrap.data?.can_local_login && !me.data && !localTried)) { + return
正在连接 AutoAnime…
+ } + if (me.error || !me.data) { + const firstRun = + me.error instanceof ApiError && me.error.status === 401 && bootstrap.data?.configured === false + return + } + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} + +export function App() { + return ( + + + + + + ) +} diff --git a/webui/src/components/AppShell.test.tsx b/webui/src/components/AppShell.test.tsx new file mode 100644 index 0000000..d1c90a2 --- /dev/null +++ b/webui/src/components/AppShell.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it } from 'vitest' + +import { AppShell } from './AppShell' + +describe('AppShell', () => { + it('renders the approved navigation and selected state', () => { + render( + +
页面内容
+
, + ) + + for (const label of [ + '概览', '扫描配置', '任务中心', '审核队列', '整理计划', + '资料库', '规则与别名', '操作历史', '系统设置', + ]) { + expect(screen.getByRole('link', { name: new RegExp(label) })).toBeInTheDocument() + } + expect(screen.getByRole('link', { name: /整理计划/ })).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('main')).toHaveTextContent('页面内容') + }) +}) diff --git a/webui/src/components/AppShell.tsx b/webui/src/components/AppShell.tsx new file mode 100644 index 0000000..dd6bb22 --- /dev/null +++ b/webui/src/components/AppShell.tsx @@ -0,0 +1,36 @@ +import type { PropsWithChildren } from 'react' +import { NavLink } from 'react-router-dom' +import { + BookOpen, ClipboardCheck, FolderCog, History, LayoutDashboard, + Library, ListChecks, Settings, SlidersHorizontal, +} from 'lucide-react' + +const navigation = [ + { to: '/', label: '概览', icon: LayoutDashboard, end: true }, + { to: '/profiles', label: '扫描配置', icon: FolderCog }, + { to: '/jobs', label: '任务中心', icon: ListChecks }, + { to: '/reviews', label: '审核队列', icon: ClipboardCheck }, + { to: '/plans', label: '整理计划', icon: SlidersHorizontal }, + { to: '/library', label: '资料库', icon: Library }, + { to: '/rules', label: '规则与别名', icon: BookOpen }, + { to: '/operations', label: '操作历史', icon: History }, + { to: '/settings', label: '系统设置', icon: Settings }, +] + +export function AppShell({ children }: PropsWithChildren) { + return
+ +
+
文件整理与资料库管理Windows Server
管理员
+
{children}
+
+
+} diff --git a/webui/src/components/Page.tsx b/webui/src/components/Page.tsx new file mode 100644 index 0000000..26c099f --- /dev/null +++ b/webui/src/components/Page.tsx @@ -0,0 +1,12 @@ +import type { PropsWithChildren, ReactNode } from 'react' + +export function Page({ title, description, actions, children }: PropsWithChildren<{ title: string; description?: string; actions?: ReactNode }>) { + return

{title}

{description ?

{description}

: null}
{actions ?
{actions}
: null}
{children}
+} + +export function Status({ value }: { value: string }) { + const kind = ['failed', 'conflict', 'stale', 'unavailable'].some(item => value.includes(item)) ? 'danger' : ['open', 'queued', 'draft', 'waiting_review'].some(item => value.includes(item)) ? 'warning' : ['running', 'leased', 'executing'].some(item => value.includes(item)) ? 'info' : 'success' + return {value} +} + +export function Empty({ children = '暂无数据' }: PropsWithChildren) { return
{children}
} diff --git a/webui/src/features/dashboard/DashboardView.test.tsx b/webui/src/features/dashboard/DashboardView.test.tsx new file mode 100644 index 0000000..5a7aaed --- /dev/null +++ b/webui/src/features/dashboard/DashboardView.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { DashboardView } from './DashboardView' + +describe('DashboardView', () => { + it('renders operational counts, roots and recent activity', () => { + render() + expect(screen.getByText('活动任务')).toBeInTheDocument() + expect(screen.getByText('4')).toBeInTheDocument() + expect(screen.getByText('F:\\动漫下载')).toBeInTheDocument() + expect(screen.getByText('识别文件')).toBeInTheDocument() + }) +}) diff --git a/webui/src/features/dashboard/DashboardView.tsx b/webui/src/features/dashboard/DashboardView.tsx new file mode 100644 index 0000000..4b106b1 --- /dev/null +++ b/webui/src/features/dashboard/DashboardView.tsx @@ -0,0 +1,21 @@ +import { Activity, AlertTriangle, CheckCircle2, Clock3 } from 'lucide-react' +import { Empty, Status } from '../../components/Page' + +export type DashboardData = { + active_jobs: number; open_reviews: number; conflicts: number; failed_jobs: number + roots: Array>; recent_jobs: Array> +} + +export function DashboardView({ data }: { data: DashboardData }) { + const metrics = [ + ['活动任务', data.active_jobs, Activity, 'info'], ['待审核', data.open_reviews, Clock3, 'warning'], + ['路径冲突', data.conflicts, AlertTriangle, 'danger'], ['失败任务', data.failed_jobs, CheckCircle2, 'neutral'], + ] as const + return <> +
{metrics.map(([label, value, Icon, tone]) =>
{label}{value}
)}
+
+

最近任务

实时状态
{data.recent_jobs.length ?
{data.recent_jobs.map(job => )}
任务阶段进度状态
#{String(job.id)} {String(job.job_type)}{String(job.current_stage || '等待处理')}{Number(job.progress_total) ? `${job.progress_current}/${job.progress_total}` : '—'}
: }
+ +
+ +} diff --git a/webui/src/features/plans/PlanWorkspace.test.tsx b/webui/src/features/plans/PlanWorkspace.test.tsx new file mode 100644 index 0000000..cb000ef --- /dev/null +++ b/webui/src/features/plans/PlanWorkspace.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { PlanWorkspace } from './PlanWorkspace' + +describe('PlanWorkspace', () => { + it('selects a row, shows inspector and prevents approval with conflicts', async () => { + const approve = vi.fn() + render() + await userEvent.click(screen.getByText('B.mkv')) + expect(screen.getByRole('complementary')).toHaveTextContent('目标已存在') + expect(screen.getByRole('button', { name: '批准并执行' })).toBeDisabled() + }) +}) diff --git a/webui/src/features/plans/PlanWorkspace.tsx b/webui/src/features/plans/PlanWorkspace.tsx new file mode 100644 index 0000000..722f162 --- /dev/null +++ b/webui/src/features/plans/PlanWorkspace.tsx @@ -0,0 +1,16 @@ +import { useMemo, useState } from 'react' +import { ArrowRight, CheckCircle2, FileVideo2 } from 'lucide-react' +import { Empty, Status } from '../../components/Page' + +type PlanItem = { id: number; source_path: string; destination_path: string; action: string; reason: string; risk_level: string; execution_status: string; source_size: number } +export type PlanDetail = { id: number; status: string; revision: number; items: PlanItem[] } + +const fileName = (path: string) => path.split(/[\\/]/).pop() || path +const formatSize = (size: number) => size < 1024 * 1024 ? `${Math.max(1, Math.round(size / 1024))} KB` : `${(size / 1024 / 1024).toFixed(1)} MB` + +export function PlanWorkspace({ plan, onApprove }: { plan: PlanDetail; onApprove: () => void }) { + const [selectedId, setSelectedId] = useState(plan.items[0]?.id) + const selected = useMemo(() => plan.items.find(item => item.id === selectedId) || plan.items[0], [plan.items, selectedId]) + const conflicts = plan.items.filter(item => item.execution_status === 'conflict' || item.action === 'conflict').length + return
计划 #{plan.id}修订 {plan.revision} · {plan.items.length} 项
{plan.items.length ?
{plan.items.map(item => setSelectedId(item.id)}>)}
源文件动作目标大小状态
{fileName(item.source_path)}
{item.action}{item.destination_path}{formatSize(item.source_size)}
: }
+} diff --git a/webui/src/main.tsx b/webui/src/main.tsx new file mode 100644 index 0000000..a7815a4 --- /dev/null +++ b/webui/src/main.tsx @@ -0,0 +1,7 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { App } from './app/App' +import './styles/tokens.css' +import './styles/global.css' + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/webui/src/pages/AutomationSettings.test.tsx b/webui/src/pages/AutomationSettings.test.tsx new file mode 100644 index 0000000..b8f9de2 --- /dev/null +++ b/webui/src/pages/AutomationSettings.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { AutomationSettings } from './ConsolePages' + +describe('AutomationSettings', () => { + it('shows configured automation and exposes a newly created token only once', async () => { + const createWebhook = vi.fn() + const toggleSchedule = vi.fn() + render() + + expect(screen.getByText('每 15 分钟')).toBeInTheDocument() + expect(screen.getByText('qBittorrent')).toBeInTheDocument() + expect(screen.getByText('one-time-secret')).toBeInTheDocument() + expect(screen.getByText(/仅显示一次/)).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: '停用计划' })) + expect(toggleSchedule).toHaveBeenCalledWith(expect.objectContaining({ id: 2 })) + await userEvent.click(screen.getByRole('button', { name: '创建 Webhook' })) + expect(createWebhook).toHaveBeenCalledWith(expect.objectContaining({ profile_id: 1 })) + }) +}) diff --git a/webui/src/pages/ConsolePages.tsx b/webui/src/pages/ConsolePages.tsx new file mode 100644 index 0000000..30cdd2a --- /dev/null +++ b/webui/src/pages/ConsolePages.tsx @@ -0,0 +1,341 @@ +import { FormEvent, useEffect, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Check, Plus, RefreshCw, RotateCcw, ScanSearch, Save, ShieldCheck } from 'lucide-react' +import { api } from '../api/client' +import { parseEventStream } from '../api/events' +import { Empty, Page, Status } from '../components/Page' +import { DashboardData, DashboardView } from '../features/dashboard/DashboardView' +import { PlanDetail, PlanWorkspace } from '../features/plans/PlanWorkspace' + +type Item = Record +type ListResponse = { items: Item[] } +const useList = (key: string, path: string, interval = 5000) => useQuery({ queryKey: [key], queryFn: () => api.get(path), refetchInterval: interval }) + +export function DashboardPage() { + const query = useQuery({ queryKey: ['dashboard'], queryFn: () => api.get('/dashboard'), refetchInterval: 5000 }) + return {query.data ? : {query.error ? '无法读取系统状态' : '正在载入…'}} +} + +const defaultProfile = { name: '', source_root_id: '', library_root_id: '', mode: 'link', execution_policy: 'review_all', min_confidence: 86, stability_seconds: 30, watch_enabled: false, enabled: true } + +export function ProfileForm({ initial, roots, editing = false, onSave, onCancel }: { initial: Item; roots: Item[]; editing?: boolean; onSave: (value: Item) => void; onCancel?: () => void }) { + const [value, setValue] = useState({ ...initial, watch_enabled: Boolean(initial.watch_enabled), enabled: Boolean(initial.enabled) }) + const sourceRoots = roots.filter(item => item.kind === 'source' && item.enabled !== 0) + const libraryRoots = roots.filter(item => item.kind === 'library' && item.enabled !== 0) + const change = (key: string, next: unknown) => setValue(current => ({ ...current, [key]: next })) + const submit = (event: FormEvent) => { + event.preventDefault() + onSave({ ...value, source_root_id: Number(value.source_root_id), library_root_id: Number(value.library_root_id), min_confidence: Number(value.min_confidence), stability_seconds: Number(value.stability_seconds) }) + } + return
+ + {!editing ? <> : null} + + + + + + +
{onCancel ? : null}
+
+} + +export function ProfilesPage() { + const roots = useList('roots', '/roots'); const profiles = useList('profiles', '/profiles'); const client = useQueryClient() + const [kind, setKind] = useState('source'); const [path, setPath] = useState(''); const [editing, setEditing] = useState(null) + const addRoot = useMutation({ mutationFn: () => api.post('/roots', { kind, path }), onSuccess: async () => { setPath(''); await client.invalidateQueries({ queryKey: ['roots'] }) } }) + const validateRoot = useMutation({ mutationFn: (id: number) => api.post(`/roots/${id}/validate`), onSuccess: () => client.invalidateQueries({ queryKey: ['roots'] }) }) + const toggleRoot = useMutation({ mutationFn: (root: Item) => api.patch(`/roots/${root.id}`, { patch: { enabled: !Boolean(root.enabled) } }), onSuccess: () => client.invalidateQueries({ queryKey: ['roots'] }) }) + const addProfile = useMutation({ mutationFn: (profile: Item) => api.post('/profiles', profile), onSuccess: () => client.invalidateQueries({ queryKey: ['profiles'] }) }) + const updateProfile = useMutation({ mutationFn: ({ profile, patch }: { profile: Item; patch: Item }) => api.patch(`/profiles/${profile.id}`, { revision: profile.revision, patch }), onSuccess: async () => { setEditing(null); await client.invalidateQueries({ queryKey: ['profiles'] }) } }) + const scan = useMutation({ mutationFn: (id: number) => api.post('/jobs/scans', { profile_id: id, paths: [] }, { 'Idempotency-Key': `manual-${id}-${Date.now()}` }) }) + return roots.refetch()}>刷新状态}> +

存储根目录

路径变更请新增根目录,已有根可安全停用
{ event.preventDefault(); addRoot.mutate() }}> setPath(event.target.value)} placeholder="F:\动漫下载" />
} />
+

扫描策略

模式、阈值、监听和执行策略均可修订
{editing ? setEditing(null)} onSave={patch => updateProfile.mutate({ profile: editing, patch: { name: patch.name, mode: patch.mode, execution_policy: patch.execution_policy, min_confidence: patch.min_confidence, stability_seconds: patch.stability_seconds, watch_enabled: patch.watch_enabled, enabled: patch.enabled } })} /> : addProfile.mutate(profile)} />}{profiles.data?.items.length ? profiles.data.items.map(profile =>
{profile.name}{profile.mode} · {profile.execution_policy} · 阈值 {profile.min_confidence}% · rev {profile.revision}
) : 添加源目录和媒体库后创建配置}
+
+} + +export function JobsPage() { + const query = useList('jobs', '/jobs'); const client = useQueryClient(); const [selected, setSelected] = useState(null) + const events = useQuery({ queryKey: ['job-events', selected?.id], queryFn: async () => parseEventStream(await api.text(`/jobs/${selected?.id}/events`)), enabled: Boolean(selected) }) + const cancel = useMutation({ mutationFn: (id: number) => api.post(`/jobs/${id}/cancel`), onSuccess: () => client.invalidateQueries({ queryKey: ['jobs'] }) }) + return
['queued', 'leased', 'running'].includes(item.status) ? : null} />
+} + +type MediaType = 'episode' | 'movie' | 'special' + +function reviewPayload(review: Item) { + return review.payload?.resolution && typeof review.payload.resolution === 'object' + ? review.payload.resolution + : (review.payload || {}) +} + +function inferMediaType(payload: Item): MediaType { + if (payload.media_type === 'episode' || payload.media_type === 'movie' || payload.media_type === 'special') return payload.media_type + if (payload.is_movie) return 'movie' + if (Number(payload.season) === 0 || (typeof payload.episode === 'string' && /^sp/i.test(payload.episode))) return 'special' + return 'episode' +} + +function inputValue(value: unknown) { + return value === null || value === undefined ? '' : String(value) +} + +function structuredEpisode(value: string): number | string { + const trimmed = value.trim() + if (/^\d+$/.test(trimmed)) return Number(trimmed) + if (/^\d+\.\d+$/.test(trimmed)) return Number(trimmed) + return trimmed +} + +function episodeToken(value: string) { + const parsed = structuredEpisode(value) + return typeof parsed === 'number' && Number.isInteger(parsed) + ? String(parsed).padStart(2, '0') + : String(parsed) +} + +export function ReviewResolutionForm({ review, onSubmit, submitting = false }: { review: Item; onSubmit: (resolution: Item) => void; submitting?: boolean }) { + const payload = reviewPayload(review) + const [title, setTitle] = useState(inputValue(payload.title || payload.canonical_title)) + const [mediaType, setMediaType] = useState(() => inferMediaType(payload)) + const [season, setSeason] = useState(inputValue(payload.season ?? (inferMediaType(payload) === 'special' ? 0 : ''))) + const [episode, setEpisode] = useState(inputValue(payload.episode)) + const [releaseTag, setReleaseTag] = useState(inputValue(payload.release_tag)) + const [manualLock, setManualLock] = useState(payload.manual_lock === undefined ? true : Boolean(payload.manual_lock)) + const evidence = payload.evidence ?? review.payload ?? {} + const extension = typeof payload.source === 'string' ? (payload.source.match(/\.[^./\\]+$/)?.[0] || '.ext') : '.ext' + const version = releaseTag.trim() ? ` [${releaseTag.trim()}]` : '' + const safeTitle = title.trim() || '未命名标题' + const token = episodeToken(episode || '0') + const preview = mediaType === 'movie' + ? `${safeTitle}/${safeTitle}${version}${extension}` + : mediaType === 'special' + ? `${safeTitle}/Specials/${/^sp/i.test(token) ? token : `SP${token}`} - ${safeTitle}${version}${extension}` + : `${safeTitle}/Season ${String(Number(season || 0)).padStart(2, '0')}/S${String(Number(season || 0)).padStart(2, '0')}E${token} - ${safeTitle}${version}${extension}` + const complete = Boolean(title.trim()) && (mediaType === 'movie' || (season !== '' && episode.trim() !== '')) + + const changeMediaType = (nextType: MediaType) => { + if (nextType === 'movie') { + setSeason('') + setEpisode('') + } else if (nextType === 'special') { + setSeason('0') + setEpisode(current => /^sp/i.test(current.trim()) ? current : 'SP01') + } else if (nextType === 'episode' && mediaType !== 'episode') { + setSeason('1') + setEpisode('') + } + setMediaType(nextType) + } + + const submit = (event: FormEvent) => { + event.preventDefault() + const resolution: Item = { + title: title.trim(), + media_type: mediaType, + release_tag: releaseTag.trim(), + manual_lock: manualLock, + } + if (mediaType !== 'movie') { + resolution.season = Number(season) + resolution.episode = structuredEpisode(episode) + } + onSubmit(resolution) + } + + return
+

识别证据 JSON

+
{JSON.stringify(evidence, null, 2)}
+ + + {mediaType !== 'movie' ? <> : null} + + +
目标路径预览{preview}实际目标会按媒体库根目录、文件扩展名和同集版本冲突规则生成。
+ +
+} + +export function ReviewsPage() { + const query = useList('reviews', '/reviews'); const client = useQueryClient(); const [selected, setSelected] = useState(null) + const resolve = useMutation({ mutationFn: (resolution: Item) => api.post(`/reviews/${selected?.id}/resolve`, { resolution }), onSuccess: async () => { setSelected(null); await client.invalidateQueries({ queryKey: ['reviews'] }); await client.invalidateQueries({ queryKey: ['plans'] }) } }) + return
+} + +export function PlansPage() { + const list = useList('plans', '/plans'); const [id, setId] = useState(null); const [manualSelection, setManualSelection] = useState(false); const detail = useQuery({ queryKey: ['plan', id], queryFn: () => api.get(`/plans/${id}`), enabled: id !== null }); const client = useQueryClient() + const approve = useMutation({ mutationFn: () => api.post(`/plans/${id}/approve`), onSuccess: async () => { await client.invalidateQueries({ queryKey: ['plans'] }); await detail.refetch() } }) + useEffect(() => { const newest = list.data?.items[0]?.id; if (!manualSelection && newest && id !== Number(newest)) setId(Number(newest)) }, [id, list.data?.items, manualSelection]) + return
{list.data?.items.map(plan => )}
{detail.data ? approve.mutate()} /> : 暂无整理计划}
+} + +export function LibraryPage() { + const query = useList('shows', '/library/shows'); const client = useQueryClient(); const [selected, setSelected] = useState(null); const [title, setTitle] = useState(''); const [reason, setReason] = useState(''); const [locked, setLocked] = useState(true); const [preview, setPreview] = useState(null) + const detail = useQuery({ queryKey: ['show', selected?.id], queryFn: () => api.get(`/library/shows/${selected?.id}`), enabled: Boolean(selected) }) + const previewChange = useMutation({ mutationFn: () => api.post('/library/changes/preview', { show_id: selected?.id, base_revision: selected?.revision, patch: { canonical_title: title, title_locked: locked }, reason }), onSuccess: setPreview }) + const approve = useMutation({ mutationFn: () => api.post(`/library/changes/${preview?.id}/approve`), onSuccess: async updated => { setPreview(null); setSelected(updated); setTitle(''); setReason(''); await client.invalidateQueries({ queryKey: ['shows'] }); await client.invalidateQueries({ queryKey: ['show', updated.id] }) } }) + const metadata = detail.data?.metadata?.[0] + return
{ setSelected(item); setTitle(item.canonical_title); setPreview(null) }} selectedId={selected?.id} />
+} + +export function RulesPage() { + const query = useList('rules', '/rules', 0); const client = useQueryClient(); const [name, setName] = useState(''); const [selectedId, setSelectedId] = useState(null); const [document, setDocument] = useState('{\n "aliases": {}\n}'); const [error, setError] = useState('') + const selected = query.data?.items.find(item => item.id === selectedId) || query.data?.items[0] + useEffect(() => { if (selectedId === null && query.data?.items[0]?.id) setSelectedId(Number(query.data.items[0].id)) }, [query.data?.items, selectedId]) + const refresh = () => client.invalidateQueries({ queryKey: ['rules'] }) + const createSet = useMutation({ mutationFn: () => api.post('/rules', { name }), onSuccess: async item => { setName(''); setSelectedId(item.id); await refresh() } }) + const createRevision = useMutation({ mutationFn: async () => { setError(''); let parsed: Item; try { parsed = JSON.parse(document) } catch { throw new Error('规则 JSON 格式无效') } return api.post('/rules/revisions', { rule_set_id: selected?.id, document: parsed }) }, onSuccess: refresh, onError: reason => setError(reason instanceof Error ? reason.message : '保存失败') }) + const validate = useMutation({ mutationFn: (id: number) => api.post(`/rules/revisions/${id}/validate`), onSuccess: refresh }) + const activate = useMutation({ mutationFn: (id: number) => api.post(`/rules/revisions/${id}/activate`), onSuccess: refresh }) + const rollback = useMutation({ mutationFn: (id: number) => api.post(`/rules/${selected?.id}/revisions/${id}/rollback`), onSuccess: refresh }) + const latest = selected?.revisions?.[0] + return
{ event.preventDefault(); createSet.mutate() }}> setName(event.target.value)} placeholder="例如:默认别名" />
setSelectedId(item.id)} selectedId={selected?.id} />