Files
MoviePilot/app/adapters/cache/backends.py
Aqr-K 6610a8c008 fix: retain plugin lifecycle ownership and recover installation state (#7146)
* fix: retain plugin lifecycle ownership and recover installation state

* test: cover plugin rollback and uninstall lifecycle boundaries

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 07:44:07 +08:00

328 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
文件缓存后端及平台缓存工厂装配,不在本模块实现内存缓存策略。
"""
import shutil
import tempfile
from pathlib import Path
from typing import Any, AsyncGenerator, Callable, Generator, Optional, Tuple
import aiofiles
import aioshutil
from anyio import Path as AsyncPath
from app.runtime.cache import (
DEFAULT_CACHE_REGION,
AsyncCacheBackend,
AtomicCacheBackend,
CacheBackend,
configure_cache_factories,
)
from app.runtime.settings import get_runtime_setting
class FileBackend(CacheBackend):
"""
以区域子目录和相对键定位二进制缓存文件。
调用方负责提供受信任的区域和相对键;后端不校验路径越界,也不自行管理过期时间。
文件读写错误向调用方传播,目录删除沿用忽略错误的清理行为。
"""
def __init__(self, base: Path) -> None:
"""
初始化缓存根目录;目录不存在时同步创建。
:param base: 此后读写缓存使用的根路径
"""
self.base = base
self.base.mkdir(parents=True, exist_ok=True)
def set(
self,
key: str,
value: Any,
ttl: Optional[int] = None,
region: Optional[str] = DEFAULT_CACHE_REGION,
**_kwargs: object,
) -> None:
"""
通过同目录临时文件替换目标,避免读者看到未写完的内容。
:param key: 区域内的相对缓存文件路径
:param value: 可写入二进制文件的字节数据;本层不序列化对象
:param ttl: 新式TTL关键字,文件后端当前不管理过期;字符串第三位置参数沿用旧region合同
:param region: 根目录下的缓存区域名称,使用时须为字符串
:param _kwargs: 为缓存接口兼容保留,文件后端不处理这些选项
"""
cache_region = ttl if isinstance(ttl, str) else (region or DEFAULT_CACHE_REGION)
cache_path = self.base / cache_region / key
cache_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
dir=cache_path.parent,
delete=False,
) as tmp_file:
tmp_file.write(value)
temp_path = Path(tmp_file.name)
temp_path.replace(cache_path)
def exists(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> bool:
"""
检查缓存路径是否存在,不区分文件与目录。
:param key: 区域内的相对缓存路径
:param region: 根目录下的缓存区域名称,使用时须为字符串
:return: 路径存在时返回 True
"""
cache_region = region or DEFAULT_CACHE_REGION
return (self.base / cache_region / key).exists()
def get(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> Optional[Any]:
"""
读取缓存文件的完整二进制内容。
:param key: 区域内的相对缓存文件路径
:param region: 根目录下的缓存区域名称,使用时须为字符串
:return: 文件内容;检查时目标不存在则返回 None
"""
cache_region = region or DEFAULT_CACHE_REGION
cache_path = self.base / cache_region / key
if not cache_path.exists():
return None
with cache_path.open("rb") as file_handle:
return file_handle.read()
def delete(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> None:
"""
删除指定缓存文件或递归清理缓存子目录。
:param key: 区域内的相对缓存路径;不存在时不操作
:param region: 根目录下的缓存区域名称,使用时须为字符串
"""
cache_region = region or DEFAULT_CACHE_REGION
cache_path = self.base / cache_region / key
if cache_path.is_file():
cache_path.unlink()
elif cache_path.exists():
shutil.rmtree(cache_path, ignore_errors=True)
def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:
"""
清理缓存目录的内容,保留区域目录本身。
:param region: 要清空的区域;为空时清空整个缓存根目录
"""
cache_path = self.base / region if region else self.base
if not cache_path.exists():
return
for item in cache_path.iterdir():
if item.is_file():
item.unlink()
else:
shutil.rmtree(item, ignore_errors=True)
def items(
self,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> Generator[Tuple[str, Any], None, None]:
"""
递归读取区域中的缓存文件,不提供并发修改时的一致性快照。
:param region: 要遍历的区域名称,使用时须为字符串
:yields: 相对于区域目录的路径与完整文件字节组成的二元组
"""
cache_path = self.base / region if region else self.base
if not cache_path.exists():
return
for item in sorted(cache_path.rglob("*")):
if item.is_file():
with item.open("rb") as file_handle:
yield item.relative_to(cache_path).as_posix(), file_handle.read()
def close(self) -> None:
"""
文件缓存没有需要关闭的持久连接。
"""
class AsyncFileBackend(AsyncCacheBackend):
"""
以区域子目录和相对键定位二进制缓存文件。
调用方负责提供受信任的区域和相对键;后端不校验路径越界,也不自行管理过期时间。
文件读写错误向调用方传播,目录删除沿用忽略错误的清理行为。
"""
def __init__(self, base: Path) -> None:
"""
初始化缓存根目录;目录不存在时同步创建。
:param base: 此后读写缓存使用的根路径
"""
self.base = base
self.base.mkdir(parents=True, exist_ok=True)
async def set( # type: ignore[override]
self,
key: str,
value: Any,
ttl: Optional[int] = None,
region: Optional[str] = DEFAULT_CACHE_REGION,
**_kwargs: object,
) -> None:
"""
通过同目录临时文件替换目标,避免读者看到未写完的内容。
:param key: 区域内的相对缓存文件路径
:param value: 可写入二进制文件的字节数据;本层不序列化对象
:param ttl: 新式TTL关键字,文件后端当前不管理过期;字符串第三位置参数沿用旧region合同
:param region: 根目录下的缓存区域名称,使用时须为字符串
:param _kwargs: 为缓存接口兼容保留,文件后端不处理这些选项
"""
cache_region = ttl if isinstance(ttl, str) else (region or DEFAULT_CACHE_REGION)
cache_path = AsyncPath(self.base) / cache_region / key
await cache_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.tempfile.NamedTemporaryFile(
dir=cache_path.parent,
delete=False,
) as tmp_file:
await tmp_file.write(value)
temp_path = AsyncPath(tmp_file.name)
await temp_path.replace(cache_path)
async def exists( # type: ignore[override]
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> bool:
"""
检查缓存路径是否存在,不区分文件与目录。
:param key: 区域内的相对缓存路径
:param region: 根目录下的缓存区域名称,使用时须为字符串
:return: 路径存在时返回 True
"""
cache_region = region or DEFAULT_CACHE_REGION
return bool(await (AsyncPath(self.base) / cache_region / key).exists())
async def get(
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> Optional[Any]:
"""
读取缓存文件的完整二进制内容。
:param key: 区域内的相对缓存文件路径
:param region: 根目录下的缓存区域名称,使用时须为字符串
:return: 文件内容;检查时目标不存在则返回 None
"""
cache_region = region or DEFAULT_CACHE_REGION
cache_path = AsyncPath(self.base) / cache_region / key
if not await cache_path.exists():
return None
async with aiofiles.open(cache_path, "rb") as file_handle:
return await file_handle.read()
async def delete( # type: ignore[override]
self,
key: str,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> None:
"""
删除指定缓存文件或递归清理缓存子目录。
:param key: 区域内的相对缓存路径;不存在时不操作
:param region: 根目录下的缓存区域名称,使用时须为字符串
"""
cache_region = region or DEFAULT_CACHE_REGION
cache_path = AsyncPath(self.base) / cache_region / key
if await cache_path.is_file():
await cache_path.unlink()
elif await cache_path.exists():
await aioshutil.rmtree(cache_path, ignore_errors=True)
async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: # type: ignore[override]
"""
清理缓存目录的内容,保留区域目录本身。
:param region: 要清空的区域;为空时清空整个缓存根目录
"""
cache_path = AsyncPath(self.base) / region if region else AsyncPath(self.base)
if not await cache_path.exists():
return
async for item in cache_path.iterdir():
if await item.is_file():
await item.unlink()
else:
await aioshutil.rmtree(item, ignore_errors=True)
async def items( # type: ignore[override]
self,
region: Optional[str] = DEFAULT_CACHE_REGION,
) -> AsyncGenerator[Tuple[str, Any], None]:
"""
递归读取区域中的缓存文件,不提供并发修改时的一致性快照。
:param region: 要遍历的区域名称,使用时须为字符串
:yields: 相对于区域目录的路径与完整文件字节组成的二元组
"""
cache_region = region or DEFAULT_CACHE_REGION
cache_path = AsyncPath(self.base) / cache_region
if not await cache_path.exists():
return
async for item in cache_path.rglob("*"):
if await item.is_file():
key = Path(str(item)).relative_to(Path(str(cache_path))).as_posix()
async with aiofiles.open(item, "rb") as file_handle:
yield key, await file_handle.read()
async def close(self) -> None: # type: ignore[override]
"""
异步文件缓存没有需要关闭的持久连接。
"""
def configure_platform_cache(
*,
backend_type_provider: Callable[[], str],
redis_factory: Optional[Callable[[Optional[int]], AtomicCacheBackend]] = None,
async_redis_factory: Optional[Callable[[Optional[int]], AsyncCacheBackend]] = None,
redis_probe: Optional[Callable[[], bool]] = None,
) -> None:
"""
把文件后端与启动层选定的 Redis 后端注册到运行时缓存工厂。
文件缓存的默认根目录与清理周期从运行时设置读取;本函数只注册工厂,不连接 Redis。
:param backend_type_provider: 返回当前缓存后端类型的配置回调
:param redis_factory: 同步 Redis 后端工厂,接收可选默认有效期(秒)
:param async_redis_factory: 异步 Redis 后端工厂,接收可选默认有效期(秒)
:param redis_probe: Redis 连通性探测回调;未装配 Redis 时可省略
"""
configure_cache_factories(
backend_type_provider=backend_type_provider,
redis_factory=redis_factory,
async_redis_factory=async_redis_factory,
redis_probe=redis_probe,
file_factory=lambda base: FileBackend(
base=base or get_runtime_setting('TEMP_PATH')
),
async_file_factory=lambda base: AsyncFileBackend(
base=base or get_runtime_setting('TEMP_PATH')
),
file_ttl_provider=lambda: get_runtime_setting('TEMP_FILE_DAYS') * 24 * 3600,
)