mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 03:48:03 +08:00
596 lines
21 KiB
Python
596 lines
21 KiB
Python
import io
|
||
import threading
|
||
from collections.abc import Mapping
|
||
from dataclasses import dataclass
|
||
from hashlib import sha256
|
||
from pathlib import Path
|
||
from typing import Any, Callable, List, Optional, Protocol
|
||
|
||
from PIL import Image
|
||
|
||
from app.application.configuration import get_chain_runtime_config_snapshot
|
||
from app.application.security.url import SecurityUtils
|
||
from app.foundation.singleton import Singleton
|
||
from app.runtime.cache import AsyncFileCache, FileCache, cached
|
||
from app.runtime.log import logger
|
||
|
||
WallpaperProvider = Callable[[], Optional[str]]
|
||
WallpaperListProvider = Callable[[int], List[str]]
|
||
_IMAGE_CACHE_PATH_PREFIX = "proxy_"
|
||
_IMAGE_CACHE_HASH_LENGTH = 16
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class _ImageFetchRequest:
|
||
"""冻结同步与异步图片获取共用的缓存键和传输参数。"""
|
||
|
||
url: str
|
||
cache_path: str
|
||
use_cache: bool
|
||
|
||
|
||
class ImageResponsePort(Protocol):
|
||
"""图片应用服务消费的最小 HTTP 响应契约。"""
|
||
|
||
status_code: int
|
||
content: bytes
|
||
headers: Mapping[str, str]
|
||
|
||
def json(self) -> Any:
|
||
"""返回响应 JSON 载荷。"""
|
||
...
|
||
|
||
|
||
class ImageTransport(Protocol):
|
||
"""图片与壁纸读取所需的同步、异步 GET 端口。"""
|
||
|
||
def get(
|
||
self,
|
||
url: str,
|
||
*,
|
||
options: Mapping[str, Any],
|
||
) -> Optional[ImageResponsePort]:
|
||
"""同步读取远端图片或壁纸响应。"""
|
||
...
|
||
|
||
async def async_get(
|
||
self,
|
||
url: str,
|
||
*,
|
||
options: Mapping[str, Any],
|
||
) -> Optional[ImageResponsePort]:
|
||
"""异步读取远端图片响应。"""
|
||
...
|
||
|
||
|
||
class InternalAddressPort(Protocol):
|
||
"""判断 URL 是否指向无需代理的内部地址。"""
|
||
|
||
def is_internal(self, url: str) -> bool:
|
||
"""返回 URL 是否属于内部地址。"""
|
||
...
|
||
|
||
|
||
_image_port_lock = threading.Lock()
|
||
_image_transport: Optional[ImageTransport] = None
|
||
_internal_address: Optional[InternalAddressPort] = None
|
||
|
||
|
||
def _clear_wallpaper_caches() -> None:
|
||
"""清除依赖外部 provider 或 transport 的壁纸函数缓存。"""
|
||
for method_name in (
|
||
"get_tmdb_wallpaper",
|
||
"get_tmdb_wallpapers",
|
||
"get_bing_wallpaper",
|
||
"get_bing_wallpapers",
|
||
"get_mediaserver_wallpaper",
|
||
"get_mediaserver_wallpapers",
|
||
"get_customize_wallpaper",
|
||
"get_customize_wallpapers",
|
||
):
|
||
cache_clear = getattr(getattr(WallpaperHelper, method_name), "cache_clear", None)
|
||
if callable(cache_clear):
|
||
cache_clear()
|
||
|
||
|
||
def configure_image_ports(
|
||
*,
|
||
transport: ImageTransport,
|
||
internal_address: InternalAddressPort,
|
||
) -> tuple[Optional[ImageTransport], Optional[InternalAddressPort]]:
|
||
"""由启动组合根装配图片 I/O 端口,并清除旧实现产生的壁纸缓存。"""
|
||
global _image_transport, _internal_address
|
||
with _image_port_lock:
|
||
previous = (_image_transport, _internal_address)
|
||
_image_transport = transport
|
||
_internal_address = internal_address
|
||
_clear_wallpaper_caches()
|
||
return previous
|
||
|
||
|
||
def reset_image_ports(
|
||
transport: Optional[ImageTransport] = None,
|
||
internal_address: Optional[InternalAddressPort] = None,
|
||
) -> None:
|
||
"""恢复指定图片端口;省略参数时回到未装配状态并清缓存。"""
|
||
global _image_transport, _internal_address
|
||
with _image_port_lock:
|
||
_image_transport = transport
|
||
_internal_address = internal_address
|
||
_clear_wallpaper_caches()
|
||
|
||
|
||
def _image_ports_snapshot() -> tuple[ImageTransport, InternalAddressPort]:
|
||
"""读取一致的图片端口快照,未装配时稳定失败。"""
|
||
with _image_port_lock:
|
||
transport = _image_transport
|
||
internal_address = _internal_address
|
||
if transport is None or internal_address is None:
|
||
raise RuntimeError("图片网络端口尚未由启动组合根装配")
|
||
return transport, internal_address
|
||
|
||
|
||
def _empty_wallpaper_provider() -> Optional[str]:
|
||
"""在启动组合根尚未装配壁纸来源时返回空结果。"""
|
||
return None
|
||
|
||
|
||
def _empty_wallpaper_list_provider(_count: int) -> List[str]:
|
||
"""在启动组合根尚未装配壁纸来源时返回空列表。"""
|
||
return []
|
||
|
||
|
||
_tmdb_wallpaper_provider: WallpaperProvider = _empty_wallpaper_provider
|
||
_tmdb_wallpaper_list_provider: WallpaperListProvider = (
|
||
_empty_wallpaper_list_provider
|
||
)
|
||
_mediaserver_wallpaper_provider: WallpaperProvider = _empty_wallpaper_provider
|
||
_mediaserver_wallpaper_list_provider: WallpaperListProvider = (
|
||
_empty_wallpaper_list_provider
|
||
)
|
||
|
||
|
||
def configure_wallpaper_providers(
|
||
*,
|
||
tmdb_wallpaper: WallpaperProvider,
|
||
tmdb_wallpapers: WallpaperListProvider,
|
||
mediaserver_wallpaper: WallpaperProvider,
|
||
mediaserver_wallpapers: WallpaperListProvider,
|
||
) -> None:
|
||
"""由启动组合根注入需要业务 Chain 才能提供的壁纸来源。"""
|
||
global _tmdb_wallpaper_provider
|
||
global _tmdb_wallpaper_list_provider
|
||
global _mediaserver_wallpaper_provider
|
||
global _mediaserver_wallpaper_list_provider
|
||
_tmdb_wallpaper_provider = tmdb_wallpaper
|
||
_tmdb_wallpaper_list_provider = tmdb_wallpapers
|
||
_mediaserver_wallpaper_provider = mediaserver_wallpaper
|
||
_mediaserver_wallpaper_list_provider = mediaserver_wallpapers
|
||
_clear_wallpaper_caches()
|
||
|
||
|
||
def reset_wallpaper_providers() -> None:
|
||
"""恢复空壁纸来源并清除旧 lifespan 产生的壁纸缓存。"""
|
||
global _tmdb_wallpaper_provider
|
||
global _tmdb_wallpaper_list_provider
|
||
global _mediaserver_wallpaper_provider
|
||
global _mediaserver_wallpaper_list_provider
|
||
_tmdb_wallpaper_provider = _empty_wallpaper_provider
|
||
_tmdb_wallpaper_list_provider = _empty_wallpaper_list_provider
|
||
_mediaserver_wallpaper_provider = _empty_wallpaper_provider
|
||
_mediaserver_wallpaper_list_provider = _empty_wallpaper_list_provider
|
||
_clear_wallpaper_caches()
|
||
|
||
|
||
class WallpaperHelper(metaclass=Singleton):
|
||
"""
|
||
壁纸帮助类
|
||
|
||
壁纸缓存键不区分来源,启动装配与生命周期撤销时需要清空;每个方法只缓存一条地址,
|
||
因此固定使用进程内缓存(local_only),清空不触及 Redis,也不跨进程沿用旧来源的结果。
|
||
"""
|
||
|
||
def clear_cache(self) -> None:
|
||
"""清除全部壁纸来源缓存,使壁纸配置变更即时生效。"""
|
||
_clear_wallpaper_caches()
|
||
|
||
def get_wallpaper(self) -> Optional[str]:
|
||
"""
|
||
获取登录页面壁纸
|
||
"""
|
||
wallpaper = get_chain_runtime_config_snapshot().wallpaper
|
||
if wallpaper == "bing":
|
||
return self.get_bing_wallpaper()
|
||
elif wallpaper == "mediaserver":
|
||
return self.get_mediaserver_wallpaper()
|
||
elif wallpaper == "customize":
|
||
return self.get_customize_wallpaper()
|
||
elif wallpaper == "static":
|
||
return self.get_static_wallpaper()
|
||
elif wallpaper == "tmdb":
|
||
return self.get_tmdb_wallpaper()
|
||
return ''
|
||
|
||
def get_wallpapers(self, num: int = 10) -> List[str]:
|
||
"""
|
||
获取登录页面壁纸列表
|
||
"""
|
||
wallpaper = get_chain_runtime_config_snapshot().wallpaper
|
||
if wallpaper == "bing":
|
||
return self.get_bing_wallpapers(num)
|
||
elif wallpaper == "mediaserver":
|
||
return self.get_mediaserver_wallpapers(num)
|
||
elif wallpaper == "customize":
|
||
return self.get_customize_wallpapers()
|
||
elif wallpaper == "static":
|
||
return self.get_static_wallpapers()
|
||
elif wallpaper == "tmdb":
|
||
return self.get_tmdb_wallpapers(num)
|
||
return []
|
||
|
||
def get_static_wallpaper(self) -> Optional[str]:
|
||
"""原样返回前端可访问的静态壁纸地址。"""
|
||
return get_chain_runtime_config_snapshot().wallpaper_image_url
|
||
|
||
def get_static_wallpapers(self) -> List[str]:
|
||
"""以单项列表返回静态壁纸,确保前端沿用统一的列表契约。"""
|
||
wallpaper = self.get_static_wallpaper()
|
||
return [wallpaper] if wallpaper else []
|
||
|
||
@cached(maxsize=1, ttl=3600, local_only=True)
|
||
def get_tmdb_wallpaper(self) -> Optional[str]:
|
||
"""
|
||
获取TMDB每日壁纸
|
||
"""
|
||
return _tmdb_wallpaper_provider()
|
||
|
||
@cached(maxsize=1, ttl=3600, skip_empty=True, local_only=True)
|
||
def get_tmdb_wallpapers(self, num: int = 10) -> List[str]:
|
||
"""
|
||
获取7天的TMDB每日壁纸
|
||
"""
|
||
return _tmdb_wallpaper_list_provider(num)
|
||
|
||
@cached(maxsize=1, ttl=3600, local_only=True)
|
||
def get_bing_wallpaper(self) -> Optional[str]:
|
||
"""
|
||
获取Bing每日壁纸
|
||
"""
|
||
url = "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1"
|
||
transport, _ = _image_ports_snapshot()
|
||
resp = transport.get(url, options={"timeout": 5})
|
||
if resp and resp.status_code == 200:
|
||
try:
|
||
result = resp.json()
|
||
if isinstance(result, dict):
|
||
for image in result.get('images') or []:
|
||
return f"https://cn.bing.com{image.get('url')}" if 'url' in image else ''
|
||
except Exception as err:
|
||
print(str(err))
|
||
return None
|
||
|
||
@cached(maxsize=1, ttl=3600, skip_empty=True, local_only=True)
|
||
def get_bing_wallpapers(self, num: int = 7) -> List[str]:
|
||
"""
|
||
获取7天的Bing每日壁纸
|
||
"""
|
||
url = f"https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n={num}"
|
||
transport, _ = _image_ports_snapshot()
|
||
resp = transport.get(url, options={"timeout": 5})
|
||
if resp and resp.status_code == 200:
|
||
try:
|
||
result = resp.json()
|
||
if isinstance(result, dict):
|
||
return [f"https://cn.bing.com{image.get('url')}" for image in result.get('images') or []]
|
||
except Exception as err:
|
||
print(str(err))
|
||
return []
|
||
|
||
@cached(maxsize=1, ttl=3600, local_only=True)
|
||
def get_mediaserver_wallpaper(self) -> Optional[str]:
|
||
"""
|
||
获取媒体服务器壁纸
|
||
"""
|
||
return _mediaserver_wallpaper_provider()
|
||
|
||
@cached(maxsize=1, ttl=3600, skip_empty=True, local_only=True)
|
||
def get_mediaserver_wallpapers(self, num: int = 10) -> List[str]:
|
||
"""
|
||
获取媒体服务器壁纸列表
|
||
"""
|
||
return _mediaserver_wallpaper_list_provider(num)
|
||
|
||
@cached(maxsize=1, ttl=3600, local_only=True)
|
||
def get_customize_wallpaper(self) -> Optional[str]:
|
||
"""
|
||
获取自定义壁纸api壁纸
|
||
"""
|
||
wallpaper_list = self.get_customize_wallpapers()
|
||
if wallpaper_list:
|
||
return wallpaper_list[0]
|
||
return None
|
||
|
||
@cached(maxsize=1, ttl=3600, skip_empty=True, local_only=True)
|
||
def get_customize_wallpapers(self) -> List[str]:
|
||
"""
|
||
获取自定义壁纸api壁纸
|
||
"""
|
||
|
||
def find_files_with_suffixes(obj, suffixes: List[str]) -> List[str]:
|
||
"""
|
||
递归查找对象中所有包含特定后缀的文件,返回匹配的字符串列表
|
||
支持输入:字典、列表、字符串
|
||
"""
|
||
_result = []
|
||
|
||
# 处理字符串
|
||
if isinstance(obj, str):
|
||
if obj.endswith(tuple(suffixes)):
|
||
_result.append(obj)
|
||
|
||
# 处理字典
|
||
elif isinstance(obj, dict):
|
||
for value in obj.values():
|
||
_result.extend(find_files_with_suffixes(value, suffixes))
|
||
|
||
# 处理列表
|
||
elif isinstance(obj, list):
|
||
for item in obj:
|
||
_result.extend(find_files_with_suffixes(item, suffixes))
|
||
|
||
return _result
|
||
|
||
# 判断是否存在自定义壁纸api
|
||
config = get_chain_runtime_config_snapshot()
|
||
if config.customize_wallpaper_api_url:
|
||
wallpaper_list = []
|
||
transport, _ = _image_ports_snapshot()
|
||
resp = transport.get(
|
||
config.customize_wallpaper_api_url,
|
||
options={"timeout": 15},
|
||
)
|
||
if resp and resp.status_code == 200:
|
||
# 如果返回的是图片格式
|
||
content_type = resp.headers.get('Content-Type')
|
||
if content_type and content_type.lower().startswith('image/'):
|
||
wallpaper_list.append(config.customize_wallpaper_api_url)
|
||
else:
|
||
try:
|
||
result = resp.json()
|
||
if isinstance(result, list) or isinstance(result, dict) or isinstance(result, str):
|
||
wallpaper_list = find_files_with_suffixes(result, config.security_image_suffixes)
|
||
except Exception as err:
|
||
print(str(err))
|
||
return wallpaper_list
|
||
else:
|
||
return []
|
||
|
||
|
||
class ImageHelper(metaclass=Singleton):
|
||
"""统一管理同步和异步图片缓存。"""
|
||
|
||
def __init__(self):
|
||
"""
|
||
按全局图片缓存天数初始化文件缓存。
|
||
|
||
图片缓存始终落在 ``CACHE_PATH/images``,即使启用了 Redis 缓存后端:图片是
|
||
大体积二进制载荷,写入 Redis 会占据其绝大部分内存并放大 RDB 重写量。
|
||
过期由启动清理任务按 ``GLOBAL_IMAGE_CACHE_DAYS`` 与文件修改时间回收;
|
||
升级前已写入 Redis 的 ``images`` 键不再读取,由其自身 TTL 自然过期。
|
||
"""
|
||
config = get_chain_runtime_config_snapshot()
|
||
_base_path = config.cache_path
|
||
_ttl = config.global_image_cache_days * 24 * 3600
|
||
self.file_cache = FileCache(base=_base_path, ttl=_ttl, local_only=True)
|
||
self.async_file_cache = AsyncFileCache(
|
||
base=_base_path, ttl=_ttl, local_only=True
|
||
)
|
||
|
||
@staticmethod
|
||
def _prepare_cache_path(url: str) -> str:
|
||
"""
|
||
根据图片 URL 生成缓存路径。
|
||
|
||
根路径或其他无有效文件名的 URL 使用完整 URL 的短哈希兜底,避免
|
||
`Path.with_suffix()` 对空路径抛出异常,并保证不同 URL 不共用该缓存名。
|
||
"""
|
||
cache_path = Path(SecurityUtils.sanitize_url_path(url))
|
||
if not cache_path.name:
|
||
hash_value = sha256(url.encode()).hexdigest()[:_IMAGE_CACHE_HASH_LENGTH]
|
||
cache_path = Path(f"{_IMAGE_CACHE_PATH_PREFIX}{hash_value}")
|
||
if not cache_path.suffix:
|
||
cache_path = cache_path.with_suffix(".jpg")
|
||
return cache_path.as_posix()
|
||
|
||
@staticmethod
|
||
def get_image_mime_type(content: bytes, verify: bool = True) -> Optional[str]:
|
||
"""
|
||
根据图片内容返回 Pillow 识别的图片 MIME 类型。
|
||
|
||
外部响应在写入缓存前需要完整校验;已校验的缓存只需读取格式头。
|
||
非图片或可脚本化的 MIME 类型不作为图片代理响应。
|
||
"""
|
||
if not content:
|
||
return None
|
||
try:
|
||
with Image.open(io.BytesIO(content)) as image:
|
||
image_format = (image.format or "").upper()
|
||
if verify:
|
||
image.verify()
|
||
mime_type = Image.MIME.get(image_format)
|
||
if (
|
||
not mime_type
|
||
or not mime_type.startswith("image/")
|
||
or mime_type == "image/svg+xml"
|
||
):
|
||
return None
|
||
return mime_type
|
||
except Exception as err:
|
||
logger.warning(f"Invalid image format: {err}")
|
||
return None
|
||
|
||
@staticmethod
|
||
def _get_request_params(url: str, proxy: Optional[bool], cookies: Optional[str | dict]) -> dict:
|
||
"""获取参数"""
|
||
referer = "https://movie.douban.com/" if "doubanio.com" in url else None
|
||
config = get_chain_runtime_config_snapshot()
|
||
if proxy is None:
|
||
_, internal_address = _image_ports_snapshot()
|
||
proxies = (
|
||
config.proxy if not (referer or internal_address.is_internal(url)) else None
|
||
)
|
||
else:
|
||
proxies = config.proxy if proxy else None
|
||
return {
|
||
"ua": config.normal_user_agent,
|
||
"proxies": proxies,
|
||
"referer": referer,
|
||
"cookies": cookies,
|
||
"accept_type": "image/avif,image/webp,image/apng,*/*",
|
||
}
|
||
|
||
@staticmethod
|
||
def _fetch_request(
|
||
url: str,
|
||
proxy: Optional[bool],
|
||
use_cache: bool,
|
||
cookies: Optional[str | dict[str, str]],
|
||
) -> Optional[_ImageFetchRequest]:
|
||
"""统一空 URL 拒绝、缓存键和远端传输参数生成。"""
|
||
if not url:
|
||
return None
|
||
return _ImageFetchRequest(
|
||
url=url,
|
||
cache_path=ImageHelper._prepare_cache_path(url),
|
||
use_cache=use_cache,
|
||
)
|
||
|
||
def _cached_image(
|
||
self, content: Optional[bytes]
|
||
) -> Optional[tuple[bytes, str]]:
|
||
"""缓存内容只读取格式头,返回可直接复用的图片结果。"""
|
||
if not content:
|
||
return None
|
||
mime_type = self.get_image_mime_type(content, verify=False)
|
||
return (content, mime_type) if mime_type else None
|
||
|
||
def _fetched_image(
|
||
self,
|
||
request: _ImageFetchRequest,
|
||
response: Optional[ImageResponsePort],
|
||
) -> Optional[tuple[bytes, str]]:
|
||
"""统一远端状态码与图片内容校验,拒绝结果不得进入缓存。"""
|
||
if response is None or response.status_code != 200:
|
||
logger.warn(f"Failed to fetch image from URL: {request.url}")
|
||
return None
|
||
mime_type = self.get_image_mime_type(response.content)
|
||
return (response.content, mime_type) if mime_type else None
|
||
|
||
def fetch_image(
|
||
self,
|
||
url: str,
|
||
proxy: Optional[bool] = None,
|
||
use_cache: bool = True,
|
||
cookies: Optional[str | dict] = None) -> Optional[bytes]:
|
||
"""
|
||
获取图片(同步版本)
|
||
"""
|
||
result = self.fetch_image_with_mime_type(
|
||
url=url,
|
||
proxy=proxy,
|
||
use_cache=use_cache,
|
||
cookies=cookies,
|
||
)
|
||
return result[0] if result else None
|
||
|
||
def fetch_image_with_mime_type(
|
||
self,
|
||
url: str,
|
||
proxy: Optional[bool] = None,
|
||
use_cache: bool = True,
|
||
cookies: Optional[str | dict] = None,
|
||
) -> Optional[tuple[bytes, str]]:
|
||
"""
|
||
同步获取图片及其内容识别 MIME 类型。
|
||
|
||
网络响应在写入缓存前完整验证一次;缓存命中仅重新识别格式头。
|
||
"""
|
||
request = ImageHelper._fetch_request(url, proxy, use_cache, cookies)
|
||
if request is None:
|
||
return None
|
||
if request.use_cache:
|
||
cached_result = self._cached_image(
|
||
self.file_cache.get(request.cache_path, region="images")
|
||
)
|
||
if cached_result is not None:
|
||
return cached_result
|
||
transport, _ = _image_ports_snapshot()
|
||
result = self._fetched_image(
|
||
request,
|
||
transport.get(
|
||
request.url,
|
||
options=ImageHelper._get_request_params(
|
||
request.url, proxy, cookies
|
||
),
|
||
),
|
||
)
|
||
if result is None:
|
||
return None
|
||
self.file_cache.set(request.cache_path, result[0], region="images")
|
||
return result
|
||
|
||
async def async_fetch_image(
|
||
self,
|
||
url: str,
|
||
proxy: Optional[bool] = None,
|
||
use_cache: bool = True,
|
||
cookies: Optional[str | dict] = None) -> Optional[bytes]:
|
||
"""
|
||
获取图片(异步版本)
|
||
"""
|
||
result = await self.async_fetch_image_with_mime_type(
|
||
url=url,
|
||
proxy=proxy,
|
||
use_cache=use_cache,
|
||
cookies=cookies,
|
||
)
|
||
return result[0] if result else None
|
||
|
||
async def async_fetch_image_with_mime_type(
|
||
self,
|
||
url: str,
|
||
proxy: Optional[bool] = None,
|
||
use_cache: bool = True,
|
||
cookies: Optional[str | dict] = None,
|
||
) -> Optional[tuple[bytes, str]]:
|
||
"""
|
||
异步获取图片及其内容识别 MIME 类型。
|
||
|
||
网络响应在写入缓存前完整验证一次;缓存命中仅重新识别格式头。
|
||
"""
|
||
request = ImageHelper._fetch_request(url, proxy, use_cache, cookies)
|
||
if request is None:
|
||
return None
|
||
if request.use_cache:
|
||
cached_result = self._cached_image(
|
||
await self.async_file_cache.get(request.cache_path, region="images")
|
||
)
|
||
if cached_result is not None:
|
||
return cached_result
|
||
transport, _ = _image_ports_snapshot()
|
||
result = self._fetched_image(
|
||
request,
|
||
await transport.async_get(
|
||
request.url,
|
||
options=ImageHelper._get_request_params(
|
||
request.url, proxy, cookies
|
||
),
|
||
),
|
||
)
|
||
if result is None:
|
||
return None
|
||
await self.async_file_cache.set(
|
||
request.cache_path, result[0], region="images"
|
||
)
|
||
return result
|