Compare commits

...

6 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
17 changed files with 307 additions and 23 deletions

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

@@ -951,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,
@@ -1777,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

@@ -39,8 +39,10 @@ class DownloadHistory(Base):
seasons = Column(String)
# Exx
episodes = Column(String)
# 海报
# 背景图
image = Column(String)
# 海报
poster = Column(String)
# 下载器
downloader = Column(String)
# 下载任务Hash

View File

@@ -14,7 +14,8 @@ class YemaSpider:
YemaPT 开放 API 索引器
"""
_size = 100
# YemaPT 开放 API 使用更大的分页容量时会返回空结果。
_size = 40
_movie_category = [4]
_tv_category = [5, 6, 13, 14, 15, 16, 17]

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, newest first. 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

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

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

@@ -94,7 +94,7 @@ def test_yemapt_search_uses_open_api_auth_and_maps_fields(monkeypatch):
"url": "https://www.yemapt.org/openApi/torrent/fetchOpenTorrentList.json",
"json": {
"keyword": "Movie",
"pageParam": {"current": 3, "pageSize": 100},
"pageParam": {"current": 3, "pageSize": 40},
"sorter": {},
},
"headers": {

View File

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