Files
MoviePilot/app/db/transferhistory_oper.py
Aqr-K a2e70b443d fix(monitor,transfer): 修复 FUSE 挂载无响应导致的监控冻死、整理链锁死与漏件 (#6276)
* wip(v3): 移植监控与整理韧性修复到 v3 基线

包含:监控看门狗隔离/挂载探测、整理队列持久化、文件系统子进程代理、
写入原子化。迁移重挂到 v3 链 8a4c7e1d2f90 -> 7f5c1d2e3a4b -> e3d9f4b7c806。
tmdb 相关测试尚未通过,待定位。

* fix(v3): 修正移植引入的 16 项测试失败

- poller.py:合并时我方保留的行仍用旧变量名 merged_snapshot,而 v3 已统一
  改名为 current_snapshot,导致 NameError 被外层 except 吞掉、快照从未保存
- smb.py:采纳 f-string 拆分写法,恢复 Python 3.11 可解析
- dispatcher 测试:历史查重由 _should_skip_by_history 统一承担,mock 点随之调整
- tmdb 缓存测试:补充 v3 新增的 media_source/media_id 字段
- tmdb 重试测试:为 fake 补充 match_multi/async_match_multi

尚余 3 项与 v3 识别流程的连接失败处理有关,待单独判断。

* fix(v3): 测试适配 v3 的 media_source/media_id 重构

v3 将媒体标识从 tmdbid 统一重构为 media_source + media_id,recognize_media
的 tmdbid 参数已被 **kwargs 静默吞掉——传了也不生效,流程会误降级到名称搜索。
tmdb 重试用例改用新参数后恢复正确路径。

同时修正 fake 的 match_multi 语义:真实实现(tmdbapi.match_multi)吞掉所有
异常并返回 None,连接失败与「未找到」在该路径上本就不可区分,fake 需保持一致。

至此移植引入的 19 项失败全部清零。

---------

Co-authored-by: Aqr-K <Aqr-K@users.noreply.github.com>
2026-08-13 08:19:54 +08:00

374 lines
14 KiB
Python
Raw 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 time
from typing import Any, List, Optional
from app.core.context import MediaInfo
from app.core.meta import MetaBase, MetaMusic
from app.db import DbOper
from app.db.models.transferhistory import TransferHistory
from app.schemas import TransferInfo, FileItem
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
from app.utils.media import normalize_media_identity_payload, resolve_media_identity
class TransferHistoryOper(DbOper):
"""
转移历史管理
"""
def get(self, historyid: int) -> TransferHistory:
"""
获取转移历史
:param historyid: 转移历史id
"""
return TransferHistory.get(self._db, historyid)
async def async_get(self, historyid: int) -> TransferHistory:
"""
异步获取转移历史。
"""
return await TransferHistory.async_get(self._db, historyid)
async def async_list_by_title(
self,
title: str,
page: Optional[int] = 1,
count: Optional[int] = 30,
status: Optional[bool] = None,
) -> List[TransferHistory]:
"""
异步按标题分页查询转移记录。
"""
return await TransferHistory.async_list_by_title(
self._db, title=title, page=page, count=count, status=status
)
async def async_list_by_page(
self,
page: Optional[int] = 1,
count: Optional[int] = 30,
status: Optional[bool] = None,
) -> List[TransferHistory]:
"""
异步分页查询转移记录。
"""
return await TransferHistory.async_list_by_page(
self._db, page=page, count=count, status=status
)
async def async_count(self, status: Optional[bool] = None) -> int:
"""
异步统计转移记录数量。
"""
return await TransferHistory.async_count(self._db, status=status)
async def async_count_by_title(
self,
title: str,
status: Optional[bool] = None,
) -> int:
"""
异步按标题统计转移记录数量。
"""
return await TransferHistory.async_count_by_title(
self._db, title=title, status=status
)
def get_by_title(self, title: str) -> List[TransferHistory]:
"""
按标题查询转移记录
:param title: 数据key
"""
return TransferHistory.list_by_title(self._db, title)
def get_by_src(
self, src: str, storage: Optional[str] = None
) -> Optional[TransferHistory]:
"""
按源查询转移记录
:param src: 数据key
:param storage: 存储类型
:return: 命中的整理记录,未命中时返回 None
"""
return TransferHistory.get_by_src(self._db, src, storage)
def get_success_by_src(
self, src: str, storage: Optional[str] = None
) -> Optional[TransferHistory]:
"""
按源查询成功的转移记录,源路径原样精确匹配
:param src: 数据key
:param storage: 存储类型
:return: 命中的成功整理记录,未命中时返回 None
"""
return TransferHistory.get_success_by_src(self._db, src, storage)
def get_by_dest(
self, dest: str, storage: Optional[str] = None
) -> Optional[TransferHistory]:
"""
按转移路径查询转移记录
:param dest: 数据key
:param storage: 存储类型
"""
return TransferHistory.get_by_dest(self._db, dest, storage)
def list_success_by_src(
self,
src: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> List[TransferHistory]:
"""
按源路径查询成功整理记录。
:param src: 源路径
:param storage: 源存储类型
:param recursive: 是否递归匹配目录子项
:return: 命中的成功整理记录
"""
return TransferHistory.list_success_by_src(
self._db,
src=src,
storage=storage,
recursive=recursive,
)
def list_success_move_by_dest(
self,
dest: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> List[TransferHistory]:
"""
按目标路径查询成功移动记录。
:param dest: 目标路径
:param storage: 目标存储类型
:param recursive: 是否递归匹配目录子项
:return: 命中的成功移动记录
"""
return TransferHistory.list_success_move_by_dest(
self._db,
dest=dest,
storage=storage,
recursive=recursive,
)
def list_by_hash(self, download_hash: str) -> List[TransferHistory]:
"""
按种子hash查询转移记录
:param download_hash: 种子hash
"""
return TransferHistory.list_by_hash(self._db, download_hash)
def add(self, **kwargs):
"""
新增转移历史
"""
kwargs = normalize_media_identity_payload(kwargs)
kwargs.update({
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
})
TransferHistory(**kwargs).create(self._db)
def statistic(self, days: Optional[int] = 7) -> List[Any]:
"""
统计最近days天的下载历史数量
"""
return TransferHistory.statistic(self._db, days)
def get_by(self, title: Optional[str] = None, year: Optional[str] = None, mtype: Optional[str] = None,
season: Optional[str] = None, episode: Optional[str] = None,
media_source: Optional[MediaSource] = None, media_id: Optional[str] = None,
dest: Optional[str] = None) -> List[TransferHistory]:
"""
按类型、标题、年份、季集查询转移记录
"""
return TransferHistory.list_by(db=self._db,
mtype=mtype,
title=title,
dest=dest,
year=year,
season=season,
episode=episode,
media_source=media_source,
media_id=media_id)
def get_by_media_identity(
self, media_source: MediaSource, media_id: str,
mtype: Optional[str] = None,
) -> TransferHistory:
"""按规范媒体身份和类型查询整理记录。"""
return TransferHistory.get_by_media_identity(
db=self._db,
media_source=media_source,
media_id=media_id,
mtype=mtype,
)
def delete(self, historyid):
"""
删除转移记录
"""
TransferHistory.delete(self._db, historyid)
async def async_delete(self, historyid):
"""
异步删除转移记录。
"""
await TransferHistory.async_delete(self._db, historyid)
def truncate(self):
"""
清空转移记录
"""
TransferHistory.truncate(self._db)
def add_force(self, **kwargs) -> TransferHistory:
"""
新增转移历史,并以同源存储的记录为准替换旧记录。
"""
kwargs = normalize_media_identity_payload(kwargs)
# 文件项的默认存储是 local归一化旧调用传入的 None确保运行时语义与
# (src, src_storage) 唯一索引一致。
kwargs["src_storage"] = kwargs.get("src_storage") or "local"
# 旧记录的清理交给 replace_by_src 按 (src, src_storage) 处理:
# 仅按 src 删除会连带删掉其他存储下同路径的记录。
kwargs.update({
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
})
TransferHistory.replace_by_src(self._db, **kwargs)
# 保持 add_force 的既有返回契约:返回可被调用方安全读取字段的查询结果,
# 而非事务提交后可能已脱离会话的新建实例。
return TransferHistory.get_by_src(
self._db,
kwargs.get("src"),
kwargs["src_storage"],
)
def update_download_hash(self, historyid, download_hash):
"""
补充转移记录download_hash
"""
TransferHistory.update_download_hash(self._db, historyid, download_hash)
@staticmethod
def _history_title(
meta: MetaBase, mediainfo: Optional[MediaInfo] = None
) -> Optional[str]:
"""音乐文件优先记录曲目标题,其它媒体保持识别标题。"""
if isinstance(meta, MetaMusic) and meta.title:
return meta.title
if mediainfo and mediainfo.title:
return mediainfo.title
return meta.name
def add_success(self, fileitem: FileItem, mode: str, meta: MetaBase,
mediainfo: MediaInfo, transferinfo: TransferInfo,
downloader: Optional[str] = None, download_hash: Optional[str] = None):
"""
新增转移成功历史记录
"""
media_source, media_id = resolve_media_identity(media=mediainfo)
return self.add_force(
src=fileitem.path,
src_storage=fileitem.storage,
src_fileitem=fileitem.model_dump(),
dest=transferinfo.target_item.path if transferinfo.target_item else None,
dest_storage=transferinfo.target_item.storage if transferinfo.target_item else None,
dest_fileitem=transferinfo.target_item.model_dump() if transferinfo.target_item else None,
mode=mode,
type=mediainfo.type.value,
category=mediainfo.category,
title=self._history_title(meta, mediainfo),
year=mediainfo.year,
media_source=media_source,
media_id=media_id,
music_type=getattr(mediainfo, "music_type", None),
total_tracks=getattr(mediainfo, "total_tracks", None),
audio_format=getattr(meta, "audio_format", None),
audio_lossless=getattr(meta, "audio_lossless", None),
bit_depth=getattr(meta, "bit_depth", None),
sample_rate=getattr(meta, "sample_rate", None),
bitrate=getattr(meta, "bitrate", None),
seasons=meta.season,
episodes=meta.episode,
image=mediainfo.get_poster_image(),
downloader=downloader,
download_hash=download_hash,
status=1,
files=transferinfo.file_list
)
def add_fail(self, fileitem: FileItem, mode: str, meta: MetaBase, mediainfo: MediaInfo = None,
transferinfo: TransferInfo = None, downloader: Optional[str] = None, download_hash: Optional[str] = None):
"""
新增转移失败历史记录
"""
if mediainfo and transferinfo:
media_source, media_id = resolve_media_identity(media=mediainfo)
his = self.add_force(
src=fileitem.path,
src_storage=fileitem.storage,
src_fileitem=fileitem.model_dump(),
dest=transferinfo.target_item.path if transferinfo.target_item else None,
dest_storage=transferinfo.target_item.storage if transferinfo.target_item else None,
dest_fileitem=transferinfo.target_item.model_dump() if transferinfo.target_item else None,
mode=mode,
type=mediainfo.type.value,
category=mediainfo.category,
title=self._history_title(meta, mediainfo),
year=mediainfo.year or meta.year,
media_source=media_source,
media_id=media_id,
music_type=getattr(mediainfo, "music_type", None),
total_tracks=getattr(mediainfo, "total_tracks", None),
audio_format=getattr(meta, "audio_format", None),
audio_lossless=getattr(meta, "audio_lossless", None),
bit_depth=getattr(meta, "bit_depth", None),
sample_rate=getattr(meta, "sample_rate", None),
bitrate=getattr(meta, "bitrate", None),
seasons=meta.season,
episodes=meta.episode,
image=mediainfo.get_poster_image(),
downloader=downloader,
download_hash=download_hash,
episode_group=mediainfo.episode_group,
status=0,
errmsg=transferinfo.message or '未知错误',
files=transferinfo.file_list
)
else:
media_source, media_id = resolve_media_identity(media=meta)
his = self.add_force(
type=meta.type.value if meta.type else None,
title=self._history_title(meta),
year=meta.year,
media_source=media_source,
media_id=media_id,
music_type=MUSIC_ENTITY_RECORDING if isinstance(meta, MetaMusic) else None,
audio_format=getattr(meta, "audio_format", None),
audio_lossless=getattr(meta, "audio_lossless", None),
bit_depth=getattr(meta, "bit_depth", None),
sample_rate=getattr(meta, "sample_rate", None),
bitrate=getattr(meta, "bitrate", None),
src=fileitem.path,
src_storage=fileitem.storage,
src_fileitem=fileitem.model_dump(),
mode=mode,
seasons=meta.season,
episodes=meta.episode,
downloader=downloader,
download_hash=download_hash,
status=0,
errmsg="未识别到媒体信息"
)
return his
def list_by_date(self, date: str) -> List[TransferHistory]:
"""
查询某时间之后的转移历史
:param date: 日期
"""
return TransferHistory.list_by_date(self._db, date)