Compare commits

...

9 Commits

Author SHA1 Message Date
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
26 changed files with 1314 additions and 300 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

@@ -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'),

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

@@ -148,14 +148,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,23 @@
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
_size = 40
_searchurl = "%sapi/torrent/fetchOpenTorrentList"
_downloadurl = "%sapi/torrent/download?id=%s"
_pageurl = "%s#/torrent/detail/%s/"
_timeout = 15
# 分类
_size = 100
_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 +33,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

@@ -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` |
| 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

@@ -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

@@ -252,18 +252,18 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
def test_search_all_sites_uses_parser_page_size_for_yema(self):
"""
验证专用解析器按自身页容量判断,避免 Yema 的 40 条分页被误停。
验证专用解析器按自身页容量判断,避免 Yema 的 100 条分页被误停。
"""
chain = self._make_chain()
requested_pages = []
def search_torrents(**kwargs):
"""
模拟 Yema 第一页满 40 条,第二页不足 40 条后停止。
模拟 Yema 第一页满 100 条,第二页不足 100 条后停止。
"""
page = kwargs["page"]
requested_pages.append(page)
count = 40 if page == 0 else 39
count = 100 if page == 0 else 99
return [
SimpleNamespace(title=f"Result Page {page}-{index}", description="")
for index in range(count)
@@ -294,14 +294,14 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual([0, 1], requested_pages)
self.assertEqual(79, len(results))
self.assertEqual(199, len(results))
def test_indexer_module_search_page_size_uses_spider_metadata(self):
"""
验证站点单页容量由索引器模块统一读取,避免搜索链写死 parser 容量。
"""
self.assertEqual(
40,
100,
IndexerModule.get_search_page_size({"parser": "Yema"}, keyword="keyword")
)
self.assertEqual(

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": 100},
"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.3'
FRONTEND_VERSION = 'v2.15.3'