Files
wehub-resource-sync 9a6da7d40d
Publish Docker image / Build and publish (push) Failing after 0s
CI / Python tests (push) Failing after 4s
chore: import upstream snapshot with attribution
2026-07-13 12:03:55 +08:00

216 lines
7.0 KiB
Python

"""视频素材缓存的统计、预览和清理服务。"""
from __future__ import annotations
import os
import re
import time
from dataclasses import dataclass
from typing import Iterator
from loguru import logger
from app.utils import utils
# 在线素材使用 URL 的 MD5 作为稳定文件名。缓存管理只接受该命名格式,避免把
# 用户误放到目录中的视频、说明文件或其它业务文件当作缓存删除。
_VIDEO_CACHE_FILE_PATTERN = re.compile(r"^vid-[0-9a-f]{32}\.mp4$")
_SECONDS_PER_DAY = 24 * 60 * 60
@dataclass(frozen=True)
class VideoCacheStats:
"""缓存目录的轻量统计结果,只包含文件系统元数据。"""
file_count: int = 0
total_size: int = 0
oldest_mtime: float | None = None
newest_mtime: float | None = None
@dataclass(frozen=True)
class VideoCacheCleanupResult:
"""一次清理的执行结果,允许部分文件删除失败。"""
deleted_count: int = 0
deleted_size: int = 0
failed_count: int = 0
@dataclass(frozen=True)
class _VideoCacheEntry:
"""扫描阶段保存的最小文件信息,避免清理时打开或解析视频。"""
path: str
name: str
size: int
mtime: float
def video_cache_dir() -> str:
"""返回项目管理的默认视频缓存目录。"""
return os.path.realpath(utils.storage_dir("cache_videos"))
def _iter_video_cache_entries() -> Iterator[_VideoCacheEntry]:
"""
顺序扫描默认缓存目录第一层。
使用 ``os.scandir`` 是为了在缓存达到数万文件时复用目录遍历返回的元数据,
避免 ``Path.iterdir`` 后再次查询文件类型。这里不递归、不打开视频,也不调用
FFmpeg,因此耗时主要与文件数量线性相关,而不是与视频总容量相关。
"""
cache_dir = video_cache_dir()
try:
entries = os.scandir(cache_dir)
except FileNotFoundError:
return
except OSError as exc:
logger.warning(
f"failed to scan video cache directory: path={cache_dir}, error={exc}"
)
return
with entries:
for entry in entries:
if not _VIDEO_CACHE_FILE_PATTERN.fullmatch(entry.name):
continue
try:
# 不跟随符号链接,确保清理逻辑不会越过默认缓存目录边界。
if not entry.is_file(follow_symlinks=False):
continue
stat_result = entry.stat(follow_symlinks=False)
except OSError as exc:
logger.warning(
f"failed to inspect video cache file: file={entry.name}, error={exc}"
)
continue
yield _VideoCacheEntry(
path=entry.path,
name=entry.name,
size=stat_result.st_size,
mtime=stat_result.st_mtime,
)
def _is_cleanup_candidate(
entry: _VideoCacheEntry,
max_age_days: int | None,
now: float,
) -> bool:
if max_age_days is None:
return True
return entry.mtime < now - max_age_days * _SECONDS_PER_DAY
def _validate_max_age_days(max_age_days: int | None) -> None:
"""即使缓存目录为空,也应稳定拒绝无效清理参数。"""
if max_age_days is None:
return
if (
isinstance(max_age_days, bool)
or not isinstance(max_age_days, int)
or max_age_days <= 0
):
raise ValueError("max_age_days must be a positive integer or None")
def get_video_cache_stats(max_age_days: int | None = None) -> VideoCacheStats:
"""
统计全部缓存,或预览修改时间早于指定天数的可清理缓存。
``max_age_days=None`` 表示全部缓存。统计过程只读取目录项的大小和修改时间,
不读取视频内容,因此即使缓存总容量很大也不会产生与容量成比例的 I/O。
"""
_validate_max_age_days(max_age_days)
now = time.time()
file_count = 0
total_size = 0
oldest_mtime = None
newest_mtime = None
for entry in _iter_video_cache_entries():
if not _is_cleanup_candidate(entry, max_age_days, now):
continue
file_count += 1
total_size += entry.size
oldest_mtime = (
entry.mtime if oldest_mtime is None else min(oldest_mtime, entry.mtime)
)
newest_mtime = (
entry.mtime if newest_mtime is None else max(newest_mtime, entry.mtime)
)
return VideoCacheStats(
file_count=file_count,
total_size=total_size,
oldest_mtime=oldest_mtime,
newest_mtime=newest_mtime,
)
def clean_video_cache(max_age_days: int | None = None) -> VideoCacheCleanupResult:
"""
清理默认视频缓存,并返回可向用户展示的汇总结果。
页面预览与真正点击清理之间可能间隔较久,所以执行时必须重新扫描和判断,
不能复用旧候选列表。删除采用逐文件容错:单个文件被占用或权限不足时记录
警告并继续,避免几百个文件中一个异常文件导致整次清理失败。
"""
_validate_max_age_days(max_age_days)
now = time.time()
logger.info(
f"start cleaning video cache: max_age_days={max_age_days}"
)
candidate_count = 0
candidate_size = 0
deleted_count = 0
deleted_size = 0
failed_count = 0
cache_dir = video_cache_dir()
# 边扫描边删除,不在内存中保留完整候选列表。即使目录增长到几十万个文件,
# 清理过程的额外内存仍保持常量级;执行时使用统一 now,避免长清理过程中
# 截止时间不断移动而产生不可预测的候选范围。
for entry in _iter_video_cache_entries():
if not _is_cleanup_candidate(entry, max_age_days, now):
continue
candidate_count += 1
candidate_size += entry.size
try:
# entry.path 来自默认目录的第一层 scandir;删除前再次校验父目录和
# 文件名,防止未来修改扫描逻辑时意外扩大可删除范围。
if (
os.path.realpath(os.path.dirname(entry.path)) != cache_dir
or not _VIDEO_CACHE_FILE_PATTERN.fullmatch(entry.name)
or os.path.islink(entry.path)
):
raise ValueError("cache file is outside the managed directory")
os.unlink(entry.path)
deleted_count += 1
deleted_size += entry.size
except (OSError, ValueError) as exc:
failed_count += 1
logger.warning(
f"failed to delete video cache file: file={entry.name}, error={exc}"
)
logger.info(
"finished cleaning video cache: "
f"candidates={candidate_count}, candidate_bytes={candidate_size}, "
f"deleted={deleted_count}, deleted_bytes={deleted_size}, failed={failed_count}"
)
return VideoCacheCleanupResult(
deleted_count=deleted_count,
deleted_size=deleted_size,
failed_count=failed_count,
)