diff --git a/app/agent/policy/resources/api_mcp_schema.json b/app/agent/policy/resources/api_mcp_schema.json index 151351fdb..3ad90b727 100644 --- a/app/agent/policy/resources/api_mcp_schema.json +++ b/app/agent/policy/resources/api_mcp_schema.json @@ -12731,6 +12731,12 @@ "description": "旧版完整列表接口的可选每页条数;提供 page 或 count 即启用分页,此时省略 count 使用 50 条。", "title": "Count" }, + "latest_seeding_only": { + "default": false, + "description": "仅窗口内最新记录返回做种明细,历史统计不变", + "title": "Latest Seeding Only", + "type": "boolean" + }, "page": { "anyOf": [ { @@ -15259,6 +15265,70 @@ "const": "torrent.cache.get", "description": "白名单中的精确操作标识,用于选择对应 oneOf 分支。", "type": "string" + }, + "query": { + "additionalProperties": false, + "description": "torrent.cache.get 的筛选和控制参数。查看种子上下文缓存及已识别的媒体身份。仅使用下列已声明字段。", + "properties": { + "brief": { + "default": false, + "description": "描述最多返回 200 字符,缓存 hash 仍基于完整原文", + "title": "Brief", + "type": "boolean" + }, + "count": { + "anyOf": [ + { + "maximum": 200, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "旧版完整列表接口的可选每页条数;提供 page 或 count 即启用分页,此时省略 count 使用 50 条。", + "title": "Count" + }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "旧版完整列表接口的可选页码,从 1 开始;同时省略 page 和 count 时保留原有完整结果。", + "title": "Page" + }, + "site": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "精确匹配的站点名称", + "title": "Site" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "不区分大小写的标题包含条件", + "title": "Title" + } + }, + "type": "object" } }, "required": [ diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index 90b134b22..e7b621e4c 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -4,7 +4,7 @@ from typing import Annotated, Any, List, Literal, Optional, cast -from fastapi import Depends, HTTPException, Response +from fastapi import Depends, HTTPException, Query, Response from app.adapters.web.security.access import verify_token from app.api.context import get_background_task_registry, resolve_background_task_registry @@ -560,15 +560,26 @@ async def read_userdata_latest( ) async def read_userdata( site_id: int, - response: Response = None, # type: ignore[assignment] + response: Response, workdate: Optional[str] = None, query: SiteQueryService = Depends(get_site_query_service), _: ApiPrincipal = Depends(get_current_active_manage_user_async), page: CompatiblePageParam = None, count: CompatibleCountParam = None, + latest_seeding_only: Annotated[bool, Query(description="仅窗口内最新记录返回做种明细,历史统计不变")] = False, ) -> Any: """ - 查询站点用户数据 + 查询站点用户数据,可仅保留窗口内最新记录的做种明细。 + + :param site_id: 站点标识 + :param response: 由 FastAPI 注入的响应,用于写入集合总数 + :param workdate: 可选采集日期 + :param query: 注入的站点查询服务 + :param _: 已验证的管理用户 + :param page: 可选页码 + :param count: 可选每页数量 + :param latest_seeding_only: 为真时历史记录不重复返回做种明细;默认保留旧合同 + :return: 按请求窗口返回的数据,不修改持久化记录 """ site = await query.get(site_id) if not site: @@ -582,10 +593,9 @@ async def read_userdata( detail=f"站点 {site_id} 未配置域名", ) page, count = resolve_compatible_pagination(page, count) - if response is not None: - response.headers[COLLECTION_TOTAL_HEADER] = str( - await query.count_userdata(site.domain, workdate) - ) + response.headers[COLLECTION_TOTAL_HEADER] = str( + await query.count_userdata(site.domain, workdate) + ) user_datas = await query.userdata( site.domain, workdate, @@ -594,6 +604,12 @@ async def read_userdata( ) if not user_datas: return _SchemaResponse(success=False, data=[]) + if latest_seeding_only: + latest = max(user_datas, key=lambda item: (item.updated_day or "", item.updated_time or "")) + user_datas = [ + item if item is latest else item.model_copy(update={"seeding_info": []}) + for item in user_datas + ] return _SchemaResponse(success=True, data=user_datas) diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 96127903a..5e95c0539 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -2,10 +2,11 @@ 提供订阅查询、创建、修改和控制接口。 """ +from datetime import date from typing import Annotated, Any, List, Optional import cn2an -from fastapi import Depends, Header, HTTPException, Request, Response +from fastapi import Depends, Header, HTTPException, Query, Request, Response from app.adapters.external.server import MoviePilotServerHelper from app.adapters.web.security.access import ( @@ -66,12 +67,14 @@ from app.application.subscription.mutation import ( ) from app.application.subscription.query import SubscriptionQueryService from app.application.subscription.status import SubscriptionExecutionStatusService +from app.chain.subscribe.calendar import SubscribeCalendarChain from app.chain.subscribe.facade import SubscribeChain from app.domain.context import MediaInfo from app.domain.metainfo import MetaInfo from app.runtime.execution import run_in_threadpool from app.runtime.log import logger from app.runtime.tasks import TaskRegistry +from app.schemas.calendar import SubscribeCalendarPage as _SchemaSubscribeCalendarPage from app.schemas.common import IdData as _SchemaIdData from app.schemas.context import MediaInfo as _SchemaMediaInfo from app.schemas.media import normalize_media_source, resolve_media_identity @@ -223,6 +226,35 @@ async def read_subscribes( return await _attach_execution_status(subscribes, status_service) +@router.get("/calendar", summary="查询日期窗口内的订阅日历", response_model=_SchemaSubscribeCalendarPage) +async def subscribe_calendar( + request: Request, + start: Annotated[date, Query(description="开始日期(包含),格式 YYYY-MM-DD")], + end: Annotated[date, Query(description="结束日期(不包含),窗口最多 93 天")], + page: Annotated[int, Query(ge=1, description="从 1 开始的页码")] = 1, + count: Annotated[int, Query(ge=1, le=200, description="每页最多返回的日历条目数")] = 50, + query: SubscriptionQueryService = Depends(get_subscription_query_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), +) -> _SchemaSubscribeCalendarPage: + """ + 只返回指定播出日期内的精简日历事件,避免客户端逐订阅查询所有季集详情。 + + :param start: 日期窗口开始,包含当天 + :param request: 用于探测翻月或离开页面后断开的旧请求 + :param end: 日期窗口结束,不包含当天,跨度不超过 93 天 + :param page: 从 1 开始的页码 + :param count: 每页条目上限,最多 200 条 + :param query: 注入的订阅查询服务 + :param current_user: 当前登录用户,普通用户仅可查询自己的订阅 + :return: 按真实播出日期排序的精简日历条目与窗口总数 + """ + if not 0 < (end - start).days <= 93: + raise HTTPException(status_code=422, detail="日历日期窗口必须大于 0 且不超过 93 天") + username = None if current_user.is_superuser else current_user.name + subscribes = await query.list_public(username) + return await SubscribeCalendarChain().window(subscribes, start, end, page, count, request.is_disconnected) + + @router.get( "/list", summary="查询所有订阅(API_TOKEN)", diff --git a/app/api/endpoints/torrent.py b/app/api/endpoints/torrent.py index d6a77a5f7..6b0e41c5b 100644 --- a/app/api/endpoints/torrent.py +++ b/app/api/endpoints/torrent.py @@ -2,15 +2,20 @@ 提供种子详情、状态查询和资源处理接口。 """ -from typing import Any, Optional +from typing import Annotated, Any, Optional -from fastapi import Depends +from fastapi import Depends, Query from app.api.dependencies.auth import ( get_current_active_superuser, get_current_active_superuser_async, ) -from app.api.response import ResponseAPIRouter +from app.api.response import ( + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, + resolve_compatible_pagination, +) from app.application.configuration import get_api_runtime_config_snapshot from app.application.torrent.cache import TorrentCacheRecognitionService from app.chain.media import MediaChain @@ -35,9 +40,21 @@ router = ResponseAPIRouter() ) async def torrents_cache( _: object = Depends(get_current_active_superuser_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, + title: Annotated[Optional[str], Query(description="不区分大小写的标题包含条件")] = None, + site: Annotated[Optional[str], Query(description="精确匹配的站点名称")] = None, + brief: Annotated[bool, Query(description="描述最多返回 200 字符,缓存 hash 仍基于完整原文")] = False, ) -> _SchemaResponse[dict[str, Any]]: """ - 获取当前种子缓存数据 + 获取种子缓存统计,并在显式窗口内构造明细与摘要键。 + + :param page: 从 1 开始的可选页码;与 count 均省略时保留全量合同 + :param count: 每页条数 + :param title: 不区分大小写的标题包含条件 + :param site: 精确匹配的站点名称 + :param brief: 为真时描述最多返回 200 字符,摘要键仍基于完整原文 + :return: 全部缓存统计、筛选后数量、可选站点名称与当前窗口明细 """ torrents_chain = TorrentsChain() @@ -49,55 +66,72 @@ async def torrents_cache( # 统计信息 torrent_count = sum(len(torrents) for torrents in cache_info.values()) + page, count = resolve_compatible_pagination(page, count) + entries = [ + (domain, context) + for domain, contexts in cache_info.items() + for context in contexts + if context.torrent_info is not None + ] + site_names = sorted( + { + context.torrent_info.site_name + for _, context in entries + if context.torrent_info and context.torrent_info.site_name + } + ) + matched = [ + (domain, context) + for domain, context in entries + if context.torrent_info + and (not title or title.casefold() in (context.torrent_info.title or "").casefold()) + and (not site or context.torrent_info.site_name == site) + ] + total = len(matched) + if page is not None and count is not None: + matched = matched[(page - 1) * count : page * count] # 转换为前端需要的格式 torrent_data = [] - for domain, contexts in cache_info.items(): - for context in contexts: - torrent_info = context.torrent_info - if torrent_info is None: - continue - torrent_hash = HashUtils.md5( - f"{torrent_info.title}{torrent_info.description}" - ) - media_source, media_id = resolve_media_identity(media=context.media_info) - torrent_data.append( - { - "hash": torrent_hash, - "domain": domain, - "title": torrent_info.title, - "description": torrent_info.description, - "size": torrent_info.size, - "pubdate": torrent_info.pubdate, - "site_name": torrent_info.site_name, - "media_name": context.media_info.title - if context.media_info - else "", - "media_year": context.media_info.year if context.media_info else "", - "media_type": context.media_info.type if context.media_info else "", - "media_source": media_source, - "media_id": media_id, - "music_type": getattr(context.media_info, "music_type", None), - "season_episode": context.meta_info.season_episode - if context.meta_info - else "", - "resource_term": context.meta_info.resource_term - if context.meta_info - else "", - "enclosure": torrent_info.enclosure, - "page_url": torrent_info.page_url, - "poster_path": context.media_info.get_poster_image() - if context.media_info - else "", - "backdrop_path": context.media_info.get_backdrop_image() - if context.media_info - else "", - } - ) + for domain, context in matched: + torrent_info = context.torrent_info + if torrent_info is None: + continue + torrent_hash = HashUtils.md5(f"{torrent_info.title}{torrent_info.description}") + media_source, media_id = resolve_media_identity(media=context.media_info) + torrent_data.append( + { + "hash": torrent_hash, + "domain": domain, + "title": torrent_info.title, + "description": (torrent_info.description or "")[:200] if brief else torrent_info.description, + "size": torrent_info.size, + "pubdate": torrent_info.pubdate, + "site_name": torrent_info.site_name, + "media_name": context.media_info.title if context.media_info else "", + "media_year": context.media_info.year if context.media_info else "", + "media_type": context.media_info.type if context.media_info else "", + "media_source": media_source, + "media_id": media_id, + "music_type": getattr(context.media_info, "music_type", None), + "season_episode": context.meta_info.season_episode if context.meta_info else "", + "resource_term": context.meta_info.resource_term if context.meta_info else "", + "enclosure": torrent_info.enclosure, + "page_url": torrent_info.page_url, + "poster_path": context.media_info.get_poster_image() if context.media_info else "", + "backdrop_path": context.media_info.get_backdrop_image() if context.media_info else "", + } + ) return _SchemaResponse( success=True, - data={"count": torrent_count, "sites": len(cache_info), "data": torrent_data}, + data={ + "count": torrent_count, + "sites": len(cache_info), + "data": torrent_data, + "total": total, + "site_names": site_names, + }, ) diff --git a/app/chain/subscribe/calendar.py b/app/chain/subscribe/calendar.py new file mode 100644 index 000000000..4545e58a6 --- /dev/null +++ b/app/chain/subscribe/calendar.py @@ -0,0 +1,139 @@ +""" +复用媒体缓存,将订阅投影为按播出日期查询的日历窗口。 +""" + +import asyncio +from collections.abc import Awaitable, Callable +from datetime import date +from typing import Optional + +from app.chain.media import MediaChain +from app.chain.tmdb import TmdbChain +from app.runtime.cache import cached +from app.runtime.log import logger +from app.schemas.calendar import ( + CalendarDate, + CalendarEpisode, + CalendarSubscription, + SubscribeCalendarEvent, + SubscribeCalendarPage, +) +from app.schemas.media import normalize_media_source +from app.schemas.subscribe import Subscribe +from app.schemas.types import MediaSource, MediaType + +CALENDAR_DETAIL_CONCURRENCY = 4 + + +class SubscribeCalendarChain: + """ + 有界查询真实播出日期,先筛选日期窗口再分页并返回精简内容。 + """ + + @staticmethod + @cached(maxsize=1024, ttl=300, empty_ttl=60, local_only=True) + async def _schedule( + media_source: MediaSource, + media_id: str, + mtype: MediaType, + season: Optional[int], + episode_group: Optional[str], + ) -> list[CalendarDate]: + """ + 读取一个媒体身份的播出日期,复用现有预缓存并短期保存精简投影。 + + :param media_source: 主媒体来源 + :param media_id: 主来源媒体标识 + :param mtype: 影视媒体类型 + :param season: 订阅季号 + :param episode_group: 可选剧集组 + :return: 按日期聚合的电影上映或电视剧分集摘要 + """ + tmdb_id = int(media_id) if media_source == MediaSource.TMDB and media_id.isdecimal() else None + if mtype == MediaType.MOVIE or not tmdb_id: + media = await MediaChain().async_recognize_media( + media_source=media_source, + media_id=media_id, + mtype=mtype, + episode_group=episode_group, + ) + if not media: + return [] + if mtype == MediaType.MOVIE: + return [CalendarDate(date=media.release_date, runtime=media.runtime)] if media.release_date else [] + tmdb_id = media.tmdb_id + if not tmdb_id or season is None: + return [] + episodes = await TmdbChain().async_tmdb_episodes( + tmdbid=tmdb_id, + season=season, + episode_group=episode_group, + ) + dates: dict[str, CalendarDate] = {} + for episode in episodes: + if not episode.air_date: + continue + day = dates.setdefault(episode.air_date, CalendarDate(date=episode.air_date)) + day.episodes.append(CalendarEpisode(episode_number=episode.episode_number or 0, name=episode.name)) + if not day.runtime: + day.runtime = episode.runtime + return list(dates.values()) + + async def window( + self, + subscribes: list[Subscribe], + start: date, + end: date, + page: int, + count: int, + is_disconnected: Optional[Callable[[], Awaitable[bool]]] = None, + ) -> SubscribeCalendarPage: + """ + 查询左闭右开的真实播出日期窗口,详情最多四路并发,返回日期排序后的当前页。 + + :param subscribes: 已按当前用户权限筛选的订阅 + :param start: 包含在窗口内的开始日期 + :param end: 不包含在窗口内的结束日期 + :param page: 从 1 开始的页码 + :param count: 当前页最大事件条目数 + :param is_disconnected: 可选请求断开探测,停止旧窗口继续派发详情查询 + :return: 窗口内的精简事件页,不包含其他月份的分集详情 + """ + pending = iter(subscribes) + events: list[SubscribeCalendarEvent] = [] + start_key, end_key = start.isoformat(), end.isoformat() + + async def worker() -> None: + """ + 顺序消费剩余订阅,失败时保留其他订阅的可用日历结果。 + """ + for subscribe in pending: + if is_disconnected and await is_disconnected(): + return + source = normalize_media_source(subscribe.media_source) + if not source or not subscribe.media_id or subscribe.type not in {"电影", "电视剧"}: + continue + try: + schedule = await self._schedule( + source, + subscribe.media_id, + MediaType(subscribe.type), + subscribe.season, + subscribe.episode_group, + ) + except Exception as error: + logger.warning(f"订阅日历查询失败:{subscribe.name},{error}") + continue + events.extend( + SubscribeCalendarEvent( + subscribe=CalendarSubscription.model_validate(subscribe, from_attributes=True), + **day.model_dump(), + ) + for day in schedule + if start_key <= day.date < end_key + ) + + await asyncio.gather(*(worker() for _ in range(min(CALENDAR_DETAIL_CONCURRENCY, len(subscribes))))) + events.sort(key=lambda event: (event.date, event.subscribe.name or "", event.subscribe.id or 0)) + offset = (page - 1) * count + return SubscribeCalendarPage(items=events[offset : offset + count], total=len(events)) diff --git a/app/schemas/cache.py b/app/schemas/cache.py index 91317df49..6ef6d41d0 100644 --- a/app/schemas/cache.py +++ b/app/schemas/cache.py @@ -43,6 +43,8 @@ class TorrentCacheData(BaseModel): count: int = Field(default=0, description="条目数量") sites: int = Field(default=0, description="包含缓存条目的站点数量") data: list[TorrentCacheItem] = Field(default_factory=list, description="缓存种子明细列表") + total: int = Field(default=0, description="筛选条件匹配的缓存数量,用于窗口续载") + site_names: list[str] = Field(default_factory=list, description="全部缓存中可筛选的站点名称") class TorrentReidentifyData(BaseModel): diff --git a/app/schemas/calendar.py b/app/schemas/calendar.py new file mode 100644 index 000000000..afb4a95dc --- /dev/null +++ b/app/schemas/calendar.py @@ -0,0 +1,63 @@ +""" +订阅日历日期窗口使用的精简事件结构。 +""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +class CalendarSubscription(BaseModel): + """ + 日历使用的订阅摘要,仅保留卡片展示与入库状态所需字段。 + """ + + id: Optional[int] = Field(default=None, description="订阅标识") + name: Optional[str] = Field(default=None, description="订阅名称") + type: Optional[str] = Field(default=None, description="电影或电视剧") + year: Optional[str] = Field(default=None, description="发行年份") + season: Optional[int] = Field(default=None, description="订阅季号") + poster: Optional[str] = Field(default=None, description="海报地址") + total_episode: Optional[int] = Field(default=None, description="总集数") + lack_episode: Optional[int] = Field(default=None, description="缺失集数") + completed_episode: Optional[int] = Field(default=None, description="已入库集数") + note: Optional[list[int]] = Field(default=None, description="已入库集号") + best_version: Optional[int] = Field(default=None, description="是否洗版") + episode_priority: Optional[dict[str, int]] = Field(default=None, description="洗版剧集优先级") + last_update: Optional[str] = Field(default=None, description="最近入库更新时间") + + +class CalendarEpisode(BaseModel): + """ + 日历展示所需的单集摘要,不携带简介与演职人员等详情。 + """ + + episode_number: int = Field(description="集号") + name: Optional[str] = Field(default=None, description="本集标题") + + +class CalendarDate(BaseModel): + """ + 单个订阅在同一播出日期的事件摘要。 + """ + + date: str = Field(description="播出或上映日期,格式为 YYYY-MM-DD") + episodes: list[CalendarEpisode] = Field(default_factory=list, description="当天播出的分集,电影为空") + runtime: Optional[int] = Field(default=None, description="单集或电影时长,单位分钟") + + +class SubscribeCalendarEvent(CalendarDate): + """ + 关联订阅与其播出日期的可展示日历项。 + """ + + subscribe: CalendarSubscription = Field(description="当前用户可见的订阅摘要及入库进度") + + +class SubscribeCalendarPage(BaseModel): + """ + 按日期排序、限定日期窗口和条目数的订阅日历结果。 + """ + + items: list[SubscribeCalendarEvent] = Field(default_factory=list, description="当前页日历条目") + total: int = Field(description="当前日期窗口的日历条目总数") diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 267ba99f0..66f5b2ab9 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -816,8 +816,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 1139 | -| 内部导入边 | 9,824 | +| Python 模块 | 1142 | +| 内部导入边 | 9,837 | | 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) | diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 11d0902b4..1ed16e0b0 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -26,6 +26,27 @@ 升级前历史没有来源标记,界面显示“未记录”,不会按当前订阅设置追溯推断。 普通订阅的首次提交与洗版订阅补缺在活动中都标记为首次订阅。 +### 大集合查询窗口 + +`GET /api/v1/site/userdata/{site_id}` 支持 `page` / `count`;趋势界面使用最近 90 条记录, +并传入 `latest_seeding_only=true`,仅窗口内最新记录携带 `seeding_info`,历史统计字段不变。 +省略该选项时保留每条记录的完整明细;这不会删除或修改采集历史。 + +`GET /api/v1/torrent/cache` 支持可选 `page` / `count`,每页最多 200 条,省略两项时保留全量合同。 +`title` 为不区分大小写的标题包含条件,`site` 为站点名称精确条件,均在分页前应用。 +`brief=true` 将描述截至 200 字符,缓存 hash 仍由完整原文生成。 +返回的 `count` / `sites` 是完整缓存统计,`total` 是筛选后数量,`site_names` 包含完整可选站点。 +分页只限制明细构造和响应,不改变底层缓存读取语义。 + +`GET /api/v1/subscribe/calendar?start=2026-10-01&end=2026-11-01&page=1&count=50` +面向日历界面,按真实播出/上映日期过滤后排序、分页,返回 `items` / `total`。 +`start` 包含当天,`end` 不包含当天,跨度最多 93 天,每页最多 200 条。 +每项仅含订阅展示/入库摘要、日期、分集标题/集号及片长,不包含窗口外分集、简介和演职人员。 +普通用户只能查询自己的订阅,管理员可查询全部;音乐订阅不进入影视日历。 +详情查询最多四路并发,复用现有媒体预缓存及五分钟的有界精简缓存;冷缓存仍可能等待媒体源。 +翻月或离开页面后客户端取消旧窗口,服务端探测断开后停止继续派发详情。 +该界面专用聚合端点未增加 Agent 操作;Agent 的 `subscription.list` 合同不变。 + ### App 小组件只读接口 主 App 使用既有登录通过 `/api/v1/widget/context` 核对账号、`/api/v1/widget/registrations` 登记。 diff --git a/scripts/generate_agent_skill_docs.py b/scripts/generate_agent_skill_docs.py index dd7a77cd5..ded9b9450 100644 --- a/scripts/generate_agent_skill_docs.py +++ b/scripts/generate_agent_skill_docs.py @@ -17,7 +17,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -MOVIEPILOT_API_SKILL_VERSION = "66" +MOVIEPILOT_API_SKILL_VERSION = "67" DATABASE_OPERATION_SKILL_VERSION = "16" diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 07ac627a5..4b0cfc4c0 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 66 +version: 67 description: >- Use this skill for MoviePilot product operations such as media search, torrent search, downloads, subscriptions, library checks, sites, storage, workflows, diff --git a/skills/moviepilot-api/api/site.md b/skills/moviepilot-api/api/site.md index b557698ba..6238d50cd 100644 --- a/skills/moviepilot-api/api/site.md +++ b/skills/moviepilot-api/api/site.md @@ -177,7 +177,7 @@ Purpose: 读取最近采集的指定站点账号统计。 Identity scope: `admin`; required role: `system_admin`. - `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated. - `path_params`: `site_id*` (integer): site.list 返回的持久化站点标识。 -- `query`: `count` (integer|null): 旧版完整列表接口的可选每页条数;提供 page 或 count 即启用分页,此时省略 count 使用 50 条。; `page` (integer|null): 旧版完整列表接口的可选页码,从 1 开始;同时省略 page 和 count 时保留原有完整结果。; `workdate` (string|null): 读取站点历史用户统计时使用的日期。 +- `query`: `count` (integer|null): 旧版完整列表接口的可选每页条数;提供 page 或 count 即启用分页,此时省略 count 使用 50 条。; `latest_seeding_only` (boolean; default `False`): 仅窗口内最新记录返回做种明细,历史统计不变; `page` (integer|null): 旧版完整列表接口的可选页码,从 1 开始;同时省略 page 和 count 时保留原有完整结果。; `workdate` (string|null): 读取站点历史用户统计时使用的日期。 - `body`: none ### `site.userdata.latest` diff --git a/skills/moviepilot-api/api/torrent.md b/skills/moviepilot-api/api/torrent.md index 44a0d45c5..a7078b806 100644 --- a/skills/moviepilot-api/api/torrent.md +++ b/skills/moviepilot-api/api/torrent.md @@ -25,7 +25,7 @@ Identity scope: `admin`; required role: `system_admin`. Purpose: 查看种子上下文缓存及已识别的媒体身份。 Identity scope: `admin`; required role: `system_admin`. - `path_params`: none -- `query`: none +- `query`: `brief` (boolean; default `False`): 描述最多返回 200 字符,缓存 hash 仍基于完整原文; `count` (integer|null): 旧版完整列表接口的可选每页条数;提供 page 或 count 即启用分页,此时省略 count 使用 50 条。; `page` (integer|null): 旧版完整列表接口的可选页码,从 1 开始;同时省略 page 和 count 时保留原有完整结果。; `site` (string|null): 精确匹配的站点名称; `title` (string|null): 不区分大小写的标题包含条件 - `body`: none ### `torrent.cache.refresh` diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 26fdbd026..ca245766d 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -959,8 +959,8 @@ "runtime_only": true } }, - "edge_count": 9824, - "edge_sha256": "3f1ac081c43f4139ba631664cea9f91bc3aaccee7b9186b0f57a9334c218b88b", + "edge_count": 9837, + "edge_sha256": "d51ff72057e0c293893d968ca72a5f76f33c0c89d4d05a31c87110ba3b2cfc56", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -3170,6 +3170,7 @@ "app.api.endpoints.subscribe -> app.application.subscription.status", "app.api.endpoints.subscribe -> app.chain", "app.api.endpoints.subscribe -> app.chain.subscribe", + "app.api.endpoints.subscribe -> app.chain.subscribe.calendar", "app.api.endpoints.subscribe -> app.chain.subscribe.facade", "app.api.endpoints.subscribe -> app.domain", "app.api.endpoints.subscribe -> app.domain.context", @@ -3180,6 +3181,7 @@ "app.api.endpoints.subscribe -> app.runtime.log", "app.api.endpoints.subscribe -> app.runtime.tasks", "app.api.endpoints.subscribe -> app.schemas", + "app.api.endpoints.subscribe -> app.schemas.calendar", "app.api.endpoints.subscribe -> app.schemas.common", "app.api.endpoints.subscribe -> app.schemas.context", "app.api.endpoints.subscribe -> app.schemas.media", @@ -5495,6 +5497,17 @@ "app.chain.storage -> app.runtime.log", "app.chain.storage -> app.schemas", "app.chain.storage -> app.schemas.file", + "app.chain.subscribe.calendar -> app.chain", + "app.chain.subscribe.calendar -> app.chain.media", + "app.chain.subscribe.calendar -> app.chain.tmdb", + "app.chain.subscribe.calendar -> app.runtime", + "app.chain.subscribe.calendar -> app.runtime.cache", + "app.chain.subscribe.calendar -> app.runtime.log", + "app.chain.subscribe.calendar -> app.schemas", + "app.chain.subscribe.calendar -> app.schemas.calendar", + "app.chain.subscribe.calendar -> app.schemas.media", + "app.chain.subscribe.calendar -> app.schemas.subscribe", + "app.chain.subscribe.calendar -> app.schemas.types", "app.chain.subscribe.completion -> app.application", "app.chain.subscribe.completion -> app.application.classification", "app.chain.subscribe.completion -> app.application.classification.reference", @@ -10787,7 +10800,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1140, + "module_count": 1142, "modules": [ "app", "app.adapters", @@ -11301,6 +11314,7 @@ "app.chain.site", "app.chain.storage", "app.chain.subscribe", + "app.chain.subscribe.calendar", "app.chain.subscribe.completion", "app.chain.subscribe.context", "app.chain.subscribe.contract", @@ -11795,6 +11809,7 @@ "app.schemas", "app.schemas.agent", "app.schemas.cache", + "app.schemas.calendar", "app.schemas.category", "app.schemas.comment", "app.schemas.common", diff --git a/tests/fixtures/architecture/mypy-policy.json b/tests/fixtures/architecture/mypy-policy.json index 282790f1f..d5f4982cf 100644 --- a/tests/fixtures/architecture/mypy-policy.json +++ b/tests/fixtures/architecture/mypy-policy.json @@ -598,16 +598,6 @@ "statement": "@router.get('/statistic', summary='所有站点统计信息', response_model=List[_SchemaSiteStatistic], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def read_statistics(response: Response=None, query: SiteQueryService=Depends(get_site_query_service), _: _SchemaTokenPayload=Depends(verify_token), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:", "reason": "fastapi-response-default" }, - { - "path": "app/api/endpoints/site.py", - "owner": "read_userdata", - "codes": [ - "assignment" - ], - "fingerprint": "e6e9db2ebeb1bfb88f648ee5a1a6f386574ecf1a0fee8e3a20f520809e463e3b", - "statement": "@router.get('/userdata/{site_id}', summary='查询某站点用户数据', response_model=_SchemaResponse[list[_SchemaSiteUserData]], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def read_userdata(site_id: int, response: Response=None, workdate: Optional[str]=None, query: SiteQueryService=Depends(get_site_query_service), _: ApiPrincipal=Depends(get_current_active_manage_user_async), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:", - "reason": "fastapi-response-default" - }, { "path": "app/api/endpoints/site.py", "owner": "read_userdata_latest", diff --git a/tests/test_bounded_collection_views.py b/tests/test_bounded_collection_views.py new file mode 100644 index 000000000..525589514 --- /dev/null +++ b/tests/test_bounded_collection_views.py @@ -0,0 +1,109 @@ +""" +大集合界面的窗口、轻量投影与旧调用兼容回归。 +""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +from fastapi import FastAPI, Response +from fastapi.testclient import TestClient + +from app.api.dependencies.auth import get_current_active_superuser_async +from app.api.endpoints import site, torrent +from app.domain.context import Context, TorrentInfo +from app.foundation.crypto import HashUtils +from app.schemas.site import SiteUserData + + +def test_site_history_keeps_only_latest_seeding_without_mutating_records(): + """ + 历史趋势保留统计量,只对最新一条返回明细,且不污染后续完整查询。 + """ + records = [ + SiteUserData(updated_day=day, upload=index, seeding_info=[(index, 1024)]) + for index, day in enumerate(["2026-10-24", "2026-10-26", "2026-10-25"]) + ] + query = Mock() + query.get = AsyncMock(return_value=SimpleNamespace(domain="example.test")) + query.userdata = AsyncMock(return_value=records) + query.count_userdata = AsyncMock(return_value=len(records)) + + result = asyncio.run(site.read_userdata(1, response=Response(), query=query, page=1, count=90, latest_seeding_only=True)) + + query.userdata.assert_awaited_once_with("example.test", None, page=1, count=90) + assert [record.upload for record in result.data] == [0, 1, 2] + assert [record.seeding_info for record in result.data] == [[], [(1, 1024)], []] + assert [record.seeding_info for record in records] == [[(0, 1024)], [(1, 1024)], [(2, 1024)]] + complete = asyncio.run(site.read_userdata(1, response=Response(), query=query)) + assert complete.data == records + + +def test_cache_window_limits_hashing_and_preserves_full_description_identity(): + """ + 大缓存只为当前页计算摘要,短描述不改变删除和重识别使用的身份。 + """ + contexts = [ + Context(torrent_info=TorrentInfo(title=f"Movie {index}", description="长描述" * 1000, site_name="站点甲")) + for index in range(1000) + ] + chain = Mock() + chain.async_get_torrents = AsyncMock(return_value={"example.test": contexts}) + original_md5 = HashUtils.md5 + with ( + patch.object(torrent, "TorrentsChain", return_value=chain), + patch.object( + HashUtils, + "md5", + wraps=original_md5, + ) as md5, + ): + result = asyncio.run(torrent.torrents_cache(page=2, count=2, brief=True)) + + assert md5.call_count == 2 + assert result.data["count"] == result.data["total"] == 1000 + assert [item["title"] for item in result.data["data"]] == ["Movie 2", "Movie 3"] + assert len(result.data["data"][0]["description"]) == 200 + assert result.data["data"][0]["hash"] == original_md5("Movie 2" + "长描述" * 1000) + assert len(contexts[2].torrent_info.description) == 3000 + + +def test_cache_filters_before_pagination_and_preserves_unpaged_contract(): + """ + 标题与站点筛选覆盖未加载页,省略窗口时仍返回完整描述与完整结果。 + """ + contexts = [ + Context(torrent_info=TorrentInfo(title=title, description="完整描述", site_name=name)) + for title, name in [("Movie A", "甲"), ("Other", "乙"), ("MOVIE B", "乙")] + ] + chain = Mock() + chain.async_get_torrents = AsyncMock(return_value={"example.test": contexts}) + with patch.object(torrent, "TorrentsChain", return_value=chain): + result = asyncio.run(torrent.torrents_cache(page=1, count=1, title="movie", site="乙")) + complete = asyncio.run(torrent.torrents_cache()) + empty = asyncio.run(torrent.torrents_cache(page=5, count=1)) + + assert result.data["site_names"] == ["乙", "甲"] + assert result.data["total"] == 1 + assert result.data["data"][0]["title"] == "MOVIE B" + assert len(complete.data["data"]) == 3 + assert complete.data["data"][0]["description"] == "完整描述" + assert empty.data["data"] == [] + assert empty.data["total"] == 3 + + +def test_cache_http_contract_rejects_invalid_windows_and_serializes_totals(): + """ + HTTP 边界校验页码和最大窗口,并保留筛选数量与可选站点元数据。 + """ + app = FastAPI() + app.include_router(torrent.router, prefix="/torrent") + app.dependency_overrides[get_current_active_superuser_async] = lambda: Mock() + chain = Mock() + chain.async_get_torrents = AsyncMock(return_value={}) + with patch.object(torrent, "TorrentsChain", return_value=chain), TestClient(app) as client: + assert client.get("/torrent/cache?page=0&count=100").status_code == 422 + assert client.get("/torrent/cache?page=1&count=201").status_code == 422 + response = client.get("/torrent/cache?page=1&count=100&brief=true") + assert response.status_code == 200 + assert response.json()["data"] == {"count": 0, "sites": 0, "data": [], "total": 0, "site_names": []} diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 594250fe3..1f49d6dcd 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -130,7 +130,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: "database-operation": "16", "feedback-issue": "12", "anysearch": "3", - "moviepilot-api": "66", + "moviepilot-api": "67", "moviepilot-update": "7", "organize-files": "6", "transfer-failed-retry": "6", diff --git a/tests/test_subscription_architecture.py b/tests/test_subscription_architecture.py index 179feb33e..26ec36c77 100644 --- a/tests/test_subscription_architecture.py +++ b/tests/test_subscription_architecture.py @@ -246,6 +246,7 @@ def test_subscribe_chain_package_has_single_responsibility_owners() -> None: assert not (APP_ROOT / "chain" / "subscribe.py").exists() expected_files = { "__init__.py", + "calendar.py", "completion.py", "contract.py", "context.py", diff --git a/tests/test_subscription_calendar.py b/tests/test_subscription_calendar.py new file mode 100644 index 000000000..e30b9b79d --- /dev/null +++ b/tests/test_subscription_calendar.py @@ -0,0 +1,184 @@ +""" +订阅日历的日期优先窗口、精简投影、并发和用户权限回归。 +""" + +import asyncio +from datetime import date +from types import SimpleNamespace +from typing import cast +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.dependencies.auth import get_current_active_user_async +from app.api.dependencies.subscription import get_subscription_query_service +from app.api.endpoints import subscribe as endpoint +from app.chain.subscribe.calendar import SubscribeCalendarChain +from app.runtime.cache import AsyncCacheControls +from app.schemas.calendar import CalendarDate, SubscribeCalendarPage +from app.schemas.subscribe import Subscribe +from app.schemas.tmdb import TmdbEpisode +from app.schemas.types import MediaSource, MediaType + + +@pytest.mark.asyncio +async def test_calendar_schedule_projects_and_reuses_episode_dates() -> None: + """ + 同日分集合并且丢弃大简介,翻月复用精简缓存,不再重复调用分集接口。 + """ + loader = SubscribeCalendarChain._schedule + controls = cast(AsyncCacheControls, loader) + await controls.cache_clear() + tmdb = Mock() + tmdb.async_tmdb_episodes = AsyncMock( + return_value=[ + TmdbEpisode(air_date="2026-10-25", episode_number=1, name="第一集", runtime=24, overview="长简介" * 1000), + TmdbEpisode(air_date="2026-10-25", episode_number=2, name="第二集"), + TmdbEpisode(air_date=None, episode_number=3), + TmdbEpisode(air_date="2026-11-01", episode_number=4), + ] + ) + try: + with patch("app.chain.subscribe.calendar.TmdbChain", return_value=tmdb): + result = await loader(MediaSource.TMDB, "874", MediaType.TV, 2, "group-a") + repeated = await loader(MediaSource.TMDB, "874", MediaType.TV, 2, "group-a") + tmdb.async_tmdb_episodes.assert_awaited_once_with(tmdbid=874, season=2, episode_group="group-a") + assert repeated == result + assert result[0].model_dump() == { + "date": "2026-10-25", + "runtime": 24, + "episodes": [{"episode_number": 1, "name": "第一集"}, {"episode_number": 2, "name": "第二集"}], + } + assert [item.date for item in result] == ["2026-10-25", "2026-11-01"] + assert "overview" not in result[0].model_dump_json() + finally: + await controls.cache_clear() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mtype", [MediaType.MOVIE, MediaType.TV]) +async def test_calendar_recognizes_movie_dates_and_converts_external_tv_identity(mtype) -> None: + """ + 电影使用上映日期,非 TMDB 剧集先转换媒体身份后查询订阅季,均不透传完整媒体详情。 + """ + loader = SubscribeCalendarChain._schedule + controls = cast(AsyncCacheControls, loader) + await controls.cache_clear() + media = Mock() + media.async_recognize_media = AsyncMock( + return_value=SimpleNamespace(tmdb_id=874, release_date="2026-10-27", runtime=120) + ) + tmdb = Mock() + tmdb.async_tmdb_episodes = AsyncMock( + return_value=[TmdbEpisode(air_date="2026-10-27", episode_number=1)] + ) + try: + with ( + patch("app.chain.subscribe.calendar.MediaChain", return_value=media), + patch("app.chain.subscribe.calendar.TmdbChain", return_value=tmdb), + ): + result = await loader(MediaSource.Douban, "external-874", mtype, 2, None) + media.async_recognize_media.assert_awaited_once_with( + media_source=MediaSource.Douban, media_id="external-874", mtype=mtype, episode_group=None + ) + assert [item.date for item in result] == ["2026-10-27"] + if mtype == MediaType.MOVIE: + assert result[0].runtime == 120 + assert result[0].episodes == [] + tmdb.async_tmdb_episodes.assert_not_awaited() + else: + tmdb.async_tmdb_episodes.assert_awaited_once_with(tmdbid=874, season=2, episode_group=None) + assert result[0].episodes[0].episode_number == 1 + finally: + await controls.cache_clear() + + +@pytest.mark.asyncio +async def test_calendar_filters_real_dates_before_sorting_and_pagination(monkeypatch) -> None: + """ + 订阅创建顺序与播出日期相反时仍先筛选窗口,最多四路查询且返回日期有序的小页。 + """ + active = peak = 0 + + async def schedule(_source, media_id, _mtype, _season, _group): + """ + 用乱序媒体播出日期与窗口外事件模拟已缓存的媒体源。 + """ + nonlocal active, peak + active += 1 + peak = max(active, peak) + await asyncio.sleep(0.001) + active -= 1 + return [ + CalendarDate(date="2020-01-01"), + CalendarDate(date=f"2026-10-{11 - int(media_id):02d}"), + CalendarDate(date="2026-11-01"), + ] + + monkeypatch.setattr(SubscribeCalendarChain, "_schedule", staticmethod(schedule)) + subscribes = [ + Subscribe( + id=index, + name=f"订阅 {index}", + type="电视剧", + media_source=MediaSource.TMDB, + media_id=str(index), + season=1, + date=f"2026-10-{index:02d}", + description="不需要的订阅描述" * 1000, + ) + for index in range(1, 11) + ] + result = await SubscribeCalendarChain().window(subscribes, date(2026, 10, 1), date(2026, 11, 1), 2, 3) + assert peak == 4 + assert result.total == 10 + assert [item.date for item in result.items] == ["2026-10-04", "2026-10-05", "2026-10-06"] + assert [item.subscribe.id for item in result.items] == [7, 6, 5] + assert "description" not in result.items[0].subscribe.model_dump() + + +@pytest.mark.asyncio +async def test_calendar_stops_dispatching_when_old_window_disconnects(monkeypatch) -> None: + """ + 翻月取消的请求不再为剩余订阅派发媒体详情查询。 + """ + schedule = AsyncMock() + monkeypatch.setattr(SubscribeCalendarChain, "_schedule", schedule) + result = await SubscribeCalendarChain().window( + [Subscribe(id=1, type="电影", media_source=MediaSource.TMDB, media_id="1")], + date(2026, 10, 1), + date(2026, 11, 1), + 1, + 50, + AsyncMock(return_value=True), + ) + assert result.total == 0 + schedule.assert_not_awaited() + + +@pytest.mark.parametrize("admin", [False, True]) +def test_calendar_http_window_keeps_owner_scope_and_validates_bounds(admin) -> None: + """ + 日期查询复用普通用户的 owner 限制,HTTP 边界拒绝过大日期和分页窗口。 + """ + app = FastAPI() + app.include_router(endpoint.router, prefix="/subscribe") + query = Mock() + query.list_public = AsyncMock(return_value=[]) + app.dependency_overrides[get_subscription_query_service] = lambda: query + app.dependency_overrides[get_current_active_user_async] = lambda: SimpleNamespace(name="alice", is_superuser=admin) + chain = Mock() + chain.window = AsyncMock(return_value=SubscribeCalendarPage(total=0)) + with patch.object(endpoint, "SubscribeCalendarChain", return_value=chain), TestClient(app) as client: + for suffix in ["&page=0", "&count=201", "&count=0"]: + assert client.get(f"/subscribe/calendar?start=2026-10-01&end=2026-11-01{suffix}").status_code == 422 + assert client.get("/subscribe/calendar?start=2026-10-01&end=2027-01-03").status_code == 422 + assert client.get("/subscribe/calendar?start=2026-10-01&end=2026-10-01").status_code == 422 + assert client.get("/subscribe/calendar?start=invalid&end=2026-11-01").status_code == 422 + response = client.get("/subscribe/calendar?start=2026-10-01&end=2026-11-01&page=2&count=10") + assert response.status_code == 200 + assert response.json()["data"] == {"items": [], "total": 0} + query.list_public.assert_awaited_once_with(None if admin else "alice") + assert chain.window.await_args.args[:5] == ([], date(2026, 10, 1), date(2026, 11, 1), 2, 10)