Files
MoviePilot/app/db/models/downloadfailure.py
T

138 lines
4.5 KiB
Python

from typing import List, Optional
from sqlalchemy import Float, Index, Integer, String, delete, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, execute_dml, get_id_column
from app.db.models._constraints import media_identity_constraint
class DownloadFailure(Base):
"""
下载失败冷却记录。
"""
id = get_id_column()
# 资源失败指纹
fingerprint: Mapped[str] = mapped_column(String, nullable=False)
# 类型 电影/电视剧
type: Mapped[Optional[str]] = mapped_column(String)
# 标题
title: Mapped[Optional[str]] = mapped_column(String)
# 年份
year: Mapped[Optional[str]] = mapped_column(String)
# 媒体数据源与原生ID
media_source: Mapped[Optional[str]] = mapped_column(String)
media_id: Mapped[Optional[str]] = mapped_column(String)
# Sxx
seasons: Mapped[Optional[str]] = mapped_column(String)
# Exx
episodes: Mapped[Optional[str]] = mapped_column(String)
# 站点ID
site: Mapped[Optional[int]] = mapped_column(Integer)
# 站点名称
site_name: Mapped[Optional[str]] = mapped_column(String)
# 种子资源键
torrent_id: Mapped[Optional[str]] = mapped_column(String)
# 种子名称
torrent_name: Mapped[Optional[str]] = mapped_column(String)
# 种子大小
torrent_size: Mapped[Optional[float]] = mapped_column(Float)
# 下载器
downloader: Mapped[Optional[str]] = mapped_column(String)
# 下载来源
source: Mapped[Optional[str]] = mapped_column(String)
# 失败原因
error_message: Mapped[Optional[str]] = mapped_column(String)
# 重试次数
retry_count: Mapped[Optional[int]] = mapped_column(Integer, default=0)
# 首次失败时间
first_failed_at: Mapped[Optional[str]] = mapped_column(String)
# 最近失败时间
last_failed_at: Mapped[Optional[str]] = mapped_column(String)
# 下次允许重试时间
next_retry_at: Mapped[Optional[str]] = mapped_column(String)
__table_args__ = (
media_identity_constraint("downloadfailure"),
Index("ux_downloadfailure_fingerprint", "fingerprint", unique=True),
Index("ix_downloadfailure_next_retry_at", "next_retry_at"),
Index("ix_downloadfailure_media_identity_site", "type", "media_source", "media_id", "site"),
)
@classmethod
def get_active_by_fingerprints(
cls,
db: Session,
fingerprints: List[str],
now_time: str,
) -> List["DownloadFailure"]:
"""
按指纹批量查询仍处于冷却期的失败记录。
"""
normalized = list(dict.fromkeys([fingerprint for fingerprint in fingerprints if fingerprint]))
if not normalized:
return []
return list(db.execute(
select(cls)
.where(cls.fingerprint.in_(normalized), cls.next_retry_at > now_time)
).scalars().all())
@classmethod
def record_failure(
cls,
db: Session,
fingerprint: str,
now_time: str,
next_retry_at: str,
**kwargs: object,
) -> "DownloadFailure":
"""
新增或更新资源失败记录。
"""
failure = db.execute(
select(cls).where(cls.fingerprint == fingerprint)
).scalars().first()
payload = {
**kwargs,
"fingerprint": fingerprint,
"last_failed_at": now_time,
"next_retry_at": next_retry_at,
}
if failure:
payload["retry_count"] = (failure.retry_count or 0) + 1
for key, value in payload.items():
setattr(failure, key, value)
return failure
failure = cls(
**payload,
retry_count=1,
first_failed_at=now_time,
)
db.add(failure)
return failure
@classmethod
def delete_expired(
cls,
db: Session,
before_time: str,
limit: Optional[int] = 500,
) -> int:
"""
分批清理已过期较久的失败冷却记录。
"""
ids = db.execute(
select(cls.id)
.where(cls.next_retry_at < before_time)
.order_by(cls.id.asc())
.limit(limit)
).scalars().all()
if not ids:
return 0
return execute_dml(
db, delete(cls).where(cls.id.in_(ids)),
execution_options={"synchronize_session": False},
)