mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-02 22:17:08 +08:00
395 lines
14 KiB
Python
395 lines
14 KiB
Python
"""插件只读数据查询使用的稳定筛选、分页与数据投影合同。"""
|
|
|
|
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",
|
|
]
|