mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
447 lines
16 KiB
Python
447 lines
16 KiB
Python
import re
|
|
import time
|
|
from typing import 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.schemas.types import 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)
|
|
# 源文件项
|
|
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)
|
|
tmdbid = Column(Integer, index=True)
|
|
imdbid = Column(String)
|
|
tvdbid = Column(Integer)
|
|
doubanid = Column(String)
|
|
# 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__ = (
|
|
Index('ix_transferhistory_status_date', 'status', 'date'),
|
|
Index('ix_transferhistory_date_id', 'date', 'id'),
|
|
)
|
|
|
|
@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):
|
|
if storage:
|
|
return db.query(cls).filter(cls.src == src,
|
|
cls.src_storage == storage).first()
|
|
else:
|
|
return db.query(cls).filter(cls.src == src).first()
|
|
|
|
@classmethod
|
|
@db_query
|
|
def get_by_dest(cls, db: Session, dest: str):
|
|
return db.query(cls).filter(cls.dest == dest).first()
|
|
|
|
@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):
|
|
"""
|
|
统计当月成功整理的电影、电视剧和剧集数量。
|
|
|
|
电影和电视剧按媒体身份去重;剧集优先按历史记录中的集数字段计算,
|
|
缺少集数时按单条成功整理记录计数。
|
|
"""
|
|
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]),
|
|
).all()
|
|
movie_identities = set()
|
|
tv_identities = set()
|
|
episode_count = 0
|
|
|
|
for history in histories:
|
|
identity = (history.tmdbid or 0, 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
|
|
|
|
@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, tmdbid: Optional[int] = None, dest: Optional[str] = None):
|
|
"""
|
|
据tmdbid、season、season_episode查询转移记录
|
|
tmdbid + mtype 或 title + year 必输
|
|
"""
|
|
# TMDBID + 类型
|
|
if tmdbid and mtype:
|
|
# 电视剧某季某集
|
|
if season is not None and episode:
|
|
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
|
cls.type == mtype,
|
|
cls.seasons == season,
|
|
cls.episodes == episode,
|
|
cls.dest == dest).all()
|
|
# 电视剧某季
|
|
elif season is not None:
|
|
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
|
cls.type == mtype,
|
|
cls.seasons == season).all()
|
|
else:
|
|
if dest:
|
|
# 电影
|
|
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
|
cls.type == mtype,
|
|
cls.dest == dest).all()
|
|
else:
|
|
# 电视剧所有季集
|
|
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
|
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()
|
|
# 类型 + 转移路径(emby webhook season无tmdbid场景)
|
|
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_type_tmdbid(cls, db: Session, mtype: Optional[str] = None, tmdbid: Optional[int] = None):
|
|
"""
|
|
据tmdbid、type查询转移记录
|
|
"""
|
|
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
|
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_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)
|
|
)
|