Files
MoviePilot/app/api/endpoints/subscribe.py

964 lines
36 KiB
Python
Raw Permalink 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 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"),
)