Compare commits

...
5 Commits
17 changed files with 1308 additions and 11 deletions
+28 -1
View File
@@ -1058,6 +1058,29 @@ class LLMHelper:
http_async_client=_build_httpx_client(llm_proxy, async_client=True),
**thinking_kwargs,
)
elif runtime["runtime"] == "bedrock":
from langchain_aws import ChatBedrockConverse
from app.agent.llm.provider import LLMProviderManager
aws_region = runtime.get("aws_region") or "us-east-1"
aws_auth = runtime.get("aws_auth") or {}
# Bearer 认证需要跳过 SigV4 签名并注入 Authorization 头,SigV4 认证
# 直接以 AK/SK 签名;两种方式统一由 provider 管理器构造 boto3 客户端。
bedrock_client = LLMProviderManager().create_bedrock_client(
"bedrock-runtime",
region=aws_region,
credentials=aws_auth,
base_url=runtime.get("base_url"),
use_proxy=use_proxy,
read_timeout=settings.LLM_TOOL_TIMEOUT,
)
model = ChatBedrockConverse(
model_id=model_name,
client=bedrock_client,
temperature=temperature_value,
disable_streaming=not streaming,
)
elif runtime["runtime"] in {"anthropic_compatible", "copilot_anthropic"}:
from langchain_anthropic import ChatAnthropic
@@ -1107,7 +1130,11 @@ class LLMHelper:
# 优先使用 provider / models.dev 目录中的上下文上限,减少用户手填成本。
model_profile = getattr(model, "profile", None)
if model_profile:
logger.debug(f"使用LLM模型: {model.model}Profile: {model.profile}")
# ChatBedrockConverse 等模型类没有 model 属性,模型名存放在 model_id。
logged_model_name = getattr(model, "model", None) or getattr(
model, "model_id", model_name
)
logger.debug(f"使用LLM模型: {logged_model_name}Profile: {model_profile}")
else:
model_record = runtime.get("model_record") or {}
model_metadata = runtime.get("model_metadata") or {}
+488 -1
View File
@@ -7,13 +7,14 @@ import base64
import copy
import hashlib
import json
import re
import secrets
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit
import aiofiles
import httpx
@@ -106,6 +107,90 @@ class LLMProviderManager(metaclass=Singleton):
_MODELS_DEV_BUNDLED_PATH = Path(__file__).with_name("models.json")
_MODELS_DEV_CACHE_TTL = 7 * 24 * 60 * 60
_AUTH_SESSION_DONE_RETENTION = 300
_BEDROCK_DEFAULT_REGION = "us-east-1"
_BEDROCK_API_KEY_PREFIX = "bedrock-api-key-"
_BEDROCK_GPT_OSS_BASE_REGIONS = (
"ap-northeast-1",
"ap-south-1",
"ap-southeast-2",
"eu-central-1",
"eu-north-1",
"eu-west-1",
"eu-west-2",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2",
)
_BEDROCK_GPT_OSS_SAFEGUARD_REGIONS = (
"ap-northeast-1",
"ap-south-1",
"ap-southeast-2",
"eu-west-1",
"eu-west-2",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2",
)
_BEDROCK_ON_DEMAND_MODEL_REGIONS = {
"openai.gpt-oss-120b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
"openai.gpt-oss-20b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
"openai.gpt-oss-safeguard-120b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
"openai.gpt-oss-safeguard-20b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
"amazon.nova-lite-v1:0": (
"ap-northeast-1",
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"amazon.nova-micro-v1:0": (
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"amazon.nova-pro-v1:0": (
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"anthropic.claude-3-5-haiku-20241022-v1:0": (
"us-west-2",
),
"anthropic.claude-3-5-sonnet-20240620-v1:0": (
"ap-northeast-1",
"ap-northeast-2",
"ap-southeast-1",
"eu-central-1",
"eu-central-2",
"us-east-1",
"us-gov-west-1",
"us-west-2",
),
"anthropic.claude-3-5-sonnet-20241022-v2:0": (
"ap-southeast-2",
"us-west-2",
),
"anthropic.claude-3-7-sonnet-20250219-v1:0": (
"eu-west-2",
"us-gov-west-1",
),
"anthropic.claude-3-haiku-20240307-v1:0": (
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-southeast-2",
"eu-central-1",
"eu-west-1",
"eu-west-3",
"us-east-1",
"us-gov-west-1",
"us-west-2",
),
}
_CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
_CHATGPT_ISSUER = "https://auth.openai.com"
_CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
@@ -367,6 +452,50 @@ class LLMProviderManager(metaclass=Singleton):
api_key_hint="填写 Anthropic API Key。",
description="Anthropic Claude 官方端点。",
),
ProviderSpec(
id="amazon-bedrock",
name="Amazon Bedrock",
runtime="bedrock",
models_dev_provider_id="amazon-bedrock",
default_base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
base_url_presets=(
url_preset(
id="bedrock-us-east-1",
label="美东(弗吉尼亚北部)us-east-1",
value="https://bedrock-runtime.us-east-1.amazonaws.com",
),
url_preset(
id="bedrock-us-west-2",
label="美西(俄勒冈)us-west-2",
value="https://bedrock-runtime.us-west-2.amazonaws.com",
),
url_preset(
id="bedrock-eu-central-1",
label="欧洲(法兰克福)eu-central-1",
value="https://bedrock-runtime.eu-central-1.amazonaws.com",
),
url_preset(
id="bedrock-ap-northeast-1",
label="亚太(东京)ap-northeast-1",
value="https://bedrock-runtime.ap-northeast-1.amazonaws.com",
),
url_preset(
id="bedrock-ap-southeast-1",
label="亚太(新加坡)ap-southeast-1",
value="https://bedrock-runtime.ap-southeast-1.amazonaws.com",
),
),
base_url_editable=True,
api_key_label="Bedrock API Key / AK:SK",
api_key_hint=(
"支持两种认证方式:填写 Amazon Bedrock API Keybedrock-api-key- 开头,"
"Bearer 认证);或填写 Access Key ID:Secret Access Key(可选追加 :Session Token"
"SigV4 认证)。Base URL 决定 AWS Region。"
),
model_list_strategy="bedrock",
description="Amazon Bedrock 托管模型服务,支持 Bedrock API Key 与 AK/SK 双认证。",
sort_order=35,
),
ProviderSpec(
id="deepseek",
name="DeepSeek",
@@ -1743,6 +1872,112 @@ class LLMProviderManager(metaclass=Singleton):
return normalized[:-3]
return normalized
@classmethod
def _extract_bedrock_region(cls, base_url: Optional[str]) -> str:
"""
从 Bedrock 运行时端点 URL 中提取 AWS Region
兼容标准端点、FIPS 端点与 PrivateLinkVPCE)端点等主机名形态,
从中识别 Region 段。
:param base_url: 形如 https://bedrock-runtime.us-east-1.amazonaws.com 的端点地址
:return: 提取到的 Region,无法识别时回退 us-east-1
"""
hostname = urlsplit((base_url or "").strip().lower()).hostname or ""
match = re.search(
r"(?:^|\.)(?:bedrock(?:-runtime)?(?:-fips)?)"
r"\.([a-z0-9-]+-\d+)(?:\.|$)",
hostname,
)
if match:
return match.group(1)
return cls._BEDROCK_DEFAULT_REGION
# Inference Profile 的地理前缀与可用 Region 的对应关系,用于降级目录按
# 当前 Region 过滤掉不可调用的 Profile 条目。
_BEDROCK_GEO_PREFIXES: dict[str, tuple[str, ...]] = {
"us": ("us-east-", "us-west-"),
"eu": ("eu-",),
"apac": ("ap-",),
"au": ("ap-southeast-2", "ap-southeast-4"),
"jp": ("ap-northeast-1", "ap-northeast-3"),
"ca": ("ca-",),
}
_BEDROCK_NON_COMMERCIAL_REGION_PREFIXES = (
"cn-",
"eu-isoe-",
"us-gov-",
"us-iso-",
"us-isob-",
"us-isof-",
)
@classmethod
def _bedrock_model_matches_region(cls, model_id: str, region: str) -> bool:
"""
判断目录中的模型 ID 在指定 Region 是否可调用
models.dev 目录同时收录裸模型 ID(直连调用)与带地理前缀的
Inference Profile IDus./eu./apac./global. 等)。带前缀的条目只在
对应地理分区和 AWS 分区的 Region 可用;global Profile 仅允许商业
AWS 分区。裸 ID 仅在明确记录的 ON_DEMAND Region 可用,未知条目
按不可直连处理。
:param model_id: 目录中的模型 ID
:param region: 当前 Base URL 对应的 AWS Region
:return: 该模型在当前 Region 可调用时返回 True
"""
prefix = model_id.split(".", 1)[0]
if prefix == "global":
return not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES)
region_prefixes = cls._BEDROCK_GEO_PREFIXES.get(prefix)
if region_prefixes is not None:
return (
not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES)
and region.startswith(region_prefixes)
)
on_demand_regions = cls._BEDROCK_ON_DEMAND_MODEL_REGIONS.get(model_id)
return on_demand_regions is not None and region in on_demand_regions
@classmethod
def _parse_bedrock_credentials(cls, api_key: Optional[str]) -> dict[str, Any]:
"""
解析 Bedrock 凭证字符串,识别 Bearer 与 SigV4 两种认证方式
- Bedrock API Keybedrock-api-key- 开头的长期 Key,或控制台生成的短期
Token)走 Bearer 认证;
- `AccessKeyId:SecretAccessKey` 或 `AccessKeyId:SecretAccessKey:SessionToken`
走 SigV4 认证,AWS Access Key ID 均以 "AKIA"/"ASIA" 开头。
:param api_key: 用户在 API Key 输入框填写的凭证内容
:return: 含 auth_scheme 及对应凭证字段的字典
"""
normalized = str(api_key or "").strip()
if not normalized:
raise LLMProviderAuthError(
"Amazon Bedrock 需要填写 Bedrock API Key 或 Access Key ID:Secret Access Key"
)
if not normalized.startswith(cls._BEDROCK_API_KEY_PREFIX):
parts = [part.strip() for part in normalized.split(":")]
if len(parts) in {2, 3} and all(parts):
credentials = {
"auth_scheme": "sigv4",
"access_key_id": parts[0],
"secret_access_key": parts[1],
}
if len(parts) == 3:
credentials["session_token"] = parts[2]
return credentials
if ":" in normalized:
raise LLMProviderAuthError(
"Amazon Bedrock AK/SK 凭证格式不正确,"
"请按 AccessKeyId:SecretAccessKey 或 "
"AccessKeyId:SecretAccessKey:SessionToken 填写"
)
return {"auth_scheme": "bearer", "bearer_token": normalized}
async def _list_models_from_google(
self,
api_key: str,
@@ -1857,6 +2092,235 @@ class LLMProviderManager(metaclass=Singleton):
)
return sorted(results, key=lambda item: item["name"].lower())
def _build_bedrock_boto3_config(
self,
use_proxy: Optional[bool] = None,
) -> Any:
"""
构造 Bedrock boto3 客户端配置,统一超时、重试与代理策略
:param use_proxy: 是否使用系统代理,None 时读取 LLM_USE_PROXY 配置
:return: botocore Config 实例
"""
from botocore.config import Config
should_use_proxy = settings.LLM_USE_PROXY if use_proxy is None else use_proxy
proxies = None
if should_use_proxy and settings.PROXY_HOST:
proxies = {"http": settings.PROXY_HOST, "https": settings.PROXY_HOST}
return Config(
connect_timeout=10,
read_timeout=60,
retries={"max_attempts": 3, "mode": "standard"},
proxies=proxies,
)
@staticmethod
def _bedrock_endpoint_url(
service_name: str, base_url: Optional[str]
) -> Optional[str]:
"""
解析应传给 boto3 客户端的自定义端点 URL
标准公有端点交由 boto3 按 Region 自行推导;用户填写 PrivateLink、
FIPS 等非标准端点时才显式透传,保证所选网络路径实际生效。
:param service_name: boto3 服务名(bedrock 或 bedrock-runtime
:param base_url: 用户配置的 Base URL
:return: 需要显式指定端点时返回 URL,否则返回 None
"""
normalized = (base_url or "").strip().rstrip("/")
if not normalized:
return None
if re.fullmatch(
rf"https://{service_name}\.[a-z0-9-]+\.amazonaws\.com",
normalized,
):
return None
return normalized
def create_bedrock_client(
self,
service_name: str,
region: str,
credentials: dict[str, Any],
base_url: Optional[str] = None,
use_proxy: Optional[bool] = None,
read_timeout: Optional[int] = None,
) -> Any:
"""
按解析后的凭证创建 Bedrock boto3 客户端,Bearer 方式注入 Authorization 头
:param service_name: boto3 服务名(bedrock 或 bedrock-runtime
:param region: AWS Region
:param credentials: `_parse_bedrock_credentials` 的解析结果
:param base_url: 用户配置的 Base URL,非标准端点(PrivateLink/FIPS 等)时透传给 boto3
:param use_proxy: 是否使用系统代理
:param read_timeout: 读取超时秒数,None 时使用默认值
:return: boto3 客户端实例
"""
import boto3
from botocore import UNSIGNED
config = self._build_bedrock_boto3_config(use_proxy)
if read_timeout:
config = config.merge(type(config)(read_timeout=read_timeout))
endpoint_kwargs: dict[str, Any] = {}
endpoint_url = self._bedrock_endpoint_url(service_name, base_url)
if endpoint_url:
endpoint_kwargs["endpoint_url"] = endpoint_url
if credentials["auth_scheme"] == "sigv4":
return boto3.client(
service_name,
region_name=region,
aws_access_key_id=credentials["access_key_id"],
aws_secret_access_key=credentials["secret_access_key"],
aws_session_token=credentials.get("session_token"),
config=config,
**endpoint_kwargs,
)
# Bearer 认证:以 UNSIGNED 跳过 SigV4 签名,再把 API Key 注入 Authorization 头。
bearer_token = credentials["bearer_token"]
config = config.merge(type(config)(signature_version=UNSIGNED))
client = boto3.client(
service_name,
region_name=region,
aws_access_key_id="unsigned",
aws_secret_access_key="unsigned",
config=config,
**endpoint_kwargs,
)
def _inject_bearer(request: Any, **_kwargs: Any) -> None:
request.headers["Authorization"] = f"Bearer {bearer_token}"
client.meta.events.register(
f"request-created.{service_name}",
_inject_bearer,
)
return client
async def _list_models_from_bedrock_fallback(
self,
region: str,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
从 models.dev 目录筛选当前 Region 可调用的 Bedrock 模型
:param region: 当前 Base URL 对应的 AWS Region
:param use_proxy: 是否使用系统代理
:return: 过滤后的标准化模型记录列表
"""
models = await self._list_models_from_models_dev_only(
provider_id="amazon-bedrock",
use_proxy=use_proxy,
)
return [
model
for model in models
if self._bedrock_model_matches_region(model["id"], region)
]
async def _list_models_from_bedrock(
self,
api_key: str,
base_url: Optional[str],
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
从 Bedrock 控制面拉取模型目录,聚合跨区 Inference Profile 与直连模型
Bedrock 多数新模型仅允许通过 Inference Profileus./eu./apac./global. 前缀)
调用,因此优先列出 Profile,再补充支持 ON_DEMAND 直连的基础模型。
:param api_key: 用户填写的凭证内容(Bedrock API Key 或 AK/SK
:param base_url: Bedrock 运行时端点,决定 Region
:param use_proxy: 是否使用系统代理
:return: 标准化后的模型记录列表
"""
credentials = self._parse_bedrock_credentials(api_key)
region = self._extract_bedrock_region(base_url)
# runtime VPCE 无法安全推导对应的控制面 VPCE;FIPS 端点也不能绕回
# 公有非 FIPS 控制面,因此直接使用本地目录。
if self._bedrock_endpoint_url("bedrock-runtime", base_url):
return await self._list_models_from_bedrock_fallback(region, use_proxy)
client = self.create_bedrock_client(
"bedrock",
region=region,
credentials=credentials,
use_proxy=use_proxy,
)
def _fetch() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
profiles: list[dict[str, Any]] = []
paginator = client.get_paginator("list_inference_profiles")
for page in paginator.paginate(typeEquals="SYSTEM_DEFINED"):
profiles.extend(page.get("inferenceProfileSummaries") or [])
foundation = client.list_foundation_models(
byOutputModality="TEXT",
byInferenceType="ON_DEMAND",
).get("modelSummaries") or []
return profiles, foundation
try:
profile_summaries, foundation_summaries = await asyncio.to_thread(_fetch)
except Exception as err:
# 部分 Bedrock API Key 的授权范围仅覆盖 bedrock-runtime 推理接口,
# 控制面查询被拒时降级到 models.dev 目录,保证仍能选择模型。
logger.warning(
f"获取 Amazon Bedrock 控制面模型列表失败,降级 models.dev 目录: {err}"
)
return await self._list_models_from_bedrock_fallback(region, use_proxy)
finally:
await asyncio.to_thread(client.close)
results: list[dict[str, Any]] = []
seen_ids: set[str] = set()
def _append_record(model_id: str, display_name: Optional[str]) -> None:
if not model_id or model_id in seen_ids:
return
seen_ids.add(model_id)
# Inference Profile 带区域前缀,models.dev 目录按基础模型 ID 收录,
# 去掉首个前缀段再查一次元数据。
metadata = self._cached_models_dev_model("amazon-bedrock", model_id)
if not metadata and "." in model_id:
metadata = self._cached_models_dev_model(
"amazon-bedrock",
model_id.split(".", 1)[1],
)
results.append(
self._normalize_model_record(
model_id=model_id,
display_name=display_name or (metadata or {}).get("name") or model_id,
metadata=metadata or {},
source="provider",
)
)
for profile in profile_summaries:
if (profile.get("status") or "ACTIVE") != "ACTIVE":
continue
_append_record(
str(profile.get("inferenceProfileId") or "").strip(),
profile.get("inferenceProfileName"),
)
# 控制面已按当前 Region 和 ON_DEMAND 筛选,不能复用仅面向
# models.dev 降级目录的静态白名单,否则 AWS 新增模型会被遗漏。
for summary in foundation_summaries:
lifecycle = (summary.get("modelLifecycle") or {}).get("status") or "ACTIVE"
if lifecycle != "ACTIVE":
continue
_append_record(
str(summary.get("modelId") or "").strip(),
summary.get("modelName"),
)
return sorted(results, key=lambda item: item["name"].lower())
@staticmethod
def _copilot_headers(
token: Optional[str] = None, include_auth: bool = True
@@ -2064,6 +2528,13 @@ class LLMProviderManager(metaclass=Singleton):
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "bedrock":
return await self._list_models_from_bedrock(
api_key=runtime["api_key"],
base_url=runtime.get("base_url"),
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "anthropic_compatible":
return await self._list_models_from_models_dev_only(
provider_id=provider_id,
@@ -2731,6 +3202,22 @@ class LLMProviderManager(metaclass=Singleton):
)
return result
if resolved_runtime == "bedrock":
effective_base_url = normalized_base_url or self._default_base_url_for_provider(
spec
)
credentials = self._parse_bedrock_credentials(normalized_api_key)
result.update(
{
"api_key": normalized_api_key,
"base_url": effective_base_url,
"aws_region": self._extract_bedrock_region(effective_base_url),
"aws_auth": credentials,
"auth_mode": "api_key",
}
)
return result
if resolved_runtime == "anthropic_compatible":
effective_base_url = normalized_base_url or self._default_base_url_for_provider(
spec
+8 -3
View File
@@ -26,13 +26,17 @@ router = APIRouter()
async def recognize(
title: str,
subtitle: Optional[str] = None,
custom_words: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
根据标题、副标题识别媒体信息
:param custom_words: 临时识别词(每行一条规则),传入时仅在本次识别中生效,不会保存到系统配置
"""
# 识别媒体信息
metainfo = MetaInfo(title, subtitle)
# 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效
metainfo = MetaInfo(
title, subtitle, custom_words=custom_words.split("\n") if custom_words else None
)
mediainfo = await MediaChain().async_recognize_by_meta(metainfo)
if mediainfo:
return Context(meta_info=metainfo, media_info=mediainfo).to_dict()
@@ -48,12 +52,13 @@ async def recognize2(
_: Annotated[str, Depends(verify_apitoken)],
title: str,
subtitle: Optional[str] = None,
custom_words: Optional[str] = None,
) -> Any:
"""
根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx
"""
# 识别媒体信息
return await recognize(title, subtitle)
return await recognize(title, subtitle, custom_words)
@router.get(
+19 -1
View File
@@ -1,6 +1,6 @@
import threading
from datetime import datetime
from typing import Callable, List, Union, Optional, Generator, Any
from typing import Callable, Dict, List, Union, Optional, Generator, Any
from app.chain import ChainBase
from app.core.config import global_vars
@@ -210,6 +210,24 @@ class MediaServerChain(ChainBase):
"""
return self.run_module("mediaserver_play_url", server=server, item_id=item_id)
def get_season_episode_ids(self, server: str, item_id: Union[str, int],
season: int) -> Dict[int, str]:
"""
获取指定季的集号到媒体服务器条目 ID 映射
:param server: 媒体服务器名称
:param item_id: 剧集在媒体服务器中的条目 ID
:param season: 季号
:return: 集号到条目 ID 的映射,无数据时返回空字典
"""
result = self.run_module(
"mediaserver_season_episode_ids",
server=server,
item_id=item_id,
season=season,
)
return result or {}
def get_image_cookies(
self, server: Optional[str], image_url: str
) -> Optional[str | dict]:
+80
View File
@@ -11,6 +11,7 @@ from app import schemas
from app.chain import ChainBase
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.mediaserver import MediaServerChain
from app.chain.search import SearchChain
from app.chain.tmdb import TmdbChain
from app.chain.torrents import TorrentsChain
@@ -34,6 +35,7 @@ from app.helper.interaction import (
supports_markdown,
update_or_post_message,
)
from app.helper.mediaserver import MediaServerHelper
from app.helper.server import MoviePilotServerHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
@@ -3642,6 +3644,84 @@ class SubscribeChain(ChainBase):
else:
episodes[0].library.append(file_info)
# 合并所有媒体服务器已存在条目(逐台查询,不只取第一个命中)
mediaserver_chain = MediaServerChain()
server_names = list(MediaServerHelper().get_services().keys())
def _has_server_entry(library_list: List[schemas.SubscribeLibraryFileInfo],
server_name: Optional[str],
server_type: Optional[str]) -> bool:
for info in library_list or []:
if info.server and server_name and info.server == server_name:
return True
if info.server_type and server_type and info.server_type == server_type \
and info.server == server_name \
and (not info.file_path or str(info.file_path).startswith(("http://", "https://"))):
return True
return False
for server_name in server_names:
exists_media = self.media_exists(mediainfo=mediainfo, server=server_name)
# 仅合并真实媒体服务器结果,跳过本地 FileManager 兜底(已由 media_files 覆盖)
if not exists_media or not (exists_media.server or exists_media.server_type):
continue
resolved_server = exists_media.server or server_name
server_storage = exists_media.server_type or resolved_server
server_itemid = str(exists_media.itemid) if exists_media.itemid is not None else None
series_detail_url = None
if resolved_server and exists_media.itemid is not None:
series_detail_url = mediaserver_chain.get_play_url(
server=resolved_server,
item_id=exists_media.itemid,
)
if subscribe.type == MediaType.TV.value:
season_number = subscribe.season if subscribe.season is not None else 1
exist_episodes = (exists_media.seasons or {}).get(season_number) or []
episode_item_ids: Dict[int, str] = {}
if resolved_server and exists_media.itemid is not None:
episode_item_ids = mediaserver_chain.get_season_episode_ids(
server=resolved_server,
item_id=exists_media.itemid,
season=season_number,
)
for episode_number in exist_episodes:
episode_info = episodes.get(episode_number)
if not episode_info:
continue
if _has_server_entry(episode_info.library, resolved_server, exists_media.server_type):
continue
episode_itemid = episode_item_ids.get(episode_number) or server_itemid
detail_url = series_detail_url
if resolved_server and episode_item_ids.get(episode_number):
detail_url = mediaserver_chain.get_play_url(
server=resolved_server,
item_id=episode_itemid,
) or series_detail_url
episode_info.library.append(
schemas.SubscribeLibraryFileInfo(
storage=server_storage,
file_path=detail_url,
server=resolved_server,
server_type=exists_media.server_type,
itemid=str(episode_itemid) if episode_itemid is not None else None,
)
)
else:
episode_info = episodes.get(0)
if episode_info and not _has_server_entry(
episode_info.library, resolved_server, exists_media.server_type):
episode_info.library.append(
schemas.SubscribeLibraryFileInfo(
storage=server_storage,
file_path=series_detail_url,
server=resolved_server,
server_type=exists_media.server_type,
itemid=server_itemid,
)
)
# 更新订阅信息
subscribe_info.subscribe = Subscribe(**subscribe.to_dict())
subscribe_info.episodes = episodes
+16 -1
View File
@@ -1,4 +1,4 @@
from typing import Any, Generator, List, Optional, Tuple, Union
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from app import schemas
from app.core.context import MediaInfo
@@ -300,6 +300,21 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
return None
return server_obj.get_play_url(item_id)
def mediaserver_season_episode_ids(self, server: str, item_id: Union[str, int],
season: int) -> Optional[Dict[int, str]]:
"""
获取指定季的集号到条目 ID 映射
:param server: Emby 媒体服务器名称
:param item_id: 剧集在 Emby 中的条目 ID
:param season: 季号
:return: 集号到条目 ID 的映射服务器不可用或无数据时返回 None
"""
server_obj: Emby = self.get_instance(server)
if not server_obj:
return None
return server_obj.get_season_episode_ids(str(item_id), season)
def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20,
username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:
"""
+31
View File
@@ -474,6 +474,37 @@ class Emby:
return None, None
return None, {}
def get_season_episode_ids(self, item_id: str, season: int) -> Dict[int, str]:
"""
获取指定季的集号到媒体服务器条目 ID 映射
:param item_id: 剧集在 Emby 中的 ID
:param season: 季号
:return: {集号: episode_item_id}
"""
if not item_id or not self._host or not self._apikey:
return {}
try:
url = f"{self._host}emby/Shows/{item_id}/Episodes"
params = {
"Season": season,
"IsMissing": "false",
"api_key": self._apikey
}
res_json = RequestUtils().get_res(url, params)
if not res_json:
return {}
episode_ids: Dict[int, str] = {}
for res_item in res_json.json().get("Items") or []:
episode_index = res_item.get("IndexNumber")
episode_id = res_item.get("Id")
if episode_index is None or not episode_id:
continue
episode_ids[int(episode_index)] = str(episode_id)
return episode_ids
except Exception as e:
logger.error(f"获取 Emby 季集条目 ID 出错:{str(e)}")
return {}
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
"""
根据ItemId从Emby查询TMDB的图片地址
+4
View File
@@ -606,8 +606,12 @@ class FileManagerModule(_ModuleBase):
"""
判断媒体文件是否存在于文件系统网盘或本地文件只支持标准媒体库结构
:param mediainfo: 识别的媒体信息
:param server: 指定媒体服务器名称时跳过本地文件系统检查
:return: 如不存在返回None存在时返回信息包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
"""
if kwargs.get("server"):
return None
if not settings.LOCAL_EXISTS_SEARCH:
return None
+16 -1
View File
@@ -1,4 +1,4 @@
from typing import Any, Generator, List, Optional, Tuple, Union
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from app import schemas
from app.core.context import MediaInfo
@@ -299,6 +299,21 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
return None
return server_obj.get_play_url(item_id)
def mediaserver_season_episode_ids(self, server: str, item_id: Union[str, int],
season: int) -> Optional[Dict[int, str]]:
"""
获取指定季的集号到条目 ID 映射
:param server: Jellyfin 媒体服务器名称
:param item_id: 剧集在 Jellyfin 中的条目 ID
:param season: 季号
:return: 集号到条目 ID 的映射服务器不可用或无数据时返回 None
"""
server_obj: Jellyfin = self.get_instance(server)
if not server_obj:
return None
return server_obj.get_season_episode_ids(str(item_id), season)
def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20,
username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:
"""
+32
View File
@@ -523,6 +523,38 @@ class Jellyfin:
return None, None
return None, {}
def get_season_episode_ids(self, item_id: str, season: int) -> Dict[int, str]:
"""
获取指定季的集号到媒体服务器条目 ID 映射
:param item_id: 剧集在 Jellyfin 中的 ID
:param season: 季号
:return: {集号: episode_item_id}
"""
if not item_id or not self._host or not self._apikey or not self.user:
return {}
try:
url = f"{self._host}Shows/{item_id}/Episodes"
params = {
"season": season,
"userId": self.user,
"isMissing": "false",
"api_key": self._apikey
}
res_json = RequestUtils().get_res(url, params)
if not res_json:
return {}
episode_ids: Dict[int, str] = {}
for res_item in res_json.json().get("Items") or []:
episode_index = res_item.get("IndexNumber")
episode_id = res_item.get("Id")
if episode_index is None or not episode_id:
continue
episode_ids[int(episode_index)] = str(episode_id)
return episode_ids
except Exception as e:
logger.error(f"获取 Jellyfin 季集条目 ID 出错:{str(e)}")
return {}
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
"""
根据ItemId从Jellyfin查询TMDB图片地址
+16 -1
View File
@@ -1,4 +1,4 @@
from typing import Optional, Tuple, Union, Any, List, Generator
from typing import Optional, Tuple, Union, Any, List, Generator, Dict
from app import schemas
from app.core.context import MediaInfo
@@ -349,3 +349,18 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
if not server_obj:
return None
return server_obj.get_play_url(item_id)
def mediaserver_season_episode_ids(self, server: str, item_id: Union[str, int],
season: int) -> Optional[Dict[int, str]]:
"""
获取指定季的集号到条目 ID 映射
:param server: Plex 媒体服务器名称
:param item_id: 剧集在 Plex 中的条目 ID / key
:param season: 季号
:return: 集号到条目 ID 的映射服务器不可用或无数据时返回 None
"""
server_obj: Plex = self.get_instance(server)
if not server_obj:
return None
return server_obj.get_season_episode_ids(str(item_id), season)
+25
View File
@@ -293,6 +293,31 @@ class Plex:
season_episodes[episode.seasonNumber].append(episode.index)
return videos.key, season_episodes
def get_season_episode_ids(self, item_id: str, season: int) -> Dict[int, str]:
"""
获取指定季的集号到媒体服务器条目 ID 映射
:param item_id: 剧集在 Plex 中的 ID / key
:param season: 季号
:return: {集号: episode_item_key}
"""
if not self._plex or not item_id:
return {}
try:
videos = self.__fetch_item(item_id)
if not videos:
return {}
episode_ids: Dict[int, str] = {}
for episode in videos.episodes():
if episode.seasonNumber != int(season):
continue
if episode.index is None or not episode.key:
continue
episode_ids[int(episode.index)] = str(episode.key)
return episode_ids
except Exception as e:
logger.error(f"获取 Plex 季集条目 ID 出错:{str(e)}")
return {}
def __search_show(self,
title: Optional[str] = None,
original_title: Optional[str] = None,
+6
View File
@@ -217,6 +217,12 @@ class SubscribeLibraryFileInfo(BaseModel):
storage: Optional[str] = "local"
# 文件路径
file_path: Optional[str] = None
# 媒体服务器名称
server: Optional[str] = None
# 媒体服务器类型:emby、jellyfin、plex 等
server_type: Optional[str] = None
# 媒体服务器条目 ID
itemid: Optional[str] = None
class SubscribeEpisodeInfo(BaseModel):
+2
View File
@@ -80,6 +80,8 @@ langchain~=1.3.9
langchain-core~=1.4.7
langchain-community~=0.4.2
langchain-anthropic~=1.4.6
langchain-aws~=1.6.2
boto3~=1.42.42
langchain-openai~=1.3.2
langchain-google-genai~=4.2.5
langchain-deepseek~=1.1.0
+392
View File
@@ -0,0 +1,392 @@
"""Amazon Bedrock provider 的凭证解析、Region 提取与运行时解析测试"""
import asyncio
import time
from unittest.mock import MagicMock, patch
import pytest
from app.agent.llm.provider import (
LLMProviderAuthError,
LLMProviderManager,
)
@pytest.fixture(autouse=True)
def _reset_manager_singleton():
"""每个用例前后清理 LLMProviderManager 单例,避免缓存互相污染"""
LLMProviderManager._instances.clear()
yield
LLMProviderManager._instances.clear()
def test_bedrock_provider_registered():
manager = LLMProviderManager()
spec = manager.get_provider("amazon-bedrock")
assert spec.runtime == "bedrock"
assert spec.model_list_strategy == "bedrock"
assert spec.base_url_editable is True
assert spec.default_base_url == "https://bedrock-runtime.us-east-1.amazonaws.com"
preset_ids = {preset.id for preset in spec.base_url_presets}
assert "bedrock-us-east-1" in preset_ids
assert "bedrock-ap-northeast-1" in preset_ids
def test_parse_bedrock_credentials_bearer_api_key():
credentials = LLMProviderManager._parse_bedrock_credentials(
"bedrock-api-key-abcdef123456"
)
assert credentials["auth_scheme"] == "bearer"
assert credentials["bearer_token"] == "bedrock-api-key-abcdef123456"
def test_parse_bedrock_credentials_sigv4_ak_sk():
credentials = LLMProviderManager._parse_bedrock_credentials(
"AKIAIOSFODNN7EXAMPLE:wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
)
assert credentials["auth_scheme"] == "sigv4"
assert credentials["access_key_id"] == "AKIAIOSFODNN7EXAMPLE"
assert credentials["secret_access_key"] == "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
assert "session_token" not in credentials
def test_parse_bedrock_credentials_sigv4_with_session_token():
credentials = LLMProviderManager._parse_bedrock_credentials(
"ASIAIOSFODNN7EXAMPLE:secret/key:session-token-value"
)
assert credentials["auth_scheme"] == "sigv4"
assert credentials["session_token"] == "session-token-value"
def test_parse_bedrock_credentials_empty_rejected():
with pytest.raises(LLMProviderAuthError):
LLMProviderManager._parse_bedrock_credentials("")
def test_parse_bedrock_credentials_malformed_colon_rejected():
with pytest.raises(LLMProviderAuthError):
LLMProviderManager._parse_bedrock_credentials("AKIA123:")
def test_extract_bedrock_region_from_base_url():
"""应从标准、FIPS 与 PrivateLink Bedrock 端点提取 Region"""
extract = LLMProviderManager._extract_bedrock_region
assert extract("https://bedrock-runtime.us-east-1.amazonaws.com") == "us-east-1"
assert extract("https://bedrock-runtime.ap-northeast-1.amazonaws.com/") == "ap-northeast-1"
assert extract("https://bedrock-runtime.mx-central-1.amazonaws.com") == "mx-central-1"
assert extract("https://bedrock.eu-central-1.amazonaws.com") == "eu-central-1"
# FIPS 与 PrivateLinkVPCE)端点同样能识别 Region
assert extract("https://bedrock-runtime-fips.us-east-1.amazonaws.com") == "us-east-1"
assert (
extract("https://vpce-0abc123-xyz.bedrock-runtime.us-west-2.vpce.amazonaws.com")
== "us-west-2"
)
# 无法识别时回退默认 Region
assert extract("https://example.com/us-west-2") == "us-east-1"
assert extract("https://example.com?region=.us-west-2.") == "us-east-1"
assert extract("https://example.com") == "us-east-1"
assert extract(None) == "us-east-1"
assert extract("") == "us-east-1"
def test_bedrock_endpoint_url_passthrough():
"""自定义 Bedrock 端点应透传,标准端点交由 boto3 推导"""
resolve = LLMProviderManager._bedrock_endpoint_url
# 标准公有端点交由 boto3 推导,不显式透传
assert resolve("bedrock-runtime", "https://bedrock-runtime.us-east-1.amazonaws.com") is None
assert resolve("bedrock", "https://bedrock.eu-central-1.amazonaws.com") is None
assert resolve("bedrock-runtime", None) is None
assert resolve("bedrock-runtime", "") is None
# FIPS / PrivateLink 等非标准端点需要显式生效
assert (
resolve("bedrock-runtime", "https://bedrock-runtime-fips.us-east-1.amazonaws.com")
== "https://bedrock-runtime-fips.us-east-1.amazonaws.com"
)
assert (
resolve(
"bedrock-runtime",
"https://vpce-0abc123-xyz.bedrock-runtime.us-west-2.vpce.amazonaws.com/",
)
== "https://vpce-0abc123-xyz.bedrock-runtime.us-west-2.vpce.amazonaws.com"
)
# runtime 端点填给控制面服务名时不匹配标准形态,同样透传
assert (
resolve("bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com")
== "https://bedrock-runtime.us-east-1.amazonaws.com"
)
def test_create_bedrock_client_uses_custom_endpoint():
"""创建 Bedrock 客户端时应把 PrivateLink 地址传给 boto3"""
manager = LLMProviderManager()
endpoint_url = (
"https://vpce-0abc123-xyz.bedrock-runtime.us-west-2.vpce.amazonaws.com"
)
client = MagicMock()
with patch("boto3.client", return_value=client) as create_client:
result = manager.create_bedrock_client(
service_name="bedrock-runtime",
region="us-west-2",
credentials={
"auth_scheme": "sigv4",
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "secret",
},
base_url=endpoint_url,
use_proxy=False,
)
assert result is client
assert create_client.call_args.kwargs["endpoint_url"] == endpoint_url
def test_resolve_runtime_bedrock_bearer():
manager = LLMProviderManager()
runtime = asyncio.run(
manager.resolve_runtime(
provider_id="amazon-bedrock",
model="global.anthropic.claude-haiku-4-5-20251001-v1:0",
api_key="bedrock-api-key-abc123",
base_url="https://bedrock-runtime.ap-northeast-1.amazonaws.com",
)
)
assert runtime["runtime"] == "bedrock"
assert runtime["aws_region"] == "ap-northeast-1"
assert runtime["aws_auth"]["auth_scheme"] == "bearer"
def test_resolve_runtime_bedrock_sigv4_default_region():
manager = LLMProviderManager()
runtime = asyncio.run(
manager.resolve_runtime(
provider_id="amazon-bedrock",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
api_key="AKIAIOSFODNN7EXAMPLE:wJalrXUtnFEMI/K7MDENG",
)
)
assert runtime["runtime"] == "bedrock"
assert runtime["aws_region"] == "us-east-1"
assert runtime["aws_auth"]["auth_scheme"] == "sigv4"
assert runtime["aws_auth"]["access_key_id"] == "AKIAIOSFODNN7EXAMPLE"
def test_resolve_runtime_bedrock_missing_credentials_rejected():
manager = LLMProviderManager()
with pytest.raises(LLMProviderAuthError):
asyncio.run(
manager.resolve_runtime(
provider_id="amazon-bedrock",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
api_key=None,
)
)
def test_bedrock_model_matches_region():
"""目录模型应按 Profile 分区及裸模型 ON_DEMAND Region 过滤"""
matches = LLMProviderManager._bedrock_model_matches_region
# 已知裸模型 ID 仅在其支持 ON_DEMAND 的 Region 保留
assert matches("anthropic.claude-3-5-sonnet-20241022-v2:0", "us-west-2")
assert matches("anthropic.claude-3-5-sonnet-20241022-v2:0", "ap-southeast-2")
assert not matches("anthropic.claude-3-5-sonnet-20241022-v2:0", "ap-northeast-1")
assert not matches("anthropic.claude-sonnet-4-5-20250929-v1:0", "us-west-2")
assert not matches("amazon.nova-premier-v1:0", "ap-northeast-1")
assert not matches("meta.llama4-maverick-17b-instruct-v1:0", "ap-northeast-1")
# 已确认支持 ON_DEMAND 的裸模型与 global Profile 维持可用
assert matches("amazon.nova-lite-v1:0", "ap-northeast-1")
assert matches("openai.gpt-oss-20b-1:0", "ap-northeast-1")
assert not matches("openai.gpt-oss-20b-1:0", "ap-southeast-1")
assert matches("global.anthropic.claude-sonnet-4-5-20250929-v1:0", "ap-northeast-1")
assert not matches(
"global.anthropic.claude-sonnet-4-5-20250929-v1:0",
"us-gov-west-1",
)
# 地理前缀只在对应分区 Region 可调用
assert matches("us.anthropic.claude-haiku-4-5-20251001-v1:0", "us-west-2")
assert not matches("us.anthropic.claude-haiku-4-5-20251001-v1:0", "ap-northeast-1")
assert not matches("us.anthropic.claude-haiku-4-5-20251001-v1:0", "us-gov-west-1")
assert matches("apac.amazon.nova-micro-v1:0", "ap-southeast-1")
assert not matches("apac.amazon.nova-micro-v1:0", "eu-central-1")
assert matches("eu.anthropic.claude-haiku-4-5-20251001-v1:0", "eu-central-1")
assert not matches("eu.anthropic.claude-haiku-4-5-20251001-v1:0", "us-east-1")
assert not matches("eu.anthropic.claude-haiku-4-5-20251001-v1:0", "eu-isoe-west-1")
def test_bedrock_au_profile_matches_melbourne_region():
"""AU Inference Profile 应允许从悉尼和墨尔本 Region 调用"""
matches = LLMProviderManager._bedrock_model_matches_region
assert matches("au.amazon.nova-lite-v1:0", "ap-southeast-2")
assert matches("au.amazon.nova-lite-v1:0", "ap-southeast-4")
def test_list_models_bedrock_custom_endpoint_skips_control_plane():
"""自定义 runtime 端点刷新模型时应直接使用离线目录"""
manager = LLMProviderManager()
manager._models_dev_data = {
"amazon-bedrock": {
"id": "amazon-bedrock",
"name": "Amazon Bedrock",
"models": {
"global.anthropic.claude-sonnet-4-5-20250929-v1:0": {
"name": "Claude Sonnet 4.5 (Global)",
"limit": {"context": 200000, "output": 64000},
},
},
}
}
manager._models_dev_loaded_at = time.time()
with patch.object(
LLMProviderManager,
"create_bedrock_client",
side_effect=AssertionError("不应访问控制面"),
):
models = asyncio.run(
manager._list_models_from_bedrock(
api_key="bedrock-api-key-runtime-only",
base_url="https://bedrock-runtime-fips.us-east-1.amazonaws.com",
use_proxy=False,
)
)
assert [model["id"] for model in models] == [
"global.anthropic.claude-sonnet-4-5-20250929-v1:0"
]
def test_list_models_bedrock_keeps_control_plane_on_demand_models():
"""控制面返回的 ON_DEMAND 基础模型不应被静态降级规则遗漏"""
manager = LLMProviderManager()
client = MagicMock()
client.get_paginator.return_value.paginate.return_value = [
{
"inferenceProfileSummaries": [
{
"inferenceProfileId": (
"global.anthropic.claude-sonnet-4-5-20250929-v1:0"
),
"inferenceProfileName": "Claude Sonnet 4.5 (Global)",
"status": "ACTIVE",
}
]
}
]
client.list_foundation_models.return_value = {
"modelSummaries": [
{
"modelId": "openai.gpt-oss-20b-1:0",
"modelName": "GPT OSS 20B",
"modelLifecycle": {"status": "ACTIVE"},
},
{
"modelId": "amazon.nova-lite-v1:0",
"modelName": "Nova Lite",
"modelLifecycle": {"status": "ACTIVE"},
},
]
}
with patch.object(
LLMProviderManager, "create_bedrock_client", return_value=client
):
models = asyncio.run(
manager._list_models_from_bedrock(
api_key="bedrock-api-key-runtime-only",
base_url="https://bedrock-runtime.ap-northeast-1.amazonaws.com",
use_proxy=False,
)
)
assert {model["id"] for model in models} == {
"amazon.nova-lite-v1:0",
"global.anthropic.claude-sonnet-4-5-20250929-v1:0",
"openai.gpt-oss-20b-1:0",
}
client.close.assert_called_once()
def test_list_models_bedrock_falls_back_to_models_dev_on_control_plane_denial():
"""控制面被拒(如 API Key 仅授权 bedrock-runtime)时降级 models.dev 目录"""
manager = LLMProviderManager()
# 预填 models.dev 内存缓存,降级路径不触发真实网络请求
manager._models_dev_data = {
"amazon-bedrock": {
"id": "amazon-bedrock",
"name": "Amazon Bedrock",
"models": {
"anthropic.claude-3-5-sonnet-20241022-v2:0": {
"name": "Claude Sonnet 3.5 v2",
"limit": {"context": 200000, "output": 8192},
},
"amazon.nova-lite-v1:0": {
"name": "Nova Lite",
"limit": {"context": 300000, "output": 5000},
},
"openai.gpt-oss-20b-1:0": {
"name": "GPT OSS 20B",
"limit": {"context": 131072, "output": 16384},
},
"apac.amazon.nova-lite-v1:0": {
"name": "Nova Lite (APAC)",
"limit": {"context": 300000, "output": 5000},
},
"meta.llama4-maverick-17b-instruct-v1:0": {
"name": "Llama 4 Maverick",
"limit": {"context": 1000000, "output": 8192},
},
"anthropic.claude-sonnet-4-5-20250929-v1:0": {
"name": "Claude Sonnet 4.5",
"limit": {"context": 200000, "output": 64000},
},
"global.anthropic.claude-sonnet-4-5-20250929-v1:0": {
"name": "Claude Sonnet 4.5 (Global)",
"limit": {"context": 200000, "output": 64000},
},
"us.anthropic.claude-haiku-4-5-20251001-v1:0": {
"name": "Claude Haiku 4.5 (US)",
"limit": {"context": 200000, "output": 64000},
},
},
}
}
manager._models_dev_loaded_at = time.time()
denied_client = MagicMock()
denied_client.get_paginator.side_effect = Exception(
"AccessDeniedException: not authorized to perform bedrock:ListInferenceProfiles"
)
with patch.object(
LLMProviderManager, "create_bedrock_client", return_value=denied_client
):
models = asyncio.run(
manager._list_models_from_bedrock(
api_key="bedrock-api-key-runtime-only",
base_url="https://bedrock-runtime.ap-northeast-1.amazonaws.com",
use_proxy=False,
)
)
# 降级后仅保留东京 Region 可调用的裸模型与 Profile
model_ids = {m["id"] for m in models}
assert "anthropic.claude-3-5-sonnet-20241022-v2:0" not in model_ids
assert "amazon.nova-lite-v1:0" in model_ids
assert "openai.gpt-oss-20b-1:0" in model_ids
assert "apac.amazon.nova-lite-v1:0" in model_ids
assert "meta.llama4-maverick-17b-instruct-v1:0" not in model_ids
assert "global.anthropic.claude-sonnet-4-5-20250929-v1:0" in model_ids
assert "us.anthropic.claude-haiku-4-5-20251001-v1:0" not in model_ids
assert "anthropic.claude-sonnet-4-5-20250929-v1:0" not in model_ids
assert all(m["source"] == "models.dev" for m in models)
denied_client.close.assert_called_once()
+143
View File
@@ -0,0 +1,143 @@
"""订阅文件统计相关测试"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from app.chain.subscribe import SubscribeChain
from app.modules.filemanager import FileManagerModule
from app.schemas.mediaserver import ExistMediaInfo
from app.schemas.types import MediaType
def _build_subscribe(**overrides):
data = {
"id": 1,
"name": "Test Show",
"year": "2026",
"type": MediaType.TV.value,
"season": 1,
"tmdbid": None,
"doubanid": None,
"imdbid": None,
"tvdbid": None,
"bangumiid": None,
"episode_group": None,
"start_episode": 1,
"total_episode": 2,
}
data.update(overrides)
subscribe = SimpleNamespace(**data)
subscribe.to_dict = lambda: dict(data)
return subscribe
def _build_mediainfo():
return SimpleNamespace(
type=MediaType.TV,
title="Test Show",
title_year="Test Show (2026)",
year="2026",
tmdb_id=None,
douban_id=None,
)
def test_filemanager_media_exists_skips_local_when_server_specified():
module = FileManagerModule()
mediainfo = _build_mediainfo()
with patch.object(module, "media_files", return_value=[SimpleNamespace(path="/media/test.mkv")]) as media_files:
result = module.media_exists(mediainfo, server="Emby1")
assert result is None
media_files.assert_not_called()
def test_subscribe_files_info_merges_multiple_mediaservers():
subscribe = _build_subscribe(season=1, total_episode=2)
mediainfo = _build_mediainfo()
def _media_exists_side_effect(*, mediainfo, server=None, **kwargs):
if server == "Emby1":
return ExistMediaInfo(
type=MediaType.TV,
seasons={1: [1]},
server_type="emby",
server="Emby1",
itemid="emby-series",
)
if server == "Jellyfin1":
return ExistMediaInfo(
type=MediaType.TV,
seasons={1: [1]},
server_type="jellyfin",
server="Jellyfin1",
itemid="jf-series",
)
return None
helper = MagicMock()
helper.get_services.return_value = {"Emby1": object(), "Jellyfin1": object()}
mediaserver_chain = MagicMock()
mediaserver_chain.get_play_url.side_effect = lambda server, item_id: f"https://{server}/item/{item_id}"
mediaserver_chain.get_season_episode_ids.side_effect = lambda server, item_id, season: {1: f"{item_id}-ep1"}
chain = SubscribeChain()
with patch("app.chain.subscribe.DownloadHistoryOper") as download_oper, \
patch.object(chain, "recognize_media", return_value=mediainfo), \
patch.object(chain, "media_files", return_value=None), \
patch.object(chain, "media_exists", side_effect=_media_exists_side_effect), \
patch("app.chain.subscribe.MediaServerHelper", return_value=helper), \
patch("app.chain.subscribe.MediaServerChain", return_value=mediaserver_chain), \
patch("app.chain.subscribe.Subscribe", side_effect=lambda **kwargs: SimpleNamespace(**kwargs)):
download_oper.return_value.get_by_mediaid.return_value = []
result = chain.subscribe_files_info(subscribe)
library = result.episodes[1].library
servers = {item.server for item in library}
assert servers == {"Emby1", "Jellyfin1"}
assert all(str(item.file_path).startswith("https://") for item in library)
def test_subscribe_files_info_uses_season_zero_for_tv():
subscribe = _build_subscribe(season=0, total_episode=1, start_episode=1)
mediainfo = _build_mediainfo()
captured_seasons = []
def _media_exists_side_effect(*, mediainfo, server=None, **kwargs):
if server == "Emby1":
return ExistMediaInfo(
type=MediaType.TV,
seasons={0: [1]},
server_type="emby",
server="Emby1",
itemid="emby-special",
)
return None
def _get_season_episode_ids(server, item_id, season):
captured_seasons.append(season)
return {1: f"{item_id}-ep1"}
helper = MagicMock()
helper.get_services.return_value = {"Emby1": object()}
mediaserver_chain = MagicMock()
mediaserver_chain.get_play_url.return_value = "https://emby/item/1"
mediaserver_chain.get_season_episode_ids.side_effect = _get_season_episode_ids
chain = SubscribeChain()
with patch("app.chain.subscribe.DownloadHistoryOper") as download_oper, \
patch.object(chain, "recognize_media", return_value=mediainfo), \
patch.object(chain, "media_files", return_value=None), \
patch.object(chain, "media_exists", side_effect=_media_exists_side_effect), \
patch("app.chain.subscribe.MediaServerHelper", return_value=helper), \
patch("app.chain.subscribe.MediaServerChain", return_value=mediaserver_chain), \
patch("app.chain.subscribe.Subscribe", side_effect=lambda **kwargs: SimpleNamespace(**kwargs)):
download_oper.return_value.get_by_mediaid.return_value = []
result = chain.subscribe_files_info(subscribe)
assert captured_seasons == [0]
assert len(result.episodes[1].library) == 1
assert result.episodes[1].library[0].server == "Emby1"
+2 -2
View File
@@ -1,2 +1,2 @@
APP_VERSION = 'v2.14.4'
FRONTEND_VERSION = 'v2.14.4'
APP_VERSION = 'v2.14.5'
FRONTEND_VERSION = 'v2.14.5'