Files
MoviePilot/app/schemas/query.py
2026-08-28 00:15:46 +08:00

395 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""插件只读数据查询使用的稳定筛选、分页与数据投影合同。"""
from enum import Enum
from typing import Any, Generic, Optional, TypeVar
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.schemas.common import JsonData
from app.schemas.media import normalize_media_source
from app.schemas.types import MediaSource, MediaType
T = TypeVar("T")
DEFAULT_QUERY_PAGE_SIZE = 50
MAX_QUERY_PAGE_SIZE = 200
class _QueryInput(BaseModel): # type: ignore[misc] # Pydantic imports are skipped by strict mypy
"""查询输入共同的严格字段合同。"""
model_config = ConfigDict(extra="forbid")
class QuerySortField(str, Enum):
"""所有公开数据查询共同支持的稳定排序字段。"""
DATE = "date"
ID = "id"
class QuerySortDirection(str, Enum):
"""公开查询的排序方向。"""
ASC = "asc"
DESC = "desc"
class QuerySort(_QueryInput):
"""声明公开查询的稳定排序字段与方向。
日期排序将空日期置于升序开头、降序末尾,并以同方向 ID 打破日期并列;ID
本身唯一,因此按 ID 排序不依赖数据库的隐式行顺序。
"""
field: QuerySortField = QuerySortField.DATE
direction: QuerySortDirection = QuerySortDirection.DESC
class QueryPageRequest(_QueryInput):
"""限制单次读取规模,并通过显式排序为跨页扫描提供稳定顺序。"""
page: int = Field(default=1, ge=1)
count: int = Field(
default=DEFAULT_QUERY_PAGE_SIZE,
ge=1,
le=MAX_QUERY_PAGE_SIZE,
)
sort: QuerySort = Field(default_factory=QuerySort)
class QueryPage(BaseModel, Generic[T]): # type: ignore[misc] # Pydantic imports are skipped by strict mypy
"""公开查询返回的分页 DTO。"""
items: list[T] = Field(default_factory=list)
total: int = Field(default=0, ge=0)
page: int = Field(default=1, ge=1)
count: int = Field(
default=DEFAULT_QUERY_PAGE_SIZE,
ge=1,
le=MAX_QUERY_PAGE_SIZE,
)
@property
def has_next(self) -> bool:
"""返回当前分页后是否仍有记录。"""
return self.page * self.count < self.total
class MediaIdentityQuery(_QueryInput):
"""允许省略身份,但显式筛选时要求来源与原生 ID 成对有效。"""
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
@model_validator(mode="after") # type: ignore[misc]
def _validate_media_identity(self) -> "MediaIdentityQuery":
"""规范化 ID,并拒绝显式半对、空白或零值身份。"""
source_provided = "media_source" in self.model_fields_set
id_provided = "media_id" in self.model_fields_set
normalized_id = str(self.media_id).strip() if self.media_id is not None else None
if source_provided != id_provided or bool(self.media_source) != bool(normalized_id):
raise ValueError("media_source 和 media_id 必须同时提供")
if normalized_id == "0":
raise ValueError("media_id 不能为 0")
object.__setattr__(self, "media_id", normalized_id)
return self
class _QuerySnapshot(BaseModel): # type: ignore[misc] # Pydantic imports are skipped by strict mypy
"""只读查询 DTO 的媒体身份与脏数据归一化边界。"""
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
@field_validator("media_source", mode="before") # type: ignore[misc]
@classmethod
def _normalize_source(cls, value: Any) -> Optional[MediaSource]:
"""未知旧来源不应使整页查询失败,统一降级为空身份。"""
return normalize_media_source(value)
@model_validator(mode="after") # type: ignore[misc]
def _normalize_identity_pair(self) -> "_QuerySnapshot":
"""输出中的脏半对身份按无身份处理,避免向插件传播不可用主键。"""
normalized_id = str(self.media_id).strip() if self.media_id is not None else ""
if not self.media_source or not normalized_id or normalized_id == "0":
object.__setattr__(self, "media_source", None)
object.__setattr__(self, "media_id", None)
else:
object.__setattr__(self, "media_id", normalized_id)
return self
class SubscriptionSnapshot(_QuerySnapshot):
"""当前订阅的稳定只读快照,不包含 ORM 或写入行为。"""
id: int
name: Optional[str] = None
year: Optional[str] = None
type: Optional[str] = None
keyword: Optional[str] = None
music_type: Optional[str] = None
total_tracks: Optional[int] = None
season: Optional[int] = None
poster: Optional[str] = None
backdrop: Optional[str] = None
vote: Optional[float] = None
description: Optional[str] = None
# 资源选择规则与质量约束均按宿主保存值只读返回。
filter: Optional[str] = None
include: Optional[str] = None
exclude: Optional[str] = None
quality: Optional[str] = None
resolution: Optional[str] = None
effect: Optional[str] = None
audio_quality: Optional[str] = None
audio_format: Optional[str] = None
min_bitrate: Optional[int] = None
min_bit_depth: Optional[int] = None
min_sample_rate: Optional[int] = None
# 订阅进度字段由宿主维护,插件查询不得据此直接写库。
total_episode: Optional[int] = None
start_episode: Optional[int] = None
lack_episode: Optional[int] = None
note: Optional[JsonData] = None
state: Optional[str] = None
last_update: Optional[str] = None
date: Optional[str] = None
username: Optional[str] = None
sites: Optional[list[int]] = None
downloader: Optional[str] = None
best_version: Optional[int] = None
best_version_full: Optional[int] = None
current_priority: Optional[int] = None
current_audio_format: Optional[str] = None
current_bitrate: Optional[int] = None
current_bit_depth: Optional[int] = None
current_sample_rate: Optional[int] = None
episode_priority: Optional[dict[str, int]] = None
save_path: Optional[str] = None
search_imdbid: Optional[int] = None
manual_total_episode: Optional[int] = None
custom_words: Optional[str] = None
media_category: Optional[str] = None
filter_groups: Optional[list[str]] = None
episode_group: Optional[str] = None
class SubscriptionHistorySnapshot(_QuerySnapshot):
"""订阅完成历史的稳定只读快照。"""
id: int
name: Optional[str] = None
year: Optional[str] = None
type: Optional[str] = None
keyword: Optional[str] = None
music_type: Optional[str] = None
total_tracks: Optional[int] = None
season: Optional[int] = None
poster: Optional[str] = None
backdrop: Optional[str] = None
vote: Optional[float] = None
description: Optional[str] = None
filter: Optional[str] = None
include: Optional[str] = None
exclude: Optional[str] = None
quality: Optional[str] = None
resolution: Optional[str] = None
effect: Optional[str] = None
audio_quality: Optional[str] = None
audio_format: Optional[str] = None
min_bitrate: Optional[int] = None
min_bit_depth: Optional[int] = None
min_sample_rate: Optional[int] = None
total_episode: Optional[int] = None
start_episode: Optional[int] = None
date: Optional[str] = None
username: Optional[str] = None
sites: Optional[list[int]] = None
best_version: Optional[int] = None
best_version_full: Optional[int] = None
current_priority: Optional[int] = None
current_audio_format: Optional[str] = None
current_bitrate: Optional[int] = None
current_bit_depth: Optional[int] = None
current_sample_rate: Optional[int] = None
episode_priority: Optional[dict[str, int]] = None
save_path: Optional[str] = None
search_imdbid: Optional[int] = None
custom_words: Optional[str] = None
media_category: Optional[str] = None
filter_groups: Optional[list[str]] = None
episode_group: Optional[str] = None
class DownloadHistorySnapshot(_QuerySnapshot):
"""下载历史的稳定只读快照。"""
id: int
path: Optional[str] = None
type: Optional[str] = None
title: Optional[str] = None
year: Optional[str] = None
music_type: Optional[str] = None
seasons: Optional[str] = None
episodes: Optional[str] = None
image: Optional[str] = None
poster: Optional[str] = None
downloader: Optional[str] = None
download_hash: Optional[str] = None
torrent_name: Optional[str] = None
torrent_description: Optional[str] = None
torrent_site: Optional[str] = None
userid: Optional[str] = None
username: Optional[str] = None
channel: Optional[str] = None
date: Optional[str] = None
note: Optional[JsonData] = None
media_category: Optional[str] = None
episode_group: Optional[str] = None
custom_words: Optional[str] = None
class TransferHistorySnapshot(_QuerySnapshot):
"""整理历史的稳定只读快照;内部任务结算标识不会暴露给插件。"""
id: int
src: Optional[str] = None
src_storage: Optional[str] = None
src_fileitem: Optional[JsonData] = None
dest: Optional[str] = None
dest_storage: Optional[str] = None
dest_fileitem: Optional[JsonData] = None
mode: Optional[str] = None
type: Optional[str] = None
category: Optional[str] = None
title: Optional[str] = None
year: Optional[str] = None
music_type: Optional[str] = None
total_tracks: Optional[int] = None
audio_format: Optional[str] = None
audio_lossless: Optional[bool] = None
bit_depth: Optional[int] = None
sample_rate: Optional[int] = None
bitrate: Optional[int] = None
seasons: Optional[str] = None
episodes: Optional[str] = None
image: Optional[str] = None
downloader: Optional[str] = None
download_hash: Optional[str] = None
status: bool = False
errmsg: Optional[str] = None
date: Optional[str] = None
files: Optional[JsonData] = None
episode_group: Optional[str] = None
@field_validator("status", mode="before") # type: ignore[misc]
@classmethod
def _normalize_status(cls, value: object) -> bool:
"""旧记录的 NULL 状态按失败处理,避免错误地向插件声明整理成功。"""
return bool(value)
class SubscriptionFilter(MediaIdentityQuery):
"""当前订阅的组合筛选合同。
所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配,其余字段均精确匹配;
``music_type=recording`` 同时匹配未标注音乐类型的旧单曲记录。
"""
ids: tuple[int, ...] = ()
names: tuple[str, ...] = ()
states: tuple[str, ...] = ()
usernames: tuple[str, ...] = ()
media_types: tuple[MediaType, ...] = ()
season: Optional[int] = None
episode_group: Optional[str] = None
music_type: Optional[str] = None
class SubscriptionHistoryFilter(MediaIdentityQuery):
"""订阅完成历史的组合筛选合同。
所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配,其余字段均精确匹配;
``music_type=recording`` 同时匹配未标注音乐类型的旧单曲记录。
"""
ids: tuple[int, ...] = ()
names: tuple[str, ...] = ()
usernames: tuple[str, ...] = ()
media_types: tuple[MediaType, ...] = ()
season: Optional[int] = None
episode_group: Optional[str] = None
music_type: Optional[str] = None
class DownloadHistoryFilter(MediaIdentityQuery):
"""下载历史的组合筛选合同。
所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配;除 ``text`` 外的字符串
字段均精确匹配。``text`` 对标题和路径执行转义后的字面包含查询,不把 ``%``
或 ``_`` 解释为通配符。``music_type=recording`` 同时匹配旧 NULL 记录。
"""
ids: tuple[int, ...] = ()
media_types: tuple[MediaType, ...] = ()
title: Optional[str] = None
text: Optional[str] = None
year: Optional[str] = None
seasons: Optional[str] = None
episodes: Optional[str] = None
path: Optional[str] = None
download_hash: Optional[str] = None
username: Optional[str] = None
usernames: tuple[str, ...] = ()
music_type: Optional[str] = None
episode_group: Optional[str] = None
class TransferHistoryFilter(MediaIdentityQuery):
"""整理历史的组合筛选合同。
所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配;除 ``text`` 外的字符串
字段均精确匹配。``text`` 对标题、源路径和目标路径执行转义后的字面包含查询。
``require_media_identity`` 只保留来源可解析且原生 ID 非空、非零的记录;
``status=False`` 包含旧 NULL 状态,``music_type=recording`` 包含旧 NULL 类型。
"""
ids: tuple[int, ...] = ()
media_types: tuple[MediaType, ...] = ()
media_sources: tuple[MediaSource, ...] = ()
require_media_identity: bool = False
title: Optional[str] = None
text: Optional[str] = None
year: Optional[str] = None
seasons: Optional[str] = None
episodes: Optional[str] = None
src: Optional[str] = None
dest: Optional[str] = None
status: Optional[bool] = None
download_hash: Optional[str] = None
music_type: Optional[str] = None
episode_group: Optional[str] = None
__all__ = [
"DEFAULT_QUERY_PAGE_SIZE",
"MAX_QUERY_PAGE_SIZE",
"DownloadHistoryFilter",
"DownloadHistorySnapshot",
"MediaIdentityQuery",
"QueryPage",
"QueryPageRequest",
"QuerySort",
"QuerySortDirection",
"QuerySortField",
"SubscriptionFilter",
"SubscriptionHistorySnapshot",
"SubscriptionHistoryFilter",
"SubscriptionSnapshot",
"TransferHistoryFilter",
"TransferHistorySnapshot",
]