Files
JavSP/core/config.py

474 lines
19 KiB
Python

import os
import re
import sys
import getpass
import logging
import argparse
import configparser
from shutil import copyfile
from string import Template
from .lib import re_escape
__all__ = ['cfg', 'args', 'is_url']
if getattr(sys, 'frozen', False):
built_in_cfg_file = os.path.join(sys._MEIPASS, 'config.ini')
else:
built_in_cfg_file = os.path.join(os.path.dirname(__file__), 'config.ini')
def rel_path_from_exe(path):
"""将一个相对于exe文件的路径转换为绝对路径"""
if getattr(sys, 'frozen', False):
# 打包后相对于exe定位
rel_start = os.path.split(sys.executable)[0]
else:
# 打包前相对于config.py文件的上一层文件夹定位
rel_start = os.path.dirname(os.path.dirname(__file__))
# 确保返回的是绝对路径(__file__可能引入相对路径)
abs_path = os.path.abspath(os.path.join(rel_start, path))
return abs_path
def gen_backup_path(path):
"""为给定文件生成一个备份路径(必要时增加序号以避免覆盖现有文件)"""
abspath = os.path.abspath(path)
backup_path = abspath + '.bak'
i = 1
while os.path.exists(backup_path):
backup_path = abspath + '.' + str(i) + '.bak'
i += 1
return backup_path
def log_filter(record):
"""只接受JavSP自身的日志,排除所依赖的库的日志"""
rname = record.name
return rname in ('main', '__main__') or rname.startswith(('core.', 'web.'))
class ColoredFormatter(logging.Formatter):
"""为不同level的日志着色"""
NO_STYLE = '\033[0m'
COLOR_MAP = {
logging.DEBUG: '\033[1;30m', # grey
logging.WARNING: '\033[1;33m', # light yellow
logging.ERROR: '\033[1;31m', # light red
logging.CRITICAL: '\033[0;31m', # red
}
def __init__(self, fmt='%(levelname)-8s:%(message)s',
datefmt='%Y-%m-%d %H:%M:%S', style='%', validate=True) -> None:
super().__init__(fmt=fmt, datefmt=datefmt, style=style, validate=validate)
def format(self, record):
# 清除exc_info异常信息,保持终端输出的整洁
record.exc_info = None
record.exc_text = None
raw = super().format(record)
color = self.COLOR_MAP.get(record.levelno, self.NO_STYLE)
return color + raw + self.NO_STYLE
class DetailedFormatter(logging.Formatter):
"""如果日志记录包含异常信息,则将传递给异常的参数一起记录下来"""
def __init__(self, fmt='%(asctime)s %(name)s:%(lineno)d %(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S', *args) -> None:
super().__init__(fmt, datefmt, *args)
username = getpass.getuser()
self.anonymize = re.compile(r'([\\/]*)' + re_escape(username) + '([\\/]*)', flags=re.I)
def format(self, record):
raw = super().format(record)
s = self.anonymize.sub(r'\1javsp\2', raw)
return s
def formatException(self, ei):
s = super().formatException(ei)
# ei[1] 是异常的实例,从中提取除了异常的message外的其他参数
if len(ei[1].args) > 1:
args = ei[1].args[1:]
s += "\nArguments: \n " + str(args).strip('(),')
return s
# 添加到root的filter无法对root的子logger生效(真是反直觉的设计),因此将filter添加到每一个handler
# https://docs.python.org/3/library/logging.html#filter-objects
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
file_handler = logging.FileHandler(
filename=rel_path_from_exe('JavSP.log'), mode='a', encoding='utf-8')
file_handler.setLevel(logging.DEBUG)
file_handler.addFilter(filter=log_filter)
file_handler.setFormatter(DetailedFormatter())
root_logger.addHandler(file_handler)
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.INFO)
stream_handler.addFilter(filter=log_filter)
stream_handler.setFormatter(ColoredFormatter(fmt='%(message)s'))
root_logger.addHandler(stream_handler)
filemove_logger = logging.getLogger('filemove')
file_handler2 = logging.FileHandler(filename=rel_path_from_exe('FileMove.log'),
mode='a', encoding='utf-8')
file_handler2.addFilter(filter=lambda r: r.name == 'filemove')
file_handler2.setFormatter(logging.Formatter(
fmt='%(asctime)s\t%(message)s', datefmt='%Y-%m-%d %H:%M:%S'))
filemove_logger.addHandler(file_handler2)
logger = logging.getLogger(__name__)
class DotDict(dict):
"""Access dict value with 'dict.key' grammar"""
__getattr__ = dict.get
__setattr__ = dict.__setitem__
__delattr__ = dict.__delitem__
class Config(configparser.ConfigParser):
def __init__(self, **kwargs):
# 使用ConfigParser的__init__方法来创建配置实例
super().__init__(dict_type=DotDict, **kwargs)
def __getattr__(self, name: str) -> None:
if name not in self._sections:
raise KeyError(name)
return self._sections.get(name)
def read_cfg(self, cfg_file):
builtin = Config()
super(Config, builtin).read(built_in_cfg_file, 'utf-8')
# 覆盖原生的read方法,以自动处理不同的编码
for encoding in ('utf-8-sig', 'utf-8', None):
try:
super(Config, self).read(cfg_file, encoding)
break
except Exception:
# 编码问题不一定报 UnicodeDecodeError, 因此要捕获所有异常
logger.debug('解析配置文件时出错', exc_info=True)
else:
backup = gen_backup_path(cfg_file)
copyfile(cfg_file, backup)
dump_config(cfg_file)
logger.warning('解析配置文件时出错,已重新生成配置文件')
logger.info('原配置文件已备份到: ' + backup)
# 直接退出,以便用户对生成的配置文件进行修改
raise SystemExit()
# 处理配置文件缺失特定字段的情况
missed_items = []
for sec in builtin.sections():
if not self.has_section(sec):
self.add_section(sec)
for opt in builtin[sec]:
if not self.has_option(sec, opt):
self[sec][opt] = builtin[sec][opt]
missed_items.append(f"'{sec}:{opt}'")
if missed_items:
logger.warning('使用默认配置补全缺失的配置项: ' + ', '.join(missed_items))
self.update_cfg_file(cfg_file)
def update_cfg_file(self, cfg_file):
"""将内置的配置文件作为模板,基于self重写cfg_file"""
backup = gen_backup_path(cfg_file)
copyfile(cfg_file, backup)
# 读取内置配置文件
with open(built_in_cfg_file, encoding='utf-8') as f:
builtin = f.readlines()
with open(cfg_file, 'wt', encoding='utf-8') as f:
for line in builtin:
# 注释行直接原样写入
value = line.strip()
if value.startswith(self._comment_prefixes):
f.write(line)
continue
# 判断section和option
mo = self.SECTCRE.match(value)
if mo: # section
sectname = mo.group('header')
f.write(line)
else:
mo = self._optcre.match(value)
if mo: # option
optname, vi, optval = mo.group('option', 'vi', 'value')
new_optval = self[sectname][optname]
f.write("{} {} {}\n".format(optname, vi, new_optval))
else: # 空行等其他信息
f.write(line)
logger.info('原配置文件已备份到: ' + backup)
def validate(self):
"""对配置中必要的项目进行验证和转换,以便于其他模块直接使用"""
# norm_config需要作为类的方法公开,以方便调用
# 由norm_config间接调用的那些实际进行转换的函数并不应当被公开,所以它们组织为模块内的函数而不是类的方法
norm_int(self)
norm_tuples(self)
norm_boolean(self)
validate_proxy(self)
norm_ignore_pattern(self)
convert_naming_rule(self)
validate_translation(self)
# 作为配置模块,始终检查免代理地址;由各个抓取器中根据代理情况选择是否启用免代理地址
check_proxy_free_url(self)
validate_media_servers(self)
validate_ai_config(self)
def is_url(url: str):
"""判断给定的字符串是否是有效的带协议字段的URL"""
# https://stackoverflow.com/a/7160778/6415337
pattern = re.compile(
r'^(?:http)s?://' # http:// or https://
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' #domain...
r'localhost|' #localhost...
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
r'(?::\d+)?' # optional port
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
return re.match(pattern, url) is not None
def norm_int(cfg: Config):
"""转换所有的整数类型配置"""
cfg.Network.retry = cfg.getint('Network', 'retry')
cfg.Network.timeout = cfg.getint('Network', 'timeout')
cfg.NamingRule.max_path_len = min(cfg.getint('NamingRule', 'max_path_len'), 256)
cfg.NamingRule.max_actress_count = max(cfg.getint('NamingRule', 'max_actress_count'), 1)
cfg.File.ignore_video_file_less_than = int(cfg.getfloat('File', 'ignore_video_file_less_than') * 2**20)
cfg.Crawler.sleep_after_scraping = max(cfg.getint('Crawler', 'sleep_after_scraping'), 0)
def norm_tuples(cfg: Config):
"""将特定的配置转换为元组类型,便于迭代的同时也防止误修改"""
# media_ext: 转换为全小写的.ext格式的元组
items = cfg.File.media_ext.lower().split(';')
exts = [i if i.startswith('.') else '.'+i for i in items]
cfg.File.media_ext = tuple(exts)
# ignore_folder: 转换为元组
items = cfg.File.ignore_folder.split(';')
cfg.File.ignore_folder = tuple(items)
# required_keys: 转换为元组
items = cfg.Crawler.required_keys.split(',')
cfg.Crawler.required_keys = tuple(items)
# use_ai_crop_labels: 转换为元祖
items = cfg.Picture.use_ai_crop_labels.upper().split(',')
cfg.Picture.use_ai_crop_labels = tuple(items)
def norm_boolean(cfg: Config):
"""转换所有的布尔类型配置"""
for sec, key in [
('Crawler', 'hardworking_mode'),
('Crawler', 'respect_site_avid'),
('Crawler', 'title__remove_actor'),
('Crawler', 'title__chinese_first'),
('Picture', 'use_big_cover'),
('Picture', 'use_ai_crop'),
('NFO', 'add_genre_to_tag'),
('Other', 'check_update'),
('Other', 'auto_update')
]:
cfg._sections[sec][key] = cfg.getboolean(sec, key)
# 特殊转换
sec, key = 'NamingRule', 'calc_path_len_by_byte'
try:
cfg._sections[sec][key] = cfg.getboolean(sec, key)
except ValueError:
# 当配置为auto时会转换失败,此时保留auto配置以待后面根据文件系统来作更新
cfg._sections[sec][key] = cfg._sections[sec][key].lower()
def norm_ignore_pattern(cfg: Config):
"""将配置文件中推测番号时的忽略列表转换为正则表达式"""
words = cfg.MovieID.ignore_whole_word.replace(' ','').split(';')
regexes = cfg.MovieID.ignore_regex.split(';')
# \b匹配单词边界时,_连接的将视为一个单词。为了让_也作为单词的边界,需要进行额外处理
words_pattern = R'(?:\b|_)({})(?=\b|_)'.format('|'.join(words))
regex_patterns = '|'.join([f'({i})' for i in regexes])
pattern_str = f'({words_pattern})|{regex_patterns}'
ignore_pattern = re.compile(pattern_str, flags=re.I | re.A)
cfg.MovieID.ignore_pattern = ignore_pattern
def validate_media_servers(cfg: Config):
"""获取媒体服务器配置并验证有效性"""
supported = set(('universal', 'plex', 'emby', 'jellyfin', 'kodi', 'video_station'))
servers = cfg.NamingRule.media_servers.lower()
items = set(re.split(r'[^\w_]+', servers, flags=re.I))
cfg.NamingRule.media_servers = items & supported
invalid = items - supported
if invalid:
logger.error("媒体服务器无效: {}。仅支持: {}".format(','.join(invalid), ','.join(supported)))
def validate_translation(cfg: Config):
"""从环境变量和配置文件解析并初步验证翻译设置"""
trans = cfg.Translate
# 尝试从环境变量获取相关的访问凭据
trans.baidu_appid = os.getenv('JAVSP_BAIDU_APPID', trans.baidu_appid)
trans.baidu_key = os.getenv('JAVSP_BAIDU_KEY', trans.baidu_key)
trans.bing_key = os.getenv('JAVSP_BING_KEY', trans.bing_key)
# 先获取访问凭据再判断翻译引擎,这样的话即使配置文件中未启用翻译也可以调试翻译功能
if trans.engine == '':
return
# 判断不同翻译引擎所需的凭据是否齐全(默认为禁用翻译状态,仅当相关配置有效时才启用引擎)
engine_name = trans.engine.lower()
trans.engine = None
if engine_name == 'baidu':
if trans.baidu_appid and trans.baidu_key:
cfg.Translate.engine = engine_name
else:
logger.error('启用百度翻译时,appid和key均不能留空')
elif engine_name == 'bing':
if trans.bing_key:
cfg.Translate.engine = engine_name
else:
logger.error('启用必应翻译时,key不能留空')
elif engine_name == 'google':
cfg.Translate.engine = engine_name
else:
logger.error('无效的翻译引擎: ' + engine_name)
def validate_ai_config(cfg: Config):
"""验证图像人脸/人体检测的相关配置是否完备"""
piccfg = cfg.Picture
if not piccfg.use_ai_crop:
return
if piccfg.ai_engine.lower() == 'baidu':
piccfg.ai_engine = 'baidu'
required_keys = ('aip_appid', 'aip_api_key', 'aip_secret_key')
empty_keys = [i for i in required_keys if not piccfg[i]]
if empty_keys:
logger.error('使用百度人体分析时,相关设置不能为空: ' + ', '.join(empty_keys))
else:
logger.error('不支持的图像识别引擎: ' + piccfg.ai_engine)
def validate_proxy(cfg: Config):
"""解析配置文件中的代理"""
proxies = {}
use_proxy = cfg.getboolean('Network', 'use_proxy')
if use_proxy:
proxy = cfg.Network.proxy.lower()
match = re.match(r'^(socks5h?|http)://([-.a-z\d]+):(\d+)$', proxy)
if match:
proxies = {'http': proxy, 'https': proxy}
else:
logger.warning(f"配置的代理格式无效,请使用类似'http://127.0.0.1:1080'的格式")
cfg.Network.proxy = proxies
def convert_naming_rule(cfg: Config):
"""NamingRule: 转换为字符串Template"""
combine = cfg.NamingRule.output_folder + os.sep + cfg.NamingRule.save_dir
cfg.NamingRule.save_dir = Template(combine)
cfg.NamingRule.filename = Template(cfg.NamingRule.filename)
cfg.NamingRule.nfo_title = Template(cfg.NamingRule.nfo_title)
cfg.NamingRule.censorship_names = {
False: cfg.NamingRule.text_for_censored,
True: cfg.NamingRule.text_for_uncensored,
None: cfg.NamingRule.text_for_unknown_censorship,
}
def check_proxy_free_url(cfg: Config):
"""检查免代理URL的格式是否有效"""
sec = cfg['ProxyFree']
for site, url in sec.items():
url = url.lower()
if not url.startswith('http'):
url = 'http://' + url
sec[site] = url if is_url(url) else ''
def parse_args():
"""解析从命令行传入的参数并进行有效性验证"""
parser = argparse.ArgumentParser(prog='JavSP', description='汇总多站点数据的AV元数据刮削器',
formatter_class=argparse.RawTextHelpFormatter)
parser.add_argument('-c', '--config', help='使用指定的配置文件')
parser.add_argument('-i', '--input', help='要扫描的文件夹')
parser.add_argument('-o', '--output', help='保存整理结果的文件夹')
parser.add_argument('-x', '--proxy', help='代理服务器地址')
parser.add_argument('-m', '--manual', nargs='?', default=-1,
help="由用户介入番号识别过程,可选值为:\n'all': 检查所有番号\n'failed': 仅检查无法识别的番号(默认)")
parser.add_argument('-e', '--auto-exit', action='store_true', help='运行结束后自动退出')
parser.add_argument('-s', '--shutdown', action='store_true', help='整理完成后关机')
# 忽略无法识别的参数,避免传入供pytest使用的参数时报错
args, unknown = parser.parse_known_args()
# 验证相关参数的有效性
if args.config:
cfg_file = os.path.abspath(args.config)
if not os.path.exists(cfg_file):
logger.error(f"找不到指定的配置文件: '{cfg_file}'")
raise SystemExit()
else:
logger.debug(f"读取指定的配置文件: '{cfg_file}'")
else:
# 未指定配置文件时,使用默认配置文件
if getattr(sys, 'frozen', False):
cfg_file = os.path.join(os.path.split(sys.executable)[0], 'config.ini')
if not os.path.exists(cfg_file):
logger.warning(f"已创建默认配置文件: '{cfg_file}'")
dump_config(cfg_file)
else:
cfg_file = built_in_cfg_file
# manual: 未传入选项时值为-1,仅传入选项时值为None,传入选项且有对应值时为对应值
# 为了方便使用,仅传入选项时的默认值修改为'failed',未传入选项时修改为None
# raise argparse.ArgumentError('a', "invalid choice: 'aa' (choose from 1, 23)")
if args.manual == None:
args.manual = 'failed'
elif args.manual == -1:
args.manual = None
elif args.manual.lower() in ('all', 'failed'):
args.manual = args.manual.lower()
else:
# 生成与argparser类似格式的异常消息
msg = f"{parser.prog}: error: argument -m/--manual: invalid choice: '{args.manual}' (choose from 'all', 'failed' or leave it empty)"
# 使用SystemExit异常以避免显示traceback信息
raise SystemExit(msg)
args.config = cfg_file
return args
def dump_config(out_file):
"""将内置的配置文件输出到指定路径"""
# 使用文件读写来创建配置文件,使得创建的配置文件具有与平台相适应的换行符
with open(built_in_cfg_file, 'rt', encoding='utf-8') as f:
content = f.read()
with open(out_file, 'wt', encoding='utf-8') as f:
f.write(content)
def overwrite_cfg(cfg, args):
"""根据配置args覆盖cfg中特定的配置项"""
if args.proxy:
cfg.Network.proxy = 'yes'
cfg.Network.proxy = args.proxy
if args.input:
cfg.File.scan_dir = args.input
if args.output:
cfg.NamingRule.output_folder = args.output
cfg = Config()
args = parse_args()
cfg.read_cfg(args.config)
# 先覆盖配置,再进行配置有效性的验证
overwrite_cfg(cfg, args)
try:
cfg.validate()
except Exception as e:
logger.error('验证配置文件时出错: ' + repr(e))
os.system('pause')
sys.exit(2)
if __name__ == "__main__":
import pretty_errors
pretty_errors.configure(display_link=True)
print(cfg.NamingRule.output_folder)
print(args)