import asyncio import copy import json import os import platform import re import secrets import sys import threading from asyncio import AbstractEventLoop from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Type from urllib.parse import urlparse from dotenv import set_key from pydantic import BaseModel, Field, ConfigDict, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from app.log import logger, log_settings, LogConfigModel from app.schemas import MediaType from app.utils.system import SystemUtils from app.utils.url import UrlUtils from version import APP_VERSION class SystemConfModel(BaseModel): """ 系统关键资源大小配置 """ # 缓存种子数量 torrents: int = 0 # 订阅刷新处理数量 refresh: int = 0 # TMDB请求缓存数量 tmdb: int = 0 # 豆瓣请求缓存数量 douban: int = 0 # Bangumi请求缓存数量 bangumi: int = 0 # Fanart请求缓存数量 fanart: int = 0 # 元数据缓存过期时间(秒) meta: int = 0 # 调度器数量 scheduler: int = 0 # 线程池大小 threadpool: int = 0 class ConfigModel(BaseModel): """ Pydantic 配置模型,描述所有配置项及其类型和默认值 """ model_config = ConfigDict(extra="ignore") # 忽略未定义的配置项 # ==================== 基础应用配置 ==================== # 项目名称 PROJECT_NAME: str = "MoviePilot" # 域名 格式;https://movie-pilot.org APP_DOMAIN: str = "" # API路径 API_V1_STR: str = "/api/v1" # 前端资源路径 FRONTEND_PATH: str = "/public" # 时区 TZ: str = "Asia/Shanghai" # API监听地址 HOST: str = "0.0.0.0" # API监听端口 PORT: int = 3001 # 前端监听端口 NGINX_PORT: int = 3000 # 配置文件目录 CONFIG_DIR: Optional[str] = None # 是否调试模式 DEBUG: bool = False # 是否开发模式 DEV: bool = False # 高级设置模式 ADVANCED_MODE: bool = True # ==================== 安全认证配置 ==================== # 密钥 SECRET_KEY: str = secrets.token_urlsafe(32) # RESOURCE密钥 RESOURCE_SECRET_KEY: str = secrets.token_urlsafe(32) # 允许的域名 ALLOWED_HOSTS: list = Field(default_factory=lambda: ["*"]) # TOKEN过期时间 ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # RESOURCE_TOKEN过期时间 RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS: int = 60 * 30 # 超级管理员初始用户名 SUPERUSER: str = "admin" # 超级管理员初始密码 SUPERUSER_PASSWORD: Optional[str] = None # 辅助认证,允许通过外部服务进行认证、单点登录以及自动创建用户 AUXILIARY_AUTH_ENABLE: bool = False # API密钥,需要更换 API_TOKEN: Optional[str] = None # 用户认证站点 AUTH_SITE: str = "" # ==================== 数据库配置 ==================== # 数据库类型,支持 sqlite 和 postgresql,默认使用 sqlite DB_TYPE: str = "sqlite" # 是否在控制台输出 SQL 语句,默认关闭 DB_ECHO: bool = False # 数据库连接超时时间(秒),默认为 60 秒 DB_TIMEOUT: int = 60 # 是否启用 WAL 模式,仅适用于SQLite,默认开启 DB_WAL_ENABLE: bool = True # 数据库连接池类型,QueuePool, NullPool DB_POOL_TYPE: str = "QueuePool" # 是否在获取连接时进行预先 ping 操作 DB_POOL_PRE_PING: bool = True # 数据库连接的回收时间(秒) DB_POOL_RECYCLE: int = 300 # 数据库连接池获取连接的超时时间(秒) DB_POOL_TIMEOUT: int = 30 # SQLite 连接池大小 DB_SQLITE_POOL_SIZE: int = 10 # SQLite 连接池溢出数量 DB_SQLITE_MAX_OVERFLOW: int = 50 # PostgreSQL 主机地址 DB_POSTGRESQL_HOST: str = "localhost" # PostgreSQL 端口 DB_POSTGRESQL_PORT: int = 5432 # PostgreSQL 数据库名 DB_POSTGRESQL_DATABASE: str = "moviepilot" # PostgreSQL 用户名 DB_POSTGRESQL_USERNAME: str = "moviepilot" # PostgreSQL 密码 DB_POSTGRESQL_PASSWORD: str = "moviepilot" # PostgreSQL 连接池大小 DB_POSTGRESQL_POOL_SIZE: int = 10 # PostgreSQL 连接池溢出数量 DB_POSTGRESQL_MAX_OVERFLOW: int = 50 # ==================== 缓存配置 ==================== # 缓存类型,支持 cachetools 和 redis,默认使用 cachetools CACHE_BACKEND_TYPE: str = "cachetools" # 缓存连接字符串,仅外部缓存(如 Redis、Memcached)需要 CACHE_BACKEND_URL: Optional[str] = "redis://localhost:6379" # Redis 缓存最大内存限制,未配置时,如开启大内存模式时为 "1024mb",未开启时为 "256mb" CACHE_REDIS_MAXMEMORY: Optional[str] = None # 全局图片缓存,将媒体图片缓存到本地 GLOBAL_IMAGE_CACHE: bool = False # 全局图片缓存保留天数 GLOBAL_IMAGE_CACHE_DAYS: int = 7 # 临时文件保留天数 TEMP_FILE_DAYS: int = 3 # 元数据识别缓存过期时间(小时),0为自动 META_CACHE_EXPIRE: int = 0 # ==================== 网络代理配置 ==================== # 网络代理服务器地址 PROXY_HOST: Optional[str] = None # 是否启用DOH解析域名 DOH_ENABLE: bool = False # 使用 DOH 解析的域名列表 DOH_DOMAINS: str = ("api.themoviedb.org," "api.tmdb.org," "webservice.fanart.tv," "api.github.com," "github.com," "raw.githubusercontent.com," "codeload.github.com," "api.telegram.org") # DOH 解析服务器列表 DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112" # ==================== 媒体元数据配置 ==================== # 媒体搜索来源 themoviedb/douban/bangumi,多个用,分隔 SEARCH_SOURCE: str = "themoviedb" # 媒体识别来源 themoviedb/douban RECOGNIZE_SOURCE: str = "themoviedb" # 刮削来源 themoviedb/douban SCRAP_SOURCE: str = "themoviedb" # 电视剧动漫的分类genre_ids ANIME_GENREIDS: List[int] = Field(default=[16]) # ==================== TMDB配置 ==================== # TMDB图片地址 TMDB_IMAGE_DOMAIN: str = "image.tmdb.org" # TMDB API地址 TMDB_API_DOMAIN: str = "api.themoviedb.org" # TMDB元数据语言 TMDB_LOCALE: str = "zh" # 刮削使用TMDB原始语种图片 TMDB_SCRAP_ORIGINAL_IMAGE: bool = False # TMDB API Key TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381" # ==================== TVDB配置 ==================== # TVDB API Key TVDB_V4_API_KEY: str = "ed2aa66b-7899-4677-92a7-67bc9ce3d93a" TVDB_V4_API_PIN: str = "" # ==================== Fanart配置 ==================== # Fanart开关 FANART_ENABLE: bool = True # Fanart语言 FANART_LANG: str = "zh,en" # Fanart API Key FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f" # ==================== 云盘配置 ==================== # 115 AppId U115_APP_ID: str = "100196807" # 115 OAuth2 Server 地址 U115_AUTH_SERVER: str = "https://movie-pilot.org" # Alipan AppId ALIPAN_APP_ID: str = "ac1bf04dc9fd4d9aaabb65b4a668d403" # ==================== 系统升级配置 ==================== # 重启自动升级 MOVIEPILOT_AUTO_UPDATE: str = 'release' # 自动检查和更新站点资源包(站点索引、认证等) AUTO_UPDATE_RESOURCE: bool = True # ==================== 媒体文件格式配置 ==================== # 支持的视频文件后缀格式 RMT_MEDIAEXT: list = Field( default_factory=lambda: ['.mp4', '.mkv', '.ts', '.iso', '.rmvb', '.avi', '.mov', '.mpeg', '.mpg', '.wmv', '.3gp', '.asf', '.m4v', '.flv', '.m2ts', '.strm', '.tp', '.f4v'] ) # 支持的字幕文件后缀格式 RMT_SUBEXT: list = Field(default_factory=lambda: ['.srt', '.ass', '.ssa', '.sup']) # 支持的音轨文件后缀格式 RMT_AUDIOEXT: list = Field( default_factory=lambda: ['.aac', '.ac3', '.amr', '.caf', '.cda', '.dsf', '.dff', '.kar', '.m4a', '.mp1', '.mp2', '.mp3', '.mid', '.mod', '.mka', '.mpc', '.nsf', '.ogg', '.pcm', '.rmi', '.s3m', '.snd', '.spx', '.tak', '.tta', '.vqf', '.wav', '.wma', '.aifc', '.aiff', '.alac', '.adif', '.adts', '.flac', '.midi', '.opus', '.sfalc'] ) # ==================== 媒体服务器配置 ==================== # 媒体服务器同步间隔(小时) MEDIASERVER_SYNC_INTERVAL: int = 6 # ==================== 订阅配置 ==================== # 订阅模式 SUBSCRIBE_MODE: str = "spider" # RSS订阅模式刷新时间间隔(分钟) SUBSCRIBE_RSS_INTERVAL: int = 30 # 订阅数据共享 SUBSCRIBE_STATISTIC_SHARE: bool = True # 订阅搜索开关 SUBSCRIBE_SEARCH: bool = False # 订阅搜索时间间隔(小时) SUBSCRIBE_SEARCH_INTERVAL: int = 24 # 检查本地媒体库是否存在资源开关 LOCAL_EXISTS_SEARCH: bool = True # ==================== 站点配置 ==================== # 站点数据刷新间隔(小时) SITEDATA_REFRESH_INTERVAL: int = 6 # 读取和发送站点消息 SITE_MESSAGE: bool = True # 不能缓存站点资源的站点域名,多个使用,分隔 NO_CACHE_SITE_KEY: str = "m-team" # OCR服务器地址,用于识别站点验证码 OCR_HOST: str = "https://movie-pilot.org" # 仿真类型:playwright 或 flaresolverr BROWSER_EMULATION: str = "playwright" # FlareSolverr 服务地址,例如 http://127.0.0.1:8191 FLARESOLVERR_URL: Optional[str] = None # ==================== 搜索配置 ==================== # 搜索多个名称 SEARCH_MULTIPLE_NAME: bool = False # 最大搜索名称数量 MAX_SEARCH_NAME_LIMIT: int = 3 # ==================== 下载配置 ==================== # 种子标签 TORRENT_TAG: str = "MOVIEPILOT" # 下载站点字幕 DOWNLOAD_SUBTITLE: bool = True # 交互搜索自动下载用户ID,使用,分割 AUTO_DOWNLOAD_USER: Optional[str] = None # 下载器临时文件后缀 DOWNLOAD_TMPEXT: list = Field(default_factory=lambda: ['.!qb', '.part']) # ==================== CookieCloud配置 ==================== # CookieCloud是否启动本地服务 COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = False # CookieCloud服务器地址 COOKIECLOUD_HOST: str = "https://movie-pilot.org/cookiecloud" # CookieCloud用户KEY COOKIECLOUD_KEY: Optional[str] = None # CookieCloud端对端加密密码 COOKIECLOUD_PASSWORD: Optional[str] = None # CookieCloud同步间隔(分钟) COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24 # CookieCloud同步黑名单,多个域名,分割 COOKIECLOUD_BLACKLIST: Optional[str] = None # ==================== 整理配置 ==================== # 文件整理线程数 TRANSFER_THREADS: int = 1 # 电影重命名格式 MOVIE_RENAME_FORMAT: str = "{{title}}{% if year %} ({{year}}){% endif %}" \ "/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}" \ "{{fileExt}}" # 电视剧重命名格式 TV_RENAME_FORMAT: str = "{{title}}{% if year %} ({{year}}){% endif %}" \ "/Season {{season}}" \ "/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}" \ "{{fileExt}}" # 重命名时支持的S0别名 RENAME_FORMAT_S0_NAMES: list = Field(default=["Specials", "SPs"]) # 为指定默认字幕添加.default后缀 DEFAULT_SUB: Optional[str] = "zh-cn" # 新增已入库媒体是否跟随TMDB信息变化 SCRAP_FOLLOW_TMDB: bool = True # 优先使用辅助识别 RECOGNIZE_PLUGIN_FIRST: bool = False # ==================== 服务地址配置 ==================== # 服务器地址,对应 https://github.com/jxxghp/MoviePilot-Server 项目 MP_SERVER_HOST: str = "https://movie-pilot.org" # ==================== 个性化 ==================== # 登录页面电影海报,tmdb/bing/mediaserver WALLPAPER: str = "tmdb" # 自定义壁纸api地址 CUSTOMIZE_WALLPAPER_API_URL: Optional[str] = None # ==================== 插件配置 ==================== # 插件市场仓库地址,多个地址使用,分隔,地址以/结尾 PLUGIN_MARKET: str = ("https://github.com/jxxghp/MoviePilot-Plugins," "https://github.com/thsrite/MoviePilot-Plugins," "https://github.com/honue/MoviePilot-Plugins," "https://github.com/InfinityPacer/MoviePilot-Plugins," "https://github.com/DDSRem-Dev/MoviePilot-Plugins," "https://github.com/madrays/MoviePilot-Plugins," "https://github.com/justzerock/MoviePilot-Plugins," "https://github.com/KoWming/MoviePilot-Plugins," "https://github.com/wikrin/MoviePilot-Plugins," "https://github.com/HankunYu/MoviePilot-Plugins," "https://github.com/baozaodetudou/MoviePilot-Plugins," "https://github.com/Aqr-K/MoviePilot-Plugins," "https://github.com/hotlcc/MoviePilot-Plugins-Third," "https://github.com/gxterry/MoviePilot-Plugins," "https://github.com/DzAvril/MoviePilot-Plugins," "https://github.com/mrtian2016/MoviePilot-Plugins," "https://github.com/Hqyel/MoviePilot-Plugins-Third," "https://github.com/xijin285/MoviePilot-Plugins," "https://github.com/Seed680/MoviePilot-Plugins," "https://github.com/imaliang/MoviePilot-Plugins") # 插件安装数据共享 PLUGIN_STATISTIC_SHARE: bool = True # 是否开启插件热加载 PLUGIN_AUTO_RELOAD: bool = False # ==================== Github & PIP ==================== # Github token,提高请求api限流阈值 ghp_**** GITHUB_TOKEN: Optional[str] = None # Github代理服务器,格式:https://mirror.ghproxy.com/ GITHUB_PROXY: Optional[str] = '' # pip镜像站点,格式:https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple PIP_PROXY: Optional[str] = '' # 指定的仓库Github token,多个仓库使用,分隔,格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_**** REPO_GITHUB_TOKEN: Optional[str] = None # ==================== 性能配置 ==================== # 大内存模式 BIG_MEMORY_MODE: bool = False # 是否启用编码探测的性能模式 ENCODING_DETECTION_PERFORMANCE_MODE: bool = True # 编码探测的最低置信度阈值 ENCODING_DETECTION_MIN_CONFIDENCE: float = 0.8 # 主动内存回收时间间隔(分钟),0为不启用 MEMORY_GC_INTERVAL: int = 30 # ==================== 安全配置 ==================== # 允许的图片缓存域名 SECURITY_IMAGE_DOMAINS: list = Field(default=[ "image.tmdb.org", "static-mdb.v.geilijiasu.com", "bing.com", "doubanio.com", "lain.bgm.tv", "raw.githubusercontent.com", "github.com", "thetvdb.com", "cctvpic.com", "iqiyipic.com", "hdslb.com", "cmvideo.cn", "ykimg.com", "qpic.cn" ]) # 允许的图片文件后缀格式 SECURITY_IMAGE_SUFFIXES: list = Field(default=[".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"]) # PassKey 是否强制用户验证(生物识别等) PASSKEY_REQUIRE_UV: bool = True # 允许在未启用 OTP 时直接注册 PassKey PASSKEY_ALLOW_REGISTER_WITHOUT_OTP: bool = False # ==================== 工作流配置 ==================== # 工作流数据共享 WORKFLOW_STATISTIC_SHARE: bool = True # ==================== 存储配置 ==================== # 对rclone进行快照对比时,是否检查文件夹的修改时间 RCLONE_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = True # 对OpenList进行快照对比时,是否检查文件夹的修改时间 OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = True # 对阿里云盘进行快照对比时,是否检查文件夹的修改时间(默认关闭,因为阿里云盘目录时间不随子文件变更而更新) ALIPAN_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = False # ==================== Docker配置 ==================== # Docker Client API地址 DOCKER_CLIENT_API: Optional[str] = "tcp://127.0.0.1:38379" # Playwright浏览器类型,chromium/firefox PLAYWRIGHT_BROWSER_TYPE: str = "chromium" # ==================== AI智能体配置 ==================== # AI智能体开关 AI_AGENT_ENABLE: bool = False # 合局AI智能体 AI_AGENT_GLOBAL: bool = False # LLM提供商 (openai/google/deepseek) LLM_PROVIDER: str = "deepseek" # LLM模型名称 LLM_MODEL: str = "deepseek-chat" # LLM API密钥 LLM_API_KEY: Optional[str] = None # LLM基础URL(用于自定义API端点) LLM_BASE_URL: Optional[str] = "https://api.deepseek.com" # LLM最大上下文Token数量(K) LLM_MAX_CONTEXT_TOKENS: int = 64 # LLM温度参数 LLM_TEMPERATURE: float = 0.1 # LLM最大迭代次数 LLM_MAX_ITERATIONS: int = 128 # LLM工具调用超时时间(秒) LLM_TOOL_TIMEOUT: int = 300 # 是否启用详细日志 LLM_VERBOSE: bool = False # 最大记忆消息数量 LLM_MAX_MEMORY_MESSAGES: int = 30 # 内存记忆保留天数 LLM_MEMORY_RETENTION_DAYS: int = 1 # Redis记忆保留天数(如果使用Redis) LLM_REDIS_MEMORY_RETENTION_DAYS: int = 7 # 是否启用AI推荐 AI_RECOMMEND_ENABLED: bool = False # AI推荐用户偏好 AI_RECOMMEND_USER_PREFERENCE: str = "" # Tavily API密钥(用于网络搜索) TAVILY_API_KEY: str = "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" # AI推荐条目数量限制 AI_RECOMMEND_MAX_ITEMS: int = 50 class Settings(BaseSettings, ConfigModel, LogConfigModel): """ 系统配置类 """ model_config = SettingsConfigDict( case_sensitive=True, env_file=SystemUtils.get_env_path(), env_file_encoding="utf-8", ) def __init__(self, **kwargs): super().__init__(**kwargs) # 初始化配置目录及子目录 for path in [self.CONFIG_PATH, self.TEMP_PATH, self.LOG_PATH, self.COOKIE_PATH]: if not path.exists(): path.mkdir(parents=True, exist_ok=True) # 如果是二进制程序,确保配置文件存在 if SystemUtils.is_frozen(): app_env_path = self.CONFIG_PATH / "app.env" if not app_env_path.exists(): SystemUtils.copy(self.INNER_CONFIG_PATH / "app.env", app_env_path) @staticmethod def validate_api_token(value: Any, original_value: Any) -> Tuple[Any, bool]: """ 校验 API_TOKEN """ if isinstance(value, (list, dict, set)): value = copy.deepcopy(value) value = value.strip() if isinstance(value, str) else None if not value or len(value) < 16: new_token = secrets.token_urlsafe(16) if not value: logger.info(f"'API_TOKEN' 未设置,已随机生成新的【API_TOKEN】{new_token}") else: logger.warning(f"'API_TOKEN' 长度不足 16 个字符,存在安全隐患,已随机生成新的【API_TOKEN】{new_token}") return new_token, True return value, str(value) != str(original_value) @staticmethod def generic_type_converter(value: Any, original_value: Any, expected_type: Type, default: Any, field_name: str, raise_exception: bool = False) -> Tuple[Any, bool]: """ 通用类型转换函数,根据预期类型转换值。如果转换失败,返回默认值 :return: 元组 (转换后的值, 是否需要更新) """ if isinstance(value, (list, dict, set)): value = copy.deepcopy(value) # 如果 value 是 None,仍需要检查与 original_value 是否不一致 if value is None: return default, str(value) != str(original_value) if isinstance(value, str): value = value.strip() try: if expected_type is bool: if isinstance(value, bool): return value, str(value).lower() != str(original_value).lower() if isinstance(value, str): value_clean = value.lower() bool_map = { "false": False, "no": False, "0": False, "off": False, "true": True, "yes": True, "1": True, "on": True } if value_clean in bool_map: converted = bool_map[value_clean] return converted, str(converted).lower() != str(original_value).lower() elif isinstance(value, (int, float)): converted = bool(value) return converted, str(converted).lower() != str(original_value).lower() return default, True elif expected_type is int: if isinstance(value, int): return value, str(value) != str(original_value) if isinstance(value, str): converted = int(value) return converted, str(converted) != str(original_value) elif expected_type is float: if isinstance(value, float): return value, str(value) != str(original_value) if isinstance(value, str): converted = float(value) return converted, str(converted) != str(original_value) elif expected_type is str: converted = str(value).strip() return converted, converted != str(original_value) elif expected_type is list: if isinstance(value, list): return value, str(value) != str(original_value) if isinstance(value, str): items = json.loads(value) if isinstance(original_value, list): return items, items != original_value else: return items, str(items) != str(original_value) else: return value, str(value) != str(original_value) except (ValueError, TypeError) as e: if raise_exception: raise ValueError(f"配置项 '{field_name}' 的值 '{value}' 无法转换成正确的类型") from e logger.error( f"配置项 '{field_name}' 的值 '{value}' 无法转换成正确的类型,使用默认值 '{default}',错误信息: {e}") return default, True @model_validator(mode='before') @classmethod def generic_type_validator(cls, data: Any): # noqa """ 通用校验器,尝试将配置值转换为期望的类型 """ if not isinstance(data, dict): return data # 处理 API_TOKEN 特殊验证 if 'API_TOKEN' in data: converted_value, needs_update = cls.validate_api_token(data['API_TOKEN'], data['API_TOKEN']) if needs_update: cls.update_env_config("API_TOKEN", data["API_TOKEN"], converted_value) data['API_TOKEN'] = converted_value # 对其他字段进行类型转换 for field_name, field_info in cls.model_fields.items(): if field_name not in data: continue value = data[field_name] if value is None: continue field = cls.model_fields.get(field_name) if field: converted_value, needs_update = cls.generic_type_converter( value, value, field.annotation, field.default, field_name ) if needs_update: cls.update_env_config(field_name, value, converted_value) data[field_name] = converted_value return data @staticmethod def update_env_config(field_name: str, original_value: Any, converted_value: Any) -> Tuple[bool, str]: """ 更新 env 配置 """ message = None is_converted = original_value is not None and str(original_value) != str(converted_value) if is_converted: message = f"配置项 '{field_name}' 的值 '{original_value}' 无效,已替换为 '{converted_value}'" logger.warning(message) if field_name in os.environ: message = f"配置项 '{field_name}' 已在环境变量中设置,请手动更新以保持一致性" logger.warning(message) return False, message else: # 如果是列表、字典或集合类型,将其转换为JSON字符串 if isinstance(converted_value, (list, dict, set)): value_to_write = json.dumps(converted_value) else: value_to_write = str(converted_value) if converted_value is not None else "" set_key(dotenv_path=SystemUtils.get_env_path(), key_to_set=field_name, value_to_set=value_to_write, quote_mode="always") if is_converted: logger.info(f"配置项 '{field_name}' 已自动修正并写入到 'app.env' 文件") return True, message def update_setting(self, key: str, value: Any) -> Tuple[Optional[bool], str]: """ 更新单个配置项 :param key: 配置项的名称 :param value: 配置项的新值 :return: (是否成功 True 成功/False 失败/None 无需更新, 错误信息) """ if not hasattr(self, key): return False, f"配置项 '{key}' 不存在" try: field = Settings.model_fields[key] original_value = getattr(self, key) if key == "API_TOKEN": converted_value, needs_update = self.validate_api_token(value, original_value) else: converted_value, needs_update = self.generic_type_converter( value, original_value, field.annotation, field.default, key ) # 如果没有抛出异常,则统一使用 converted_value 进行更新 if needs_update or str(value) != str(converted_value): success, message = self.update_env_config(key, value, converted_value) # 仅成功更新配置时,才更新内存 if success: setattr(self, key, converted_value) if hasattr(log_settings, key): setattr(log_settings, key, converted_value) return success, message return None, "" except Exception as e: return False, str(e) def update_settings(self, env: Dict[str, Any]) -> Dict[str, Tuple[Optional[bool], str]]: """ 更新多个配置项 """ results = {} for k, v in env.items(): results[k] = self.update_setting(k, v) return results @property def VERSION_FLAG(self) -> str: """ 版本标识,用来区分重大版本,为空则为v1,不允许外部修改 """ return "v2" @property def USER_AGENT(self) -> str: """ 全局用户代理字符串 """ return f"{self.PROJECT_NAME}/{APP_VERSION[1:]} ({platform.system()} {platform.release()}; {SystemUtils.cpu_arch()})" @property def NORMAL_USER_AGENT(self) -> str: """ 默认浏览器用户代理字符串 """ return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" @property def INNER_CONFIG_PATH(self): return self.ROOT_PATH / "config" @property def CONFIG_PATH(self): if self.CONFIG_DIR: return Path(self.CONFIG_DIR) elif SystemUtils.is_docker(): return Path("/config") elif SystemUtils.is_frozen(): return Path(sys.executable).parent / "config" return self.ROOT_PATH / "config" @property def TEMP_PATH(self): return self.CONFIG_PATH / "temp" @property def CACHE_PATH(self): return self.CONFIG_PATH / "cache" @property def ROOT_PATH(self): return Path(__file__).parents[2] @property def PLUGIN_DATA_PATH(self): return self.CONFIG_PATH / "plugins" @property def LOG_PATH(self): return self.CONFIG_PATH / "logs" @property def COOKIE_PATH(self): return self.CONFIG_PATH / "cookies" @property def CONF(self) -> SystemConfModel: """ 根据内存模式返回系统配置 """ if self.BIG_MEMORY_MODE: return SystemConfModel( torrents=200, refresh=100, tmdb=1024, douban=512, bangumi=512, fanart=512, meta=(self.META_CACHE_EXPIRE or 72) * 3600, scheduler=100, threadpool=100 ) return SystemConfModel( torrents=100, refresh=50, tmdb=256, douban=256, bangumi=256, fanart=128, meta=(self.META_CACHE_EXPIRE or 24) * 3600, scheduler=50, threadpool=50 ) @property def PROXY(self): if self.PROXY_HOST: return { "http": self.PROXY_HOST, "https": self.PROXY_HOST, } return None @property def PROXY_SERVER(self): if self.PROXY_HOST: try: parsed = urlparse(self.PROXY_HOST) if not parsed.scheme: return {"server": self.PROXY_HOST} host = parsed.hostname or "" port = f":{parsed.port}" if parsed.port else "" server = f"{parsed.scheme}://{host}{port}" proxy = {"server": server} if parsed.username: proxy["username"] = parsed.username if parsed.password: proxy["password"] = parsed.password return proxy except Exception as err: logger.error(f"解析代理服务器地址 '{self.PROXY_HOST}' 时出错: {err}") return {"server": self.PROXY_HOST} return None @property def GITHUB_HEADERS(self): """ Github请求头 """ if self.GITHUB_TOKEN: return { "Authorization": f"Bearer {self.GITHUB_TOKEN}", "User-Agent": self.NORMAL_USER_AGENT, } return {} def REPO_GITHUB_HEADERS(self, repo: str = None): """ Github指定的仓库请求头 :param repo: 指定的仓库名称,格式为 "user/repo"。如果为空,或者没有找到指定仓库请求头,则返回默认的请求头信息 :return: Github请求头 """ # 如果没有传入指定的仓库名称,或没有配置指定的仓库Token,则返回默认的请求头信息 if not repo or not self.REPO_GITHUB_TOKEN: return self.GITHUB_HEADERS headers = {} # 格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_**** token_pairs = self.REPO_GITHUB_TOKEN.split(",") for token_pair in token_pairs: try: parts = token_pair.split(":") if len(parts) != 2: print(f"无效的令牌格式: {token_pair}") continue repo_info = parts[0].strip() token = parts[1].strip() if not repo_info or not token: print(f"无效的令牌或仓库信息: {token_pair}") continue headers[repo_info] = { "Authorization": f"Bearer {token}", "User-Agent": self.NORMAL_USER_AGENT, } except Exception as e: print(f"处理令牌对 '{token_pair}' 时出错: {e}") # 如果传入了指定的仓库名称,则返回该仓库的请求头信息,否则返回默认请求头 return headers.get(repo, self.GITHUB_HEADERS) @property def VAPID(self): return { "subject": f"mailto:{self.SUPERUSER}@movie-pilot.org", "publicKey": "BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM", "privateKey": "JTixnYY0vEw97t9uukfO3UWKfHKJdT5kCQDiv3gu894" } def MP_DOMAIN(self, url: str = None): if not self.APP_DOMAIN: return None return UrlUtils.combine_url(host=self.APP_DOMAIN, path=url) def RENAME_FORMAT(self, media_type: MediaType): """ 获取指定类型的重命名格式 :param media_type: MediaType.TV 或 MediaType.Movie :return: 重命名格式 """ rename_format = ( self.TV_RENAME_FORMAT if media_type == MediaType.TV else self.MOVIE_RENAME_FORMAT ) # 规范重命名格式 rename_format = rename_format.replace("\\", "/") rename_format = re.sub(r'/+', '/', rename_format) return rename_format.strip("/") def TMDB_IMAGE_URL( self, file_path: Optional[str], file_size: str = "original" ) -> Optional[str]: """ 获取TMDB图片网址 :param file_path: TMDB API返回的xxx_path :param file_size: 图片大小,例如:'original', 'w500' 等 :return: 图片的完整URL,如果 file_path 为空则返回 None """ if not file_path: return None return ( f"https://{self.TMDB_IMAGE_DOMAIN}/t/p/{file_size}/{file_path.removeprefix('/')}" ) # 实例化配置 settings = Settings() class GlobalVar(object): """ 全局标识 """ # 系统停止事件 STOP_EVENT: threading.Event = threading.Event() # webpush订阅 SUBSCRIPTIONS: List[dict] = [] # 需应急停止的工作流 EMERGENCY_STOP_WORKFLOWS: List[int] = [] # 需应急停止文件整理 EMERGENCY_STOP_TRANSFER: List[str] = [] # 当前事件循环 CURRENT_EVENT_LOOP: AbstractEventLoop = asyncio.get_event_loop() def stop_system(self): """ 停止系统 """ self.STOP_EVENT.set() @property def is_system_stopped(self): """ 是否停止 """ return self.STOP_EVENT.is_set() def get_subscriptions(self): """ 获取webpush订阅 """ return self.SUBSCRIPTIONS def push_subscription(self, subscription: dict): """ 添加webpush订阅 """ self.SUBSCRIPTIONS.append(subscription) def stop_workflow(self, workflow_id: int): """ 停止工作流 """ if workflow_id not in self.EMERGENCY_STOP_WORKFLOWS: self.EMERGENCY_STOP_WORKFLOWS.append(workflow_id) def workflow_resume(self, workflow_id: int): """ 恢复工作流 """ if workflow_id in self.EMERGENCY_STOP_WORKFLOWS: self.EMERGENCY_STOP_WORKFLOWS.remove(workflow_id) def is_workflow_stopped(self, workflow_id: int) -> bool: """ 是否停止工作流 """ return self.is_system_stopped or workflow_id in self.EMERGENCY_STOP_WORKFLOWS def stop_transfer(self, path: str): """ 停止文件整理 """ if path not in self.EMERGENCY_STOP_TRANSFER: self.EMERGENCY_STOP_TRANSFER.append(path) def is_transfer_stopped(self, path: str) -> bool: """ 是否停止文件整理 """ if self.is_system_stopped: return True if path in self.EMERGENCY_STOP_TRANSFER: self.EMERGENCY_STOP_TRANSFER.remove(path) return True return False @property def loop(self) -> AbstractEventLoop: """ 当前循环 """ return self.CURRENT_EVENT_LOOP def set_loop(self, loop: AbstractEventLoop): """ 设置循环 """ self.CURRENT_EVENT_LOOP = loop # 全局标识 global_vars = GlobalVar()