diff --git a/app/domain/meta/metabase.py b/app/domain/meta/metabase.py index 655195d07..5fd6e7552 100644 --- a/app/domain/meta/metabase.py +++ b/app/domain/meta/metabase.py @@ -770,4 +770,5 @@ class MetaBase(object): dicts["edition"] = self.edition dicts["name"] = self.name dicts["episode_list"] = self.episode_list + dicts["season_list"] = self.season_list return dicts diff --git a/app/schemas/context.py b/app/schemas/context.py index d9b1bec0e..e4fd8c76a 100644 --- a/app/schemas/context.py +++ b/app/schemas/context.py @@ -1,13 +1,13 @@ from typing import Annotated, Any, Dict, List, Optional, Union -from pydantic import BaseModel, Discriminator, Field, RootModel, Tag, model_validator +from pydantic import BaseModel, Discriminator, Field, PrivateAttr, RootModel, Tag, model_validator from app.schemas.category import ClassificationFactValue, ClassificationResult from app.schemas.common import JsonData from app.schemas.media import OptionalMediaIdentityMixin from app.schemas.music import MusicArtistInfo as _MusicArtistInfo from app.schemas.music import MusicInfo, MusicMeta -from app.schemas.types import MediaSource +from app.schemas.types import MediaSource, MediaType class MetaInfo(OptionalMediaIdentityMixin, BaseModel): @@ -75,6 +75,71 @@ class MetaInfo(OptionalMediaIdentityMixin, BaseModel): # 显式媒体数据源原生ID media_id: Optional[str] = None + _season_list: Optional[List[int]] = PrivateAttr(default=None) + + @model_validator(mode="wrap") + @classmethod + def _handle_season_list(cls, data: Any, handler: Any) -> "MetaInfo": + season_list = None + if isinstance(data, dict) and "season_list" in data: + data = data.copy() + season_list = data.pop("season_list") + instance = handler(data) + if season_list is not None and isinstance(instance, MetaInfo): + instance._season_list = list(season_list) + return instance + + @property + def season_list(self) -> List[int]: + """返回识别的季数字列表。""" + if self._season_list is not None: + return self._season_list + if self.begin_season is None: + if self.type in (MediaType.TV, MediaType.TV.value): + return [1] + return [] + if self.end_season is not None: + return list(range(self.begin_season, self.end_season + 1)) + return [self.begin_season] + + @season_list.setter + def season_list(self, value: Optional[List[int]]) -> None: + self._season_list = list(value) if value is not None else None + + @property + def season(self) -> str: + """返回开始季、结束季字符串,确定是剧集没有季的返回S01。""" + if self.begin_season is not None: + return "S%s" % str(self.begin_season).rjust(2, "0") \ + if self.end_season is None \ + else "S%s-S%s" % \ + (str(self.begin_season).rjust(2, "0"), + str(self.end_season).rjust(2, "0")) + if self.type in (MediaType.TV, MediaType.TV.value): + return "S01" + return "" + + @property + def season_seq(self) -> str: + """返回 begin_season 的数字,电视剧没有季的返回1。""" + if self.begin_season is not None: + return str(self.begin_season) + if self.type in (MediaType.TV, MediaType.TV.value): + return "1" + return "" + + @property + def episode(self) -> str: + """返回开始集、结束集字符串。""" + if self.begin_episode is not None: + return "E%s" % str(self.begin_episode).rjust(2, "0") \ + if self.end_episode is None \ + else "E%s-E%s" % \ + ( + str(self.begin_episode).rjust(2, "0"), + str(self.end_episode).rjust(2, "0")) + return "" + class MediaImageSet(BaseModel): """跨媒体源兼容的人物图片尺寸集合。""" diff --git a/app/workflow/actions/add_download.py b/app/workflow/actions/add_download.py index e5da47c6b..18e0df94f 100644 --- a/app/workflow/actions/add_download.py +++ b/app/workflow/actions/add_download.py @@ -86,11 +86,16 @@ class AddDownloadAction(BaseAction): logger.warning(f"{t.meta_info.title} 有多季,跳过") continue else: - exists_episodes = exists_seasons.get(t.meta_info.begin_season) - if exists_episodes: + season_num = ( + t.meta_info.begin_season + if t.meta_info.begin_season is not None + else (t.meta_info.season_list[0] if t.meta_info.season_list else 1) + ) + exists_episodes = exists_seasons.get(season_num) + if exists_episodes and t.meta_info.episode_list: if set(t.meta_info.episode_list).issubset(exists_episodes): logger.warning( - f"{t.meta_info.title} 第 {t.meta_info.begin_season} 季第 {t.meta_info.episode_list} 集已存在,跳过") + f"{t.meta_info.title} 第 {season_num} 季第 {t.meta_info.episode_list} 集已存在,跳过") continue _started = True diff --git a/tests/test_search_response_models.py b/tests/test_search_response_models.py index 2243c0535..4c89d9db8 100644 --- a/tests/test_search_response_models.py +++ b/tests/test_search_response_models.py @@ -46,3 +46,45 @@ def test_last_search_context_keeps_subtitle_result_shape() -> None: assert result["title"] == "Example subtitle" assert result["site_name"] == "Subtitle Site" assert result["enclosure"] == "https://example.test/subtitle.srt" + + +def test_schema_metainfo_season_and_episode_properties() -> None: + """Schema MetaInfo 应具备与领域 MetaBase 一致的季、集属性和序列化兼容性。""" + from app.schemas.types import MediaType + + # 单季剧集(未显式指定季) + tv_no_season = MetaInfo(type="电视剧", begin_episode=1, end_episode=2) + assert tv_no_season.season_list == [1] + assert tv_no_season.season == "S01" + assert tv_no_season.season_seq == "1" + assert tv_no_season.episode == "E01-E02" + + # 指定单季剧集 + tv_single_season = MetaInfo(type=MediaType.TV, begin_season=2, begin_episode=5) + assert tv_single_season.season_list == [2] + assert tv_single_season.season == "S02" + assert tv_single_season.season_seq == "2" + assert tv_single_season.episode == "E05" + + # 多季剧集 + tv_multi_season = MetaInfo(type="电视剧", begin_season=1, end_season=3) + assert tv_multi_season.season_list == [1, 2, 3] + assert tv_multi_season.season == "S01-S03" + + # 电影 + movie = MetaInfo(type="电影") + assert movie.season_list == [] + assert movie.season == "" + assert movie.season_seq == "" + assert movie.episode == "" + + # 显式覆盖与字典解析 + custom = MetaInfo(season_list=[0]) + assert custom.season_list == [0] + + from_dict = MetaInfo.model_validate({"season_list": [2, 4], "type": "电视剧"}) + assert from_dict.season_list == [2, 4] + + # setter 覆写 + tv_no_season.season_list = [5] + assert tv_no_season.season_list == [5] diff --git a/tests/test_workflow_actions.py b/tests/test_workflow_actions.py index ce15b61bf..fc121db27 100644 --- a/tests/test_workflow_actions.py +++ b/tests/test_workflow_actions.py @@ -249,3 +249,73 @@ def test_workflow_manager_list_actions_exposes_contract(): assert isinstance(actions[0]["data"], dict) assert actions[0]["contract"]["outputs"][0]["name"] == "torrents" assert actions[0]["contract"]["condition_fields"][0]["label"] == "资源" + + +def test_add_download_only_lack_handles_schema_metainfo(monkeypatch): + """添加下载动作开启仅下载缺失资源时应支持 Schema MetaInfo 的 season_list 属性并正确过滤已存在剧集。""" + from app.schemas.context import ( + Context as SchemaContext, + MediaInfo as SchemaMediaInfo, + MetaInfo as SchemaMetaInfo, + TorrentInfo as SchemaTorrentInfo, + ) + from app.schemas.types import MediaType + from app.workflow.actions.add_download import AddDownloadAction + import app.workflow.actions.add_download as add_download_module + + downloaded = [] + + class FakeDownloadChain: + def media_exists(self, mediainfo): + # 模拟媒体库已存在第 1 季的第 1、2 集 + return SimpleNamespace(seasons={1: [1, 2]}) + + def download_single(self, context=None, **kwargs): + downloaded.append(context.torrent_info.title) + return f"hash-{context.torrent_info.title}" + + monkeypatch.setattr(add_download_module, "DownloadChain", FakeDownloadChain) + monkeypatch.setattr(add_download_module.runtime_stop_state, "is_workflow_stopped", lambda _wid: False) + + action = AddDownloadAction("test-add-download") + action.check_cache = lambda _wid, _key: False + action.save_cache = lambda _wid, _key: None + action.job_done = lambda *_args, **_kwargs: None + + # 1. 第 1 季第 1 集(已存在,跳过) + t1 = SchemaContext( + meta_info=SchemaMetaInfo(title="Show S01E01", type="电视剧", begin_season=1, episode_list=[1]), + media_info=SchemaMediaInfo(title="Show", type=MediaType.TV), + torrent_info=SchemaTorrentInfo(title="Show S01E01", site=1), + ) + # 2. 第 1 季第 3 集(缺失,应下载) + t2 = SchemaContext( + meta_info=SchemaMetaInfo(title="Show S01E03", type="电视剧", begin_season=1, episode_list=[3]), + media_info=SchemaMediaInfo(title="Show", type=MediaType.TV), + torrent_info=SchemaTorrentInfo(title="Show S01E03", site=1), + ) + # 3. 多季资源(有多季,跳过) + t3 = SchemaContext( + meta_info=SchemaMetaInfo(title="Show S01-S02", type="电视剧", begin_season=1, end_season=2, episode_list=[1]), + media_info=SchemaMediaInfo(title="Show", type=MediaType.TV), + torrent_info=SchemaTorrentInfo(title="Show S01-S02", site=1), + ) + # 4. 未显式标季但为电视剧(begin_season=None,默认归入第 1 季;第 1 集已存在,跳过) + t4 = SchemaContext( + meta_info=SchemaMetaInfo(title="Show Ep01", type="电视剧", begin_season=None, episode_list=[1]), + media_info=SchemaMediaInfo(title="Show", type=MediaType.TV), + torrent_info=SchemaTorrentInfo(title="Show Ep01", site=1), + ) + # 5. 未显式标季但为电视剧(begin_season=None,默认归入第 1 季;第 4 集缺失,应下载) + t5 = SchemaContext( + meta_info=SchemaMetaInfo(title="Show Ep04", type="电视剧", begin_season=None, episode_list=[4]), + media_info=SchemaMediaInfo(title="Show", type=MediaType.TV), + torrent_info=SchemaTorrentInfo(title="Show Ep04", site=1), + ) + + context = ActionContext(torrents=[t1, t2, t3, t4, t5]) + result = action.execute(workflow_id=1, params={"only_lack": True}, context=context) + + assert downloaded == ["Show S01E03", "Show Ep04"] + assert len(result.downloads) == 2 + assert [d.download_id for d in result.downloads] == ["hash-Show S01E03", "hash-Show Ep04"]