refactor(media): unify media identity and chain responsibilities

This commit is contained in:
jxxghp
2026-08-12 18:31:09 +08:00
parent cbb969e0ff
commit ca32922a7f
155 changed files with 8371 additions and 4526 deletions
+6 -5
View File
@@ -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):
"""
搜索种子信息
"""
+3 -2
View File
@@ -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 事件的数据模型
+3 -2
View File
@@ -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):
"""
文件整理历史记录
"""
+38
View File
@@ -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
+53 -3
View File
@@ -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):
"""
+8 -7
View File
@@ -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
+19 -8
View File
@@ -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 -2
View File
@@ -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。"""
# 文件项
+5 -1
View File
@@ -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):