mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 21:07:01 +08:00
* fix: retain plugin lifecycle ownership and recover installation state * test: cover plugin rollback and uninstall lifecycle boundaries --------- Co-authored-by: jxxghp <jxxghp@gmail.com>
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""
|
||
文件缓存后端及平台缓存工厂装配,不在本模块实现内存缓存策略。
|
||
"""
|
||
|
||
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,
|
||
)
|