mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 11:54:50 +08:00
964 lines
36 KiB
Python
964 lines
36 KiB
Python
"""
|
||
提供订阅查询、创建、修改和控制接口。
|
||
"""
|
||
|
||
from datetime import date
|
||
from typing import Annotated, Any, List, Optional
|
||
|
||
import cn2an
|
||
from fastapi import Depends, Header, HTTPException, Query, Request, Response
|
||
|
||
from app.adapters.external.server import MoviePilotServerHelper
|
||
from app.adapters.web.security.access import (
|
||
validate_api_credential_identity,
|
||
verify_apitoken,
|
||
verify_token,
|
||
)
|
||
from app.api.context import (
|
||
get_background_task_registry,
|
||
get_sync_subscription_repository,
|
||
resolve_background_task_registry,
|
||
)
|
||
from app.api.dependencies.auth import (
|
||
get_current_active_user,
|
||
get_current_active_user_async,
|
||
)
|
||
from app.api.dependencies.subscription import (
|
||
get_delete_subscribe_command,
|
||
get_delete_subscriptions_by_identity_command,
|
||
get_subscription_execution_status_service,
|
||
get_subscription_mutation_service,
|
||
get_subscription_query_service,
|
||
)
|
||
from app.api.endpoints.submaintenance import (
|
||
check_subscribes,
|
||
refresh_subscribes,
|
||
reset_subscribes,
|
||
search_subscribe,
|
||
search_subscribes,
|
||
)
|
||
from app.api.endpoints.submaintenance import (
|
||
router as subscribe_maintenance_router,
|
||
)
|
||
from app.api.principal import ApiPrincipal
|
||
from app.api.response import (
|
||
COLLECTION_TOTAL_HEADER,
|
||
COLLECTION_TOTAL_OPENAPI_KEY,
|
||
CompatibleCountParam,
|
||
CompatiblePageParam,
|
||
ResponseAPIRouter,
|
||
resolve_compatible_pagination,
|
||
)
|
||
from app.application.configuration import (
|
||
get_api_runtime_config_snapshot,
|
||
get_configured_system_config,
|
||
)
|
||
from app.application.subscription.contract import SubscriptionIdentityConflictError, SubscriptionQueryPort
|
||
from app.application.subscription.delete import (
|
||
DeleteSubscribeCommand,
|
||
SubscribeDeletionActor,
|
||
)
|
||
from app.application.subscription.identity import (
|
||
DeleteSubscriptionsByIdentityCommand,
|
||
)
|
||
from app.application.subscription.mutation import (
|
||
SubscriptionActor,
|
||
SubscriptionMutationService,
|
||
)
|
||
from app.application.subscription.query import SubscriptionQueryService
|
||
from app.application.subscription.status import SubscriptionExecutionStatusService
|
||
from app.chain.subscribe.calendar import SubscribeCalendarChain
|
||
from app.chain.subscribe.facade import SubscribeChain
|
||
from app.domain.context import MediaInfo
|
||
from app.domain.metainfo import MetaInfo
|
||
from app.runtime.execution import run_in_threadpool
|
||
from app.runtime.log import logger
|
||
from app.runtime.tasks import TaskRegistry
|
||
from app.schemas.calendar import SubscribeCalendarPage as _SchemaSubscribeCalendarPage
|
||
from app.schemas.common import IdData as _SchemaIdData
|
||
from app.schemas.context import MediaInfo as _SchemaMediaInfo
|
||
from app.schemas.media import normalize_media_source, resolve_media_identity
|
||
from app.schemas.response import Response as _SchemaResponse
|
||
from app.schemas.subscribe import SubscrbieInfo as _SchemaSubscrbieInfo
|
||
from app.schemas.subscribe import Subscribe as _SchemaSubscribe
|
||
from app.schemas.subscribe import SubscribeDeletionResult as _SchemaSubscribeDeletionResult
|
||
from app.schemas.subscribe import SubscribeShare as _SchemaSubscribeShare
|
||
from app.schemas.subscribe import SubscribeShareStatistics as _SchemaSubscribeShareStatistics
|
||
from app.schemas.subscribe import SubscriptionExecutionStatus as _SchemaSubscriptionExecutionStatus
|
||
from app.schemas.token import TokenPayload as _SchemaTokenPayload
|
||
from app.schemas.types import (
|
||
MUSIC_ENTITY_ALBUM,
|
||
MUSIC_ENTITY_RECORDING,
|
||
MediaSource,
|
||
MediaType,
|
||
SystemConfigKey,
|
||
)
|
||
|
||
router = ResponseAPIRouter()
|
||
router.routes.extend(subscribe_maintenance_router.routes)
|
||
|
||
__all__ = [
|
||
"check_subscribes",
|
||
"refresh_subscribes",
|
||
"reset_subscribes",
|
||
"search_subscribe",
|
||
"search_subscribes",
|
||
]
|
||
|
||
|
||
def _public_subscription_message(message: Optional[object]) -> str:
|
||
"""
|
||
延迟加载订阅错误转换器,避免启动阶段增加宿主模块。
|
||
"""
|
||
from app.runtime.errors import public_error_message
|
||
|
||
return public_error_message(message, context="subscription")
|
||
|
||
|
||
async def _attach_execution_status(
|
||
subscribes: list[_SchemaSubscribe],
|
||
status_service: object,
|
||
) -> list[_SchemaSubscribe]:
|
||
"""
|
||
批量附加当前执行状态,保持无执行记录时的旧响应形状。
|
||
"""
|
||
loader = getattr(status_service, "for_subscriptions", None)
|
||
if not callable(loader):
|
||
return subscribes
|
||
statuses = await loader(tuple(item.id for item in subscribes if item.id is not None))
|
||
for subscribe in subscribes:
|
||
if subscribe.id is not None and (status := statuses.get(subscribe.id)) is not None:
|
||
subscribe.execution_status = _SchemaSubscriptionExecutionStatus.model_validate(status)
|
||
return subscribes
|
||
|
||
|
||
def start_subscribe_add(
|
||
title: str,
|
||
year: str,
|
||
mtype: MediaType,
|
||
media_source: MediaSource,
|
||
media_id: str,
|
||
season: int,
|
||
username: str,
|
||
) -> None:
|
||
"""
|
||
启动订阅任务
|
||
"""
|
||
SubscribeChain().add(
|
||
title=title,
|
||
year=year,
|
||
mtype=mtype,
|
||
media_source=media_source,
|
||
media_id=media_id,
|
||
season=season,
|
||
username=username,
|
||
)
|
||
|
||
|
||
def build_subscribe_event_payload(subscribe: Any) -> dict[str, Any]:
|
||
"""
|
||
从 ORM 已加载字段构造订阅事件快照,避免异步接口里属性懒加载触发隐式 IO。
|
||
"""
|
||
payload = subscribe.to_dict()
|
||
return payload if isinstance(payload, dict) else {}
|
||
|
||
|
||
def can_access_subscribe(subscribe: Any, current_user: ApiPrincipal) -> bool:
|
||
"""
|
||
判断当前用户是否可访问订阅及其历史记录。
|
||
|
||
超级用户拥有全局订阅管理能力;普通用户只能访问 username 精确匹配自己的订阅。
|
||
空 username 表示无法归属的 legacy 订阅,只能由超级用户管理。
|
||
"""
|
||
if not subscribe:
|
||
return False
|
||
if current_user.is_superuser:
|
||
return True
|
||
username = subscribe.username
|
||
return bool(username) and username == current_user.name
|
||
|
||
|
||
def select_accessible_subscribe(subscribes: List[Any], current_user: ApiPrincipal) -> Any:
|
||
"""
|
||
从候选订阅中选择当前用户可访问的第一条记录。
|
||
"""
|
||
for subscribe in subscribes or []:
|
||
if can_access_subscribe(subscribe, current_user):
|
||
return subscribe
|
||
return None
|
||
|
||
|
||
def matches_subscribe_music_type(
|
||
subscribe: Any,
|
||
music_type: Optional[str],
|
||
) -> bool:
|
||
"""
|
||
匹配订阅音乐实体,并把迁移前未标注类型的历史记录兼容为单曲。
|
||
"""
|
||
if not music_type:
|
||
return True
|
||
subscribe_music_type = getattr(subscribe, "music_type", None)
|
||
return subscribe_music_type == music_type or (music_type == MUSIC_ENTITY_RECORDING and subscribe_music_type is None)
|
||
|
||
|
||
@router.get(
|
||
"/",
|
||
summary="查询所有订阅",
|
||
response_model=List[_SchemaSubscribe],
|
||
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||
)
|
||
async def read_subscribes(
|
||
response: Response = None, # type: ignore[assignment]
|
||
query: SubscriptionQueryService = Depends(get_subscription_query_service),
|
||
status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service),
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
page: CompatiblePageParam = None,
|
||
count: CompatibleCountParam = None,
|
||
) -> Any:
|
||
"""
|
||
查询所有订阅
|
||
"""
|
||
username = None if current_user.is_superuser else current_user.name
|
||
page, count = resolve_compatible_pagination(page, count)
|
||
if response is not None:
|
||
response.headers[COLLECTION_TOTAL_HEADER] = str(await query.count_public(username))
|
||
subscribes = await query.list_public(username, page=page, count=count)
|
||
return await _attach_execution_status(subscribes, status_service)
|
||
|
||
|
||
@router.get("/calendar", summary="查询日期窗口内的订阅日历", response_model=_SchemaSubscribeCalendarPage)
|
||
async def subscribe_calendar(
|
||
request: Request,
|
||
start: Annotated[date, Query(description="开始日期(包含),格式 YYYY-MM-DD")],
|
||
end: Annotated[date, Query(description="结束日期(不包含),窗口最多 93 天")],
|
||
page: Annotated[int, Query(ge=1, description="从 1 开始的页码")] = 1,
|
||
count: Annotated[int, Query(ge=1, le=200, description="每页最多返回的日历条目数")] = 50,
|
||
query: SubscriptionQueryService = Depends(get_subscription_query_service),
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
) -> _SchemaSubscribeCalendarPage:
|
||
"""
|
||
只返回指定播出日期内的精简日历事件,避免客户端逐订阅查询所有季集详情。
|
||
|
||
:param start: 日期窗口开始,包含当天
|
||
:param request: 用于探测翻月或离开页面后断开的旧请求
|
||
:param end: 日期窗口结束,不包含当天,跨度不超过 93 天
|
||
:param page: 从 1 开始的页码
|
||
:param count: 每页条目上限,最多 200 条
|
||
:param query: 注入的订阅查询服务
|
||
:param current_user: 当前登录用户,普通用户仅可查询自己的订阅
|
||
:return: 按真实播出日期排序的精简日历条目与窗口总数
|
||
"""
|
||
if not 0 < (end - start).days <= 93:
|
||
raise HTTPException(status_code=422, detail="日历日期窗口必须大于 0 且不超过 93 天")
|
||
username = None if current_user.is_superuser else current_user.name
|
||
subscribes = await query.list_public(username)
|
||
return await SubscribeCalendarChain().window(subscribes, start, end, page, count, request.is_disconnected)
|
||
|
||
|
||
@router.get(
|
||
"/list",
|
||
summary="查询所有订阅(API_TOKEN)",
|
||
response_model=List[_SchemaSubscribe],
|
||
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||
)
|
||
async def list_subscribes(
|
||
_: Annotated[str, Depends(verify_apitoken)],
|
||
response: Response = None, # type: ignore[assignment]
|
||
query: SubscriptionQueryService = Depends(get_subscription_query_service),
|
||
status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service),
|
||
page: CompatiblePageParam = None,
|
||
count: CompatibleCountParam = None,
|
||
) -> Any:
|
||
"""
|
||
通过 API_TOKEN 及当前管理员身份校验后查询全部用户的订阅。
|
||
|
||
认证依赖必须放在最外层 Annotated 中,避免被可空联合类型隐藏。
|
||
|
||
:param _: 由 ?token= 或 X-API-KEY 传入并已通过校验的凭据
|
||
:return: 按可选分页返回的订阅列表
|
||
"""
|
||
page, count = resolve_compatible_pagination(page, count)
|
||
if response is not None:
|
||
response.headers[COLLECTION_TOTAL_HEADER] = str(await query.count_public())
|
||
subscribes = await query.list_public(page=page, count=count)
|
||
return await _attach_execution_status(subscribes, status_service)
|
||
|
||
|
||
@router.post(
|
||
"/",
|
||
summary="新增订阅",
|
||
response_model=_SchemaResponse[_SchemaIdData],
|
||
)
|
||
async def create_subscribe(
|
||
*,
|
||
subscribe_in: _SchemaSubscribe,
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
) -> _SchemaResponse[dict[str, Any]]:
|
||
"""
|
||
新增订阅
|
||
"""
|
||
# 类型转换
|
||
if subscribe_in.type:
|
||
mtype = MediaType.from_agent(subscribe_in.type) or MediaType(subscribe_in.type)
|
||
else:
|
||
mtype = None
|
||
# 非 TMDB 来源的标题可能自带季标记,入库前统一拆分。
|
||
if mtype != MediaType.MUSIC and normalize_media_source(subscribe_in.media_source) not in (None, MediaSource.TMDB):
|
||
meta = MetaInfo(subscribe_in.name or "")
|
||
subscribe_in.name = meta.name
|
||
if subscribe_in.season is None:
|
||
subscribe_in.season = meta.begin_season
|
||
# 空标题由订阅识别链按显式媒体身份补全,但调用契约始终使用字符串。
|
||
title = subscribe_in.name or ""
|
||
subscribe_dict = subscribe_in.to_public_write_payload()
|
||
identity_fields = {"media_source", "media_id"}.intersection(subscribe_in.model_fields_set)
|
||
if identity_fields:
|
||
media_source, media_id = resolve_media_identity(
|
||
media_source=subscribe_in.media_source,
|
||
media_id=subscribe_in.media_id,
|
||
)
|
||
if media_source and media_id:
|
||
subscribe_dict["media_source"] = media_source
|
||
subscribe_dict["media_id"] = media_id
|
||
elif subscribe_in.media_source is None and subscribe_in.media_id is None:
|
||
# 完整空对表示订阅暂无可用身份,与只提交其中一个字段语义不同。
|
||
subscribe_dict["media_source"] = None
|
||
subscribe_dict["media_id"] = None
|
||
else:
|
||
return _SchemaResponse(
|
||
success=False,
|
||
message="新增订阅时必须同时提供有效的 media_source 和 media_id",
|
||
)
|
||
subscribe_dict["username"] = current_user.name
|
||
sid, message = await SubscribeChain().async_add(
|
||
mtype=mtype,
|
||
title=title,
|
||
exist_ok=True,
|
||
owner_scope=not current_user.is_superuser,
|
||
**subscribe_dict,
|
||
)
|
||
return _SchemaResponse(
|
||
success=bool(sid),
|
||
message=(_public_subscription_message(message) if message else ""),
|
||
data={"id": sid},
|
||
)
|
||
|
||
|
||
@router.put("/", summary="更新订阅", response_model=_SchemaResponse[None])
|
||
async def update_subscribe(
|
||
*,
|
||
subscribe_in: _SchemaSubscribe,
|
||
mutation: SubscriptionMutationService = Depends(get_subscription_mutation_service),
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
) -> Any:
|
||
"""
|
||
更新订阅信息
|
||
"""
|
||
actor = SubscriptionActor(
|
||
name=current_user.name,
|
||
is_superuser=current_user.is_superuser,
|
||
)
|
||
if subscribe_in.id is None:
|
||
return _SchemaResponse(success=False, message="订阅不存在")
|
||
subscribe_id = subscribe_in.id
|
||
subscribe = await mutation.get_accessible(subscribe_id, actor)
|
||
if not subscribe:
|
||
return _SchemaResponse(success=False, message="订阅不存在")
|
||
subscribe_dict = subscribe_in.to_public_write_payload(exclude_unset=True)
|
||
identity_fields = {"media_source", "media_id"}.intersection(subscribe_in.model_fields_set)
|
||
if identity_fields:
|
||
media_source, media_id = resolve_media_identity(
|
||
media_source=subscribe_in.media_source,
|
||
media_id=subscribe_in.media_id,
|
||
)
|
||
if media_source and media_id:
|
||
subscribe_dict["media_source"] = media_source
|
||
subscribe_dict["media_id"] = media_id
|
||
elif subscribe_in.media_source is None and subscribe_in.media_id is None:
|
||
# 只有两个身份键都显式为空时才清空;全部省略则保留存量身份。
|
||
subscribe_dict["media_source"] = None
|
||
subscribe_dict["media_id"] = None
|
||
else:
|
||
return _SchemaResponse(
|
||
success=False,
|
||
message="更新媒体身份时必须同时提供有效的 media_source 和 media_id",
|
||
)
|
||
subscribe_dict["username"] = subscribe.username
|
||
if getattr(subscribe, "type", None) == MediaType.MUSIC.value:
|
||
# 音乐实体与曲目总数来自识别链,编辑接口不得把专辑改成单曲而提前完成订阅。
|
||
subscribe_dict["type"] = subscribe.type
|
||
subscribe_dict["music_type"] = subscribe.music_type
|
||
subscribe_dict["total_tracks"] = subscribe.total_tracks if subscribe.music_type == MUSIC_ENTITY_ALBUM else None
|
||
if identity_fields and subscribe.music_type == MUSIC_ENTITY_ALBUM:
|
||
old_identity = resolve_media_identity(media=subscribe)
|
||
new_identity = resolve_media_identity(
|
||
media_source=subscribe_dict.get("media_source"),
|
||
media_id=subscribe_dict.get("media_id"),
|
||
)
|
||
if old_identity != new_identity:
|
||
# 媒体身份变化后,旧专辑的音轨事实不能迁移到新专辑。
|
||
subscribe_dict["downloaded_tracks"] = []
|
||
total_episode_updated = "total_episode" in subscribe_in.model_fields_set
|
||
if (
|
||
total_episode_updated
|
||
and subscribe_in.total_episode
|
||
and subscribe_in.total_episode > (subscribe.total_episode or 0)
|
||
):
|
||
# 扩大目标范围时,新增加的集数尚无下载事实,应同步计入缺失集数。
|
||
subscribe_dict["lack_episode"] = (subscribe.lack_episode or 0) + (
|
||
subscribe_in.total_episode - (subscribe.total_episode or 0)
|
||
)
|
||
# 是否手动修改过总集数
|
||
if total_episode_updated and subscribe_in.total_episode != subscribe.total_episode:
|
||
subscribe_dict["manual_total_episode"] = 1
|
||
# 更新到数据库
|
||
try:
|
||
change = await mutation.update(
|
||
subscribe_id,
|
||
subscribe_dict,
|
||
actor,
|
||
existing=subscribe,
|
||
)
|
||
except SubscriptionIdentityConflictError:
|
||
return _SchemaResponse(success=False, message="目标媒体身份已有订阅,当前修改已取消")
|
||
except ValueError as error:
|
||
logger.error(f"订阅分类设置无效:{error}", exc_info=True)
|
||
return _SchemaResponse(
|
||
success=False,
|
||
message="订阅分类设置无效,请重新选择分类后重试",
|
||
)
|
||
if not change:
|
||
return _SchemaResponse(success=False, message="订阅不存在")
|
||
return _SchemaResponse(success=True)
|
||
|
||
|
||
@router.put("/status/{subid}", summary="更新订阅状态", response_model=_SchemaResponse[None])
|
||
async def update_subscribe_status(
|
||
subid: int,
|
||
state: str,
|
||
mutation: SubscriptionMutationService = Depends(get_subscription_mutation_service),
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
) -> Any:
|
||
"""
|
||
更新订阅状态
|
||
"""
|
||
valid_states = ["R", "P", "S"]
|
||
if state not in valid_states:
|
||
return _SchemaResponse(success=False, message="无效的订阅状态")
|
||
actor = SubscriptionActor(
|
||
name=current_user.name,
|
||
is_superuser=current_user.is_superuser,
|
||
)
|
||
change = await mutation.update_status(subid, state, actor)
|
||
if not change:
|
||
return _SchemaResponse(success=False, message="订阅不存在")
|
||
return _SchemaResponse(success=True)
|
||
|
||
|
||
@router.get("/media/{media_id}", summary="查询订阅", response_model=_SchemaSubscribe)
|
||
async def subscribe_media_identity(
|
||
media_id: str,
|
||
media_source: MediaSource,
|
||
season: Optional[int] = None,
|
||
title: Optional[str] = None,
|
||
year: Optional[str] = None,
|
||
mtype: Optional[MediaType] = None,
|
||
music_type: Optional[str] = None,
|
||
query: SubscriptionQueryService = Depends(get_subscription_query_service),
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
) -> Any:
|
||
"""
|
||
根据媒体身份查询订阅,影视身份未命中时按类型、标题和可用年份跨源回退。
|
||
"""
|
||
metadata = MetaInfo(title) if title else None
|
||
normalized_title = metadata.name if metadata else None
|
||
if season is None and metadata:
|
||
season = metadata.begin_season
|
||
subscribes = await query.list_by_media_identity(media_source, media_id, music_type)
|
||
if season is not None:
|
||
subscribes = [subscribe for subscribe in subscribes if subscribe.season == season]
|
||
result = select_accessible_subscribe(subscribes, current_user)
|
||
if (
|
||
not result
|
||
and mtype in (MediaType.MOVIE, MediaType.TV)
|
||
and normalized_title
|
||
):
|
||
subscribes = await query.list_by_video_metadata(
|
||
title=normalized_title,
|
||
year=year,
|
||
media_type=mtype,
|
||
season=season,
|
||
)
|
||
result = select_accessible_subscribe(subscribes, current_user)
|
||
return result if result else _SchemaSubscribe()
|
||
|
||
|
||
@router.delete("/media/{media_id}", summary="删除订阅", response_model=_SchemaResponse[None])
|
||
async def delete_subscribe_by_media_identity(
|
||
media_id: str,
|
||
media_source: MediaSource,
|
||
season: Optional[int] = None,
|
||
music_type: Optional[str] = None,
|
||
command: DeleteSubscriptionsByIdentityCommand = Depends(get_delete_subscriptions_by_identity_command),
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
) -> Any:
|
||
"""
|
||
根据任意媒体数据源 ID 删除订阅。
|
||
"""
|
||
await command.execute(
|
||
media_source,
|
||
media_id,
|
||
season,
|
||
music_type,
|
||
SubscribeDeletionActor(
|
||
username=current_user.name,
|
||
is_superuser=current_user.is_superuser,
|
||
),
|
||
)
|
||
return _SchemaResponse(success=True)
|
||
|
||
|
||
@router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=_SchemaResponse[None])
|
||
async def seerr_subscribe(
|
||
request: Request,
|
||
task_registry: Annotated[TaskRegistry, Depends(get_background_task_registry)],
|
||
authorization: Annotated[str | None, Header()] = None,
|
||
) -> Any:
|
||
"""
|
||
Jellyseerr/Overseerr网络勾子通知订阅
|
||
"""
|
||
if not authorization or authorization != get_api_runtime_config_snapshot().api_token:
|
||
raise HTTPException(
|
||
status_code=401,
|
||
detail="授权失败",
|
||
headers={"WWW-Authenticate": "Bearer"},
|
||
)
|
||
await run_in_threadpool(validate_api_credential_identity)
|
||
req_json = await request.json()
|
||
if not req_json:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="报文内容为空",
|
||
)
|
||
notification_type = req_json.get("notification_type")
|
||
if notification_type not in ["MEDIA_APPROVED", "MEDIA_AUTO_APPROVED"]:
|
||
return _SchemaResponse(success=False, message="不支持的通知类型")
|
||
subject = req_json.get("subject")
|
||
media_type = MediaType.MOVIE if req_json.get("media", {}).get("media_type") == "movie" else MediaType.TV
|
||
tmdbId = req_json.get("media", {}).get("tmdbId")
|
||
if not media_type or not tmdbId or not subject:
|
||
return _SchemaResponse(success=False, message="请求参数不正确")
|
||
user_name = req_json.get("request", {}).get("requestedBy_username")
|
||
# 添加订阅
|
||
if media_type == MediaType.MOVIE:
|
||
resolve_background_task_registry(task_registry).create_sync(
|
||
start_subscribe_add,
|
||
mtype=media_type,
|
||
media_source=MediaSource.TMDB,
|
||
media_id=str(tmdbId),
|
||
title=subject,
|
||
year="",
|
||
# 电影不传季号,避免被误判为剧集(S00)并污染通知标题
|
||
season=None,
|
||
username=user_name,
|
||
owner="api.subscribe.seerr",
|
||
)
|
||
else:
|
||
seasons = []
|
||
for extra in req_json.get("extra", []):
|
||
if extra.get("name") == "Requested Seasons":
|
||
seasons = [int(str(sea).strip()) for sea in extra.get("value").split(", ") if str(sea).isdigit()]
|
||
break
|
||
for season in seasons:
|
||
resolve_background_task_registry(task_registry).create_sync(
|
||
start_subscribe_add,
|
||
mtype=media_type,
|
||
media_source=MediaSource.TMDB,
|
||
media_id=str(tmdbId),
|
||
title=subject,
|
||
year="",
|
||
season=season,
|
||
username=user_name,
|
||
owner="api.subscribe.seerr",
|
||
)
|
||
|
||
return _SchemaResponse(success=True)
|
||
|
||
|
||
@router.get(
|
||
"/history/{mtype}",
|
||
summary="查询订阅历史",
|
||
response_model=List[_SchemaSubscribe],
|
||
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||
)
|
||
async def subscribe_history(
|
||
mtype: str,
|
||
page: Optional[int] = 1,
|
||
count: Optional[int] = 30,
|
||
query: SubscriptionQueryService = Depends(get_subscription_query_service),
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
response: Response = None, # type: ignore[assignment]
|
||
) -> Any:
|
||
"""
|
||
查询电影、电视剧或音乐订阅历史
|
||
"""
|
||
username = None if current_user.is_superuser else current_user.name
|
||
page = page if page is not None else 1
|
||
count = count if count is not None else 30
|
||
results = await query.list_history(
|
||
mtype,
|
||
page=page,
|
||
count=count,
|
||
username=username,
|
||
)
|
||
if response is not None:
|
||
response.headers[COLLECTION_TOTAL_HEADER] = str(await query.count_history(mtype, username=username))
|
||
return results
|
||
|
||
|
||
@router.delete(
|
||
"/history/{history_id}",
|
||
summary="删除订阅历史",
|
||
response_model=_SchemaResponse[_SchemaSubscribeDeletionResult],
|
||
)
|
||
async def delete_subscribe_history(
|
||
history_id: int,
|
||
mutation: SubscriptionMutationService = Depends(get_subscription_mutation_service),
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
) -> Any:
|
||
"""
|
||
删除订阅历史
|
||
"""
|
||
actor = SubscriptionActor(
|
||
name=current_user.name,
|
||
is_superuser=current_user.is_superuser,
|
||
)
|
||
status = await mutation.delete_history_with_status(history_id, actor)
|
||
if status == "not_found":
|
||
raise HTTPException(status_code=404, detail="订阅历史不存在")
|
||
if status == "forbidden":
|
||
raise HTTPException(status_code=403, detail="无权删除该订阅历史")
|
||
return _SchemaResponse(
|
||
success=True,
|
||
data=_SchemaSubscribeDeletionResult(status="deleted"),
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/popular",
|
||
summary="热门订阅(基于用户共享数据)",
|
||
response_model=List[_SchemaMediaInfo],
|
||
)
|
||
async def popular_subscribes(
|
||
stype: str,
|
||
page: Optional[int] = 1,
|
||
count: Optional[int] = 30,
|
||
min_sub: Optional[int] = None,
|
||
genre_id: Optional[int] = None,
|
||
min_rating: Optional[float] = None,
|
||
max_rating: Optional[float] = None,
|
||
sort_type: Optional[str] = None,
|
||
_: _SchemaTokenPayload = Depends(verify_token),
|
||
) -> Any:
|
||
"""
|
||
查询热门订阅
|
||
"""
|
||
subscribes = await MoviePilotServerHelper.async_get_subscribe_statistic(
|
||
stype=stype,
|
||
page=page,
|
||
count=count,
|
||
genre_id=genre_id,
|
||
min_rating=min_rating,
|
||
max_rating=max_rating,
|
||
sort_type=sort_type,
|
||
)
|
||
if subscribes:
|
||
ret_medias = []
|
||
for sub in subscribes:
|
||
# 订阅人数
|
||
raw_count = sub.get("count")
|
||
count = (
|
||
int(raw_count)
|
||
if isinstance(raw_count, (int, float, str)) and str(raw_count).strip()
|
||
else 0
|
||
)
|
||
if min_sub and count < min_sub:
|
||
continue
|
||
media = MediaInfo()
|
||
raw_type = sub.get("type")
|
||
if not isinstance(raw_type, str):
|
||
continue
|
||
media.type = MediaType.from_agent(raw_type) or MediaType(raw_type)
|
||
raw_source = sub.get("media_source")
|
||
if isinstance(raw_source, str):
|
||
normalized_source = normalize_media_source(raw_source)
|
||
if normalized_source is not None:
|
||
media.media_source = normalized_source
|
||
raw_media_id = sub.get("media_id")
|
||
if raw_media_id is not None:
|
||
media.media_id = str(raw_media_id)
|
||
# 处理标题
|
||
raw_title = sub.get("name")
|
||
title = str(raw_title) if raw_title is not None else ""
|
||
raw_season = sub.get("season")
|
||
season = int(raw_season) if isinstance(raw_season, (int, float, str)) and str(raw_season).strip() else None
|
||
if season is not None and season != 1:
|
||
# 小写数据转大写
|
||
season_str = cn2an.an2cn(season, "low")
|
||
title = f"{title} 第{season_str}季"
|
||
media.title = title
|
||
raw_year = sub.get("year")
|
||
if isinstance(raw_year, (int, float, str)) and str(raw_year).strip():
|
||
# 共享数据可能使用数值年份,公开响应仍遵循字符串年份契约
|
||
media.year = str(int(float(raw_year)))
|
||
if season is not None:
|
||
media.season = season
|
||
raw_description = sub.get("description")
|
||
if raw_description is not None:
|
||
media.overview = str(raw_description)
|
||
raw_vote = sub.get("vote")
|
||
if isinstance(raw_vote, (int, float, str)) and str(raw_vote).strip():
|
||
media.vote_average = float(raw_vote)
|
||
raw_poster = sub.get("poster")
|
||
if raw_poster is not None:
|
||
media.poster_path = str(raw_poster)
|
||
raw_backdrop = sub.get("backdrop")
|
||
if raw_backdrop is not None:
|
||
media.backdrop_path = str(raw_backdrop)
|
||
media.popularity = count
|
||
ret_medias.append(media)
|
||
return [media.to_dict() for media in ret_medias]
|
||
return []
|
||
|
||
|
||
@router.get(
|
||
"/user/{username}",
|
||
summary="用户订阅",
|
||
response_model=List[_SchemaSubscribe],
|
||
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||
)
|
||
async def user_subscribes(
|
||
username: str,
|
||
response: Response = None, # type: ignore[assignment]
|
||
query: SubscriptionQueryService = Depends(get_subscription_query_service),
|
||
status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service),
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
page: CompatiblePageParam = None,
|
||
count: CompatibleCountParam = None,
|
||
) -> Any:
|
||
"""
|
||
查询用户订阅
|
||
"""
|
||
if not current_user.is_superuser and username != current_user.name:
|
||
return []
|
||
page, count = resolve_compatible_pagination(page, count)
|
||
if response is not None:
|
||
response.headers[COLLECTION_TOTAL_HEADER] = str(await query.count_public(username))
|
||
subscribes = await query.list_public(username, page=page, count=count)
|
||
return await _attach_execution_status(subscribes, status_service)
|
||
|
||
|
||
@router.get(
|
||
"/files/{subscribe_id}",
|
||
summary="订阅相关文件信息",
|
||
response_model=_SchemaSubscrbieInfo,
|
||
)
|
||
def subscribe_files(
|
||
subscribe_id: int,
|
||
repository: SubscriptionQueryPort = Depends(get_sync_subscription_repository),
|
||
current_user: ApiPrincipal = Depends(get_current_active_user),
|
||
) -> Any:
|
||
"""
|
||
订阅相关文件信息
|
||
"""
|
||
actor = SubscriptionActor(
|
||
name=current_user.name,
|
||
is_superuser=current_user.is_superuser,
|
||
)
|
||
subscribe = repository.get(subscribe_id)
|
||
if subscribe is not None and SubscriptionMutationService.can_access(subscribe, actor):
|
||
return SubscribeChain().subscribe_files_info(subscribe)
|
||
return _SchemaSubscrbieInfo()
|
||
|
||
|
||
@router.post("/share", summary="分享订阅", response_model=_SchemaResponse[None])
|
||
async def subscribe_share(
|
||
sub: _SchemaSubscribeShare,
|
||
mutation: SubscriptionMutationService = Depends(get_subscription_mutation_service),
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
) -> Any:
|
||
"""
|
||
分享订阅
|
||
"""
|
||
actor = SubscriptionActor(
|
||
name=current_user.name,
|
||
is_superuser=current_user.is_superuser,
|
||
)
|
||
if sub.subscribe_id is None or sub.share_title is None or sub.share_comment is None or sub.share_user is None:
|
||
return _SchemaResponse(success=False, message="订阅分享参数不完整")
|
||
subscribe = await mutation.get_accessible(sub.subscribe_id, actor)
|
||
if not subscribe:
|
||
return _SchemaResponse(success=False, message="订阅不存在")
|
||
state, errmsg = await MoviePilotServerHelper.async_sub_share(
|
||
subscribe_id=sub.subscribe_id,
|
||
share_title=sub.share_title,
|
||
share_comment=sub.share_comment,
|
||
share_user=sub.share_user,
|
||
)
|
||
return _SchemaResponse(
|
||
success=state,
|
||
message=(_public_subscription_message(errmsg) if errmsg else ""),
|
||
)
|
||
|
||
|
||
@router.delete("/share/{share_id}", summary="删除分享", response_model=_SchemaResponse[None])
|
||
async def subscribe_share_delete(share_id: int, _: _SchemaTokenPayload = Depends(verify_token)) -> Any:
|
||
"""
|
||
删除分享
|
||
"""
|
||
state, errmsg = await MoviePilotServerHelper.async_share_delete(share_id=share_id)
|
||
return _SchemaResponse(
|
||
success=state,
|
||
message=(_public_subscription_message(errmsg) if errmsg else ""),
|
||
)
|
||
|
||
|
||
@router.post("/fork", summary="复用订阅", response_model=_SchemaResponse[_SchemaIdData])
|
||
async def subscribe_fork(
|
||
sub: _SchemaSubscribeShare,
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
) -> Any:
|
||
"""
|
||
复用订阅
|
||
"""
|
||
sub_dict = sub.model_dump()
|
||
sub_dict.pop("id")
|
||
for key in list(sub_dict.keys()):
|
||
if not hasattr(_SchemaSubscribe(), key):
|
||
sub_dict.pop(key)
|
||
result = await create_subscribe(subscribe_in=_SchemaSubscribe(**sub_dict), current_user=current_user)
|
||
if result.success and sub.id is not None:
|
||
await MoviePilotServerHelper.async_sub_fork(share_id=sub.id)
|
||
return result
|
||
|
||
|
||
@router.get("/follow", summary="查询已Follow的订阅分享人", response_model=List[str])
|
||
async def followed_subscribers(
|
||
_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None
|
||
) -> Any:
|
||
"""
|
||
查询已Follow的订阅分享人
|
||
"""
|
||
return get_configured_system_config().get(SystemConfigKey.FollowSubscribers) or []
|
||
|
||
|
||
@router.post("/follow", summary="Follow订阅分享人", response_model=_SchemaResponse[None])
|
||
async def follow_subscriber(share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)) -> Any:
|
||
"""
|
||
Follow订阅分享人
|
||
"""
|
||
subscribers = get_configured_system_config().get(SystemConfigKey.FollowSubscribers) or []
|
||
if share_uid and share_uid not in subscribers:
|
||
subscribers.append(share_uid)
|
||
await get_configured_system_config().async_set(SystemConfigKey.FollowSubscribers, subscribers)
|
||
return _SchemaResponse(success=True)
|
||
|
||
|
||
@router.delete("/follow", summary="取消Follow订阅分享人", response_model=_SchemaResponse[None])
|
||
async def unfollow_subscriber(share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)) -> Any:
|
||
"""
|
||
取消Follow订阅分享人
|
||
"""
|
||
subscribers = get_configured_system_config().get(SystemConfigKey.FollowSubscribers) or []
|
||
if share_uid and share_uid in subscribers:
|
||
subscribers.remove(share_uid)
|
||
await get_configured_system_config().async_set(SystemConfigKey.FollowSubscribers, subscribers)
|
||
return _SchemaResponse(success=True)
|
||
|
||
|
||
@router.get("/shares", summary="查询分享的订阅", response_model=List[_SchemaSubscribeShare])
|
||
async def subscribe_shares(
|
||
name: Optional[str] = None,
|
||
page: Optional[int] = 1,
|
||
count: Optional[int] = 30,
|
||
genre_id: Optional[int] = None,
|
||
min_rating: Optional[float] = None,
|
||
max_rating: Optional[float] = None,
|
||
sort_type: Optional[str] = None,
|
||
_: _SchemaTokenPayload = Depends(verify_token),
|
||
) -> Any:
|
||
"""
|
||
查询分享的订阅
|
||
"""
|
||
return await MoviePilotServerHelper.async_get_subscribe_shares(
|
||
name=name,
|
||
page=page,
|
||
count=count,
|
||
genre_id=genre_id,
|
||
min_rating=min_rating,
|
||
max_rating=max_rating,
|
||
sort_type=sort_type,
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/share/statistics",
|
||
summary="查询订阅分享统计",
|
||
response_model=List[_SchemaSubscribeShareStatistics],
|
||
)
|
||
async def subscribe_share_statistics(
|
||
_: _SchemaTokenPayload = Depends(verify_token),
|
||
page: CompatiblePageParam = None,
|
||
count: CompatibleCountParam = None,
|
||
) -> Any:
|
||
"""
|
||
查询订阅分享统计
|
||
返回每个分享人分享的媒体数量以及总的复用人次
|
||
"""
|
||
return await MoviePilotServerHelper.async_get_subscribe_share_statistics()
|
||
|
||
|
||
@router.get("/{subscribe_id}", summary="订阅详情", response_model=_SchemaSubscribe)
|
||
async def read_subscribe(
|
||
subscribe_id: int,
|
||
query: SubscriptionQueryService = Depends(get_subscription_query_service),
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
) -> Any:
|
||
"""
|
||
根据订阅编号查询订阅信息
|
||
"""
|
||
if not subscribe_id:
|
||
return _SchemaSubscribe()
|
||
subscribe = await query.get_public(subscribe_id)
|
||
return subscribe if subscribe and can_access_subscribe(subscribe, current_user) else _SchemaSubscribe()
|
||
|
||
|
||
@router.delete(
|
||
"/{subscribe_id}",
|
||
summary="删除订阅",
|
||
response_model=_SchemaResponse[_SchemaSubscribeDeletionResult],
|
||
)
|
||
async def delete_subscribe(
|
||
subscribe_id: int,
|
||
command: DeleteSubscribeCommand = Depends(get_delete_subscribe_command),
|
||
current_user: ApiPrincipal = Depends(get_current_active_user_async),
|
||
) -> Any:
|
||
"""
|
||
删除订阅信息
|
||
"""
|
||
status = await command.execute_with_status(
|
||
subscribe_id,
|
||
SubscribeDeletionActor(
|
||
username=current_user.name,
|
||
is_superuser=current_user.is_superuser,
|
||
),
|
||
)
|
||
if status == "not_found":
|
||
raise HTTPException(status_code=404, detail="订阅不存在")
|
||
if status == "forbidden":
|
||
raise HTTPException(status_code=403, detail="无权删除该订阅")
|
||
return _SchemaResponse(
|
||
success=True,
|
||
data=_SchemaSubscribeDeletionResult(status="deleted"),
|
||
)
|