mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
refactor(media): unify media identity and chain responsibilities
This commit is contained in:
@@ -3,10 +3,11 @@ from typing import Optional, Dict, List, Union, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.music import MusicInfo, MusicMeta
|
||||
from app.schemas.media import OptionalMediaIdentityMixin
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
class MetaInfo(BaseModel):
|
||||
class MetaInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""
|
||||
识别元数据
|
||||
"""
|
||||
@@ -67,17 +68,17 @@ class MetaInfo(BaseModel):
|
||||
# 剧集组
|
||||
episode_group: Optional[str] = None
|
||||
# 显式媒体数据源
|
||||
media_source: Optional[Union[MediaSource, str]] = None
|
||||
media_source: Optional[MediaSource] = None
|
||||
# 显式媒体数据源原生ID
|
||||
media_id: Optional[str] = None
|
||||
|
||||
|
||||
class MediaInfo(BaseModel):
|
||||
class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""
|
||||
识别媒体信息
|
||||
"""
|
||||
# 媒体主身份来源
|
||||
media_source: Optional[Union[MediaSource, str]] = None
|
||||
media_source: Optional[MediaSource] = None
|
||||
# 请求级刮削来源
|
||||
scrape_source: Optional[str] = None
|
||||
# 类型 电影、电视剧、合集
|
||||
@@ -188,7 +189,7 @@ class MediaInfo(BaseModel):
|
||||
episode_group: Optional[str] = None
|
||||
|
||||
|
||||
class TorrentInfo(BaseModel):
|
||||
class TorrentInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""
|
||||
搜索种子信息
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,7 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from app.schemas.message import MessageChannel
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
@@ -532,7 +533,7 @@ class RecommendSourceEventData(ChainEventData):
|
||||
)
|
||||
|
||||
|
||||
class MediaRecognizeConvertEventData(ChainEventData):
|
||||
class MediaRecognizeConvertEventData(RequiredMediaIdentityMixin, ChainEventData):
|
||||
"""
|
||||
MediaRecognizeConvert 事件的数据模型
|
||||
|
||||
@@ -576,7 +577,7 @@ class StorageOperSelectionEventData(ChainEventData):
|
||||
storage_oper: Optional[Callable] = Field(default=None, description="存储操作对象")
|
||||
|
||||
|
||||
class SubscribeEpisodesRefreshEventData(ChainEventData):
|
||||
class SubscribeEpisodesRefreshEventData(OptionalMediaIdentityMixin, ChainEventData):
|
||||
"""
|
||||
SubscribeEpisodesRefresh 事件的数据模型
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ from typing import Optional, Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.schemas.media import OptionalMediaIdentityMixin
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
class DownloadHistory(BaseModel):
|
||||
class DownloadHistory(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""
|
||||
下载历史记录
|
||||
"""
|
||||
@@ -60,7 +61,7 @@ class DownloadHistory(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TransferHistory(BaseModel):
|
||||
class TransferHistory(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""
|
||||
文件整理历史记录
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from pydantic import model_validator
|
||||
|
||||
|
||||
class OptionalMediaIdentityMixin:
|
||||
"""为可选媒体身份模型统一校验来源枚举与原生 ID 的成对约束。"""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_optional_media_identity(self):
|
||||
"""规范化 ID,并拒绝显式半对、空白或零值身份。"""
|
||||
source_provided = "media_source" in self.model_fields_set
|
||||
id_provided = "media_id" in self.model_fields_set
|
||||
if source_provided != id_provided:
|
||||
raise ValueError("media_source 和 media_id 必须同时提供")
|
||||
normalized_id = (
|
||||
str(self.media_id).strip()
|
||||
if self.media_id is not None
|
||||
else None
|
||||
)
|
||||
if bool(self.media_source) != bool(normalized_id):
|
||||
raise ValueError("media_source 和 media_id 必须同时提供")
|
||||
if normalized_id == "0":
|
||||
raise ValueError("media_id 不能为 0")
|
||||
# 校验器内部的规范化不能伪装成请求显式提交字段,否则 PATCH 会误清空存量身份。
|
||||
object.__setattr__(self, "media_id", normalized_id)
|
||||
return self
|
||||
|
||||
|
||||
class RequiredMediaIdentityMixin:
|
||||
"""为必填媒体身份模型统一校验来源枚举与原生 ID。"""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_required_media_identity(self):
|
||||
"""去除 ID 两端空白,并拒绝空白或零值身份。"""
|
||||
normalized_id = str(self.media_id).strip()
|
||||
if not normalized_id or normalized_id == "0":
|
||||
raise ValueError("media_id 必须是非零的来源原生 ID")
|
||||
self.media_id = normalized_id
|
||||
return self
|
||||
@@ -1,8 +1,9 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Union, List, Any
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||
|
||||
from app.schemas.media import OptionalMediaIdentityMixin
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
@@ -99,7 +100,7 @@ class MediaServerItemUserState(BaseModel):
|
||||
percentage: Optional[float] = None
|
||||
|
||||
|
||||
class MediaServerItem(BaseModel):
|
||||
class MediaServerItem(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""
|
||||
媒体服务器媒体信息
|
||||
"""
|
||||
@@ -158,7 +159,8 @@ class WebhookEventInfo(BaseModel):
|
||||
item_path: Optional[str] = None
|
||||
season_id: Optional[str] = None
|
||||
episode_id: Optional[str] = None
|
||||
tmdb_id: Optional[str] = None
|
||||
media_source: Optional[MediaSource] = None
|
||||
media_id: Optional[str] = None
|
||||
overview: Optional[str] = None
|
||||
percentage: Optional[float] = None
|
||||
ip: Optional[str] = None
|
||||
@@ -172,6 +174,54 @@ class WebhookEventInfo(BaseModel):
|
||||
media_type: Optional[str] = None
|
||||
json_object: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _migrate_legacy_tmdb_identity(cls, data: Any) -> Any:
|
||||
"""在旧事件输入边界把 tmdb_id 迁移为统一媒体身份。"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
if data.get("media_source") is not None or data.get("media_id") is not None:
|
||||
migrated = dict(data)
|
||||
migrated.pop("tmdb_id", None)
|
||||
return migrated
|
||||
legacy_tmdb_id = data.get("tmdb_id")
|
||||
if legacy_tmdb_id in (None, ""):
|
||||
return data
|
||||
migrated = dict(data)
|
||||
migrated["media_source"] = MediaSource.TMDB
|
||||
migrated["media_id"] = str(legacy_tmdb_id)
|
||||
migrated.pop("tmdb_id", None)
|
||||
return migrated
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_media_identity(self) -> "WebhookEventInfo":
|
||||
"""确保 webhook 媒体身份始终完整成对且不接受零值。"""
|
||||
normalized_id = str(self.media_id).strip() if self.media_id is not None else None
|
||||
if bool(self.media_source) != bool(normalized_id):
|
||||
raise ValueError("media_source 和 media_id 必须同时提供")
|
||||
if normalized_id == "0":
|
||||
raise ValueError("media_id 不能为 0")
|
||||
self.media_id = normalized_id
|
||||
return self
|
||||
|
||||
@property
|
||||
def tmdb_id(self) -> Optional[str]:
|
||||
"""兼容旧插件读取 TMDB 身份;新事件输出不再包含该字段。"""
|
||||
if self.media_source == MediaSource.TMDB:
|
||||
return self.media_id
|
||||
return None
|
||||
|
||||
@tmdb_id.setter
|
||||
def tmdb_id(self, value: Optional[Union[str, int]]) -> None:
|
||||
"""兼容旧插件写入 TMDB 身份,并同步为统一字段。"""
|
||||
if value in (None, ""):
|
||||
if self.media_source == MediaSource.TMDB:
|
||||
self.media_source = None
|
||||
self.media_id = None
|
||||
return
|
||||
self.media_source = MediaSource.TMDB
|
||||
self.media_id = str(value)
|
||||
|
||||
|
||||
class MediaServerPlayItem(BaseModel):
|
||||
"""
|
||||
|
||||
@@ -2,10 +2,11 @@ from typing import Any, Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
|
||||
from app.schemas.types import MediaSource, MusicEntityType, MusicTargetEntityType
|
||||
|
||||
|
||||
class MusicMeta(BaseModel):
|
||||
class MusicMeta(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""音乐名称及音频文件解析结果。"""
|
||||
|
||||
type: Literal["音乐"] = "音乐"
|
||||
@@ -31,17 +32,17 @@ class MusicMeta(BaseModel):
|
||||
bitrate: Optional[int] = None
|
||||
duration: Optional[int] = None
|
||||
isrc: Optional[str] = None
|
||||
media_source: Optional[Union[MediaSource, str]] = None
|
||||
media_source: Optional[MediaSource] = None
|
||||
media_id: Optional[str] = None
|
||||
|
||||
|
||||
class MusicInfo(BaseModel):
|
||||
class MusicInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""标准化音乐元数据信息。"""
|
||||
|
||||
type: Literal["音乐"] = "音乐"
|
||||
# 音乐实体类型:recording 单曲、album 专辑、artist 艺术家
|
||||
music_type: MusicEntityType = "recording"
|
||||
media_source: Optional[Union[MediaSource, str]] = None
|
||||
media_source: Optional[MediaSource] = None
|
||||
media_id: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
artists: list[str] = Field(default_factory=list)
|
||||
@@ -97,7 +98,7 @@ class MusicRelease(BaseModel):
|
||||
cover_url: Optional[str] = None
|
||||
|
||||
|
||||
class MusicAlbumInfo(BaseModel):
|
||||
class MusicAlbumInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""标准化音乐专辑信息。"""
|
||||
|
||||
type: Literal["音乐"] = "音乐"
|
||||
@@ -132,7 +133,7 @@ class MusicAlbumInfo(BaseModel):
|
||||
vote_average: float = 0.0
|
||||
|
||||
|
||||
class MusicArtistInfo(BaseModel):
|
||||
class MusicArtistInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""标准化音乐艺术家信息。"""
|
||||
|
||||
type: Literal["音乐"] = "音乐"
|
||||
@@ -164,7 +165,7 @@ class MusicArtistInfo(BaseModel):
|
||||
overview: Optional[str] = None
|
||||
|
||||
|
||||
class MusicRecognizeRequest(BaseModel):
|
||||
class MusicRecognizeRequest(RequiredMediaIdentityMixin, BaseModel):
|
||||
"""音乐元数据详情识别请求。"""
|
||||
|
||||
media_source: MediaSource
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import Optional, List, Dict, Any, ClassVar
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||
|
||||
from app.schemas.media import OptionalMediaIdentityMixin
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
@@ -43,7 +44,9 @@ def compute_subscribe_completed_episode(subscribe: "Subscribe") -> Optional[int]
|
||||
return min(max(start_episode - 1, 0), total_episode) + priority_completed
|
||||
|
||||
|
||||
class Subscribe(BaseModel):
|
||||
class Subscribe(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""订阅输入与响应模型,媒体身份必须为空对或完整有效对。"""
|
||||
|
||||
# 公共创建和更新接口不得接收系统字段和运行事实;其余字段默认作为订阅输入透传。
|
||||
PUBLIC_WRITE_EXCLUDED_FIELDS: ClassVar[frozenset[str]] = frozenset({
|
||||
"id", "poster", "backdrop", "vote", "description", "lack_episode", "completed_episode",
|
||||
@@ -155,17 +158,22 @@ class Subscribe(BaseModel):
|
||||
@classmethod
|
||||
def _normalize_empty_strings(cls, data: Any) -> Any:
|
||||
"""
|
||||
将前端清空输入框后残留的空字符串视为未提供,移除该键由字段默认值兜底。
|
||||
将前端清空输入框后残留的空字符串视为空值。
|
||||
|
||||
音乐等媒体类型的 season、total_episode、episode_priority 等数值或容器字段
|
||||
在表单中常以空字符串提交,而 Pydantic 不会把空字符串自动转为 None,会直接抛出
|
||||
校验异常导致接口返回 422。这里把空字符串键移除,等价于该字段未提供,从而复用字段
|
||||
默认值(如 ``total_episode`` 回退为 0、``sites`` 回退为空列表)。
|
||||
默认值(如 ``total_episode`` 回退为 0、``sites`` 回退为空列表)。媒体身份键保留为
|
||||
None,以便更新接口区分“未提交”与“显式清空完整身份对”。
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
data = dict(data)
|
||||
for key, value in list(data.items()):
|
||||
if isinstance(value, str) and value == "":
|
||||
data.pop(key)
|
||||
if key in {"media_source", "media_id"}:
|
||||
data[key] = None
|
||||
else:
|
||||
data.pop(key)
|
||||
return data
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -180,12 +188,15 @@ class Subscribe(BaseModel):
|
||||
self.completed_episode = compute_subscribe_completed_episode(self)
|
||||
return self
|
||||
|
||||
def to_public_write_payload(self) -> Dict[str, Any]:
|
||||
"""裁剪公共订阅写入字段,避免请求体覆盖下载事实和运行状态。"""
|
||||
return self.model_dump(exclude=self.PUBLIC_WRITE_EXCLUDED_FIELDS)
|
||||
def to_public_write_payload(self, *, exclude_unset: bool = False) -> Dict[str, Any]:
|
||||
"""裁剪公共订阅写入字段,可仅保留更新请求显式提交的字段。"""
|
||||
return self.model_dump(
|
||||
exclude=self.PUBLIC_WRITE_EXCLUDED_FIELDS,
|
||||
exclude_unset=exclude_unset,
|
||||
)
|
||||
|
||||
|
||||
class SubscribeShare(BaseModel):
|
||||
class SubscribeShare(OptionalMediaIdentityMixin, BaseModel):
|
||||
# 分享ID
|
||||
id: Optional[int] = None
|
||||
# 订阅ID
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any, Callable, List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.media import OptionalMediaIdentityMixin
|
||||
from app.schemas.types import MediaSource, MusicTargetEntityType
|
||||
|
||||
from app.schemas.context import MetaInfo, MediaInfo
|
||||
@@ -58,7 +59,7 @@ class DownloadingTorrent(DownloaderTorrent):
|
||||
"""
|
||||
|
||||
|
||||
class TransferTask(BaseModel):
|
||||
class TransferTask(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""
|
||||
文件整理任务
|
||||
"""
|
||||
@@ -199,7 +200,7 @@ class EpisodeFormatRecommendItem(BaseModel):
|
||||
fileitems: Optional[List[FileItem]] = None
|
||||
|
||||
|
||||
class ManualTransferItem(BaseModel):
|
||||
class ManualTransferItem(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""手动整理请求,媒体身份只接受来源枚举与原生 ID。"""
|
||||
|
||||
# 文件项
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from enum import Enum
|
||||
from typing import Literal, Optional
|
||||
from typing import Literal, Optional, Tuple, Union
|
||||
|
||||
|
||||
# 音乐实体命名空间由公共类型模块统一持有,避免模型、接口和工具层重复定义。
|
||||
@@ -68,6 +68,10 @@ class MediaSource(str, Enum):
|
||||
return self.value
|
||||
|
||||
|
||||
# 搜索可以选择一个或多个来源,但集合中的每一项都必须是固定枚举。
|
||||
MediaSourceSelection = Union[MediaSource, Tuple[MediaSource, ...]]
|
||||
|
||||
|
||||
def media_type_to_agent(value) -> Optional[str]:
|
||||
"""将枚举、Agent 键或数据库枚举值统一转换为 Agent 媒体类型。"""
|
||||
if isinstance(value, MediaType):
|
||||
|
||||
Reference in New Issue
Block a user