Files
MoviePilot/app/db/models/transferhistory.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

648 lines
24 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 re
import time
from pathlib import Path
from typing import List, Optional
from sqlalchemy import Boolean, Column, Index, Integer, JSON, String, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db import db_query, db_update, get_id_column, Base, async_db_query
from app.db.models.media_identity import media_identity_constraint
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType
def _text_like(column, pattern: str, wildcard: bool = False):
"""构造跨数据库大小写不敏感的文本匹配条件。"""
if wildcard:
return column.ilike(pattern, escape='\\')
return column.ilike(pattern)
class TransferHistory(Base):
"""
整理记录
"""
id = get_id_column()
# 源路径
src = Column(String, index=True)
# 源存储
src_storage = Column(String, nullable=False, default="local")
# 源文件项
src_fileitem = Column(JSON, default=dict)
# 目标路径
dest = Column(String)
# 目标存储
dest_storage = Column(String)
# 目标文件项
dest_fileitem = Column(JSON, default=dict)
# 转移模式 move/copy/link...
mode = Column(String)
# 类型 电影/电视剧
type = Column(String)
# 二级分类
category = Column(String)
# 标题
title = Column(String, index=True)
# 年份
year = Column(String)
# 媒体数据源与原生ID
media_source = Column(String, index=True)
media_id = Column(String, index=True)
# 音乐实体类型recording 单曲、album 专辑
music_type = Column(String)
# 专辑预期总曲目数
total_tracks = Column(Integer)
# 实际音频格式
audio_format = Column(String)
# 是否无损音频
audio_lossless = Column(Boolean)
# 实际位深bit
bit_depth = Column(Integer)
# 实际采样率Hz
sample_rate = Column(Integer)
# 实际码率bps
bitrate = Column(Integer)
# Sxx
seasons = Column(String)
# Exx
episodes = Column(String)
# 海报
image = Column(String)
# 下载器
downloader = Column(String)
# 下载器hash
download_hash = Column(String, index=True)
# 转移成功状态
status = Column(Boolean(), default=True)
# 转移失败信息
errmsg = Column(String)
# 时间
date = Column(String)
# 文件清单以JSON存储
files = Column(JSON, default=list)
# 剧集组
episode_group = Column(String)
__table_args__ = (
media_identity_constraint("transferhistory"),
Index('ix_transferhistory_status_date', 'status', 'date'),
Index('ix_transferhistory_date_id', 'date', 'id'),
Index('ix_transferhistory_media_identity', 'media_source', 'media_id'),
Index('ux_transferhistory_src_storage', 'src', 'src_storage', unique=True),
)
@classmethod
@db_query
def list_by_title(cls, db: Session, title: str, page: Optional[int] = 1, count: Optional[int] = 30,
status: bool = None, wildcard: bool = False):
if wildcard:
text_filter = or_(
_text_like(cls.title, title, wildcard=True),
_text_like(cls.src, title, wildcard=True),
_text_like(cls.dest, title, wildcard=True),
)
else:
text_filter = or_(
_text_like(cls.title, f'%{title}%'),
_text_like(cls.src, f'%{title}%'),
_text_like(cls.dest, f'%{title}%'),
)
query = db.query(cls).filter(text_filter)
if status is not None:
query = query.filter(cls.status == status)
query = query.order_by(cls.date.desc())
# 当count为负数时不限制页数查询所有
if count >= 0:
query = query.offset((page - 1) * count).limit(count)
return query.all()
@classmethod
@async_db_query
async def async_list_by_title(cls, db: AsyncSession, title: str, page: Optional[int] = 1, count: Optional[int] = 30,
status: bool = None, wildcard: bool = False):
if wildcard:
text_filter = or_(
_text_like(cls.title, title, wildcard=True),
_text_like(cls.src, title, wildcard=True),
_text_like(cls.dest, title, wildcard=True),
)
else:
text_filter = or_(
_text_like(cls.title, f'%{title}%'),
_text_like(cls.src, f'%{title}%'),
_text_like(cls.dest, f'%{title}%'),
)
query = select(cls).filter(text_filter)
if status is not None:
query = query.filter(cls.status == status)
query = query.order_by(cls.date.desc())
# 当count为负数时不限制页数查询所有
if count >= 0:
query = query.offset((page - 1) * count).limit(count)
result = await db.execute(query)
return result.scalars().all()
@classmethod
@db_query
def list_by_page(cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30, status: bool = None):
if status is not None:
query = db.query(cls).filter(
cls.status == status
).order_by(
cls.date.desc()
)
else:
query = db.query(cls).order_by(
cls.date.desc()
)
# 当count为负数时不限制页数查询所有
if count >= 0:
query = query.offset((page - 1) * count).limit(count)
return query.all()
@classmethod
@async_db_query
async def async_list_by_page(cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30,
status: bool = None):
if status is not None:
query = select(cls).filter(
cls.status == status
).order_by(
cls.date.desc()
)
else:
query = select(cls).order_by(
cls.date.desc()
)
# 当count为负数时不限制页数查询所有
if count >= 0:
query = query.offset((page - 1) * count).limit(count)
result = await db.execute(query)
return result.scalars().all()
@classmethod
@db_query
def get_by_hash(cls, db: Session, download_hash: str):
return db.query(cls).filter(cls.download_hash == download_hash).first()
@classmethod
@db_query
def get_by_src(
cls, db: Session, src: str, storage: Optional[str] = None
) -> Optional["TransferHistory"]:
"""
按源路径和存储查询单条整理记录。
:param db: 数据库会话
:param src: 源路径
:param storage: 源存储类型
:return: 命中的整理记录,未命中时返回 None
"""
if storage:
query = db.query(cls).filter(cls.src == src, cls.src_storage == storage)
else:
query = db.query(cls).filter(cls.src == src)
return query.order_by(cls.id.desc()).first()
@classmethod
@db_query
def get_success_by_src(
cls, db: Session, src: str, storage: Optional[str] = None
) -> Optional["TransferHistory"]:
"""
按源路径和存储查询成功的整理记录,源路径原样精确匹配。
与 list_success_by_src 不同,这里不对源路径做归一化,蓝光原盘目录记录
带尾斜杠,归一化后反而匹配不到。
:param db: 数据库会话
:param src: 源路径
:param storage: 源存储类型
:return: 命中的成功整理记录,未命中时返回 None
"""
query = db.query(cls).filter(cls.src == src, cls.status.is_(True))
if storage:
query = query.filter(cls.src_storage == storage)
return query.order_by(cls.id.desc()).first()
@classmethod
@db_query
def get_by_dest(
cls, db: Session, dest: str, storage: Optional[str] = None
) -> Optional["TransferHistory"]:
"""
按目标路径和存储查询单条整理记录。
:param db: 数据库会话
:param dest: 目标路径
:param storage: 目标存储类型
:return: 命中的整理记录,未命中时返回 None
"""
query = db.query(cls).filter(cls.dest == dest)
if storage:
query = query.filter(cls.dest_storage == storage)
return query.order_by(cls.id.desc()).first()
@classmethod
@db_query
def list_success_by_src(
cls,
db: Session,
src: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> List["TransferHistory"]:
"""
按源路径查询成功整理记录,目录模式仅匹配其直接或间接子项。
:param db: 数据库会话
:param src: 源路径
:param storage: 源存储类型
:param recursive: 是否递归匹配目录子项
:return: 命中的成功整理记录
"""
normalized_src = (
Path(str(src).replace("\\", "/")).as_posix().rstrip("/") or "/"
)
query = db.query(cls).filter(cls.status.is_(True))
if recursive:
escaped_src = (
normalized_src.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
query = query.filter(
or_(
cls.src == normalized_src,
cls.src.like(f"{escaped_src.rstrip('/')}/%", escape="\\"),
)
)
else:
query = query.filter(cls.src == normalized_src)
if storage:
query = query.filter(cls.src_storage == storage)
return query.all()
@classmethod
@db_query
def list_success_move_by_dest(
cls,
db: Session,
dest: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> List["TransferHistory"]:
"""
按目标路径查询成功移动记录,供从媒体库现址发起重新整理时识别历史。
:param db: 数据库会话
:param dest: 目标路径
:param storage: 目标存储类型
:param recursive: 是否递归匹配目录子项
:return: 命中的成功移动记录
"""
normalized_dest = (
Path(str(dest).replace("\\", "/")).as_posix().rstrip("/") or "/"
)
query = db.query(cls).filter(
cls.status.is_(True),
cls.mode.contains("move"),
)
if recursive:
escaped_dest = (
normalized_dest.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
query = query.filter(
or_(
cls.dest == normalized_dest,
cls.dest.like(f"{escaped_dest.rstrip('/')}/%", escape="\\"),
)
)
else:
query = query.filter(cls.dest == normalized_dest)
if storage:
query = query.filter(cls.dest_storage == storage)
return query.all()
@classmethod
@db_query
def list_by_hash(cls, db: Session, download_hash: str):
return db.query(cls).filter(cls.download_hash == download_hash).all()
@classmethod
@db_query
def statistic(cls, db: Session, days: Optional[int] = 7):
"""
统计最近days天的下载历史数量按日期分组返回每日数量
"""
sub_query = db.query(func.substr(cls.date, 1, 10).label('date'),
cls.id.label('id')).filter(
cls.date >= time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - 86400 * days))).subquery()
return db.query(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date).all()
@classmethod
@db_query
def monthly_media_statistics(cls, db: Session):
"""
统计当月成功整理的电影、电视剧、剧集和音乐数量。
电影和电视剧按媒体身份去重;剧集优先按历史记录中的集数字段计算,
缺少集数时按单条成功整理记录计数;音乐按曲目身份去重,整专记录不能只按专辑 ID 合并。
"""
month_prefix = time.strftime("%Y-%m-", time.localtime())
histories = db.query(cls).filter(
cls.status.is_(True),
cls.date.like(f"{month_prefix}%"),
cls.type.in_([MediaType.MOVIE.value, MediaType.TV.value, MediaType.MUSIC.value]),
).all()
movie_identities = set()
tv_identities = set()
episode_count = 0
music_identities = set()
for history in histories:
if history.type == MediaType.MUSIC.value:
music_identities.add(cls._music_history_identity(history))
continue
identity = (
history.media_source or "",
history.media_id or "",
history.title or "",
history.year or "",
)
if history.type == MediaType.MOVIE.value:
movie_identities.add(identity)
continue
tv_identities.add(identity)
episode_count += cls._history_episode_count(history)
return len(movie_identities), len(tv_identities), episode_count, len(music_identities)
@staticmethod
def _music_history_identity(history: "TransferHistory") -> tuple:
"""构造曲目级历史身份,避免整专内全部曲目被同一专辑 ID 合并。"""
source = str(history.media_source or "").strip().casefold()
media_id = str(history.media_id or "").strip()
music_type = str(history.music_type or MUSIC_ENTITY_RECORDING).strip().casefold()
path_identity = str(history.dest or history.src or "").replace("\\", "/").casefold()
if music_type == MUSIC_ENTITY_ALBUM:
return source, media_id, music_type, path_identity or history.title or history.id
if media_id:
return source, media_id, music_type
return source, music_type, path_identity or history.title or history.id
@staticmethod
def _history_episode_count(history: "TransferHistory") -> int:
"""从单条整理历史中估算成功入库的剧集数量。"""
episode_numbers = [int(value) for value in re.findall(r"\d+", history.episodes or "")]
if len(episode_numbers) >= 2 and "-" in (history.episodes or ""):
return max(1, episode_numbers[-1] - episode_numbers[0] + 1)
if episode_numbers:
return len(set(episode_numbers))
if isinstance(history.files, list) and history.files:
return len(history.files)
return 1
@classmethod
@async_db_query
async def async_statistic(cls, db: AsyncSession, days: Optional[int] = 7):
"""
统计最近days天的下载历史数量按日期分组返回每日数量
"""
sub_query = select(func.substr(cls.date, 1, 10).label('date'),
cls.id.label('id')).filter(
cls.date >= time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - 86400 * days))).subquery()
result = await db.execute(
select(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date)
)
return result.all()
@classmethod
@db_query
def count(cls, db: Session, status: bool = None):
if status is not None:
return db.query(func.count(cls.id)).filter(cls.status == status).first()[0]
else:
return db.query(func.count(cls.id)).first()[0]
@classmethod
@async_db_query
async def async_count(cls, db: AsyncSession, status: bool = None):
if status is not None:
result = await db.execute(
select(func.count(cls.id)).filter(cls.status == status)
)
else:
result = await db.execute(
select(func.count(cls.id))
)
return result.scalar()
@classmethod
@db_query
def count_by_title(cls, db: Session, title: str, status: bool = None, wildcard: bool = False):
if wildcard:
text_filter = or_(
_text_like(cls.title, title, wildcard=True),
_text_like(cls.src, title, wildcard=True),
_text_like(cls.dest, title, wildcard=True),
)
else:
text_filter = or_(
_text_like(cls.title, f'%{title}%'),
_text_like(cls.src, f'%{title}%'),
_text_like(cls.dest, f'%{title}%'),
)
query = db.query(func.count(cls.id)).filter(text_filter)
if status is not None:
query = query.filter(cls.status == status)
return query.first()[0]
@classmethod
@async_db_query
async def async_count_by_title(cls, db: AsyncSession, title: str, status: bool = None, wildcard: bool = False):
if wildcard:
text_filter = or_(
_text_like(cls.title, title, wildcard=True),
_text_like(cls.src, title, wildcard=True),
_text_like(cls.dest, title, wildcard=True),
)
else:
text_filter = or_(
_text_like(cls.title, f'%{title}%'),
_text_like(cls.src, f'%{title}%'),
_text_like(cls.dest, f'%{title}%'),
)
stmt = select(func.count(cls.id)).filter(text_filter)
if status is not None:
stmt = stmt.filter(cls.status == status)
result = await db.execute(stmt)
return result.scalar()
@classmethod
@db_query
def list_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: 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):
"""
按媒体身份、季集或标题年份查询整理记录。
"""
if media_source and media_id and mtype:
# 电视剧某季某集
if season is not None and episode:
return db.query(cls).filter(cls.media_source == str(media_source),
cls.media_id == str(media_id),
cls.type == mtype,
cls.seasons == season,
cls.episodes == episode,
cls.dest == dest).all()
# 电视剧某季
elif season is not None:
return db.query(cls).filter(cls.media_source == str(media_source),
cls.media_id == str(media_id),
cls.type == mtype,
cls.seasons == season).all()
else:
if dest:
# 电影
return db.query(cls).filter(cls.media_source == str(media_source),
cls.media_id == str(media_id),
cls.type == mtype,
cls.dest == dest).all()
else:
# 电视剧所有季集
return db.query(cls).filter(cls.media_source == str(media_source),
cls.media_id == str(media_id),
cls.type == mtype).all()
# 标题 + 年份
elif title and year:
# 电视剧某季某集
if season is not None and episode:
return db.query(cls).filter(cls.title == title,
cls.year == year,
cls.seasons == season,
cls.episodes == episode,
cls.dest == dest).all()
# 电视剧某季
elif season is not None:
return db.query(cls).filter(cls.title == title,
cls.year == year,
cls.seasons == season).all()
else:
if dest:
# 电影
return db.query(cls).filter(cls.title == title,
cls.year == year,
cls.dest == dest).all()
else:
# 电视剧所有季集
return db.query(cls).filter(cls.title == title,
cls.year == year).all()
# 类型 + 转移路径(媒体服务器 webhook 缺少远端身份场景)
elif mtype and season is not None and dest:
# 电视剧某季
return db.query(cls).filter(cls.type == mtype,
cls.seasons == season,
cls.dest.like(f"{dest}%")).all()
return []
@classmethod
@db_query
def get_by_media_identity(
cls, db: Session, media_source: MediaSource, media_id: str,
mtype: Optional[str] = None,
):
"""按规范媒体身份和类型查询整理记录。"""
return db.query(cls).filter(
cls.media_source == str(media_source),
cls.media_id == str(media_id),
cls.type == mtype,
).first()
@classmethod
@db_update
def update_download_hash(cls, db: Session, historyid: Optional[int] = None, download_hash: Optional[str] = None):
db.query(cls).filter(cls.id == historyid).update(
{
"download_hash": download_hash
}
)
@classmethod
@db_update
def replace_by_src(cls, db: Session, **kwargs) -> "TransferHistory":
"""
用同源存储的新记录原子替换旧整理历史。
同一源路径在一个存储中只能对应一条最新整理记录。先在同一事务内清理旧行再
插入,避免旧的“查询一条再删除一条”在遗留重复数据下留下脏记录。
:param db: 数据库会话
:param kwargs: 整理历史字段
:return: 新创建的整理历史
"""
src = kwargs.get("src")
src_storage = kwargs.get("src_storage") or "local"
kwargs["src_storage"] = src_storage
if src:
db.query(cls).filter(
cls.src == src,
cls.src_storage == src_storage,
).delete(synchronize_session=False)
history = cls(**kwargs)
db.add(history)
db.flush()
return history
@classmethod
@db_query
def list_by_date(cls, db: Session, date: str):
"""
查询某时间之后的转移历史
"""
return db.query(cls).filter(cls.date > date).order_by(cls.id.desc()).all()
@classmethod
@db_update
def delete_before(
cls,
db: Session,
before_time: str,
limit: Optional[int] = 500,
) -> int:
"""
分批删除指定时间之前的整理历史。
"""
ids = [
row[0]
for row in db.query(cls.id)
.filter(cls.date < before_time)
.order_by(cls.id.asc())
.limit(limit)
.all()
]
if not ids:
return 0
return (
db.query(cls)
.filter(cls.id.in_(ids))
.delete(synchronize_session=False)
)