fix(workflow): 工作流添加下载支持 MetaInfo.season_list (#6637)

This commit is contained in:
jxxghp
2026-09-10 20:13:04 +08:00
parent 0192669077
commit 60a95090b6
5 changed files with 188 additions and 5 deletions

View File

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

View File

@@ -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):
"""跨媒体源兼容的人物图片尺寸集合。"""

View File

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

View File

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

View File

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