Compare commits

..

15 Commits

Author SHA1 Message Date
jxxghp
48f4bd5f18 更新 version.py 2026-08-05 07:49:58 +08:00
jxxghp
7a2b003d02 fix(media): restore legacy discover detail fallback 2026-08-05 07:27:44 +08:00
jxxghp
a700ba9379 feat(download): persist poster and backdrop images (#6232) 2026-08-03 21:38:26 +08:00
jxxghp
36dd381260 feat(download): expose source site for active tasks (#6231) 2026-08-03 18:40:08 +08:00
jxxghp
f25ee25bb0 fix(indexer): restore YemaPT search page size 2026-08-03 18:10:43 +08:00
jxxghp
d625196b08 chore(deps): upgrade cloakbrowser to 0.5.3 2026-08-03 13:49:15 +08:00
jxxghp
b1b6a81cef fix(jellyfin): support multiple authentication versions 2026-08-03 11:59:58 +08:00
jxxghp
c6b94d4908 更新 version.py 2026-08-03 09:13:19 +08:00
jxxghp
52ca375f3d fix(history): stabilize download history pagination 2026-08-03 08:45:35 +08:00
jxxghp
d8adb4fbfe fix(u115): handle missing path response 2026-08-03 08:16:45 +08:00
mustangpt
51d2ed1200 feat: support YemaPT Open API (#6227) 2026-08-03 07:01:21 +08:00
thelinyue
702801d0dc fix(subscribe): seerr 端点电影订阅不传季号,避免误判为剧集(S00) (#6226) 2026-08-02 21:06:00 +08:00
jxxghp
3e32eab98f fix(feishu): 媒体列表显示海报 (#6224) 2026-08-02 15:49:32 +08:00
cyt-666
4292678672 fix(emby): fall back to configured user (#6222) 2026-08-02 14:16:57 +08:00
InfinityPacer
7c3f9629bf fix(subscribe): scope duplicate checks by episode group (#6219) 2026-08-01 18:40:29 +08:00
35 changed files with 1606 additions and 308 deletions

View File

@@ -140,7 +140,7 @@ async def download_history(
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
查询下载历史记录
按下载时间倒序查询下载历史记录
"""
return await DownloadHistory.async_list_by_page(db, page, count)

View File

@@ -16,7 +16,7 @@ from app.db.user_oper import get_current_active_user, get_current_active_superus
from app.schemas import MediaType, MediaRecognizeConvertEventData
from app.schemas.category import CategoryConfig
from app.schemas.types import ChainEventType
from app.utils.media import parse_media_key
from app.utils.media import MEDIA_SOURCE_ID_FIELDS, parse_media_key
router = APIRouter()
MediaSource = str
@@ -414,8 +414,10 @@ async def detail(
mediaid=source_media_id,
mtype=mtype,
)
else:
# 广播事件解析媒体信息
if not mediainfo and (
not media_source or media_source not in MEDIA_SOURCE_ID_FIELDS
):
# 旧探索插件可能只提供列表或转换事件,原生 ID 直识别失败后需保留原有兼容链路。
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
)
@@ -432,7 +434,7 @@ async def detail(
mediaid=str(new_id),
mtype=mtype,
)
elif title:
if not mediainfo and title:
# 使用名称识别兜底
meta = MetaInfo(title)
if year:

View File

@@ -504,7 +504,8 @@ async def seerr_subscribe(
tmdbid=tmdbId,
title=subject,
year="",
season=0,
# 电影不传季号避免被误判为剧集S00并污染通知标题
season=None,
username=user_name,
)
else:

View File

@@ -7,7 +7,7 @@ import shutil
import time
from pathlib import Path
from typing import List, Optional, Tuple, Set, Dict, Union
from urllib.parse import parse_qs, urljoin, urlparse
from urllib.parse import parse_qs, urlencode, urljoin, urlparse
from app import schemas
from app.chain import ChainBase
@@ -713,10 +713,21 @@ class DownloadChain(ChainBase):
return res.text
else:
data = res.json()
success_key = req_params.get('success')
if success_key and not data.get(success_key):
return None
for key in str(req_params.get('result')).split("."):
data = data.get(key)
if not data:
return None
result_path = req_params.get('result_path')
result_query_param = req_params.get('result_query_param')
if result_path and result_query_param:
result_url = urljoin(
f"{str(req_params.get('result_base_url')).rstrip('/')}/",
str(result_path).lstrip('/'),
)
return f"{result_url}?{urlencode({result_query_param: data})}"
data = self._normalize_indirect_download_url(
url=data,
base_url=req_params.get('result_base_url'),
@@ -940,6 +951,7 @@ class DownloadChain(ChainBase):
seasons=_meta.season,
episodes=download_episodes or _meta.episode,
image=_media.get_backdrop_image(),
poster=_media.get_poster_image(),
downloader=_downloader,
download_hash=_hash,
torrent_name=_torrent.title,
@@ -1766,8 +1778,11 @@ class DownloadChain(ChainBase):
"title": history.title,
"season": history.seasons,
"episode": history.episodes,
"image": history.image,
"image": history.poster,
"poster": history.poster,
"backdrop": history.image,
}
torrent.site_name = history.torrent_site
# 下载用户
torrent.userid = history.userid
torrent.username = history.username

View File

@@ -1295,6 +1295,7 @@ class SubscribeChain(ChainBase):
media_source=media_source,
media_id=media_id,
season=meta.begin_season if meta else None,
episode_group=mediainfo.episode_group,
):
return True
return False
@@ -2288,7 +2289,8 @@ class SubscribeChain(ChainBase):
anilistid=share_sub.get("anilistid"),
media_source=share_sub.get("media_source"),
media_id=share_sub.get("media_id"),
season=share_sub.get("season")):
season=share_sub.get("season"),
episode_group=share_sub.get("episode_group")):
continue
# 已经订阅过跳过
if subscribeoper.exist_history(tmdbid=share_sub.get("tmdbid"),
@@ -2297,7 +2299,8 @@ class SubscribeChain(ChainBase):
anilistid=share_sub.get("anilistid"),
media_source=share_sub.get("media_source"),
media_id=share_sub.get("media_id"),
season=share_sub.get("season")):
season=share_sub.get("season"),
episode_group=share_sub.get("episode_group")):
continue
# 去除无效属性
for key in list(share_sub.keys()):
@@ -2328,6 +2331,7 @@ class SubscribeChain(ChainBase):
year=subscribe_in.year,
tmdbid=subscribe_in.tmdbid,
season=subscribe_in.season,
episode_group=subscribe_in.episode_group,
doubanid=subscribe_in.doubanid,
bangumiid=subscribe_in.bangumiid,
anilistid=subscribe_in.anilistid,

View File

@@ -39,8 +39,10 @@ class DownloadHistory(Base):
seasons = Column(String)
# Exx
episodes = Column(String)
# 海报
# 背景图
image = Column(String)
# 海报
poster = Column(String)
# 下载器
downloader = Column(String)
# 下载任务Hash
@@ -148,14 +150,25 @@ class DownloadHistory(Base):
def list_by_page(
cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30
):
return db.query(DownloadHistory).offset((page - 1) * count).limit(count).all()
return (
db.query(DownloadHistory)
.order_by(DownloadHistory.date.desc(), DownloadHistory.id.desc())
.offset((page - 1) * count)
.limit(count)
.all()
)
@classmethod
@async_db_query
async def async_list_by_page(
cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30
):
result = await db.execute(select(cls).offset((page - 1) * count).limit(count))
result = await db.execute(
select(cls)
.order_by(cls.date.desc(), cls.id.desc())
.offset((page - 1) * count)
.limit(count)
)
return result.scalars().all()
@classmethod

View File

@@ -130,8 +130,9 @@ class Subscribe(Base):
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
episode_group: Optional[str] = None,
):
"""按媒体身份季号查询已有订阅。"""
"""按媒体身份季号与剧集组查询已有订阅。"""
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
@@ -140,6 +141,7 @@ class Subscribe(Base):
query = db.query(cls).filter(condition)
if season is not None:
query = query.filter(cls.season == season)
query = query.filter(cls.episode_group == episode_group)
return query.first()
@classmethod
@@ -149,8 +151,9 @@ class Subscribe(Base):
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
episode_group: Optional[str] = None,
):
"""异步按媒体身份季号查询已有订阅。"""
"""异步按媒体身份季号与剧集组查询已有订阅。"""
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
@@ -159,6 +162,7 @@ class Subscribe(Base):
query = select(cls).filter(condition)
if season is not None:
query = query.filter(cls.season == season)
query = query.filter(cls.episode_group == episode_group)
result = await db.execute(query)
return result.scalars().first()
@@ -169,9 +173,10 @@ class Subscribe(Base):
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
episode_group: Optional[str] = None,
):
"""
按订阅 owner 查询同一媒体的订阅行。
按订阅 owner、媒体身份、季号与剧集组查询订阅行。
"""
if not username:
return None
@@ -183,6 +188,7 @@ class Subscribe(Base):
query = db.query(cls).filter(cls.username == username, condition)
if season is not None:
query = query.filter(cls.season == season)
query = query.filter(cls.episode_group == episode_group)
return query.first()
@classmethod
@@ -192,9 +198,10 @@ class Subscribe(Base):
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
episode_group: Optional[str] = None,
):
"""
异步按订阅 owner 查询同一媒体的订阅行。
异步按订阅 owner、媒体身份、季号与剧集组查询订阅行。
"""
if not username:
return None
@@ -206,6 +213,7 @@ class Subscribe(Base):
query = select(cls).filter(cls.username == username, condition)
if season is not None:
query = query.filter(cls.season == season)
query = query.filter(cls.episode_group == episode_group)
result = await db.execute(query)
return result.scalars().first()

View File

@@ -161,8 +161,9 @@ class SubscribeHistory(Base):
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
episode_group: Optional[str] = None,
):
"""按媒体身份季号查询订阅历史。"""
"""按媒体身份季号及可选剧集组查询订阅历史。"""
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
@@ -171,6 +172,7 @@ class SubscribeHistory(Base):
query = db.query(cls).filter(condition)
if season is not None:
query = query.filter(cls.season == season)
query = query.filter(cls.episode_group == episode_group)
return query.first()
@classmethod
@@ -180,8 +182,9 @@ class SubscribeHistory(Base):
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
episode_group: Optional[str] = None,
):
"""异步按媒体身份季号查询订阅历史。"""
"""异步按媒体身份季号及可选剧集组查询订阅历史。"""
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
@@ -190,5 +193,6 @@ class SubscribeHistory(Base):
query = select(cls).filter(condition)
if season is not None:
query = query.filter(cls.season == season)
query = query.filter(cls.episode_group == episode_group)
result = await db.execute(query)
return result.scalars().first()

View File

@@ -45,6 +45,7 @@ class SubscribeOper(DbOper):
"media_source": media_source,
"media_id": media_id,
"season": kwargs.get("season"),
"episode_group": mediainfo.episode_group,
}
if username:
subscribe = Subscribe.exists_by_username(self._db,
@@ -106,6 +107,7 @@ class SubscribeOper(DbOper):
"media_source": media_source,
"media_id": media_id,
"season": kwargs.get("season"),
"episode_group": mediainfo.episode_group,
}
if username:
subscribe = await Subscribe.async_exists_by_username(self._db,
@@ -152,21 +154,22 @@ class SubscribeOper(DbOper):
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
season: Optional[int] = None,
season: Optional[int] = None, episode_group: Optional[str] = None,
) -> bool:
"""
判断是否存在
按媒体身份、季号及可选剧集组判断订阅是否存在
"""
return bool(Subscribe.exists(
self._db,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
media_source=media_source,
media_id=media_id,
season=season,
))
identity_params = {
"tmdbid": tmdbid,
"doubanid": doubanid,
"bangumiid": bangumiid,
"anilistid": anilistid,
"media_source": media_source,
"media_id": media_id,
"season": season,
"episode_group": episode_group,
}
return bool(Subscribe.exists(self._db, **identity_params))
def get(self, sid: int) -> Subscribe:
"""
@@ -300,18 +303,19 @@ class SubscribeOper(DbOper):
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
season: Optional[int] = None,
season: Optional[int] = None, episode_group: Optional[str] = None,
) -> bool:
"""
判断是否存在订阅历史
按媒体身份、季号及可选剧集组判断订阅历史是否存在。
"""
return bool(SubscribeHistory.exists(
self._db,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
media_source=media_source,
media_id=media_id,
season=season,
))
identity_params = {
"tmdbid": tmdbid,
"doubanid": doubanid,
"bangumiid": bangumiid,
"anilistid": anilistid,
"media_source": media_source,
"media_id": media_id,
"season": season,
"episode_group": episode_group,
}
return bool(SubscribeHistory.exists(self._db, **identity_params))

View File

@@ -191,7 +191,12 @@ class Emby:
def get_user(self, user_name: Optional[str] = None) -> Optional[Union[str, int]]:
"""
得管理员用户
取用于查询用户范围数据的用户ID
优先匹配指定用户名,其次匹配媒体服务器配置用户名,最后回退管理员。
:param user_name: 优先匹配的用户名
:return: 匹配到的用户ID未找到可用用户时返回None
"""
if not self._host or not self._apikey:
return None
@@ -203,15 +208,18 @@ class Emby:
res = RequestUtils().get_res(url, params)
if res:
users = res.json()
# 先查询是否有与当前用户名称匹配的
if user_name:
for user in users:
if user.get("Name") == user_name:
return user.get("Id")
candidate_usernames = []
for candidate_username in (user_name, self._username):
if candidate_username and candidate_username not in candidate_usernames:
candidate_usernames.append(candidate_username)
for candidate_username in candidate_usernames:
for emby_user in users:
if emby_user.get("Name") == candidate_username:
return emby_user.get("Id")
# 查询管理员
for user in users:
if user.get("Policy", {}).get("IsAdministrator"):
return user.get("Id")
for emby_user in users:
if emby_user.get("Policy", {}).get("IsAdministrator"):
return emby_user.get("Id")
else:
logger.error(f"Users 未获取到返回数据")
except Exception as e:

View File

@@ -1909,12 +1909,16 @@ class Feishu:
) -> Optional[dict]:
"""发送媒体列表消息,复用通知发送链路。"""
lines = []
image = message.image
for index, media in enumerate(medias[:10], start=1):
if not image:
image = media.get_message_image()
title = getattr(media, "title_year", None) or getattr(media, "title", None) or "未知媒体"
lines.append(f"{index}. {title}")
proxy_message = Notification(
title=message.title,
text="\n".join(lines),
image=image,
link=message.link,
buttons=message.buttons,
userid=message.userid,

View File

@@ -29,6 +29,8 @@ lock = Lock()
MIN_U115_UPLOAD_PART_SIZE = 1 * 1024 * 1024
U115_UPLOAD_PART_COUNT_TARGET = 96
U115_DEFAULT_ACCEPTED_CODES = (0, 20004)
U115_GET_INFO_ACCEPTED_CODES = (*U115_DEFAULT_ACCEPTED_CODES, 430004)
U115_UPLOAD_PART_SIZE_STEPS = (
10 * 1024 * 1024,
16 * 1024 * 1024,
@@ -298,10 +300,18 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
return result.get("data")
def _request_api(
self, method: str, endpoint: str, result_key: Optional[str] = None, **kwargs
self,
method: str,
endpoint: str,
result_key: Optional[str] = None,
*,
accepted_codes: Tuple[int, ...] = U115_DEFAULT_ACCEPTED_CODES,
**kwargs,
) -> Optional[Union[dict, list]]:
"""
带错误处理和速率限制的API请求
:param accepted_codes: 当前接口可确认处理的业务码
"""
# 检查会话
self._check_session()
@@ -358,7 +368,13 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
time.sleep(self.limit_sleep_seconds)
kwargs["retry_limit"] = retry_times - 1
kwargs["no_error_log"] = no_error_log
return self._request_api(method, endpoint, result_key, **kwargs)
return self._request_api(
method,
endpoint,
result_key,
accepted_codes=accepted_codes,
**kwargs,
)
# 处理请求错误
try:
@@ -376,11 +392,17 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
f"【115】{method} 请求 {endpoint} 错误 {e},等待 {sleep_duration} 秒后重试..."
)
time.sleep(sleep_duration)
return self._request_api(method, endpoint, result_key, **kwargs)
return self._request_api(
method,
endpoint,
result_key,
accepted_codes=accepted_codes,
**kwargs,
)
# 返回数据
ret_data = resp.json()
if ret_data.get("code") not in (0, 20004):
if ret_data.get("code") not in accepted_codes:
error_msg = ret_data.get("message", "")
if not no_error_log:
logger.warn(f"【115】{method} 请求 {endpoint} 出错:{error_msg}")
@@ -402,7 +424,13 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
time.sleep(self.limit_sleep_seconds)
kwargs["retry_limit"] = retry_times - 1
kwargs["no_error_log"] = no_error_log
return self._request_api(method, endpoint, result_key, **kwargs)
return self._request_api(
method,
endpoint,
result_key,
accepted_codes=accepted_codes,
**kwargs,
)
return None
if result_key:
@@ -910,20 +938,22 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
def __get_info_item(self, path: Path) -> Optional[schemas.FileItem]:
"""
查询指定路径的文件/目录项,无法确认状态时抛出 StorageQueryError。
接口业务码 20004记录不存在与 0 一样视为确认结果,其余错误
(网络失败、限流重试用尽、未知业务错误)均无法确认目标状态。
接口业务码 20004记录不存在、430004路径不存在与 0 一样
视为确认结果,其余错误(网络失败、限流重试用尽、未知业务错误)
均无法确认目标状态。
"""
resp = self._request_api(
"POST",
"/open/folder/get_info",
data={"path": path.as_posix()},
no_error_log=True,
accepted_codes=U115_GET_INFO_ACCEPTED_CODES,
)
if resp is None:
raise StorageQueryError(f"【115】无法确认文件状态请求失败或接口错误: {path}")
data = resp.get("data") if isinstance(resp, dict) else None
if not data or not data.get("file_id"):
# code 20004记录不存在等场景确认目标不存在
# 115 对记录不存在和路径不存在返回不同业务码,两者都可确认目标不存在
return None
return schemas.FileItem(
storage=self.schema.value,

View File

@@ -129,6 +129,8 @@ class SiteParserBase(metaclass=ABCMeta):
self._user_basic_page = None
# 用户基础信息参数
self._user_basic_params = None
# 用户基础信息请求方法
self._user_basic_method = None
# 用户基础信息请求头
self._user_basic_headers = None
@@ -208,7 +210,8 @@ class SiteParserBase(metaclass=ABCMeta):
self._get_page_content(
url=urljoin(self._base_url, self._user_basic_page),
params=self._user_basic_params,
headers=self._user_basic_headers
headers=self._user_basic_headers,
**({"method": self._user_basic_method} if self._user_basic_method else {}),
)
)
else:
@@ -325,12 +328,19 @@ class SiteParserBase(metaclass=ABCMeta):
"""
pass
def _get_page_content(self, url: str, params: dict = None, headers: dict = None):
def _get_page_content(
self,
url: str,
params: dict = None,
headers: dict = None,
method: Optional[str] = None,
):
"""
获取页面内容
:param url: 网页地址
:param params: post参数
:param headers: 额外的请求头
:param method: 强制使用的 HTTP 请求方法
:return:
"""
req_headers = None
@@ -363,19 +373,19 @@ class SiteParserBase(metaclass=ABCMeta):
cookie = self._site_cookie
session = self._session
if params:
if req_headers.get("Content-Type") == "application/json":
if method == "post" or params:
if (req_headers or {}).get("Content-Type") == "application/json":
res = RequestUtils(cookies=cookie,
session=session,
timeout=60,
proxies=proxies,
headers=req_headers).post_res(url=url, json=params)
headers=req_headers).post_res(url=url, json=params or {})
else:
res = RequestUtils(cookies=cookie,
session=session,
timeout=60,
proxies=proxies,
headers=req_headers).post_res(url=url, data=params)
headers=req_headers).post_res(url=url, data=params or {})
else:
res = RequestUtils(cookies=cookie,
session=session,

View File

@@ -2,109 +2,121 @@
import json
from typing import Optional, Tuple
from app.log import logger
from app.modules.indexer.parser import SiteParserBase, SiteSchema
from app.utils.string import StringUtils
class TYemaSiteUserInfo(SiteParserBase):
schema = SiteSchema.Yema
class YemaSiteUserInfo(SiteParserBase):
"""
YemaPT 开放 API 用户数据解析器
"""
def _parse_site_page(self, html_text: str):
schema = SiteSchema.Yema
request_mode = "apikey"
def _parse_site_page(self, html_text: str) -> None:
"""
获取站点页面地址
配置 YemaPT 用户基本信息接口和认证请求头
:param html_text: API AuthKey 模式下的空首页数据
"""
self._user_traffic_page = None
self._user_detail_page = None
self._user_basic_page = "api/consumer/fetchSelfDetail"
self._user_basic_page = "openApi/user/fetchBasicInfo.json"
self._user_basic_params = {}
self._user_basic_method = "post"
self._user_detail_page = None
self._user_traffic_page = None
self._torrent_seeding_page = None
self._sys_mail_unread_page = None
self._user_mail_unread_page = None
self._mail_unread_params = {}
self._torrent_seeding_page = "/api/userTorrent/fetchSeedTorrentInfo"
self._torrent_seeding_params = {
# 虽然这个参数是无意义的,但这个 API 必须用 POST
"status": "seeding"
}
self._torrent_seeding_headers = {}
self._addition_headers = {
"Authorization": self.apikey,
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"User-Agent": self._ua,
}
def _parse_logged_in(self, html_text):
def _parse_user_base_info(self, html_text: str) -> None:
"""
判断是否登录成功, 通过判断是否存在用户信息
暂时跳过检测,待后续优化
:param html_text:
:return:
"""
return True
解析开放 API 返回的用户基本信息和促销流量
def _parse_user_base_info(self, html_text: str):
"""
解析用户基本信息这里把_parse_user_traffic_info和_parse_user_detail_info合并到这里
:param html_text: fetchBasicInfo 接口响应文本
"""
if not html_text:
return None
detail = json.loads(html_text)
if not detail or not detail.get("success"):
self.err_msg = "获取用户信息失败,未收到开放 API 响应"
return
user_info = detail.get("data", {})
try:
payload = json.loads(html_text)
except (TypeError, json.JSONDecodeError) as err:
self.err_msg = "获取用户信息失败,开放 API 响应不是有效 JSON"
logger.warning(f"{self._site_name} {self.err_msg}{str(err)}")
return
if not isinstance(payload, dict):
self.err_msg = "获取用户信息失败,开放 API 响应结构无效"
logger.warning(f"{self._site_name} {self.err_msg}")
return
if not payload.get("success") or not isinstance(payload.get("data"), dict):
self.err_msg = payload.get("errorMessage") or "获取用户信息失败"
logger.warning(f"{self._site_name} 获取用户信息失败:{self.err_msg}")
return
user_info = payload["data"]
self.userid = user_info.get("id")
self.username = user_info.get("name")
self.user_level = str(user_info.get("level")) if user_info.get("level") is not None else None
self.user_level = str(user_info.get("level")) \
if user_info.get("level") is not None else None
self.join_at = StringUtils.unify_datetime_str(user_info.get("registerTime"))
self.upload = user_info.get('uploadSize')
# 使用 promotionDownloadSize 获取真实下载量(考虑促销因素)
if "promotionDownloadSize" in user_info:
self.download = user_info.get('promotionDownloadSize')
else:
self.download = user_info.get('downloadSize')
self.upload = int(user_info.get("promotionUploadSize") or 0)
self.download = int(user_info.get("promotionDownloadSize") or 0)
self.ratio = round(self.upload / (self.download or 1), 2)
self.bonus = user_info.get("bonus")
self.message_unread = 0
self.bonus = float(user_info.get("bonus") or 0)
def _parse_user_traffic_info(self, html_text: str):
def _parse_user_traffic_info(self, html_text: str) -> None:
"""
解析用户流量信息
跳过独立流量页面,用户基本信息接口已经返回促销流量
:param html_text: 未使用的页面文本
"""
pass
def _parse_user_detail_info(self, html_text: str):
def _parse_user_detail_info(self, html_text: str) -> None:
"""
解析用户详细信息
跳过独立用户详情页面,开放 API 未提供该接口
:param html_text: 未使用的页面文本
"""
pass
def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:
def _parse_user_torrent_seeding_info(
self,
html_text: str,
multi_page: bool = False,
) -> Optional[str]:
"""
解析用户做种信息
跳过做种统计,开放 API 未提供该接口
:param html_text: 未使用的页面文本
:param multi_page: 是否为后续分页
:return: 始终返回 None
"""
if not html_text:
return None
seeding_info = json.loads(html_text)
if not seeding_info or not seeding_info.get("success") or not seeding_info.get("data"):
return None
torrents = seeding_info.get("data")
self.seeding += torrents.get("num")
self.seeding_size += torrents.get("fileSize")
# 是否存在下页数据
next_page = None
return next_page
return None
def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:
"""
解析未读消息链接,这里直接读出详情
"""
pass
跳过站内消息,开放 API 未提供该接口
def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]:
:param html_text: 未使用的页面文本
:param msg_links: 未使用的消息链接容器
:return: 始终返回 None
"""
解析消息内容
return None
def _parse_message_content(
self,
html_text: str,
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""
pass
跳过消息详情,开放 API 未提供该接口
:param html_text: 未使用的页面文本
:return: 三个空值
"""
return None, None, None

View File

@@ -1,34 +1,24 @@
from typing import Tuple, List, Optional
import base64
import json
from typing import List, Optional, Tuple
from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas import MediaType
from app.utils.http import RequestUtils, AsyncRequestUtils
from app.utils.http import AsyncRequestUtils, RequestUtils
from app.utils.string import StringUtils
class YemaSpider:
"""
YemaPT API
YemaPT 开放 API 索引器
"""
_indexerid = None
_domain = None
_name = ""
_proxy = None
_cookie = None
_ua = None
# YemaPT 开放 API 使用更大的分页容量时会返回空结果。
_size = 40
_searchurl = "%sapi/torrent/fetchOpenTorrentList"
_downloadurl = "%sapi/torrent/download?id=%s"
_pageurl = "%s#/torrent/detail/%s/"
_timeout = 15
# 分类
_movie_category = [4]
_tv_category = [5, 13, 14, 17, 15, 6, 16]
_tv_category = [5, 6, 13, 14, 15, 16, 17]
# 标签 https://wiki.yemapt.org/developer/constants
_labels = {
"1": "禁转",
"2": "首发",
@@ -44,173 +34,248 @@ class YemaSpider:
"12": "完结",
}
def __init__(self, indexer: dict):
"""
初始化 YemaPT 开放 API 索引器
:param indexer: 合并站点认证信息后的索引配置
"""
indexer = indexer or {}
self._name = indexer.get("name") or "YemaPT"
self._site_url = str(indexer.get("domain") or "https://www.yemapt.org/").rstrip("/")
self._proxy = settings.PROXY if indexer.get("proxy") else None
self._use_proxy = bool(indexer.get("proxy"))
self._user_agent = indexer.get("ua") or settings.USER_AGENT
self._api_key = indexer.get("apikey")
self._timeout = indexer.get("timeout") or 15
self._search_url = f"{self._site_url}/openApi/torrent/fetchOpenTorrentList.json"
self._download_key_url = f"{self._site_url}/openApi/torrent/generateDownloadKey.json"
@classmethod
def get_search_page_size(cls, keyword: Optional[str] = None) -> Optional[int]:
"""
获取搜索接口单页容量
获取搜索接口单页容量
:param keyword: 搜索关键字YemaPT 不按关键字改变分页容量
:return: 搜索接口单页容量
"""
return cls._size
def __init__(self, indexer: dict):
self.systemconfig = SystemConfigOper()
if indexer:
self._indexerid = indexer.get('id')
self._domain = indexer.get('domain')
self._searchurl = self._searchurl % self._domain
self._name = indexer.get('name')
if indexer.get('proxy'):
self._proxy = settings.PROXY
self._cookie = indexer.get('cookie')
self._ua = indexer.get('ua')
self._timeout = indexer.get('timeout') or 15
def __get_params(self, keyword: str = None, page: Optional[int] = 0) -> dict:
def _request_headers(self) -> dict:
"""
获取搜索参数
构造开放 API 请求头
:return: 不包含 Cookie 的 AuthKey 请求头
"""
return {
"Authorization": self._api_key,
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"User-Agent": self._user_agent,
}
def _build_params(
self,
keyword: Optional[str],
page: Optional[int],
) -> dict:
"""
构造公开种子列表查询参数
:param keyword: 搜索关键字
:param page: MoviePilot 从 0 开始的页码
:return: YemaPT 开放 API 请求体
"""
params = {
"pageParam": {
"current": page + 1,
"current": int(page or 0) + 1,
"pageSize": self._size,
"total": self._size
},
"sorter": {}
"sorter": {},
}
if keyword:
params.update({
"keyword": keyword,
})
params["keyword"] = keyword
return params
def __parse_result(self, results: List[dict]) -> List[dict]:
def _parse_result(self, results: List[dict]) -> List[dict]:
"""
解析搜索结果
将开放 API 种子数据转换为 MoviePilot 标准字段
:param results: 公开种子列表接口 data 数组
:return: MoviePilot 标准种子字典列表
"""
torrents = []
if not results:
return torrents
for result in results:
category_value = result.get('categoryId')
for result in results or []:
if not isinstance(result, dict):
continue
category_value = result.get("categoryId")
if category_value in self._tv_category:
category = MediaType.TV.value
elif category_value in self._movie_category:
category = MediaType.MOVIE.value
else:
category = MediaType.UNKNOWN.value
pass
torrentLabelIds = result.get('tagList', []) or []
torrentLabels = []
for labelId in torrentLabelIds:
if self._labels.get(labelId) is not None:
torrentLabels.append(self._labels.get(labelId))
pass
pass
torrent = {
'title': result.get('showName'),
'description': result.get('shortDesc'),
'enclosure': self.__get_download_url(result.get('id')),
'pubdate': StringUtils.unify_datetime_str(result.get('listingTime')),
'size': result.get('fileSize'),
'seeders': result.get('seedNum'),
'peers': result.get('leechNum'),
'grabs': result.get('completedNum'),
'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('downloadPromotion')),
'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('uploadPromotion')),
'freedate': StringUtils.unify_datetime_str(result.get('downloadPromotionEndTime')),
'page_url': self._pageurl % (self._domain, result.get('id')),
'labels': torrentLabels,
'category': category
}
torrents.append(torrent)
labels = [
self._labels[label_id]
for label_id in result.get("tagList") or []
if label_id in self._labels
]
torrent_id = result.get("id")
torrents.append({
"title": result.get("showName"),
"description": result.get("shortDesc"),
"enclosure": self._build_download_url(torrent_id),
"pubdate": StringUtils.unify_datetime_str(result.get("listingTime")),
"size": result.get("fileSize"),
"seeders": result.get("seedNum"),
"peers": result.get("leechNum"),
"grabs": result.get("completedNum"),
"downloadvolumefactor": self._download_factor(result.get("downloadPromotion")),
"uploadvolumefactor": self._upload_factor(result.get("uploadPromotion")),
"freedate": StringUtils.unify_datetime_str(result.get("downloadPromotionEndTime")),
"page_url": f"{self._site_url}/#/torrent/detail/{torrent_id}/",
"labels": labels,
"hit_and_run": bool(result.get("hrPunishEnable")),
"category": category,
})
return torrents
def search(self, keyword: str,
mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:
"""
搜索
def _process_search_response(self, response) -> Tuple[bool, List[dict]]:
"""
校验开放 API 通用响应并解析搜索结果
res = RequestUtils(
headers={
"Content-Type": "application/json",
"User-Agent": f"{self._ua}",
"Accept": "application/json, text/plain, */*"
},
cookies=self._cookie,
:param response: RequestUtils 返回的响应对象
:return: 是否失败及标准种子列表
"""
if response is None:
logger.warning(f"{self._name} 搜索失败,无法连接开放 API")
return True, []
if response.status_code != 200:
logger.warning(f"{self._name} 搜索失败HTTP 错误码:{response.status_code}")
return True, []
try:
payload = response.json() or {}
except (TypeError, ValueError) as err:
logger.warning(f"{self._name} 搜索响应不是有效 JSON{str(err)}")
return True, []
if not isinstance(payload, dict):
logger.warning(f"{self._name} 搜索响应结构无效")
return True, []
if not payload.get("success"):
logger.warning(f"{self._name} 搜索失败:{payload.get('errorMessage') or '未知错误'}")
return True, []
results = payload.get("data")
if not isinstance(results, list):
logger.warning(f"{self._name} 搜索响应 data 不是数组")
return True, []
return False, self._parse_result(results)
def search(
self,
keyword: Optional[str],
mtype: MediaType = None,
page: Optional[int] = 0,
) -> Tuple[bool, List[dict]]:
"""
同步搜索 YemaPT 公开种子
:param keyword: 搜索关键字
:param mtype: MoviePilot 媒体类型,开放 API 不支持直接按媒体类型查询
:param page: MoviePilot 从 0 开始的页码
:return: 是否失败及标准种子列表
"""
if not self._api_key:
logger.warning(f"{self._name} 未配置 API AuthKey")
return True, []
response = RequestUtils(
headers=self._request_headers(),
proxies=self._proxy,
referer=f"{self._domain}",
timeout=self._timeout
).post_res(url=self._searchurl, json=self.__get_params(keyword, page))
if res and res.status_code == 200:
results = res.json().get('data', []) or []
return False, self.__parse_result(results)
elif res is not None:
logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}")
return True, []
else:
logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}")
return True, []
timeout=self._timeout,
).post_res(
url=self._search_url,
json=self._build_params(keyword, page),
)
return self._process_search_response(response)
async def async_search(self, keyword: str,
mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:
async def async_search(
self,
keyword: Optional[str],
mtype: MediaType = None,
page: Optional[int] = 0,
) -> Tuple[bool, List[dict]]:
"""
异步搜索
异步搜索 YemaPT 公开种子
:param keyword: 搜索关键字
:param mtype: MoviePilot 媒体类型,开放 API 不支持直接按媒体类型查询
:param page: MoviePilot 从 0 开始的页码
:return: 是否失败及标准种子列表
"""
res = await AsyncRequestUtils(
headers={
"Content-Type": "application/json",
"User-Agent": f"{self._ua}",
"Accept": "application/json, text/plain, */*"
},
cookies=self._cookie,
if not self._api_key:
logger.warning(f"{self._name} 未配置 API AuthKey")
return True, []
response = await AsyncRequestUtils(
headers=self._request_headers(),
proxies=self._proxy,
referer=f"{self._domain}",
timeout=self._timeout
).post_res(url=self._searchurl, json=self.__get_params(keyword, page))
if res and res.status_code == 200:
results = res.json().get('data', []) or []
return False, self.__parse_result(results)
elif res is not None:
logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}")
return True, []
else:
logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}")
return True, []
timeout=self._timeout,
).post_res(
url=self._search_url,
json=self._build_params(keyword, page),
)
return self._process_search_response(response)
@staticmethod
def __get_downloadvolumefactor(discount: str) -> float:
def _download_factor(promotion: str) -> float:
"""
获取下载系数
转换下载促销类型
:param promotion: 开放 API 下载促销枚举
:return: MoviePilot 下载系数
"""
discount_dict = {
return {
"free": 0,
"half": 0.5,
"none": 1
}
if discount:
return discount_dict.get(discount, 1)
return 1
"none": 1,
}.get(promotion, 1)
@staticmethod
def __get_uploadvolumefactor(discount: str) -> float:
def _upload_factor(promotion: str) -> float:
"""
获取上传系数
转换上传促销类型
:param promotion: 开放 API 上传促销枚举
:return: MoviePilot 上传系数
"""
discount_dict = {
return {
"none": 1,
"one_half": 1.5,
"double_upload": 2
}
if discount:
return discount_dict.get(discount, 1)
return 1
"double_upload": 2,
}.get(promotion, 1)
def __get_download_url(self, torrent_id: str) -> str:
def _build_download_url(self, torrent_id: int) -> str:
"""
获取下载链接
构造先生成下载凭证再获取种子文件的两段式链接
:param torrent_id: YemaPT 种子 ID
:return: Base64 请求配置与下载凭证接口 URL
"""
return self._downloadurl % (self._domain, torrent_id)
request_config = {
"method": "post",
"cookie": False,
"header": {
"Authorization": self._api_key,
"Accept": "application/json",
},
"params": {"id": torrent_id},
"proxy": self._use_proxy,
"success": "success",
"result": "data",
"result_base_url": self._site_url,
"result_path": "api/torrent/download1",
"result_query_param": "token",
}
encoded_config = base64.b64encode(
json.dumps(request_config).encode("utf-8")
).decode("ascii")
return f"[{encoded_config}]{self._download_key_url}"

View File

@@ -52,6 +52,17 @@ class Jellyfin:
self.user = self.get_user()
self.serverid = self.get_server_id()
def _request(self, headers: Optional[dict] = None, **kwargs: Any) -> RequestUtils:
"""创建兼容不同 Jellyfin 版本鉴权方式的请求工具"""
request_headers = dict(headers or {})
if not any(str(name).lower() == "authorization" for name in request_headers):
request_headers["Authorization"] = f'MediaBrowser Token="{self._apikey}"'
if kwargs.get("accept_type") and "Accept" not in request_headers:
request_headers["Accept"] = kwargs["accept_type"]
if kwargs.get("content_type") and "Content-Type" not in request_headers:
request_headers["Content-Type"] = kwargs["content_type"]
return RequestUtils(headers=request_headers, **kwargs)
def get_jellyfin_folders(self) -> List[dict]:
"""
获取Jellyfin媒体库路径列表
@@ -63,7 +74,7 @@ class Jellyfin:
'api_key': self._apikey
}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if res:
return res.json()
else:
@@ -85,7 +96,7 @@ class Jellyfin:
'api_key': self._apikey
}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if res:
library_items = res.json()
librarys = []
@@ -132,7 +143,7 @@ class Jellyfin:
return None
params = {"api_key": self._apikey}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if res:
items = res.json().get("Items")
return items if isinstance(items, list) else None
@@ -199,7 +210,7 @@ class Jellyfin:
"api_key": self._apikey
}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if res:
return len(res.json())
else:
@@ -220,7 +231,7 @@ class Jellyfin:
"api_key": self._apikey
}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if res:
users = res.json()
# 先查询是否有与当前用户名称匹配的
@@ -276,7 +287,7 @@ class Jellyfin:
return None
url = f"{self._host}Users/authenticatebyname"
try:
res = RequestUtils(headers={
res = self._request(headers={
'X-Emby-Authorization': f'MediaBrowser Client="MoviePilot", '
f'Device="requests", '
f'DeviceId="1", '
@@ -313,7 +324,7 @@ class Jellyfin:
'api_key': self._apikey
}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if res:
return res.json().get("Id")
else:
@@ -342,7 +353,7 @@ class Jellyfin:
'api_key': self._apikey
}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if res:
result = res.json()
return schemas.Statistic(
@@ -398,7 +409,7 @@ class Jellyfin:
"api_key": self._apikey
}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if res:
res_items = res.json().get("Items")
if res_items:
@@ -435,7 +446,7 @@ class Jellyfin:
"api_key": self._apikey
}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if res:
res_items = res.json().get("Items")
if res_items:
@@ -507,7 +518,7 @@ class Jellyfin:
"api_key": self._apikey
}
try:
res_json = RequestUtils().get_res(url, params)
res_json = self._request().get_res(url, params)
if res_json:
tv_info = res_json.json()
res_items = tv_info.get("Items")
@@ -548,7 +559,7 @@ class Jellyfin:
"isMissing": "false",
"api_key": self._apikey
}
res_json = RequestUtils().get_res(url, params)
res_json = self._request().get_res(url, params)
if not res_json:
return {}
episode_ids: Dict[int, str] = {}
@@ -575,7 +586,7 @@ class Jellyfin:
url = f"{self._host}Items/{item_id}/RemoteImages"
params = {"api_key": self._apikey}
try:
res = RequestUtils(timeout=10).get_res(url, params)
res = self._request(timeout=10).get_res(url, params)
if res:
images = res.json().get("Images") or []
for image in images:
@@ -600,7 +611,7 @@ class Jellyfin:
url = f"{self._host}Items/{item_id}/PlaybackInfo"
params = {"api_key": self._apikey}
try:
res = RequestUtils(timeout=10).get_res(url, params)
res = self._request(timeout=10).get_res(url, params)
if res:
media_sources = res.json().get("MediaSources")
if media_sources:
@@ -634,7 +645,7 @@ class Jellyfin:
_host = self._playhost
url = f"{_host}Items/{item_id}/Images/{image_type}"
try:
res = RequestUtils().get_res(url)
res = self._request().get_res(url)
if res and res.status_code != 404:
logger.info(f"影片图片链接:{res.url}")
return res.url
@@ -658,7 +669,7 @@ class Jellyfin:
"api_key": self._apikey
}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if res:
return res.json()[index].get(key)
else:
@@ -679,7 +690,7 @@ class Jellyfin:
"api_key": self._apikey
}
try:
res = RequestUtils().post_res(url, params=params)
res = self._request().post_res(url, params=params)
if res:
return True
else:
@@ -876,7 +887,7 @@ class Jellyfin:
"api_key": self._apikey
}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if res and res.status_code == 200:
return self.__format_item_info(res.json())
except Exception as e:
@@ -903,7 +914,7 @@ class Jellyfin:
"api_key": self._apikey,
}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if not res or res.status_code != 200:
return None
total_count = res.json().get("TotalRecordCount")
@@ -937,7 +948,7 @@ class Jellyfin:
"Limit": limit
})
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if not res or res.status_code != 200:
return None
items = res.json().get("Items") or []
@@ -963,7 +974,7 @@ class Jellyfin:
.replace("[APIKEY]", self._apikey or '') \
.replace("[USER]", self.user or '')
try:
return RequestUtils(accept_type="application/json").get_res(url=url)
return self._request(accept_type="application/json").get_res(url=url)
except Exception as e:
logger.error(f"连接Jellyfin出错" + str(e))
return None
@@ -981,7 +992,7 @@ class Jellyfin:
.replace("[APIKEY]", self._apikey or '') \
.replace("[USER]", self.user or '')
try:
return RequestUtils(
return self._request(
headers=headers
).post_res(url=url, data=data)
except Exception as e:
@@ -1046,7 +1057,7 @@ class Jellyfin:
"api_key": self._apikey,
}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if res:
result = res.json().get("Items") or []
ret_resume = []
@@ -1069,7 +1080,7 @@ class Jellyfin:
else:
image = self.__get_local_image_by_id(item.get("Id"))
# 小部分剧集无[xxx-S01E01-thumb.jpg]图片
image_res = RequestUtils().get_res(image)
image_res = self._request().get_res(image)
if not image_res or image_res.status_code == 404:
image = self.generate_image_link(item.get("Id"), "Backdrop", False)
if item_type == MediaType.MOVIE.value:
@@ -1115,7 +1126,7 @@ class Jellyfin:
"api_key": self._apikey,
}
try:
res = RequestUtils().get_res(url, params)
res = self._request().get_res(url, params)
if res:
result = res.json() or []
ret_latest = []

View File

@@ -4,6 +4,10 @@ from pydantic import BaseModel, ConfigDict, Field
class DownloadHistory(BaseModel):
"""
下载历史记录
"""
# ID
id: int
# 保存路程
@@ -34,8 +38,10 @@ class DownloadHistory(BaseModel):
seasons: Optional[str] = None
# 集Exx
episodes: Optional[str] = None
# 海报
# 背景图
image: Optional[str] = None
# 海报
poster: Optional[str] = None
# 下载器Hash
download_hash: Optional[str] = None
# 种子名称

View File

@@ -17,6 +17,7 @@ class DownloaderTorrent(BaseModel):
downloader: Optional[str] = None
hash: Optional[str] = None
title: Optional[str] = None
site_name: Optional[str] = None
name: Optional[str] = None
year: Optional[str] = None
season_episode: Optional[str] = None

View File

@@ -0,0 +1,42 @@
"""2.2.15
为下载历史增加海报字段
Revision ID: a8c4e2f6b1d9
Revises: f7b2d5c9a301
Create Date: 2026-08-03
"""
from alembic import op
import sqlalchemy as sa
revision = "a8c4e2f6b1d9"
down_revision = "f7b2d5c9a301"
branch_labels = None
depends_on = None
def _has_column(table_name: str, column_name: str) -> bool:
"""检查数据表是否已存在指定字段。"""
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return False
return any(
column["name"] == column_name
for column in inspector.get_columns(table_name)
)
def upgrade() -> None:
"""为下载历史增加可空海报字段。"""
if not _has_column("downloadhistory", "poster"):
op.add_column(
"downloadhistory",
sa.Column("poster", sa.String(), nullable=True),
)
def downgrade() -> None:
"""移除下载历史海报字段。"""
if _has_column("downloadhistory", "poster"):
op.drop_column("downloadhistory", "poster")

View File

@@ -170,7 +170,7 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/download/` | 查询正在下载的任务,参数:`name` |
| GET | `/api/v1/download/` | 查询正在下载的任务,参数:`name`;关联下载历史时返回媒体类型、来源站点 `site_name`,以及 `media.poster` 海报和 `media.backdrop` 背景图;兼容字段 `media.image``media.poster` 相同 |
| POST | `/api/v1/download/` | 添加含媒体信息的下载任务,请求体包含媒体信息和种子信息 |
| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选 `media_source` + `media_id`;继续兼容四种专用 ID并支持 `downloader``save_path` |
| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,可选 `media_source` + `media_id`;继续兼容四种专用 ID并支持 `save_path` |
@@ -180,6 +180,13 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
| GET | `/api/v1/download/paths` | 查询可用于下载接口 `save_path` 参数的下载路径 |
| DELETE | `/api/v1/download/{hashString}` | 删除下载任务,参数:`name` |
#### 历史
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/history/download` | 按下载时间倒序查询下载历史,参数:`page``count``poster` 为海报,兼容字段 `image` 为背景图 |
| DELETE | `/api/v1/history/download` | 删除下载历史,请求体为下载历史记录 |
#### 系统
| 方法 | 路径 | 说明 |

View File

@@ -38,7 +38,7 @@ pillow~=12.2.0
pillow-avif-plugin~=1.5.5
pyTelegramBotAPI~=4.34.0
telegramify-markdown~=1.2.0
cloakbrowser~=0.4.0
cloakbrowser~=0.5.3
torrentool~=1.2.0
fast-bencode~=1.1.8
slack-bolt~=1.28.0

View File

@@ -1,6 +1,6 @@
---
name: moviepilot-api
version: 6
version: 7
description: >-
Use this skill when you need to call MoviePilot REST API endpoints directly
with the bundled Python client. Covers MoviePilot HTTP endpoints across media
@@ -175,7 +175,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/download/` | List active downloads. Params: `name` (downloader name) |
| GET | `/api/v1/download/` | List active downloads. Params: `name` (downloader name); linked history adds media type and source `site_name` |
| POST | `/api/v1/download/` | Add download (with media info). Body: JSON |
| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional `media_source` + `media_id` (all four dedicated IDs remain supported), `downloader`, `save_path` |
| POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, optional `media_source` + `media_id` (all four dedicated IDs remain supported), `save_path` |
@@ -251,7 +251,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/history/download` | Download history. Params: `page`, `count` |
| GET | `/api/v1/history/download` | Download history, newest first. Params: `page`, `count`. `poster` is the poster image; legacy `image` is the backdrop image. |
| DELETE | `/api/v1/history/download` | Delete download history. Body: DownloadHistory JSON |
| GET | `/api/v1/history/transfer` | Transfer history. Params: `title`, `page`, `count`, `status` |
| DELETE | `/api/v1/history/transfer` | Delete transfer history. Params: `deletesrc`, `deletedest`. Body: TransferHistory |

View File

@@ -23,7 +23,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
expected_versions = {
"database-operation": "3",
"moviepilot-api": "6",
"moviepilot-api": "7",
"moviepilot-cli": "6",
"moviepilot-update": "3",
"transfer-failed-retry": "2",

View File

@@ -9,7 +9,7 @@ from app.chain.download import DownloadChain
from app.core.config import settings
from app.core.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.core.metainfo import MetaInfo
from app.schemas import FileItem, NotExistMediaInfo, TransferDirectoryConf
from app.schemas import DownloaderTorrent, FileItem, NotExistMediaInfo, TransferDirectoryConf
from app.schemas.types import MediaType
@@ -249,9 +249,11 @@ def test_download_single_persists_custom_words_snapshot(monkeypatch):
"""捕获写入下载历史的字段,验证识别词快照确实落库。"""
def add(self, **kwargs):
"""捕获下载历史字段。"""
captured.update(kwargs)
def add_files(self, _files):
"""忽略与当前断言无关的下载文件记录。"""
pass
_FakeThreadHelper.submitted = []
@@ -278,6 +280,8 @@ def test_download_single_persists_custom_words_snapshot(monkeypatch):
year="2024",
tmdb_id=1,
genre_ids=[18],
poster_path="https://images.example.com/original/poster.jpg",
backdrop_path="https://images.example.com/original/backdrop.jpg",
),
torrent_info=TorrentInfo(
title="Demo Show 2024",
@@ -298,6 +302,8 @@ def test_download_single_persists_custom_words_snapshot(monkeypatch):
assert result == "hash123"
assert captured["custom_words"] == custom_words
assert captured["poster"] == "https://images.example.com/w500/poster.jpg"
assert captured["image"] == "https://images.example.com/w500/backdrop.jpg"
def test_save_subtitle_response_creates_missing_temp_directory(monkeypatch, tmp_path):
@@ -1036,3 +1042,40 @@ def test_batch_download_keeps_count_check_without_complete_coverage(monkeypatch)
assert lefts == {}
assert context.confirmed_full_coverage is False
chain.download_single.assert_called_once()
def test_downloading_includes_media_type_and_source_site(monkeypatch):
"""
正在下载任务应从下载历史回填媒体类型和来源站点。
"""
torrent = DownloaderTorrent(hash="download-hash", title="Demo.Release")
history = SimpleNamespace(
episodes="E02",
image="https://images.example.com/backdrop.jpg",
poster="https://images.example.com/poster.jpg",
seasons="S01",
title="示例剧集",
tmdbid=1001,
torrent_site="示例站点",
type="电视剧",
userid="user-1",
username="tester",
)
chain = DownloadChain.__new__(DownloadChain)
monkeypatch.setattr(chain, "list_torrents", lambda **_kwargs: [torrent])
monkeypatch.setattr(
download_module,
"DownloadHistoryOper",
lambda: SimpleNamespace(get_by_hashes=lambda _hashes: {torrent.hash: history}),
)
result = chain.downloading(name="qb-main")
assert result == [torrent]
assert torrent.media["type"] == "电视剧"
assert torrent.media["image"] == "https://images.example.com/poster.jpg"
assert torrent.media["poster"] == "https://images.example.com/poster.jpg"
assert torrent.media["backdrop"] == "https://images.example.com/backdrop.jpg"
assert torrent.site_name == "示例站点"
assert torrent.userid == "user-1"
assert torrent.username == "tester"

View File

@@ -0,0 +1,50 @@
import importlib
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
def test_downloadhistory_poster_migration_adds_and_removes_column(monkeypatch) -> None:
"""迁移应为下载历史增加可空海报字段,并支持回滚。"""
migration = importlib.import_module(
"database.versions.a8c4e2f6b1d9_2_2_15"
)
engine = sa.create_engine("sqlite://")
metadata = sa.MetaData()
download_history = sa.Table(
"downloadhistory",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("image", sa.String()),
)
with engine.begin() as connection:
metadata.create_all(connection)
connection.execute(download_history.insert(), {
"id": 1,
"image": "https://images.example.com/backdrop.jpg",
})
context = MigrationContext.configure(connection)
monkeypatch.setattr(migration, "op", Operations(context))
migration.upgrade()
migration.upgrade()
migrated = sa.Table(
"downloadhistory",
sa.MetaData(),
autoload_with=connection,
)
row = connection.execute(sa.select(migrated)).mappings().one()
assert "poster" in migrated.c
assert row["image"] == "https://images.example.com/backdrop.jpg"
assert row["poster"] is None
migration.downgrade()
rolled_back = sa.Table(
"downloadhistory",
sa.MetaData(),
autoload_with=connection,
)
assert "poster" not in rolled_back.c

View File

@@ -0,0 +1,59 @@
from unittest.mock import Mock, patch
from app.modules.emby.emby import Emby
def _resolve_user(users: list[dict], requested_username: str, configured_username: str):
emby = Emby.__new__(Emby)
emby._host = "http://emby.local/"
emby._apikey = "test-api-key"
emby._username = configured_username
response = Mock()
response.json.return_value = users
with patch("app.modules.emby.emby.RequestUtils") as request_utils:
request_utils.return_value.get_res.return_value = response
return emby.get_user(requested_username)
def test_get_user_prefers_requested_username():
"""指定用户名存在时应优先使用该用户。"""
result = _resolve_user(
users=[
{"Id": "requested-id", "Name": "mp-user", "Policy": {}},
{"Id": "configured-id", "Name": "configured-user", "Policy": {}},
{"Id": "admin-id", "Name": "admin", "Policy": {"IsAdministrator": True}},
],
requested_username="mp-user",
configured_username="configured-user",
)
assert result == "requested-id"
def test_get_user_falls_back_to_configured_username():
"""指定用户名不存在时应回退媒体服务器配置用户。"""
result = _resolve_user(
users=[
{"Id": "configured-id", "Name": "configured-user", "Policy": {}},
{"Id": "admin-id", "Name": "admin", "Policy": {"IsAdministrator": True}},
],
requested_username="missing-mp-user",
configured_username="configured-user",
)
assert result == "configured-id"
def test_get_user_falls_back_to_administrator():
"""指定用户和配置用户均不存在时应回退管理员。"""
result = _resolve_user(
users=[
{"Id": "regular-id", "Name": "regular", "Policy": {}},
{"Id": "admin-id", "Name": "admin", "Policy": {"IsAdministrator": True}},
],
requested_username="missing-mp-user",
configured_username="missing-configured-user",
)
assert result == "admin-id"

View File

@@ -0,0 +1,50 @@
from unittest.mock import MagicMock, patch
from app.testing.bootstrap import ensure_optional_stub
ensure_optional_stub("psutil")
ensure_optional_stub("dateparser")
ensure_optional_stub("Pinyin2Hanzi", is_pinyin=lambda value: False)
from app.core.context import MediaInfo
from app.modules.feishu.feishu import Feishu
from app.schemas import Notification
def _build_feishu_client() -> Feishu:
"""构造不会启动真实飞书长连接的测试客户端。"""
with (
patch.object(Feishu, "_build_api_client", return_value=MagicMock()),
patch.object(Feishu, "_start_ws_client"),
):
return Feishu(
FEISHU_APP_ID="test_app_id",
FEISHU_APP_SECRET="test_app_secret",
name="feishu-test",
)
def test_send_medias_message_passes_first_available_image() -> None:
"""飞书媒体列表应将首张可用媒体图片传入通知卡片。"""
client = _build_feishu_client()
first_media = MediaInfo()
first_media.title = "无海报媒体"
second_media = MediaInfo()
second_media.title = "有海报媒体"
second_media.poster_path = "https://example.com/poster.jpg"
with patch.object(
client,
"send_notification",
return_value={"success": True},
) as send_notification:
result = client.send_medias_message(
message=Notification(title="搜索结果", userid="ou_test"),
medias=[first_media, second_media],
)
assert result == {"success": True}
proxy_message = send_notification.call_args.args[0]
assert proxy_message.image == "https://example.com/poster.jpg"
assert proxy_message.text == "1. 无海报媒体\n2. 有海报媒体"
assert send_notification.call_args.kwargs["userid"] == "ou_test"

View File

@@ -98,3 +98,92 @@ def test_download_history_title_search_is_case_insensitive(tmp_path: Path):
await engine.dispose()
asyncio.run(run_case())
def test_download_history_page_is_newest_first(tmp_path: Path):
"""下载历史分页应按时间和 ID 倒序稳定返回。"""
engine = create_engine(f"sqlite:///{tmp_path / 'download_history_page.db'}")
SessionFactory = sessionmaker(bind=engine)
Base.metadata.create_all(bind=engine)
try:
with SessionFactory() as db:
db.add_all(
[
DownloadHistory(
path="/downloads/oldest",
type="电影",
title="Oldest",
date="2026-06-01 00:00:00",
),
DownloadHistory(
path="/downloads/newer-first",
type="电影",
title="Newer First",
date="2026-06-02 00:00:00",
),
DownloadHistory(
path="/downloads/newer-second",
type="电影",
title="Newer Second",
date="2026-06-02 00:00:00",
),
]
)
db.commit()
first_page = DownloadHistory.list_by_page(db, page=1, count=2)
second_page = DownloadHistory.list_by_page(db, page=2, count=2)
assert [item.title for item in first_page] == ["Newer Second", "Newer First"]
assert [item.title for item in second_page] == ["Oldest"]
finally:
engine.dispose()
def test_async_download_history_page_is_newest_first(tmp_path: Path):
"""异步下载历史分页应按时间和 ID 倒序稳定返回。"""
async def run_case():
"""执行异步分页顺序断言。"""
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'async_download_history_page.db'}")
SessionFactory = async_sessionmaker(bind=engine)
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with SessionFactory() as db:
db.add_all(
[
DownloadHistory(
path="/downloads/oldest",
type="电影",
title="Oldest",
date="2026-06-01 00:00:00",
),
DownloadHistory(
path="/downloads/newer-first",
type="电影",
title="Newer First",
date="2026-06-02 00:00:00",
),
DownloadHistory(
path="/downloads/newer-second",
type="电影",
title="Newer Second",
date="2026-06-02 00:00:00",
),
]
)
await db.commit()
first_page = await DownloadHistory.async_list_by_page(db, page=1, count=2)
second_page = await DownloadHistory.async_list_by_page(db, page=2, count=2)
assert [item.title for item in first_page] == ["Newer Second", "Newer First"]
assert [item.title for item in second_page] == ["Oldest"]
finally:
await engine.dispose()
asyncio.run(run_case())

View File

@@ -0,0 +1,82 @@
from unittest.mock import patch
from app.modules.jellyfin.jellyfin import Jellyfin
class _FakeResponse:
"""模拟 Jellyfin HTTP 响应。"""
def __init__(self, payload: object):
"""保存响应数据。"""
self._payload = payload
def json(self) -> object:
"""返回模拟的 JSON 数据。"""
return self._payload
def _make_client() -> Jellyfin:
"""构造跳过初始化的 Jellyfin 客户端。"""
client = Jellyfin.__new__(Jellyfin)
client._host = "http://jellyfin.local:8096/"
client._apikey = "api-key"
client._playhost = None
client._sync_libraries = []
client.user = "user-id"
return client
def test_get_user_supports_legacy_query_and_jellyfin_12_header():
"""用户查询应同时兼容旧版查询参数与 Jellyfin 12 请求头鉴权。"""
client = _make_client()
with patch("app.modules.jellyfin.jellyfin.RequestUtils") as request_utils_cls:
request_utils_cls.return_value.get_res.return_value = _FakeResponse(
[{"Id": "user-id", "Name": "admin"}]
)
user_id = client.get_user("admin")
assert user_id == "user-id"
assert request_utils_cls.call_args.kwargs["headers"] == {
"Authorization": 'MediaBrowser Token="api-key"'
}
request_utils_cls.return_value.get_res.assert_called_once_with(
"http://jellyfin.local:8096/Users",
{"api_key": "api-key"},
)
def test_authenticate_preserves_client_headers_and_adds_jellyfin_12_header():
"""用户认证应保留客户端声明并补充 Jellyfin 12 请求头鉴权。"""
client = _make_client()
with patch("app.modules.jellyfin.jellyfin.RequestUtils") as request_utils_cls:
request_utils_cls.return_value.post_res.return_value = _FakeResponse(
{"AccessToken": "user-token"}
)
token = client.authenticate("admin", "password")
assert token == "user-token"
headers = request_utils_cls.call_args.kwargs["headers"]
assert headers["Authorization"] == 'MediaBrowser Token="api-key"'
assert headers["X-Emby-Authorization"].endswith('Token="api-key"')
assert headers["Content-Type"] == "application/json"
assert headers["Accept"] == "application/json"
def test_post_data_preserves_explicit_authorization_header():
"""自定义请求显式提供 Authorization 时不应被服务器密钥覆盖。"""
client = _make_client()
with patch("app.modules.jellyfin.jellyfin.RequestUtils") as request_utils_cls:
client.post_data(
"[HOST]Sessions/Playing",
headers={"Authorization": "Custom token", "X-Test": "value"},
)
assert request_utils_cls.call_args.kwargs["headers"] == {
"Authorization": "Custom token",
"X-Test": "value",
}

View File

@@ -119,6 +119,132 @@ def test_media_detail_forwards_custom_plugin_source(monkeypatch) -> None:
assert captured["mediaid"] == "custom-1"
def test_media_detail_falls_back_to_title_for_legacy_discover_source(
monkeypatch,
) -> None:
"""仅提供探索列表的旧插件应在原生 ID 识别失败后按标题年份兜底。"""
media = MediaInfo(
source="themoviedb",
type=MediaType.TV,
title="旧版剧集",
tmdb_id=12345,
)
media_chain = Mock()
media_chain.async_recognize_media = AsyncMock(return_value=None)
media_chain.async_recognize_by_meta = AsyncMock(return_value=media)
media_chain.async_obtain_images = AsyncMock(return_value=None)
monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain))
monkeypatch.setattr(
media_endpoint.eventmanager,
"async_send_event",
AsyncMock(return_value=None),
)
result = asyncio.run(
media_endpoint.detail(
mediaid="tvdb:81189",
type_name=MediaType.TV.value,
title="旧版剧集",
year="2026",
_=None,
)
)
assert result["tmdb_id"] == 12345
media_chain.async_recognize_media.assert_awaited_once_with(
source="tvdb",
mediaid="81189",
mtype=MediaType.TV,
)
fallback_meta = media_chain.async_recognize_by_meta.await_args.args[0]
assert fallback_meta.name == "旧版剧集"
assert fallback_meta.year == "2026"
assert fallback_meta.type == MediaType.TV
def test_media_detail_uses_convert_event_for_legacy_discover_source(
monkeypatch,
) -> None:
"""旧探索插件提供 ID 转换事件时应优先转换到系统识别源。"""
media = MediaInfo(
source="themoviedb",
type=MediaType.MOVIE,
title="转换电影",
tmdb_id=54321,
)
event_data = media_endpoint.MediaRecognizeConvertEventData(
mediaid="legacy:42",
convert_type="themoviedb",
)
event_data.media_dict["id"] = 54321
media_chain = Mock()
media_chain.async_recognize_media = AsyncMock(side_effect=[None, media])
media_chain.async_recognize_by_meta = AsyncMock(
side_effect=AssertionError("转换成功后不应再按标题识别")
)
media_chain.async_obtain_images = AsyncMock(return_value=None)
monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain))
monkeypatch.setattr(
media_endpoint.eventmanager,
"async_send_event",
AsyncMock(return_value=Mock(event_data=event_data)),
)
result = asyncio.run(
media_endpoint.detail(
mediaid="legacy:42",
type_name=MediaType.MOVIE.value,
title="转换电影",
year="2026",
_=None,
)
)
assert result["tmdb_id"] == 54321
assert media_chain.async_recognize_media.await_count == 2
assert media_chain.async_recognize_media.await_args_list[0].kwargs == {
"source": "legacy",
"mediaid": "42",
"mtype": MediaType.MOVIE,
}
assert media_chain.async_recognize_media.await_args_list[1].kwargs == {
"source": "themoviedb",
"mediaid": "54321",
"mtype": MediaType.MOVIE,
}
media_chain.async_recognize_by_meta.assert_not_awaited()
def test_media_detail_does_not_fallback_for_builtin_source(monkeypatch) -> None:
"""内置来源的明确 ID 查询失败时不应被标题识别替换身份。"""
media_chain = Mock()
media_chain.async_recognize_media = AsyncMock(return_value=None)
media_chain.async_recognize_by_meta = AsyncMock(
side_effect=AssertionError("不应按标题切换识别源")
)
convert_event = AsyncMock(return_value=None)
monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain))
monkeypatch.setattr(
media_endpoint.eventmanager,
"async_send_event",
convert_event,
)
result = asyncio.run(
media_endpoint.detail(
mediaid="tmdb:999999",
type_name=MediaType.MOVIE.value,
title="错误兜底电影",
year="2026",
_=None,
)
)
assert isinstance(result, media_endpoint.schemas.MediaInfo)
media_chain.async_recognize_by_meta.assert_not_awaited()
convert_event.assert_not_awaited()
def test_media_seasons_builds_anilist_season_response(monkeypatch) -> None:
"""AniList 详情应能通过统一季信息接口返回剧集季。"""
captured = {}

View File

@@ -933,17 +933,21 @@ class SubscribeChainTest(TestCase):
self.assertEqual(meta.begin_season, 0)
self.assertEqual(meta.type, MediaType.TV)
def test_follow_preserves_shared_special_season_zero(self):
"""follow 分享订阅携带 S0 时,标题规整不能把合法季号覆盖成未指定"""
def test_follow_preserves_shared_special_season_and_episode_group(self):
"""Follow 分享必须保留合法 S0 与自定义剧集组的完整订阅范围"""
added_calls = []
exists_calls = []
history_calls = []
class _SubscribeOper:
"""提供订阅存在性查询,避免依赖真实数据库。"""
def exists(self, *args, **kwargs):
exists_calls.append(kwargs)
return False
def exist_history(self, *args, **kwargs):
history_calls.append(kwargs)
return False
class _SystemConfigOper:
@@ -966,6 +970,7 @@ class SubscribeChainTest(TestCase):
"tmdbid": None,
"doubanid": "12345",
"season": 0,
"episode_group": "eg-special",
"best_version": 0,
"save_path": None,
"search_imdbid": False,
@@ -1003,6 +1008,9 @@ class SubscribeChainTest(TestCase):
self.assertEqual(len(added_calls), 1)
self.assertEqual(added_calls[0]["season"], 0)
self.assertEqual(added_calls[0]["episode_group"], "eg-special")
self.assertEqual(exists_calls[0]["episode_group"], "eg-special")
self.assertEqual(history_calls[0]["episode_group"], "eg-special")
def test_resolve_subscribe_missing_accepts_downloaded_episode_best_version_targets(self):
"""外部完成守卫可按任意已下载版本判定分集洗版目标已满足。"""

View File

@@ -1,5 +1,38 @@
import asyncio
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.db.subscribe_oper import SubscribeOper
from app.schemas.types import MediaType
def _media(episode_group):
"""构造订阅新增路径所需的稳定 MediaInfo 契约替身。"""
return SimpleNamespace(
title="测试剧",
year="2026",
type=MediaType.TV,
source="themoviedb",
media_source="themoviedb",
media_id="987654321",
mediaid="tmdb:987654321",
tmdb_id=987654321,
imdb_id=None,
tvdb_id=None,
douban_id=None,
bangumi_id=None,
anilist_id=None,
episode_group=episode_group,
vote_average=8.0,
overview="测试简介",
get_poster_image=lambda: None,
get_backdrop_image=lambda: None,
)
def test_add_history_converts_boolean_integer_flags(monkeypatch):
@@ -37,3 +70,176 @@ def test_add_history_converts_boolean_integer_flags(monkeypatch):
"best_version_full": 1,
"search_imdbid": 0,
}
@pytest.mark.parametrize("episode_group", [None, "eg-1"])
def test_add_scopes_duplicate_lookup_by_episode_group(episode_group):
"""同步新增前后都必须按剧集组查询,主季和自定义组不能互相去重。"""
persisted = SimpleNamespace(id=88)
created = SimpleNamespace(create=MagicMock())
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.exists.side_effect = [None, persisted]
subscribe_model.return_value = created
sid, message = SubscribeOper(db=object()).add(
mediainfo=_media(episode_group),
season=1,
)
assert (sid, message) == (88, "新增订阅成功")
assert subscribe_model.exists.call_count == 2
assert all(
call.kwargs["episode_group"] == episode_group
for call in subscribe_model.exists.call_args_list
)
created.create.assert_called_once()
@pytest.mark.parametrize("episode_group", [None, "eg-1"])
def test_async_add_scopes_duplicate_lookup_by_episode_group(episode_group):
"""异步新增与同步路径使用相同的剧集组身份契约。"""
persisted = SimpleNamespace(id=89)
created = SimpleNamespace(async_create=AsyncMock())
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.async_exists = AsyncMock(side_effect=[None, persisted])
subscribe_model.return_value = created
sid, message = asyncio.run(SubscribeOper(db=object()).async_add(
mediainfo=_media(episode_group),
season=1,
))
assert (sid, message) == (89, "新增订阅成功")
assert subscribe_model.async_exists.await_count == 2
assert all(
call.kwargs["episode_group"] == episode_group
for call in subscribe_model.async_exists.await_args_list
)
created.async_create.assert_awaited_once()
def test_owner_scoped_add_forwards_episode_group_sync_and_async():
"""按 owner 去重的同步与异步新增也必须使用同一剧集组身份。"""
media = _media("eg-owner")
sync_persisted = SimpleNamespace(id=90)
sync_created = SimpleNamespace(create=MagicMock())
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.exists_by_username.side_effect = [None, sync_persisted]
subscribe_model.return_value = sync_created
sid, _ = SubscribeOper(db=object()).add(
mediainfo=media,
season=1,
username="alice",
owner_scope=True,
)
assert sid == 90
assert all(
call.kwargs["episode_group"] == "eg-owner"
for call in subscribe_model.exists_by_username.call_args_list
)
async_persisted = SimpleNamespace(id=91)
async_created = SimpleNamespace(async_create=AsyncMock())
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.async_exists_by_username = AsyncMock(
side_effect=[None, async_persisted]
)
subscribe_model.return_value = async_created
sid, _ = asyncio.run(SubscribeOper(db=object()).async_add(
mediainfo=media,
season=1,
username="alice",
owner_scope=True,
))
assert sid == 91
assert all(
call.kwargs["episode_group"] == "eg-owner"
for call in subscribe_model.async_exists_by_username.await_args_list
)
def test_exists_defaults_to_main_season_episode_group():
"""省略剧集组时按主季查询,显式剧集组按对应范围查询。"""
oper = SubscribeOper(db=object())
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.exists.return_value = SimpleNamespace(id=1)
assert oper.exists(tmdbid=100, season=1) is True
assert subscribe_model.exists.call_args.kwargs["episode_group"] is None
assert oper.exists(tmdbid=100, season=1, episode_group="eg-1") is True
assert subscribe_model.exists.call_args.kwargs["episode_group"] == "eg-1"
with patch("app.db.subscribe_oper.SubscribeHistory") as history_model:
history_model.exists.return_value = SimpleNamespace(id=2)
assert oper.exist_history(tmdbid=100, season=1) is True
assert history_model.exists.call_args.kwargs["episode_group"] is None
assert oper.exist_history(tmdbid=100, season=1, episode_group="eg-1") is True
assert history_model.exists.call_args.kwargs["episode_group"] == "eg-1"
def test_subscribe_exists_distinguishes_same_season_episode_groups():
"""同一媒体同一季的主季、自定义剧集组应分别命中各自订阅。"""
oper = SubscribeOper()
tmdbid = -(900_000_000 + os.getpid())
created_ids = []
rows = [
Subscribe(name="主季订阅", type=MediaType.TV.value, state="N",
tmdbid=tmdbid, season=1, episode_group=None),
Subscribe(name="剧集组订阅", type=MediaType.TV.value, state="N",
tmdbid=tmdbid, season=1, episode_group="eg-1"),
]
try:
for row in rows:
row.create(oper._db)
main_season = Subscribe.exists(
oper._db, tmdbid=tmdbid, season=1, episode_group=None,
)
created_ids.append(main_season.id)
main_name = main_season.name
episode_group = Subscribe.exists(
oper._db, tmdbid=tmdbid, season=1, episode_group="eg-1",
)
created_ids.append(episode_group.id)
episode_group_name = episode_group.name
assert main_name == "主季订阅"
assert episode_group_name == "剧集组订阅"
Subscribe.delete(oper._db, rid=created_ids.pop(0))
assert Subscribe.exists(oper._db, tmdbid=tmdbid, season=1) is None
finally:
for subscribe_id in created_ids:
Subscribe.delete(oper._db, rid=subscribe_id)
def test_subscribe_chain_exists_forwards_episode_group():
"""订阅前置存在性检查必须查询当前剧集组,不能退回主季范围。"""
from app.chain.subscribe import SubscribeChain
media = _media("eg-1")
meta = SimpleNamespace(begin_season=1)
with patch("app.chain.subscribe.SubscribeOper") as subscribe_oper_cls:
subscribe_oper_cls.return_value.exists.return_value = True
assert SubscribeChain.exists(media, meta) is True
subscribe_oper_cls.return_value.exists.assert_called_once_with(
tmdbid=media.tmdb_id,
doubanid=media.douban_id,
bangumiid=media.bangumi_id,
anilistid=media.anilist_id,
media_source="themoviedb",
media_id=str(media.tmdb_id),
season=1,
episode_group="eg-1",
)

View File

@@ -1,4 +1,5 @@
from pathlib import Path
from threading import Lock
from unittest.mock import MagicMock
import pytest
@@ -24,6 +25,24 @@ def _u115() -> U115Pan:
return object.__new__(U115Pan)
def _u115_with_api_payload(payload: dict) -> U115Pan:
"""
构造返回固定业务响应的 115 存储实例。
"""
storage = _u115()
response = MagicMock(status_code=200)
response.json.return_value = payload
storage.session = MagicMock()
storage.session.request.return_value = response
storage._check_session = MagicMock()
storage._download_limiter = MagicMock()
storage._api_limiter = MagicMock()
storage._rate_stats = MagicMock()
storage._limit_lock = Lock()
storage._limit_until = 0.0
return storage
def _alipan(monkeypatch) -> AliPan:
"""
构造阿里云盘存储实例跳过初始化_default_drive_id 为只读属性需在类级替换)。
@@ -111,6 +130,41 @@ def test_u115_strict_confirmed_absent_returns_none():
assert storage.get_item_strict(Path("/movie.mkv")) is None
def test_u115_strict_path_not_found_returns_none():
"""
115 查询路径返回 430004 时应确认为不存在,允许首次整理。
"""
storage = _u115_with_api_payload(
{"state": False, "code": 430004, "message": "路径不存在", "data": {}}
)
assert storage.get_item_strict(Path("/movie.mkv")) is None
def test_u115_strict_empty_list_returns_none():
"""
115 查询不存在目标返回空列表时也应确认为不存在。
"""
storage = _u115_with_api_payload(
{"state": True, "code": 0, "message": "", "data": []}
)
assert storage.get_item_strict(Path("/movie.mkv")) is None
def test_u115_path_not_found_code_is_not_globally_accepted():
"""
非路径查询接口返回 430004 时仍应视为业务错误。
"""
storage = _u115_with_api_payload(
{"state": False, "code": 430004, "message": "路径不存在", "data": {}}
)
result = storage._request_api("POST", "/open/folder/add", data={"file_name": "TV"})
assert result is None
def test_u115_strict_returns_item():
"""
115 返回有效文件数据时应构造文件项。

View File

@@ -0,0 +1,284 @@
import asyncio
import base64
import json
from app.chain.download import DownloadChain
from app.core.context import TorrentInfo
from app.modules.indexer.parser.yema import YemaSiteUserInfo
from app.modules.indexer.spider.yema import YemaSpider
from app.schemas import MediaType
class _FakeResponse:
"""构造 YemaPT 开放 API 测试使用的最小响应对象。"""
def __init__(self, payload: dict, status_code: int = 200):
"""保存响应数据和状态码。"""
self._payload = payload
self.status_code = status_code
self.reason = "OK"
self.text = json.dumps(payload)
def __bool__(self) -> bool:
"""按 HTTP 成功状态模拟 requests.Response 的布尔值。"""
return self.status_code < 400
def json(self) -> dict:
"""返回预设 JSON 数据。"""
return self._payload
def _build_indexer() -> dict:
"""构造 YemaPT 开放 API Spider 所需的最小站点配置。"""
return {
"id": "yemapt",
"name": "YemaPT",
"domain": "https://www.yemapt.org/",
"apikey": "yema-auth-key",
"ua": "MoviePilot-Test",
"proxy": False,
}
def _torrent_response() -> _FakeResponse:
"""构造 YemaPT 公开种子列表响应。"""
return _FakeResponse({
"success": True,
"showType": 0,
"data": [{
"id": 100,
"showName": "Movie.2026.2160p.WEB-DL.H.265-GROUP",
"shortDesc": "电影中文副标题",
"categoryId": 4,
"fileSize": 21474836480,
"seedNum": 15,
"leechNum": 2,
"completedNum": 30,
"listingTime": "2026-08-02T14:00:00+08:00",
"uploadPromotion": "double_upload",
"downloadPromotion": "free",
"downloadPromotionEndTime": "2026-08-04T14:00:00+08:00",
"tagList": ["6", "9"],
"hrPunishEnable": True,
}],
})
def test_yemapt_search_uses_open_api_auth_and_maps_fields(monkeypatch):
"""YemaPT 搜索应使用 AuthKey、开放接口及标准字段映射。"""
captured = {}
def fake_post_res(request, url: str, json: dict = None, **_kwargs):
"""记录开放 API 搜索请求并回放种子数据。"""
captured.update({
"url": url,
"json": json,
"headers": request._headers,
"cookies": request._cookies,
})
return _torrent_response()
monkeypatch.setattr(
"app.modules.indexer.spider.yema.RequestUtils.post_res",
fake_post_res,
)
error, torrents = YemaSpider(_build_indexer()).search(
keyword="Movie",
mtype=MediaType.MOVIE,
page=2,
)
assert not error
assert captured == {
"url": "https://www.yemapt.org/openApi/torrent/fetchOpenTorrentList.json",
"json": {
"keyword": "Movie",
"pageParam": {"current": 3, "pageSize": 40},
"sorter": {},
},
"headers": {
"Authorization": "yema-auth-key",
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"User-Agent": "MoviePilot-Test",
},
"cookies": None,
}
assert torrents[0] == {
"title": "Movie.2026.2160p.WEB-DL.H.265-GROUP",
"description": "电影中文副标题",
"enclosure": torrents[0]["enclosure"],
"pubdate": "2026-08-02 14:00:00",
"size": 21474836480,
"seeders": 15,
"peers": 2,
"grabs": 30,
"downloadvolumefactor": 0,
"uploadvolumefactor": 2,
"freedate": "2026-08-04 14:00:00",
"page_url": "https://www.yemapt.org/#/torrent/detail/100/",
"labels": ["中字", "HDR10"],
"hit_and_run": True,
"category": MediaType.MOVIE.value,
}
encoded_config, credential_url = torrents[0]["enclosure"].split("]", 1)
request_config = json.loads(base64.b64decode(encoded_config[1:]).decode("utf-8"))
assert credential_url == (
"https://www.yemapt.org/openApi/torrent/generateDownloadKey.json"
)
assert request_config["header"]["Authorization"] == "yema-auth-key"
assert request_config["params"] == {"id": 100}
assert request_config["success"] == "success"
assert request_config["result_path"] == "api/torrent/download1"
assert request_config["result_query_param"] == "token"
def test_yemapt_search_rejects_business_failure(monkeypatch):
"""HTTP 成功但业务失败时YemaPT 搜索必须返回错误。"""
monkeypatch.setattr(
"app.modules.indexer.spider.yema.RequestUtils.post_res",
lambda *_args, **_kwargs: _FakeResponse({
"success": False,
"errorCode": 403,
"errorMessage": "need api auth",
}),
)
error, torrents = YemaSpider(_build_indexer()).search(keyword="Movie")
assert error
assert torrents == []
def test_yemapt_async_search_uses_open_api(monkeypatch):
"""YemaPT 异步搜索应使用与同步搜索相同的开放 API 契约。"""
captured = {}
async def fake_post_res(request, url: str, json: dict = None, **_kwargs):
"""记录异步开放 API 请求并回放种子数据。"""
captured.update({
"url": url,
"json": json,
"headers": request._headers,
"cookies": request._cookies,
})
return _torrent_response()
monkeypatch.setattr(
"app.modules.indexer.spider.yema.AsyncRequestUtils.post_res",
fake_post_res,
)
error, torrents = asyncio.run(
YemaSpider(_build_indexer()).async_search(keyword="Movie", page=0)
)
assert not error
assert len(torrents) == 1
assert captured["url"].endswith("/openApi/torrent/fetchOpenTorrentList.json")
assert captured["headers"]["Authorization"] == "yema-auth-key"
assert captured["cookies"] is None
def test_yemapt_user_parser_uses_basic_info_only(monkeypatch):
"""YemaPT 用户解析器应仅调用开放 API 已提供的基本信息接口。"""
captured = []
payload = {
"success": True,
"showType": 0,
"data": {
"id": 10,
"name": "yema-user",
"bonus": 1000000,
"level": 7,
"registerTime": "2024-05-01T00:00:00+08:00",
"promotionUploadSize": 2000000,
"promotionDownloadSize": 1000000,
},
}
def fake_post_res(request, url: str, json: dict = None, **_kwargs):
"""记录用户基本信息请求并回放开放 API 响应。"""
captured.append((url, json, request._headers, request._cookies))
return _FakeResponse(payload)
monkeypatch.setattr(
"app.modules.indexer.parser.RequestUtils.post_res",
fake_post_res,
)
parser = YemaSiteUserInfo(
site_name="YemaPT",
url="https://www.yemapt.org/",
site_cookie="legacy-cookie",
apikey="yema-auth-key",
token=None,
ua="MoviePilot-Test",
proxy=False,
)
parser.parse()
assert len(captured) == 1
assert captured[0][0] == "https://www.yemapt.org/openApi/user/fetchBasicInfo.json"
assert captured[0][1] == {}
assert captured[0][2]["Authorization"] == "yema-auth-key"
assert captured[0][3] is None
assert parser.userid == 10
assert parser.username == "yema-user"
assert parser.user_level == "7"
assert parser.upload == 2000000
assert parser.download == 1000000
assert parser.ratio == 2.0
def test_yemapt_download_generates_and_urlencodes_temporary_key(monkeypatch):
"""YemaPT 下载应临时生成凭证并在下载 URL 中安全编码。"""
captured = {}
def fake_post_res(request, url: str, params: dict = None, **_kwargs):
"""回放下载凭证接口响应。"""
captured.update({
"credential_url": url,
"credential_params": params,
"credential_headers": request._headers,
"credential_cookies": request._cookies,
})
return _FakeResponse({"success": True, "data": "abc+/="})
def fake_download_torrent(_helper, **kwargs):
"""记录最终种子文件下载地址并返回有效种子内容。"""
captured.update(kwargs)
return None, b"torrent-content", "Movie", ["Movie.mkv"], ""
monkeypatch.setattr("app.chain.download.RequestUtils.post_res", fake_post_res)
monkeypatch.setattr(
"app.chain.download.TorrentHelper.download_torrent",
fake_download_torrent,
)
enclosure = YemaSpider(_build_indexer())._build_download_url(100)
torrent = TorrentInfo(
title="Movie.2026",
enclosure=enclosure,
site_cookie="legacy-cookie",
site_ua="MoviePilot-Test",
site_proxy=False,
)
content, folder, files = object.__new__(DownloadChain).download_torrent(torrent)
assert content == b"torrent-content"
assert folder == "Movie"
assert files == ["Movie.mkv"]
assert captured["credential_url"] == (
"https://www.yemapt.org/openApi/torrent/generateDownloadKey.json"
)
assert captured["credential_params"] == {"id": 100}
assert captured["credential_headers"]["Authorization"] == "yema-auth-key"
assert captured["credential_cookies"] is None
assert captured["url"] == (
"https://www.yemapt.org/api/torrent/download1?token=abc%2B%2F%3D"
)
assert captured["cookie"] is None
assert captured["cache_invalid"] is False

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.15.2'
FRONTEND_VERSION = 'v2.15.2'
APP_VERSION = 'v2.15.4'
FRONTEND_VERSION = 'v2.15.4'