mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 11:54:50 +08:00
1717 lines
80 KiB
Python
1717 lines
80 KiB
Python
"""
|
||
部署配置声明、环境文件更新与兼容迁移,提供进程运行所需的配置视图。
|
||
"""
|
||
|
||
import copy
|
||
import json
|
||
import os
|
||
import platform
|
||
import re
|
||
import secrets
|
||
import shutil
|
||
import sys
|
||
import threading
|
||
from asyncio import AbstractEventLoop
|
||
from contextvars import ContextVar
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import (
|
||
Annotated,
|
||
Any,
|
||
Callable,
|
||
Dict,
|
||
List,
|
||
Optional,
|
||
Tuple,
|
||
Union,
|
||
cast,
|
||
get_args,
|
||
get_origin,
|
||
)
|
||
from urllib.parse import quote, urlencode, urlparse
|
||
|
||
from dotenv import set_key, unset_key
|
||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, TypeAdapter, model_validator
|
||
from pydantic_settings import (
|
||
BaseSettings,
|
||
PydanticBaseSettingsSource,
|
||
SecretsSettingsSource,
|
||
SettingsConfigDict,
|
||
)
|
||
|
||
from app.foundation.environment import (
|
||
cpu_arch,
|
||
get_env_path,
|
||
is_docker,
|
||
is_free_threaded_runtime,
|
||
is_frozen,
|
||
)
|
||
from app.foundation.url import UrlUtils
|
||
from app.runtime.log import (
|
||
LogConfigModel,
|
||
configure_log_settings,
|
||
log_settings,
|
||
logger,
|
||
)
|
||
from app.runtime.loop import MainLoopRegistry, main_loop_registry
|
||
from app.runtime.stop import runtime_stop_state
|
||
from app.runtime.version import get_app_version
|
||
from app.runtime.webpush import WebPushRegistry, webpush_registry
|
||
from app.schemas.types import MediaType
|
||
|
||
SettingConverter = Callable[[Any, Any], Tuple[Any, bool]]
|
||
SettingMigration = Callable[[Dict[str, Any]], Dict[str, Tuple[Any, Any]]]
|
||
SettingValidator = Callable[[Any], Optional[str]]
|
||
SettingSerializer = Callable[[Any], str]
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class SettingPolicy:
|
||
"""
|
||
部署配置字段的专属处理策略,供通用更新流程按字段声明执行。
|
||
|
||
:param before_convert: 通用类型转换前的值归一化函数;为 None 时不预处理
|
||
:param converter: 替代通用转换的专用函数,返回转换值及是否需要更新;为 None 时使用通用规则
|
||
:param migrate: 旧配置迁移函数,可修改输入字典并返回字段变更;为 None 时不迁移
|
||
:param validate: 字段校验函数,返回错误说明或 None;为 None 时不额外校验
|
||
:param serialize: 写入环境文件前的序列化函数;为 None 时使用通用格式
|
||
:param sensitive: 是否在变更日志中隐藏字段原值
|
||
"""
|
||
|
||
before_convert: Optional[Callable[[Any], Any]] = None
|
||
converter: Optional[SettingConverter] = None
|
||
migrate: Optional[SettingMigration] = None
|
||
validate: Optional[SettingValidator] = None
|
||
serialize: Optional[SettingSerializer] = None
|
||
sensitive: bool = False
|
||
|
||
|
||
def _get_setting_policy(field_info: Any) -> Optional[SettingPolicy]:
|
||
"""
|
||
读取 Pydantic 字段声明上的部署配置策略。
|
||
"""
|
||
return next(
|
||
(
|
||
metadata
|
||
for metadata in getattr(field_info, "metadata", ())
|
||
if isinstance(metadata, SettingPolicy)
|
||
),
|
||
None,
|
||
)
|
||
|
||
|
||
def _normalize_legacy_update_mode(value: Any) -> Any:
|
||
"""
|
||
将旧自动更新模式转换为布尔开关。
|
||
"""
|
||
if isinstance(value, str) and value.strip().lower() in {"dev", "release"}:
|
||
return True
|
||
return value
|
||
|
||
|
||
def _migrate_legacy_update_mode(
|
||
data: Dict[str, Any],
|
||
) -> Dict[str, Tuple[Any, Any]]:
|
||
"""
|
||
迁移旧自动更新配置中的开发分支跟踪偏好。
|
||
"""
|
||
if (
|
||
str(data.get("MOVIEPILOT_AUTO_UPDATE", "")).strip().lower() != "dev"
|
||
or "MOVIEPILOT_UPDATE_DEV" in data
|
||
):
|
||
return {}
|
||
|
||
data["MOVIEPILOT_UPDATE_DEV"] = True
|
||
return {"MOVIEPILOT_UPDATE_DEV": (None, True)}
|
||
|
||
|
||
def _normalize_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:
|
||
return None, str(original_value) not in {"", "None"}
|
||
if len(value) < 16:
|
||
new_token = secrets.token_urlsafe(16)
|
||
logger.warning(
|
||
"'API_TOKEN' 长度不足 16 个字符,存在安全隐患,已随机生成新的安全令牌"
|
||
)
|
||
return new_token, True
|
||
return value, str(value) != str(original_value)
|
||
|
||
|
||
def _validate_rust_accel(value: Any) -> Optional[str]:
|
||
"""
|
||
校验 free-threaded 运行时的 Rust 加速约束。
|
||
"""
|
||
if is_free_threaded_runtime() and value is not True:
|
||
return "free-threaded 运行时必须启用 Rust 加速"
|
||
return None
|
||
|
||
|
||
def _serialize_bool(value: Any) -> str:
|
||
"""
|
||
将布尔配置按启动脚本兼容的形式持久化。
|
||
"""
|
||
return str(value).lower()
|
||
|
||
|
||
class SystemConfModel(BaseModel):
|
||
"""
|
||
系统关键资源大小配置。
|
||
"""
|
||
|
||
torrents: int = Field(default=0, description='缓存种子数量')
|
||
refresh: int = Field(default=0, description='订阅刷新处理数量')
|
||
tmdb: int = Field(default=0, description='TMDB请求缓存数量')
|
||
douban: int = Field(default=0, description='豆瓣请求缓存数量')
|
||
bangumi: int = Field(default=0, description='Bangumi请求缓存数量')
|
||
anilist: int = Field(default=0, description='AniList请求缓存数量')
|
||
imdb: int = Field(default=0, description='IMDb请求缓存数量')
|
||
fanart: int = Field(default=0, description='Fanart请求缓存数量')
|
||
musicbrainz: int = Field(default=0, description='MusicBrainz请求缓存数量')
|
||
theaudiodb: int = Field(default=0, description='TheAudioDB请求缓存数量')
|
||
listenbrainz: int = Field(default=0, description='ListenBrainz请求缓存数量')
|
||
meta: int = Field(default=0, description='元数据缓存过期时间(秒)')
|
||
scheduler: int = Field(default=0, description='调度器数量')
|
||
threadpool: int = Field(default=0, description='线程池大小')
|
||
|
||
|
||
class ConfigModel(BaseModel):
|
||
"""
|
||
Pydantic 配置模型,描述所有配置项及其类型和默认值。
|
||
|
||
:cvar model_config: 模型解析策略,忽略未声明的配置项
|
||
"""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
# 基础应用配置
|
||
PROJECT_NAME: str = Field(default="MoviePilot", description='项目名称')
|
||
APP_DOMAIN: str = Field(default="", description='域名 格式;https://movie-pilot.org')
|
||
API_V1_STR: str = Field(default="/api/v1", description='API路径')
|
||
FRONTEND_PATH: str = Field(default="/public", description='前端资源路径')
|
||
TZ: str = Field(default="Asia/Shanghai", description='时区')
|
||
HOST: str = Field(default="0.0.0.0", description='API监听地址')
|
||
PORT: int = Field(default=3001, description='API监听端口')
|
||
NGINX_PORT: int = Field(default=3000, description='前端监听端口')
|
||
CONFIG_DIR: Optional[str] = Field(default=None, description='配置文件目录')
|
||
MOVIEPILOT_SAFE_MODE: bool = Field(default=False, description='安全模式,仅保留核心 API,跳过插件、调度器、监控、命令和工作流等扩展启动项')
|
||
BTRFS_FSID_DEDUP: bool = Field(default=False, description='是否启用 Btrfs FSID 子卷容量去重(仅 Linux amd64/arm64)')
|
||
DEBUG: bool = Field(default=False, description='是否调试模式')
|
||
DEV: bool = Field(default=False, description='是否开发模式')
|
||
# 安全认证配置
|
||
SECRET_KEY: str = Field(default=secrets.token_urlsafe(32), description='密钥')
|
||
RESOURCE_SECRET_KEY: str = Field(default=secrets.token_urlsafe(32), description='RESOURCE密钥')
|
||
ALLOWED_HOSTS: list[str] = Field(
|
||
default_factory=lambda: ["*"],
|
||
description='允许的跨域来源;为 "*" 时浏览器跨域请求不携带凭据,需要跨域带 Cookie 请列出显式来源',
|
||
)
|
||
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60 * 24 * 8, description='TOKEN过期时间')
|
||
RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS: int = Field(default=60 * 30, description='RESOURCE_TOKEN过期时间')
|
||
SUPERUSER: str = Field(default="", description='超级管理员用户名;V3 首次启动时为空,由初始化页面设置')
|
||
SUPERUSER_PASSWORD: str = Field(default="", description='超级管理员密码不再通过部署配置初始化,始终由数据库存储哈希')
|
||
AUXILIARY_AUTH_ENABLE: bool = Field(default=False, description='辅助认证,允许通过外部服务进行认证、单点登录以及自动创建用户')
|
||
API_TOKEN: Annotated[
|
||
Optional[str],
|
||
SettingPolicy(converter=_normalize_api_token, sensitive=True),
|
||
] = Field(default=None, description='API密钥,需要更换')
|
||
AUTH_SITE: str = Field(default="", description='用户认证站点')
|
||
|
||
# 数据库配置
|
||
API_WORKERS: int = Field(
|
||
default=1,
|
||
ge=1,
|
||
description=(
|
||
'API 服务的 worker 进程数。连接池是进程级的,每个 worker 各持一份,数据库连接额度校验按它换算总用量。注意:当前主程序以单进程方式启动(uvicorn.Config 的 '
|
||
'workers 仅在多进程 supervisor 路径下生效),调大此项前需先解决调度器会在每个 worker 内重复执行的问题'
|
||
),
|
||
)
|
||
DB_TYPE: str = Field(default="sqlite", description='数据库类型,支持 sqlite 和 postgresql,默认使用 sqlite')
|
||
DB_ECHO: bool = Field(default=False, description='是否在控制台输出 SQL 语句,默认关闭')
|
||
DB_TIMEOUT: int = Field(default=60, description='数据库连接超时时间(秒),默认为 60 秒')
|
||
DB_WAL_ENABLE: bool = Field(default=True, description='是否启用 WAL 模式,仅适用于SQLite,默认开启')
|
||
DB_POOL_TYPE: str = Field(default="QueuePool", description='数据库连接池类型,QueuePool, NullPool')
|
||
DB_POOL_PRE_PING: bool = Field(default=True, description='是否在获取连接时进行预先 ping 操作')
|
||
DB_POOL_RECYCLE: int = Field(default=300, description='数据库连接的回收时间(秒)')
|
||
DB_POOL_TIMEOUT: int = Field(default=30, description='数据库连接池获取连接的超时时间(秒)')
|
||
DB_SQLITE_POOL_SIZE: int = Field(default=10, description='SQLite 连接池大小')
|
||
DB_SQLITE_MAX_OVERFLOW: int = Field(default=50, description='SQLite 连接池溢出数量')
|
||
DB_POSTGRESQL_HOST: str = Field(default="localhost", description='PostgreSQL 主机地址')
|
||
DB_POSTGRESQL_PORT: str = Field(default="5432", description='PostgreSQL 端口;使用 Unix Socket 时可留空')
|
||
DB_POSTGRESQL_DATABASE: str = Field(default="moviepilot", description='PostgreSQL 数据库名')
|
||
DB_POSTGRESQL_USERNAME: str = Field(default="moviepilot", description='PostgreSQL 用户名')
|
||
DB_POSTGRESQL_PASSWORD: str = Field(default="moviepilot", description='PostgreSQL 密码')
|
||
DB_POSTGRESQL_POOL_SIZE: int = Field(default=10, description='PostgreSQL 连接池大小')
|
||
DB_POSTGRESQL_MAX_OVERFLOW: int = Field(default=50, description='PostgreSQL 连接池溢出数量')
|
||
DB_ASYNC_POOL_TYPE: str = Field(
|
||
default="QueuePool",
|
||
description=(
|
||
'异步连接池类型:QueuePool / NullPool。NullPool 下每个异步会话独占一条物理连接、零复用且无上限,突发并发会直接顶穿PostgreSQL 的 '
|
||
'max_connections(表现为 TooManyConnectionsError),在 SQLite 上则表现为 WAL 写争用导致的长时间卡顿。默认 '
|
||
'QueuePool:仅对常驻主事件循环池化,其余事件循环自动回退 NullPool,避免跨循环复用连接。遇到兼容问题可设为 NullPool回到旧行为。'
|
||
),
|
||
)
|
||
DB_ASYNC_POOL_SIZE: int = Field(default=5, description='异步连接池大小(每个被池化的事件循环)')
|
||
DB_ASYNC_MAX_OVERFLOW: int = Field(default=10, description='异步连接池溢出数量(每个被池化的事件循环)')
|
||
DB_ASYNC_FALLBACK_LIMIT: int = Field(
|
||
default=10,
|
||
description=(
|
||
'未被池化的事件循环(临时循环)共享的全局并发连接配额。池化路径由连接池自身限流,这里只为 NullPool 兜底路径补上背压,防止临时循环上的突发并发再次无界增长。实测常驻的 '
|
||
'feishu/discord 循环不访问数据库,走此路径的只有插件与调度器兜底分支的零星调用,因此取值不必大——它直接计入连接总额度'
|
||
),
|
||
)
|
||
DB_CONNECT_ARGS: dict[str, Any] = Field(
|
||
default_factory=dict,
|
||
description=(
|
||
'驱动级连接参数,透传给 create_engine/create_async_engine 的 connect_args。例如经 PgBouncer 事务模式接入时 asyncpg 需要 '
|
||
'{"statement_cache_size": 0}'
|
||
),
|
||
)
|
||
|
||
# 数据库备份配置
|
||
DB_BACKUP_ENABLE: bool = Field(default=False, description='是否启用主程序数据库自动备份')
|
||
DB_BACKUP_CRON: str = Field(default="0 3 * * *", description='定时备份的 Cron 表达式,留空时不注册定时任务')
|
||
DB_BACKUP_ON_UPGRADE: bool = Field(default=True, description='检测到现有数据库需要迁移时,在结构变更前创建恢复点')
|
||
DB_BACKUP_PATH: Optional[str] = Field(default=None, description='备份根目录;未配置时使用 CONFIG_PATH/database_backup')
|
||
DB_BACKUP_RETENTION_DAYS: int = Field(default=30, description='本地备份的保留天数,0 表示不按时间清理')
|
||
DB_BACKUP_MAX_COUNT: int = Field(default=30, description='本地备份的最大保留份数,0 表示不按数量清理')
|
||
# 数据清理配置
|
||
DATA_CLEANUP_ENABLE: bool = Field(default=False, description='是否启用数据表定时清理')
|
||
DATA_CLEANUP_MESSAGE_DAYS: int = Field(default=90, description='消息表保留天数,0为不清理')
|
||
DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS: int = Field(default=180, description='下载历史表保留天数,0为不清理')
|
||
DATA_CLEANUP_SITE_USERDATA_DAYS: int = Field(default=180, description='站点用户数据表保留天数,0为不清理')
|
||
DATA_CLEANUP_TRANSFER_HISTORY_DAYS: int = Field(default=365 * 3, description='整理历史表保留天数,0为不清理')
|
||
DATA_CLEANUP_DOWNLOAD_FAILURE_DAYS: int = Field(default=7, description='下载失败冷却记录保留天数,0为不清理')
|
||
DATA_CLEANUP_SUBSCRIBE_HISTORY_DAYS: int = Field(default=365 * 3, description='订阅完成历史保留天数,0为不清理')
|
||
DATA_CLEANUP_AGENT_CHAT_DAYS: int = Field(default=180, description='Agent 会话历史保留天数,0为不清理')
|
||
DATA_CLEANUP_AGENT_TASK_RUN_DAYS: int = Field(default=180, description='Agent 定时任务运行历史保留天数,0为不清理')
|
||
DATA_CLEANUP_OUTBOX_COMPLETED_DAYS: int = Field(default=30, description='Outbox 已完成记录保留天数,0为不清理')
|
||
DATA_CLEANUP_OUTBOX_DEAD_DAYS: int = Field(default=90, description='Outbox 死信记录保留天数,0为不清理')
|
||
|
||
# 缓存配置
|
||
CACHE_BACKEND_TYPE: str = Field(default="cachetools", description='缓存类型,支持 cachetools 和 redis,默认使用 cachetools')
|
||
CACHE_BACKEND_URL: Optional[str] = Field(
|
||
default="redis://localhost:6379",
|
||
description='缓存连接字符串,仅外部缓存(如 Redis、Memcached)需要,支持 Redis Unix Socket URL',
|
||
)
|
||
CACHE_REDIS_MAXMEMORY: Optional[str] = Field(
|
||
default=None,
|
||
description='Redis 缓存最大内存限制,未配置时,如开启大内存模式时为 "1024mb",未开启时为 "256mb"',
|
||
)
|
||
CACHE_REDIS_MAX_CONNECTIONS: int = Field(default=512, description='Redis 单个连接池最大连接数;异步连接池按事件循环分别应用该上限')
|
||
CACHE_REDIS_POOL_TIMEOUT: int = Field(default=10, description='Redis 连接池耗尽时等待可用连接的时间(秒)')
|
||
GLOBAL_IMAGE_CACHE: bool = Field(default=False, description='全局图片缓存,将媒体图片缓存到本地')
|
||
GLOBAL_IMAGE_CACHE_DAYS: int = Field(default=7, description='全局图片缓存保留天数')
|
||
TEMP_FILE_DAYS: int = Field(default=3, description='临时文件保留天数')
|
||
PACKAGE_CACHE_DAYS: int = Field(default=90, description='pip/uv 包下载缓存保留天数')
|
||
PACKAGE_CACHE_ROOT: Optional[str] = Field(default=None, description='pip/uv 包下载缓存根目录,留空时使用配置目录下的 .cache')
|
||
META_CACHE_EXPIRE: int = Field(default=0, description='单条元数据识别缓存有效期(小时),0为自动')
|
||
|
||
# 网络代理配置
|
||
PROXY_HOST: Optional[str] = Field(default=None, description='网络代理服务器地址')
|
||
DOH_ENABLE: bool = Field(default=False, description='是否启用DOH解析域名')
|
||
DOH_DOMAINS: str = Field(default="api.themoviedb.org,"
|
||
"api.tmdb.org,"
|
||
"webservice.fanart.tv,"
|
||
"api.github.com,"
|
||
"github.com,"
|
||
"raw.githubusercontent.com,"
|
||
"codeload.github.com,"
|
||
"api.telegram.org", description='使用 DOH 解析的域名列表')
|
||
DOH_RESOLVERS: str = Field(default="1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112", description='DOH 解析服务器列表')
|
||
|
||
# 媒体元数据配置
|
||
MODULE_ENABLE: Dict[str, bool] = Field(default_factory=dict, description='无独立配置项的内置模块开关;未列出的模块默认开启。')
|
||
SEARCH_SOURCE: str = Field(
|
||
default="themoviedb",
|
||
description='媒体搜索来源 themoviedb/douban/bangumi/anilist/imdb/musicbrainz/theaudiodb/doubanmusic,多个用,分隔',
|
||
)
|
||
RECOGNIZE_SOURCE: str = Field(
|
||
default="themoviedb",
|
||
description='媒体识别来源 themoviedb/douban/bangumi/anilist/imdb/musicbrainz/theaudiodb/doubanmusic',
|
||
)
|
||
SCRAP_SOURCE: str = Field(
|
||
default="themoviedb",
|
||
description='刮削来源 themoviedb/douban/bangumi/anilist/imdb/musicbrainz/theaudiodb/doubanmusic',
|
||
)
|
||
SCRAP_SEASON_IMAGE_TO_ROOT: bool = Field(
|
||
default=True,
|
||
description='季图片是否同时写入剧集根目录;关闭后仅写季目录,不清理已有图片',
|
||
)
|
||
SCRAP_IMAGE_ALIAS: bool = Field(
|
||
default=True,
|
||
description='是否为背景图和缩略图写入兼容别名文件;关闭后每张图片只写入来源名称',
|
||
)
|
||
SCRAP_4K_BACKGROUND: bool = Field(
|
||
default=True,
|
||
description='是否获取 Fanart 的 show4kbackground 和 movie4kbackground 图片',
|
||
)
|
||
ANIME_GENREIDS: List[int] = Field(default=[16], description='电视剧动漫的分类genre_ids')
|
||
|
||
# TMDB配置
|
||
TMDB_IMAGE_DOMAIN: str = Field(default="image.tmdb.org", description='TMDB图片地址')
|
||
TMDB_API_DOMAIN: str = Field(default="api.themoviedb.org", description='TMDB API地址')
|
||
TMDB_LOCALE: str = Field(default="zh", description='TMDB元数据语言')
|
||
EMPTY_RESULT_CACHE_TTL: int = Field(default=30 * 60, description='TMDB空结果缓存独立过期时间(秒),故障期间产生的空响应快速过期,故障自愈后可自然恢复')
|
||
TMDB_SCRAP_ORIGINAL_IMAGE: bool = Field(default=False, description='刮削使用TMDB原始语种图片')
|
||
TMDB_API_KEY: str = Field(default="db55323b8d3e4154498498a75642b381", description='TMDB 接口访问密钥')
|
||
|
||
# Bangumi配置
|
||
BANGUMI_PROXY_ENABLE: bool = Field(default=False, description='是否启用 Bangumi 数据与图片代理')
|
||
BANGUMI_API_DOMAIN: str = Field(default="", description='Bangumi API代理地址,留空使用官方地址')
|
||
BANGUMI_IMAGE_DOMAIN: str = Field(default="", description='Bangumi图片代理地址,留空时由后端代理端点直接拉取原始图片')
|
||
|
||
# 音乐配置
|
||
MUSIC_COVER_PROXY: str = Field(
|
||
default="",
|
||
description='音乐封面代理地址(用于解决 coverartarchive.org 无法访问导致的封面不显示问题,留空则使用官方地址)',
|
||
)
|
||
MUSIC_LOCAL_COVER_ENABLE: bool = Field(default=True, description='是否同步整理同目录音乐封面,并在在线封面不可用时用于内嵌回退')
|
||
MUSIC_COVER_FILE_PATTERNS: str = Field(
|
||
default="cover.jpg,cover.jpeg,cover.png,folder.jpg,folder.jpeg,folder.png,front.jpg,front.jpeg,front.png,*.png",
|
||
description='同目录音乐封面匹配规则,按顺序选择,逗号分隔、大小写不敏感;留空禁用本地封面',
|
||
)
|
||
ACOUSTID_API_KEY: str = Field(default="b1auxfOzAg", description='AcoustID 应用 API Key,用于查询本地音频的 Chromaprint 指纹')
|
||
MUSIC_METADATA_TO_SIMPLIFIED: bool = Field(default=True, description='是否将识别到的音乐标题、艺术家、专辑等标准元数据转换为简体中文')
|
||
MUSIC_LYRICS_TO_SIMPLIFIED: bool = Field(
|
||
default=False,
|
||
description='是否将写入 LRC/TXT 的歌词正文转为简体中文;保留时间轴、LRC 标签和来源 Lyricsfile',
|
||
)
|
||
MUSIC_CUE_ENABLE: bool = Field(default=True, description='是否读取关联 CUE;已分轨音频可关闭以忽略错误索引,整轨专辑应保持开启')
|
||
MUSIC_RELEASE_REGION_PRIORITY: str = Field(
|
||
default="CN,TW,HK",
|
||
description='MusicBrainz 发行地区优先级,按 ISO 3166-1 两位代码从高到低排列',
|
||
)
|
||
MUSIC_RELEASE_SCRIPT_PRIORITY: str = Field(
|
||
default="Hans,Hant,Latn",
|
||
description='MusicBrainz 发行文字字形优先级,按 ISO 15924 四位代码从高到低排列',
|
||
)
|
||
THEAUDIODB_API_KEY: str = Field(default="123", description='TheAudioDB API Key,默认使用官方公开的免费 V1 Key,可通过环境变量覆盖')
|
||
LRCLIB_BASE_URL: str = Field(default="https://lrclib.net", description='LRCLIB 服务地址,可指向兼容官方 API 的自建实例')
|
||
AMLL_BASE_URL: str = Field(default="https://api.amll.dev", description='AMLL TTML 歌词 API 地址,可指向兼容原生接口的自建实例')
|
||
LYRICS_BATCH_TIMEOUT: int = Field(default=120, description='单次音乐刮削批次用于在线歌词查询的总预算(秒)')
|
||
LYRICS_PROVIDER_RETRY_MAX_WAIT: int = Field(default=5, description='供应商要求的重试等待超过该值时进入冷却,不阻塞整个批次')
|
||
|
||
# TVDB配置
|
||
TVDB_V4_API_KEY: str = Field(default="ed2aa66b-7899-4677-92a7-67bc9ce3d93a", description='TVDB 接口访问密钥')
|
||
TVDB_V4_API_PIN: str = Field(default="", description='TVDB V4 订阅 PIN')
|
||
|
||
# Fanart配置
|
||
FANART_ENABLE: bool = Field(default=True, description='Fanart开关')
|
||
FANART_LANG: str = Field(default="zh,en", description='Fanart语言')
|
||
FANART_API_KEY: str = Field(default="d2d31f9ecabea050fc7d68aa3146015f", description='Fanart 接口访问密钥')
|
||
|
||
# 云盘配置
|
||
U115_APP_ID: str = Field(default="100197847", description='115 云盘开放平台应用标识')
|
||
U115_AUTH_SERVER: str = Field(default="https://movie-pilot.org", description='115 OAuth2 Server 地址')
|
||
ALIPAN_APP_ID: str = Field(default="ac1bf04dc9fd4d9aaabb65b4a668d403", description='阿里云盘开放平台应用标识')
|
||
|
||
# 系统升级配置
|
||
MOVIEPILOT_AUTO_UPDATE: Annotated[
|
||
bool,
|
||
SettingPolicy(
|
||
before_convert=_normalize_legacy_update_mode,
|
||
migrate=_migrate_legacy_update_mode,
|
||
serialize=_serialize_bool,
|
||
),
|
||
] = Field(default=False, description='自动检查稳定版本并提示升级,不自动下载或安装。')
|
||
MOVIEPILOT_UPDATE_DEV: Annotated[
|
||
bool, SettingPolicy(serialize=_serialize_bool)
|
||
] = Field(default=False, description='Docker 启动时跟踪开发分支;本地 CLI 仅在手动 update 时默认使用 DEV 模式。')
|
||
AUTO_UPDATE_RESOURCE: bool = Field(default=True, description='后台检查站点资源包,确认后由启动器在进程拉起前应用')
|
||
|
||
# 媒体文件格式配置
|
||
RMT_MEDIAEXT: list[str] = Field(
|
||
default_factory=lambda: [
|
||
".mp4",
|
||
".mkv",
|
||
".ts",
|
||
".iso",
|
||
".rmvb",
|
||
".avi",
|
||
".mov",
|
||
".mpeg",
|
||
".mpg",
|
||
".wmv",
|
||
".3gp",
|
||
".asf",
|
||
".m4v",
|
||
".flv",
|
||
".m2ts",
|
||
".strm",
|
||
".tp",
|
||
".f4v",
|
||
], description='支持的视频文件后缀格式')
|
||
RMT_SUBEXT: list[str] = Field(default_factory=lambda: [".srt", ".ass", ".ssa", ".sup"], description='支持的字幕文件后缀格式')
|
||
RMT_AUDIOEXT: list[str] = 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",
|
||
".ape",
|
||
".flac",
|
||
".midi",
|
||
".opus",
|
||
".sfalc",
|
||
], description='支持的音轨文件后缀格式')
|
||
|
||
# 媒体服务器配置
|
||
MEDIASERVER_SYNC_INTERVAL: int = Field(default=6, description='媒体服务器同步间隔(小时)')
|
||
|
||
# 订阅配置
|
||
SUBSCRIBE_MODE: str = Field(default="spider", description='订阅模式')
|
||
SUBSCRIBE_RSS_INTERVAL: int = Field(default=30, description='RSS订阅模式刷新时间间隔(分钟)')
|
||
SUBSCRIBE_STATISTIC_SHARE: bool = Field(default=True, description='订阅数据共享')
|
||
SUBSCRIBE_SEARCH: bool = Field(default=False, description='订阅搜索开关')
|
||
SUBSCRIBE_SEARCH_INTERVAL: int = Field(default=24, description='订阅搜索时间间隔(小时)')
|
||
LOCAL_EXISTS_SEARCH: bool = Field(default=True, description='检查本地媒体库是否存在资源开关')
|
||
|
||
# 站点配置
|
||
SITEDATA_REFRESH_INTERVAL: int = Field(default=6, description='站点数据刷新间隔(小时)')
|
||
SITE_MESSAGE: bool = Field(default=True, description='读取和发送站点消息')
|
||
NO_CACHE_SITE_KEY: str = Field(default="", description='不缓存站点资源的域名关键字,多个使用,分隔;留空时所有站点使用缓存')
|
||
OCR_HOST: str = Field(default="https://movie-pilot.org", description='OCR服务器地址,用于识别站点验证码')
|
||
BROWSER_EMULATION: str = Field(
|
||
default="cloakbrowser",
|
||
description='仿真类型:cloakbrowser 或 flaresolverr,其他值按 cloakbrowser 处理',
|
||
)
|
||
CLOAKBROWSER_HUMANIZE: bool = Field(default=True, description='CloakBrowser 是否启用拟人化输入')
|
||
CLOAKBROWSER_HUMAN_PRESET: str = Field(default="default", description='CloakBrowser 拟人化输入预设:default 或 careful')
|
||
FLARESOLVERR_URL: Optional[str] = Field(default=None, description='FlareSolverr 服务地址,例如 http://127.0.0.1:8191')
|
||
|
||
# 搜索配置
|
||
SEARCH_MULTIPLE_NAME: bool = Field(default=False, description='搜索多个名称')
|
||
MAX_SEARCH_NAME_LIMIT: int = Field(default=3, description='最大搜索名称数量')
|
||
SEARCH_RESOURCE_PAGES: int = Field(default=1, description='搜索资源获取页数')
|
||
|
||
# 下载配置
|
||
TORRENT_TAG: str = Field(default="MOVIEPILOT", description='种子标签')
|
||
DOWNLOAD_SUBTITLE: bool = Field(default=True, description='下载站点字幕')
|
||
AUTO_DOWNLOAD_USER: Optional[str] = Field(default=None, description='交互搜索自动下载用户ID,使用,分割')
|
||
DOWNLOAD_TMPEXT: list[str] = Field(default_factory=lambda: [".!qb", ".part"], description='下载器临时文件后缀')
|
||
|
||
# 目录监控配置
|
||
MONITOR_NETWORK_FAST_MODE: bool = Field(
|
||
default=False,
|
||
description=(
|
||
'允许网络文件系统使用快速模式(inotify)。部分 FUSE 实现(如 CloudDrive2)会正常下发内核通知,快速模式可用,且比每 N 秒 stat '
|
||
'全部目录的轮询对挂载后端的压力小得多,由用户确认后开启'
|
||
),
|
||
)
|
||
MONITOR_POLL_DELAY_NETWORK: int = Field(default=0, description='网络文件系统的轮询扫描间隔(毫秒),0 表示使用内置默认值')
|
||
MONITOR_RESCAN_DELAYS: str = Field(
|
||
default="30,120,600,1800",
|
||
description=(
|
||
'新增目录延迟重扫的轮次延迟秒数,多个使用,分隔,如 "30,120,600,1800"。FUSE 挂载(如 CloudDrive2)上超大目录树呈现完整内容可能超过分钟级,默认值在常见的 '
|
||
'30/120 秒窗口后追加两轮成本极低的长延迟轮次兜底;解析失败(如格式非法、包含非正整数)时回退默认值并记录 warn 日志'
|
||
),
|
||
)
|
||
|
||
# CookieCloud配置
|
||
COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = Field(default=False, description='CookieCloud是否启动本地服务')
|
||
COOKIECLOUD_HOST: str = Field(default="https://movie-pilot.org/cookiecloud", description='CookieCloud服务器地址')
|
||
COOKIECLOUD_KEY: Optional[str] = Field(default=None, description='CookieCloud用户KEY')
|
||
COOKIECLOUD_PASSWORD: Optional[str] = Field(default=None, description='CookieCloud端对端加密密码')
|
||
COOKIECLOUD_AUTH_HEADER: Optional[str] = Field(
|
||
default=None,
|
||
description='CookieCloud本地上传接口的X-CookieCloud-Auth期望值,留空表示不校验',
|
||
)
|
||
COOKIECLOUD_INTERVAL: Optional[int] = Field(default=60 * 24, description='CookieCloud同步间隔(分钟)')
|
||
COOKIECLOUD_BLACKLIST: Optional[str] = Field(default=None, description='CookieCloud同步黑名单,多个域名,分割')
|
||
|
||
# 整理配置
|
||
TRANSFER_THREADS: int = Field(default=1, description='文件整理线程数')
|
||
TRANSFER_COPY_TRY_REFLINK: bool = Field(
|
||
default=True,
|
||
description='本地文件复制时,在同一磁盘且支持写时复制时优先尝试引用链接 (reflink / 克隆)。若明确知晓底层文件系统不支持 CoW,可关闭此选项跳过探测直接进行全量复制以提升性能。',
|
||
)
|
||
FS_PROXY_ENABLED: bool = Field(
|
||
default=True,
|
||
description=(
|
||
'本地文件操作是否走可强杀的子进程代理。FUSE/网络挂载进入「请求永不返回」状态时,stat/listdir 这类调用会永久悬挂,而 Python 既不能中断已发出的系统调用、'
|
||
'也不能强杀线程,阻塞其上的线程无法回收。走子进程后超时可以 SIGKILL 真正回收,block 型故障被转换成各层已能处理的crash 型故障(OSError)。'
|
||
'出现兼容问题时可关闭回到直接调用。'
|
||
),
|
||
)
|
||
FS_PROXY_TIMEOUT: int = Field(default=30, description='单次本地快操作(stat/listdir/删除/重命名)的超时秒数,超时即判定挂载无响应并回收代理进程')
|
||
FS_PROXY_STALL_TIMEOUT: int = Field(
|
||
default=120,
|
||
description='复制大文件时两次进度上报之间的最长间隔秒数。代理每秒上报一次进度作为心跳,因此这个阈值判定的是「传输完全没有推进」而不是「传输很慢」,复制几小时的大文件也不会被误杀',
|
||
)
|
||
TRANSFER_MAX_FAILED_RETRIES: int = Field(
|
||
default=3,
|
||
description=(
|
||
'自动整理(目录监控、下载器轮询)遇到失败整理记录时,同一源路径允许自动重试的最大次数。一次瞬时故障(网络抖动、TMDB 瞬断、移动失败)不该让文件永久漏整理,因此必须重试;'
|
||
'但永远识别不出的文件重试再多也不会成功,只会重复推送失败通知,批量导入时更会刷屏,因此必须有界。取值区间 1-10,越界或非法值会被钳制到边界并记录 warn:既不支持关闭重试,'
|
||
'也不支持无限重试。整理成功或删除整理记录时计数清零。与本项无关的是同路径新版本:已成功整理的文件源大小变化时一律放行,由整理链的 overwrite_mode 决断是否覆盖'
|
||
),
|
||
)
|
||
TRANSFER_TASK_TIMEOUT: int = Field(default=120, description='外部接管的运行中整理任务无状态心跳超时(分钟),0 表示禁用')
|
||
MOVIE_RENAME_FORMAT: str = Field(
|
||
default=(
|
||
'{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% '
|
||
'if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}'
|
||
),
|
||
description='电影重命名格式',
|
||
)
|
||
TV_RENAME_FORMAT: str = Field(
|
||
default=(
|
||
'{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - '
|
||
'{{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif '
|
||
'%}{{fileExt}}'
|
||
),
|
||
description='电视剧重命名格式',
|
||
)
|
||
MUSIC_RENAME_FORMAT: str = Field(default="{{album_artist or artist or 'Unknown Artist'}}"
|
||
"/{{album or 'Unknown Album'}}{% if year %} ({{year}}){% endif %}"
|
||
"{% if total_discs and total_discs > 1 %}/Disc {{disc_number or 1}}{% endif %}"
|
||
"/{% if track %}{{track}} - {% endif %}{{title}}{{fileExt}}", description='音乐重命名格式')
|
||
RENAME_FORMAT_S0_NAMES: list[str] = Field(default=["Specials", "SPs"], description='重命名时支持的S0别名')
|
||
DEFAULT_SUB: Optional[str] = Field(default="zh-cn", description='为指定默认字幕添加.default后缀')
|
||
SCRAP_FOLLOW_TMDB: bool = Field(default=True, description='新增已入库媒体是否跟随TMDB信息变化')
|
||
RECOGNIZE_PLUGIN_FIRST: bool = Field(default=False, description='优先使用辅助识别')
|
||
MEDIA_RECOGNIZE_SHARE: bool = Field(default=True, description='共享使用媒体识别数据')
|
||
|
||
# 服务地址配置
|
||
MP_SERVER_HOST: str = Field(
|
||
default="https://movie-pilot.org",
|
||
description='服务器地址,对应 https://github.com/jxxghp/MoviePilot-Server 项目',
|
||
)
|
||
MEDIA_RECOGNIZE_SHARE_API: Optional[str] = Field(
|
||
default=None,
|
||
description='共享媒体识别API地址,留空时默认拼接为 MP_SERVER_HOST + /recognize/share',
|
||
)
|
||
|
||
# 个性化
|
||
WALLPAPER: str = Field(default="", description='登录页面壁纸来源:空字符串表示无壁纸,另支持 tmdb/bing/mediaserver/customize/static')
|
||
WALLPAPER_ROTATION_INTERVAL: int = Field(default=15, description='壁纸轮换间隔(秒),0 表示不轮换')
|
||
WALLPAPER_IMAGE_URL: Optional[str] = Field(default=None, description='静态壁纸地址,可使用前端可访问的本地路径或 URL')
|
||
CUSTOMIZE_WALLPAPER_API_URL: Optional[str] = Field(default=None, description='自定义壁纸api地址')
|
||
|
||
# 插件配置
|
||
PLUGIN_MARKET: str = Field(
|
||
default="https://github.com/jxxghp/MoviePilot-Plugins",
|
||
description='插件市场仓库地址,多个地址使用,分隔,地址以/结尾',
|
||
)
|
||
PLUGIN_STATISTIC_SHARE: bool = Field(default=True, description='插件安装数据共享')
|
||
USAGE_STATISTIC_SHARE: bool = Field(default=True, description='安装版本统计上报')
|
||
PLUGIN_AUTO_RELOAD: bool = Field(default=False, description='是否开启插件热加载')
|
||
PLUGIN_INSTALL_FROM_RELEASE: bool = Field(
|
||
default=True,
|
||
description='插件默认优先安装 Release 资产;false 直接安装当前索引文件,显式选择版本不受影响',
|
||
)
|
||
PLUGIN_RELEASE_TIMEOUT: int = Field(
|
||
default=30, ge=1, le=300,
|
||
description='插件 Release 元数据和资产各出口的请求超时秒数,默认 30,范围 1 至 300',
|
||
)
|
||
DEPRECATION_ENABLED: Optional[str] = Field(default=None, description='临时放行的废弃标识,多个用,分隔;仅对已进入停用阶段的接口有效,用于观察真实依赖方')
|
||
PLUGIN_LOCAL_REPO_PATHS: Optional[str] = Field(default=None, description='本地插件仓库目录,多个地址使用,分隔')
|
||
|
||
# 技能配置
|
||
SKILL_MARKET: str = Field(default="https://clawhub.ai,"
|
||
"https://github.com/openai/skills,"
|
||
"https://github.com/anthropics/skills,"
|
||
"https://github.com/vercel-labs/agent-skills", description='技能市场仓库地址,多个地址使用,分隔')
|
||
|
||
# GitHub 与 Python 包索引配置
|
||
GITHUB_TOKEN: Annotated[
|
||
Optional[str],
|
||
SettingPolicy(sensitive=True),
|
||
] = Field(default=None, description='GitHub Token 供 Agent 提交 Issue/PR 和访问 API;原文只保留在服务端运行配置中')
|
||
GITHUB_PROXY: Optional[str] = Field(default="", description='Github代理服务器,格式:https://mirror.ghproxy.com/')
|
||
PIP_PROXY: Optional[str] = Field(
|
||
default="",
|
||
description='pip镜像站点,格式:https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple',
|
||
)
|
||
REPO_GITHUB_TOKEN: Optional[str] = Field(
|
||
default=None,
|
||
description='指定的仓库Github token,多个仓库使用,分隔,格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****',
|
||
)
|
||
COMMUNITY_MEDALS_ENABLE: bool = Field(
|
||
default=True,
|
||
description='社区勋章:关闭后不查询服务端开关、不拉取勋章名单;开启时仍需 MoviePilot 服务端开放该功能',
|
||
)
|
||
|
||
# 飞书通知配置
|
||
FEISHU_APP_ID: Optional[str] = Field(default=None, description='飞书应用 App ID')
|
||
FEISHU_APP_SECRET: Optional[str] = Field(default=None, description='飞书应用 App Secret')
|
||
FEISHU_OPEN_ID: Optional[str] = Field(default=None, description='飞书默认接收用户 Open ID')
|
||
FEISHU_CHAT_ID: Optional[str] = Field(default=None, description='飞书默认接收群聊 Chat ID')
|
||
FEISHU_ADMINS: Optional[str] = Field(default=None, description='飞书管理员 Open ID 列表,多个使用 , 分隔')
|
||
FEISHU_VERIFICATION_TOKEN: Optional[str] = Field(default=None, description='飞书事件校验 Token')
|
||
FEISHU_ENCRYPT_KEY: Optional[str] = Field(default=None, description='飞书事件加密 Key')
|
||
|
||
# 性能配置
|
||
BIG_MEMORY_MODE: bool = Field(default=False, description='大内存模式')
|
||
RUST_ACCEL: Annotated[bool, SettingPolicy(validate=_validate_rust_accel)] = Field(
|
||
default=True,
|
||
description='Rust 加速总开关,free-threaded 运行时固定启用',
|
||
)
|
||
ENCODING_DETECTION_PERFORMANCE_MODE: bool = Field(default=True, description='是否启用编码探测的性能模式')
|
||
ENCODING_DETECTION_MIN_CONFIDENCE: float = Field(default=0.8, description='编码探测的最低置信度阈值')
|
||
MEMORY_GC_INTERVAL: int = Field(default=30, description='主动内存回收时间间隔(分钟),0为不启用')
|
||
|
||
# 安全配置
|
||
SECURITY_IMAGE_DOMAINS: list[str] = Field(
|
||
default=[
|
||
"image.tmdb.org",
|
||
"images.tmdb.org",
|
||
"static-mdb.v.geilijiasu.com",
|
||
"bing.com",
|
||
"doubanio.com",
|
||
"lain.bgm.tv",
|
||
"bgm.tv",
|
||
"bangumi.tv",
|
||
"bangumi.lol",
|
||
"raw.githubusercontent.com",
|
||
"github.com",
|
||
"thetvdb.com",
|
||
"cctvpic.com",
|
||
"iqiyipic.com",
|
||
"hdslb.com",
|
||
"cmvideo.cn",
|
||
"ykimg.com",
|
||
"qpic.cn",
|
||
"anilist.co",
|
||
"coverartarchive.org",
|
||
"archive.org",
|
||
"theaudiodb.com",
|
||
"commons.wikimedia.org",
|
||
"upload.wikimedia.org",
|
||
], description='允许的图片缓存域名')
|
||
IMAGE_PROXY_ALLOWED_PRIVATE_RANGES: list[str] = Field(default=[], description='图片代理允许访问的非公网 IP/CIDR,默认不放行任何非公网解析结果')
|
||
SECURITY_IMAGE_SUFFIXES: list[str] = Field(
|
||
default=[".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"], description='允许的图片文件后缀格式')
|
||
PASSKEY_REQUIRE_UV: bool = Field(default=True, description='PassKey 是否强制用户验证(生物识别等)')
|
||
API_DOCS_ENABLE: bool = Field(
|
||
default=False,
|
||
description='是否开放 API 文档;开放 /docs、/redoc 与 /api/v1/openapi.json,文档首次生成后常驻约 20–30MB 内存,修改后立即生效',
|
||
)
|
||
|
||
# 工作流配置
|
||
WORKFLOW_STATISTIC_SHARE: bool = Field(default=True, description='工作流数据共享')
|
||
|
||
# 存储配置
|
||
RCLONE_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = Field(default=True, description='对rclone进行快照对比时,是否检查文件夹的修改时间')
|
||
OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = Field(default=True, description='对OpenList进行快照对比时,是否检查文件夹的修改时间')
|
||
ALIPAN_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = Field(
|
||
default=False,
|
||
description='对阿里云盘进行快照对比时,是否检查文件夹的修改时间(默认关闭,因为阿里云盘目录时间不随子文件变更而更新)',
|
||
)
|
||
|
||
PLAYWRIGHT_BROWSER_TYPE: str = Field(
|
||
default="chromium",
|
||
description='Playwright浏览器类型,供智能体浏览器工具和插件直接使用 Playwright 时读取',
|
||
)
|
||
|
||
# AI智能体配置
|
||
AI_AGENT_ENABLE: bool = Field(default=False, description='AI智能体开关')
|
||
AGENT_INTERACTIVE_PRIORITY: bool = Field(default=True, description='为交互式智能助手隔离执行容量,并让新启动的非关键后台服务有限避让')
|
||
AGENT_BACKGROUND_DEFER_SECONDS: int = Field(default=30, ge=0, description='交互活跃时非关键后台服务最多避让的秒数,0为不避让;不暂停已开始的任务')
|
||
AGENT_LLM_TIMEOUT: int = Field(default=180, ge=0, description='Agent单次模型请求的总超时秒数,包含供应商重试;0为不限制')
|
||
SCHEDULER_JOB_TIMEOUT: int = Field(default=3600, ge=0, description='协程调度任务默认执行超时秒数,0为不限制;不强行终止同步线程')
|
||
AI_AGENT_GLOBAL: bool = Field(default=False, description='合局AI智能体')
|
||
AI_AGENT_HIDE_ENTRY: bool = Field(default=False, description='是否隐藏前端全局智能体入口')
|
||
AI_AGENT_PET: str = Field(default="", description='默认助手形象,格式为 插件ID:形象key,留空使用内置机器人;用户个人选择优先')
|
||
AI_AGENT_OUTPUT_LANGUAGE: str = Field(default="zh-CN", description='内置智能助手输出语言,默认使用简体中文')
|
||
LLM_PROVIDER: str = Field(default="deepseek", description='LLM提供商(支持内置 provider,以及从 models.dev 动态补充的平台)')
|
||
LLM_MODEL: str = Field(default="deepseek-chat", description='LLM模型名称')
|
||
LLM_THINKING_LEVEL: Optional[str] = Field(
|
||
default="off",
|
||
description='思考模式/深度配置:off/auto/minimal/low/medium/high/max/xhigh;OpenAI兼容接口保留max/xhigh,仅按已知模型能力降级',
|
||
)
|
||
LLM_API_PROTOCOL: str = Field(default="auto", description='OpenAI兼容接口API协议:auto(自动)/ chat_completions / responses')
|
||
LLM_WEB_SEARCH_MODE: str = Field(
|
||
default="local",
|
||
description='联网搜索模式:local(本地)/ builtin(模型服务端)/ auto(自动)/ disabled(关闭)',
|
||
)
|
||
LLM_SUPPORT_IMAGE_INPUT: bool = Field(default=True, description='LLM是否支持图片输入,开启后消息图片会按多模态输入发送给模型')
|
||
LLM_SUPPORT_AUDIO_INPUT: bool = Field(default=False, description='是否启用音频输入,开启后用户语音会先转写为文本再进入 Agent')
|
||
LLM_SUPPORT_AUDIO_OUTPUT: bool = Field(default=False, description='是否启用音频输出,开启后 Agent 可在支持渠道发送语音回复')
|
||
LLM_API_KEY: Optional[str] = Field(default=None, description='LLM API密钥')
|
||
LLM_BASE_URL: Optional[str] = Field(default="https://api.deepseek.com", description='LLM基础URL(用于自定义API端点)')
|
||
LLM_USE_PROXY: bool = Field(default=True, description='LLM调用是否使用系统代理')
|
||
LLM_BASE_URL_PRESET: Optional[str] = Field(default=None, description='LLM Base URL 预设标识,用于区分同一 Base URL 下的不同模型目录')
|
||
LLM_MAX_CONTEXT_TOKENS: int = Field(default=256, description='LLM最大上下文Token数量(K),用于目录缺失回退和未匹配兼容端点的用户上限')
|
||
LLM_USER_AGENT: Optional[str] = Field(default=None, description='LLM OpenAI兼容接口请求User-Agent')
|
||
LLM_TEMPERATURE: Optional[float] = Field(default=None, description='LLM温度参数,留空时不覆盖提供商默认值')
|
||
LLM_MAX_ITERATIONS: int = Field(default=512, description='LLM最大迭代次数')
|
||
LLM_TOOL_TIMEOUT: int = Field(default=300, description='LLM工具调用超时时间(秒)')
|
||
LLM_VERBOSE: bool = Field(default=False, description='是否启用详细日志')
|
||
LLM_MEMORY_RETENTION_DAYS: int = Field(default=1, description='内存记忆保留天数')
|
||
AI_RECOMMEND_ENABLED: bool = Field(default=False, description='是否启用AI推荐')
|
||
AI_RECOMMEND_USER_PREFERENCE: str = Field(default="", description='AI推荐用户偏好')
|
||
|
||
AI_RECOMMEND_MAX_ITEMS: int = Field(default=50, description='AI推荐条目数量限制')
|
||
LLM_MAX_TOOLS: int = Field(default=0, description='LLM工具选择中间件最大工具数量,0为不启用工具选择中间件')
|
||
AI_AGENT_JOB_INTERVAL: int = Field(default=0, description='AI智能体定时任务检查间隔(小时),0为不启用,默认24小时')
|
||
AI_AGENT_VERBOSE: bool = Field(default=False, description='AI智能体啰嗦模式,开启后会回复工具调用过程')
|
||
AI_AGENT_RETRY_TRANSFER: bool = Field(default=False, description='AI智能体自动重试整理失败记录开关')
|
||
TRANSFER_FAILURE_NOTIFICATION_AGGREGATION: bool = Field(default=True, description='是否按媒体聚合整理失败通知,关闭时保持逐条发送')
|
||
TRANSFER_SUCCESS_NOTIFICATION_AGGREGATION: bool = Field(default=True, description='是否按批次聚合电影、电视剧和音乐的整理成功通知')
|
||
|
||
AUDIO_INPUT_PROVIDER: str = Field(default="openai", description='音频输入提供商:openai/openai_chat_audio/mimo/minimax')
|
||
AUDIO_INPUT_API_KEY: Optional[str] = Field(default=None, description='音频输入 API 密钥')
|
||
AUDIO_INPUT_BASE_URL: Optional[str] = Field(default=None, description='音频输入基础URL')
|
||
AUDIO_INPUT_MODEL: str = Field(default="gpt-4o-mini-transcribe", description='音频输入模型')
|
||
AUDIO_INPUT_LANGUAGE: str = Field(default="zh", description='音频输入识别语言')
|
||
AUDIO_OUTPUT_PROVIDER: str = Field(default="openai", description='音频输出提供商:openai/openai_chat_audio/mimo/minimax')
|
||
AUDIO_OUTPUT_API_KEY: Optional[str] = Field(default=None, description='音频输出 API 密钥')
|
||
AUDIO_OUTPUT_BASE_URL: Optional[str] = Field(default=None, description='音频输出基础URL')
|
||
AUDIO_OUTPUT_MODEL: str = Field(default="gpt-4o-mini-tts", description='音频输出模型')
|
||
AUDIO_OUTPUT_VOICE: str = Field(default="alloy", description='音频输出音色/发音人')
|
||
AUDIO_OUTPUT_INCLUDE_TEXT: bool = Field(default=False, description='回复语音时是否同时附带文字说明')
|
||
|
||
|
||
# 每次构造独立记录外部文件字段,避免并发构造配置时互相污染写回保护。
|
||
_secret_fields_context: ContextVar[Optional[set[str]]] = ContextVar(
|
||
"settings_secret_fields", default=None
|
||
)
|
||
|
||
|
||
def _get_secrets_dir() -> Optional[str]:
|
||
"""
|
||
从进程环境选择 secrets 目录;显式空值或 false 关闭文件来源。
|
||
|
||
:return: secrets 目录路径;关闭时返回 None
|
||
"""
|
||
value = os.environ.get(
|
||
"MOVIEPILOT_SECRETS_DIR", os.environ.get("SECRETS_DIR", "/run/secrets")
|
||
).strip()
|
||
return None if value.lower() in {"", "false", "off", "0"} else value
|
||
|
||
|
||
class _DeploymentSecretsSource(SecretsSettingsSource):
|
||
"""
|
||
按配置名读取外部文件,隔离坏文件并保护编排管理的字段不被写回。
|
||
|
||
复用 Pydantic 的来源顺序和复杂值解析;只记录字段名,不缓存或记录原文。
|
||
"""
|
||
|
||
def __call__(self) -> dict[str, Any]:
|
||
"""
|
||
逐项读取并校验 secret,缺失目录静默跳过,其余错误只告警字段名。
|
||
|
||
:return: 通过字段类型与约束校验的配置值
|
||
"""
|
||
values: dict[str, Any] = {}
|
||
if self.secrets_dir is None:
|
||
return values
|
||
directories = (
|
||
[self.secrets_dir]
|
||
if isinstance(self.secrets_dir, (str, os.PathLike))
|
||
else self.secrets_dir
|
||
)
|
||
for directory in directories:
|
||
path = Path(directory).expanduser()
|
||
try:
|
||
if not path.exists():
|
||
continue
|
||
if not path.is_dir():
|
||
raise NotADirectoryError
|
||
self._read_directory(path, values)
|
||
except OSError:
|
||
logger.warning("secrets 目录不可用,请检查目录类型与读取权限")
|
||
return values
|
||
|
||
def _read_directory(self, directory: Path, values: dict[str, Any]) -> None:
|
||
"""
|
||
一次索引目录后按精确字段名定位文件,支持 K8s 投影链接。
|
||
|
||
:param directory: 已确认存在的 secrets 目录
|
||
:param values: 按目录顺序合并的配置值,后面的同名文件优先
|
||
"""
|
||
protected = _secret_fields_context.get()
|
||
entries = {entry.name: entry for entry in directory.iterdir()}
|
||
for name, field in self.settings_cls.model_fields.items():
|
||
path = entries.get(name)
|
||
if path is None:
|
||
continue
|
||
try:
|
||
if protected is not None:
|
||
protected.add(name)
|
||
# 即使该文件无效,外部字段也不得被默认值或自动修正写回遮蔽。
|
||
values.pop(name, None)
|
||
if name in self.current_state:
|
||
continue
|
||
raw = path.read_text(encoding="utf-8-sig").strip()
|
||
if not raw or (name == "API_TOKEN" and len(raw) < 16):
|
||
raise ValueError
|
||
value = self.prepare_field_value(name, field, raw, False)
|
||
values[name] = TypeAdapter(field.rebuild_annotation()).validate_python(value)
|
||
except (OSError, ValueError):
|
||
if protected is not None:
|
||
protected.add(name)
|
||
logger.warning(f"配置项 '{name}' 的 secret 无法读取或格式无效,已跳过,请检查文件与字段格式")
|
||
|
||
|
||
class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||
"""
|
||
加载部署环境配置并提供运行目录、外部地址和配置更新入口。
|
||
|
||
字段语义继承配置模型;构造实例会创建配置目录,二进制部署还会补齐内置环境文件。
|
||
|
||
:cvar model_config: 来源优先级为构造参数、环境变量、环境文件、secrets、默认值
|
||
:ivar _secret_fields: 存在外部 secret 的字段名,包含被高优先级来源遮蔽或无效的文件
|
||
"""
|
||
|
||
model_config = SettingsConfigDict(
|
||
case_sensitive=True,
|
||
env_file=get_env_path(),
|
||
env_file_encoding="utf-8",
|
||
secrets_dir=_get_secrets_dir(),
|
||
)
|
||
_secret_fields: set[str] = PrivateAttr(default_factory=set)
|
||
|
||
@classmethod
|
||
def settings_customise_sources(
|
||
cls,
|
||
settings_cls: type[BaseSettings],
|
||
init_settings: PydanticBaseSettingsSource,
|
||
env_settings: PydanticBaseSettingsSource,
|
||
dotenv_settings: PydanticBaseSettingsSource,
|
||
file_secret_settings: PydanticBaseSettingsSource,
|
||
) -> tuple[PydanticBaseSettingsSource, ...]:
|
||
"""
|
||
保持既有来源优先级,仅替换 secrets 读取以隔离文件错误。
|
||
|
||
:param settings_cls: 本次构造的设置类型
|
||
:param init_settings: 显式构造参数来源
|
||
:param env_settings: 进程环境来源
|
||
:param dotenv_settings: 部署环境文件来源
|
||
:param file_secret_settings: 框架按 model_config 或 _secrets_dir 创建的来源
|
||
:return: 从高到低排列的来源
|
||
"""
|
||
secret_source = _DeploymentSecretsSource(
|
||
settings_cls, secrets_dir=cast(SecretsSettingsSource, file_secret_settings).secrets_dir
|
||
)
|
||
return init_settings, env_settings, dotenv_settings, secret_source
|
||
|
||
def __init__(self, **kwargs: Any) -> None:
|
||
"""
|
||
加载环境配置并确保运行时目录和内置配置可用。
|
||
"""
|
||
protected: set[str] = set()
|
||
token = _secret_fields_context.set(protected)
|
||
try:
|
||
super().__init__(**kwargs)
|
||
self._secret_fields = protected
|
||
finally:
|
||
_secret_fields_context.reset(token)
|
||
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 is_frozen():
|
||
app_env_path = self.CONFIG_PATH / "app.env"
|
||
if not app_env_path.exists():
|
||
shutil.copy2(self.INNER_CONFIG_PATH / "app.env", app_env_path)
|
||
|
||
@staticmethod
|
||
def validate_api_token(value: Any, original_value: Any) -> Tuple[Any, bool]:
|
||
"""
|
||
校验 API_TOKEN。
|
||
"""
|
||
return _normalize_api_token(value, original_value)
|
||
|
||
@staticmethod
|
||
def generic_type_converter(
|
||
value: Any,
|
||
original_value: Any,
|
||
expected_type: object,
|
||
default: Any,
|
||
field_name: str,
|
||
raise_exception: bool = False,
|
||
) -> Tuple[Any, bool]:
|
||
"""
|
||
先执行字段声明的转换策略,再根据预期类型转换值。如果转换失败,返回默认值。
|
||
|
||
:param expected_type: Pydantic 字段注解;字段未提供注解时为 None
|
||
:return: 元组 (转换后的值, 是否需要更新)
|
||
"""
|
||
if isinstance(value, (list, dict, set)):
|
||
value = copy.deepcopy(value)
|
||
|
||
field = Settings.model_fields.get(field_name)
|
||
policy = _get_setting_policy(field)
|
||
if policy:
|
||
if policy.before_convert:
|
||
value = policy.before_convert(value)
|
||
if policy.converter:
|
||
return policy.converter(value, original_value)
|
||
|
||
# 如果 value 是 None,仍需要检查与 original_value 是否不一致
|
||
if value is None:
|
||
return default, str(value) != str(original_value)
|
||
|
||
if isinstance(value, str):
|
||
value = value.strip()
|
||
|
||
# 处理 Optional 类型:当值为空字符串且类型允许 None 时,转为 None
|
||
# 兼容 typing.Union (Python 3.9) 与 types.UnionType (Python 3.10+ PEP 604)
|
||
origin = get_origin(expected_type)
|
||
is_union = origin is Union or getattr(origin, "__name__", None) == "UnionType"
|
||
if (
|
||
is_union
|
||
and type(None) in get_args(expected_type)
|
||
and isinstance(value, str)
|
||
and not value
|
||
):
|
||
return default, str(default) != str(original_value)
|
||
|
||
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: object = 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 in (float, Optional[float]):
|
||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||
converted = float(value)
|
||
return converted, str(converted) != 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
|
||
|
||
# 校验器由 Pydantic 在模型构造前调用,保留框架要求的类方法签名。
|
||
@model_validator(mode="before")
|
||
@classmethod
|
||
def generic_type_validator(cls, data: Any) -> object: # noqa
|
||
"""
|
||
通用校验器,迁移旧 Dev 跟踪偏好后将配置值转换为期望的类型。
|
||
"""
|
||
if not isinstance(data, dict):
|
||
return data
|
||
|
||
# 字段策略负责兼容迁移,公共校验器只负责执行并持久化迁移结果。
|
||
for field_info in cls.model_fields.values():
|
||
policy = _get_setting_policy(field_info)
|
||
if not policy or not policy.migrate:
|
||
continue
|
||
updates = policy.migrate(data)
|
||
for field_name, (original_value, converted_value) in updates.items():
|
||
cls.update_env_config(field_name, original_value, converted_value)
|
||
|
||
for field_name, field in cls.model_fields.items():
|
||
if field_name not in data:
|
||
continue
|
||
value = data[field_name]
|
||
if value is None:
|
||
continue
|
||
|
||
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 配置。
|
||
"""
|
||
if field_name in (_secret_fields_context.get() or set()):
|
||
message = f"配置项 '{field_name}' 由 secret 文件管理,请修改文件并重启"
|
||
logger.warning(message)
|
||
return False, message
|
||
policy = _get_setting_policy(Settings.model_fields.get(field_name))
|
||
# 成功且无提示时使用空字符串,保证与 Tuple[bool, str] 返回类型一致
|
||
message = ""
|
||
is_converted = original_value is not None and str(original_value) != str(
|
||
converted_value
|
||
)
|
||
if is_converted:
|
||
if policy and policy.sensitive:
|
||
message = f"配置项 '{field_name}' 的值无效,已替换为安全值"
|
||
else:
|
||
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:
|
||
# 当值为 None 时,从 env 文件中删除该键,恢复为默认值
|
||
if converted_value is None:
|
||
unset_key(
|
||
dotenv_path=get_env_path(),
|
||
key_to_unset=field_name,
|
||
)
|
||
logger.info(f"配置项 '{field_name}' 已清空,从 'app.env' 中移除")
|
||
return True, message
|
||
if policy and policy.serialize:
|
||
value_to_write = policy.serialize(converted_value)
|
||
elif isinstance(converted_value, (list, dict, set)):
|
||
value_to_write = json.dumps(converted_value)
|
||
else:
|
||
value_to_write = str(converted_value)
|
||
|
||
set_key(
|
||
dotenv_path=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.get(key)
|
||
if not field:
|
||
return False, f"配置项 '{key}' 不存在"
|
||
original_value = getattr(self, key)
|
||
if key in self._secret_fields:
|
||
if value == original_value:
|
||
return None, ""
|
||
return False, f"配置项 '{key}' 由 secret 文件管理,请修改文件并重启"
|
||
converted_value, needs_update = self.generic_type_converter(
|
||
value, original_value, field.annotation, field.default, key
|
||
)
|
||
policy = _get_setting_policy(field)
|
||
if policy and policy.validate:
|
||
validation_message = policy.validate(converted_value)
|
||
if validation_message:
|
||
return False, validation_message
|
||
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 "v3"
|
||
|
||
@property
|
||
def RESOURCE_VERSION_FLAG(self) -> str:
|
||
"""
|
||
返回站点索引和认证资源使用的重大版本标识。
|
||
"""
|
||
return "v3"
|
||
|
||
@property
|
||
def USER_AGENT(self) -> str:
|
||
"""
|
||
全局用户代理字符串。
|
||
"""
|
||
return (
|
||
f"{self.PROJECT_NAME}/{get_app_version()[1:]} "
|
||
f"({platform.system()} {platform.release()}; {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) -> Path:
|
||
"""
|
||
返回随程序发布的内置配置目录。
|
||
"""
|
||
return self.ROOT_PATH / "config"
|
||
|
||
@property
|
||
def CONFIG_PATH(self) -> Path:
|
||
"""
|
||
按显式配置、容器和冻结运行环境确定配置目录。
|
||
"""
|
||
if self.CONFIG_DIR:
|
||
return Path(self.CONFIG_DIR)
|
||
elif is_docker():
|
||
return Path("/config")
|
||
elif is_frozen():
|
||
return Path(sys.executable).parent / "config"
|
||
return self.ROOT_PATH / "config"
|
||
|
||
@property
|
||
def TEMP_PATH(self) -> Path:
|
||
"""
|
||
返回运行时临时目录。
|
||
"""
|
||
return self.CONFIG_PATH / "temp"
|
||
|
||
@property
|
||
def CACHE_PATH(self) -> Path:
|
||
"""
|
||
返回业务缓存目录。
|
||
"""
|
||
return self.CONFIG_PATH / "cache"
|
||
|
||
@property
|
||
def PACKAGE_CACHE_PATH(self) -> Path:
|
||
"""
|
||
返回 pip 和 uv 等包工具使用的缓存根目录。
|
||
"""
|
||
if self.PACKAGE_CACHE_ROOT and self.PACKAGE_CACHE_ROOT.strip():
|
||
return Path(self.PACKAGE_CACHE_ROOT).expanduser()
|
||
return self.CONFIG_PATH / ".cache"
|
||
|
||
@property
|
||
def ROOT_PATH(self) -> Path:
|
||
"""
|
||
返回项目源码或冻结程序的根目录。
|
||
"""
|
||
return Path(__file__).parents[2]
|
||
|
||
@property
|
||
def PLUGIN_DATA_PATH(self) -> Path:
|
||
"""
|
||
返回插件持久化数据目录。
|
||
"""
|
||
return self.CONFIG_PATH / "plugins"
|
||
|
||
@property
|
||
def DATABASE_BACKUP_PATH(self) -> Path:
|
||
"""
|
||
返回数据库备份根目录,允许相对当前配置目录进行配置。
|
||
"""
|
||
configured = str(self.DB_BACKUP_PATH or "").strip()
|
||
if not configured:
|
||
return self.CONFIG_PATH / "database_backup"
|
||
path = Path(configured).expanduser()
|
||
return path if path.is_absolute() else self.CONFIG_PATH / path
|
||
|
||
@property
|
||
def LOG_PATH(self) -> Path:
|
||
"""
|
||
返回应用日志目录。
|
||
"""
|
||
return self.CONFIG_PATH / "logs"
|
||
|
||
@property
|
||
def COOKIE_PATH(self) -> Path:
|
||
"""
|
||
返回站点 Cookie 文件目录。
|
||
"""
|
||
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,
|
||
imdb=512,
|
||
fanart=512,
|
||
musicbrainz=512,
|
||
theaudiodb=512,
|
||
listenbrainz=256,
|
||
meta=(self.META_CACHE_EXPIRE or 72) * 3600,
|
||
scheduler=100,
|
||
threadpool=100,
|
||
)
|
||
return SystemConfModel(
|
||
torrents=100,
|
||
refresh=50,
|
||
tmdb=256,
|
||
douban=256,
|
||
bangumi=256,
|
||
imdb=256,
|
||
fanart=128,
|
||
musicbrainz=256,
|
||
theaudiodb=256,
|
||
listenbrainz=128,
|
||
meta=(self.META_CACHE_EXPIRE or 24) * 3600,
|
||
scheduler=50,
|
||
threadpool=50,
|
||
)
|
||
|
||
@property
|
||
def PROXY(self) -> Optional[Dict[str, str]]:
|
||
"""
|
||
获取 requests 兼容的系统代理配置。
|
||
"""
|
||
if self.PROXY_HOST and self.PROXY_HOST.strip():
|
||
proxy_host = self.PROXY_HOST.strip()
|
||
return {
|
||
"http": proxy_host,
|
||
"https": proxy_host,
|
||
}
|
||
https_proxy = self._get_env_proxy("HTTPS_PROXY", "https_proxy")
|
||
http_proxy = self._get_env_proxy("HTTP_PROXY", "http_proxy")
|
||
fallback_proxy_host = https_proxy or http_proxy
|
||
if fallback_proxy_host:
|
||
return {
|
||
"http": http_proxy or fallback_proxy_host,
|
||
"https": https_proxy or fallback_proxy_host,
|
||
}
|
||
return None
|
||
|
||
@staticmethod
|
||
def _get_env_proxy(*names: str) -> Optional[str]:
|
||
"""
|
||
按顺序读取非空代理环境变量。
|
||
"""
|
||
for name in names:
|
||
proxy_host = os.environ.get(name)
|
||
if proxy_host and proxy_host.strip():
|
||
return proxy_host.strip()
|
||
return None
|
||
|
||
@property
|
||
def DB_POSTGRESQL_SOCKET_MODE(self) -> bool:
|
||
"""
|
||
判断 PostgreSQL 主机配置是否为 Unix Socket 路径。
|
||
"""
|
||
host = (self.DB_POSTGRESQL_HOST or "").strip()
|
||
return host.startswith("/")
|
||
|
||
@property
|
||
def DB_POSTGRESQL_TARGET(self) -> str:
|
||
"""
|
||
返回适合日志展示的 PostgreSQL 连接目标。
|
||
"""
|
||
if self.DB_POSTGRESQL_SOCKET_MODE:
|
||
target = f"socket {self.DB_POSTGRESQL_HOST}"
|
||
if self.DB_POSTGRESQL_PORT:
|
||
target = f"{target} (port {self.DB_POSTGRESQL_PORT})"
|
||
return target
|
||
if self.DB_POSTGRESQL_PORT:
|
||
return f"{self.DB_POSTGRESQL_HOST}:{self.DB_POSTGRESQL_PORT}"
|
||
return self.DB_POSTGRESQL_HOST
|
||
|
||
def DB_SQLITE_URL(self, driver: Optional[str] = None) -> str:
|
||
"""
|
||
SQLite 连接串。与 DB_POSTGRESQL_URL 对称,避免各调用点各自拼接后悄悄漂移
|
||
——迁移与应用连到不同的库文件是不会报错的。
|
||
|
||
:param driver: 驱动名,如 aiosqlite;留空为同步驱动
|
||
"""
|
||
scheme = "sqlite" if not driver else f"sqlite+{driver}"
|
||
return f"{scheme}:///{self.CONFIG_PATH}/user.db"
|
||
|
||
def DB_POSTGRESQL_URL(self, driver: Optional[str] = None) -> str:
|
||
"""
|
||
按同步或异步驱动构造 PostgreSQL SQLAlchemy URL。
|
||
"""
|
||
scheme = "postgresql" if not driver else f"postgresql+{driver}"
|
||
username = quote(str(self.DB_POSTGRESQL_USERNAME), safe="")
|
||
database = quote(str(self.DB_POSTGRESQL_DATABASE), safe="")
|
||
auth = username
|
||
if self.DB_POSTGRESQL_PASSWORD:
|
||
auth = f"{auth}:{quote(str(self.DB_POSTGRESQL_PASSWORD), safe='')}"
|
||
|
||
if self.DB_POSTGRESQL_SOCKET_MODE:
|
||
query = {"host": self.DB_POSTGRESQL_HOST}
|
||
if self.DB_POSTGRESQL_PORT:
|
||
query["port"] = self.DB_POSTGRESQL_PORT
|
||
return f"{scheme}://{auth}@/{database}?{urlencode(query)}"
|
||
|
||
port = f":{self.DB_POSTGRESQL_PORT}" if self.DB_POSTGRESQL_PORT else ""
|
||
return f"{scheme}://{auth}@{self.DB_POSTGRESQL_HOST}{port}/{database}"
|
||
|
||
@property
|
||
def PROXY_SERVER(self) -> Optional[dict[str, str]]:
|
||
"""
|
||
返回浏览器自动化使用的代理服务器配置。
|
||
"""
|
||
if self.PROXY_HOST and self.PROXY_HOST.strip():
|
||
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) -> dict[str, str]:
|
||
"""
|
||
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: Optional[str] = None) -> dict[str, str]:
|
||
"""
|
||
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("无效的仓库 GitHub Token 配置格式")
|
||
continue
|
||
repo_info = parts[0].strip()
|
||
token = parts[1].strip()
|
||
if not repo_info or not token:
|
||
print("仓库 GitHub Token 配置缺少仓库名或 Token")
|
||
continue
|
||
headers[repo_info] = {
|
||
"Authorization": f"Bearer {token}",
|
||
"User-Agent": self.NORMAL_USER_AGENT,
|
||
}
|
||
except Exception:
|
||
print("解析仓库 GitHub Token 配置失败")
|
||
# 如果传入了指定的仓库名称,则返回该仓库的请求头信息,否则返回默认请求头
|
||
return headers.get(repo, self.GITHUB_HEADERS)
|
||
|
||
@property
|
||
def VAPID(self) -> dict[str, str]:
|
||
"""
|
||
返回 Web Push 使用的 VAPID 配置。
|
||
"""
|
||
return {
|
||
"subject": f"mailto:{self.SUPERUSER or 'moviepilot'}@movie-pilot.org",
|
||
"publicKey": "BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM",
|
||
"privateKey": "JTixnYY0vEw97t9uukfO3UWKfHKJdT5kCQDiv3gu894",
|
||
}
|
||
|
||
def MP_DOMAIN(self, url: Optional[str] = None) -> Optional[str]:
|
||
"""
|
||
将相对路径组合为当前 MoviePilot 对外访问地址。
|
||
"""
|
||
if not self.APP_DOMAIN:
|
||
return None
|
||
return UrlUtils.combine_url(host=self.APP_DOMAIN, path=url)
|
||
|
||
def RENAME_FORMAT(self, media_type: MediaType) -> str:
|
||
"""
|
||
获取指定类型的重命名格式。
|
||
|
||
:param media_type: 电影、电视剧或音乐媒体类型
|
||
:return: 重命名格式
|
||
"""
|
||
if media_type == MediaType.TV:
|
||
rename_format = self.TV_RENAME_FORMAT
|
||
elif media_type == MediaType.MUSIC:
|
||
rename_format = self.MUSIC_RENAME_FORMAT
|
||
else:
|
||
rename_format = 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()
|
||
configure_log_settings(settings)
|
||
|
||
|
||
class GlobalVar(object):
|
||
"""
|
||
保留旧调用方使用的全局运行标识,停止状态统一转发给进程状态服务。
|
||
"""
|
||
|
||
# 需应急停止的工作流
|
||
EMERGENCY_STOP_WORKFLOWS: List[int] = []
|
||
# 需应急停止文件整理
|
||
EMERGENCY_STOP_TRANSFER: List[str] = []
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
loop_registry: Optional[MainLoopRegistry] = None,
|
||
push_registry: Optional[WebPushRegistry] = None,
|
||
) -> None:
|
||
"""
|
||
绑定兼容门面背后的显式 owner;普通实例保持循环状态隔离。
|
||
"""
|
||
self._loop_registry = loop_registry or MainLoopRegistry()
|
||
self._push_registry = push_registry or webpush_registry
|
||
|
||
@property
|
||
def CURRENT_EVENT_LOOP(self) -> Optional[AbstractEventLoop]:
|
||
"""
|
||
兼容旧代码读取未经可用性校验的主循环。
|
||
"""
|
||
return self._loop_registry.current
|
||
|
||
@CURRENT_EVENT_LOOP.setter
|
||
def CURRENT_EVENT_LOOP(self, loop: Optional[AbstractEventLoop]) -> None:
|
||
"""
|
||
兼容旧测试和插件直接替换主循环投递目标。
|
||
"""
|
||
self._loop_registry.replace_compat(loop)
|
||
|
||
@CURRENT_EVENT_LOOP.deleter
|
||
def CURRENT_EVENT_LOOP(self) -> None:
|
||
"""
|
||
兼容属性 patch 清理,删除时仅清空当前投递目标。
|
||
"""
|
||
self._loop_registry.replace_compat(None)
|
||
|
||
@property
|
||
def SUBSCRIPTIONS(self) -> List[dict[str, Any]]:
|
||
"""
|
||
兼容旧代码在持锁后直接访问订阅列表。
|
||
"""
|
||
return self._push_registry.compat_items
|
||
|
||
@property
|
||
def SUBSCRIPTIONS_LOCK(self) -> threading.Lock:
|
||
"""
|
||
兼容旧代码保护原始订阅列表的互斥锁。
|
||
"""
|
||
return self._push_registry.compat_lock
|
||
|
||
@property
|
||
def STOP_EVENT(self) -> threading.Event:
|
||
"""
|
||
兼容旧代码读取进程停止事件。
|
||
"""
|
||
return runtime_stop_state.system_event
|
||
|
||
@STOP_EVENT.setter
|
||
def STOP_EVENT(self, event: threading.Event) -> None:
|
||
"""
|
||
兼容旧测试替换事件,同时保持新 StopState 为唯一状态源。
|
||
"""
|
||
runtime_stop_state.replace_system_event(event)
|
||
|
||
def stop_system(self) -> None:
|
||
"""
|
||
停止系统。
|
||
"""
|
||
runtime_stop_state.stop_system()
|
||
|
||
@property
|
||
def is_system_stopped(self) -> bool:
|
||
"""
|
||
是否停止。
|
||
"""
|
||
return runtime_stop_state.is_system_stopped
|
||
|
||
def get_subscriptions(self) -> list[dict[str, Any]]:
|
||
"""
|
||
获取webpush订阅。
|
||
"""
|
||
return self._push_registry.list()
|
||
|
||
def push_subscription(self, subscription: dict[str, Any]) -> None:
|
||
"""
|
||
添加或更新webpush订阅。
|
||
"""
|
||
self._push_registry.upsert(subscription)
|
||
|
||
def remove_subscription(self, subscription: dict[str, Any]) -> bool:
|
||
"""
|
||
根据 endpoint 移除webpush订阅,返回是否实际删除。
|
||
"""
|
||
return self._push_registry.remove(subscription)
|
||
|
||
def stop_workflow(self, workflow_id: int) -> None:
|
||
"""
|
||
停止工作流。
|
||
"""
|
||
runtime_stop_state.stop_workflow(workflow_id)
|
||
|
||
def workflow_resume(self, workflow_id: int) -> None:
|
||
"""
|
||
恢复工作流。
|
||
"""
|
||
runtime_stop_state.resume_workflow(workflow_id)
|
||
|
||
def is_workflow_stopped(self, workflow_id: int) -> bool:
|
||
"""
|
||
是否停止工作流。
|
||
"""
|
||
return runtime_stop_state.is_workflow_stopped(workflow_id)
|
||
|
||
def stop_transfer(self, path: str) -> None:
|
||
"""
|
||
停止文件整理。
|
||
"""
|
||
runtime_stop_state.stop_transfer(path)
|
||
|
||
def is_transfer_stopped(self, path: str) -> bool:
|
||
"""
|
||
是否停止文件整理。
|
||
"""
|
||
return runtime_stop_state.consume_transfer_stop(path)
|
||
|
||
@property
|
||
def loop(self) -> AbstractEventLoop:
|
||
"""
|
||
返回由应用生命周期登记的主事件循环。
|
||
"""
|
||
return self._loop_registry.require()
|
||
|
||
def set_loop(self, loop: AbstractEventLoop) -> object:
|
||
"""
|
||
登记主事件循环,并返回仅供当前生命周期释放的 owner。
|
||
"""
|
||
return self._loop_registry.register(loop)
|
||
|
||
def clear_loop(self, owner: object) -> None:
|
||
"""
|
||
释放指定 owner,保留仍然有效的其他生命周期登记。
|
||
"""
|
||
self._loop_registry.release(owner)
|
||
|
||
|
||
global_vars = GlobalVar(
|
||
loop_registry=main_loop_registry,
|
||
push_registry=webpush_registry,
|
||
)
|