Files
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

1269 lines
45 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.
"""
绿联内部加密接口与 Emby 兼容媒体库服务的两套独立 API 客户端。
"""
import base64
import uuid
from dataclasses import dataclass
from typing import Any, Mapping, Optional, Union
from urllib.parse import urlsplit, urlunsplit
from app.adapters.network.http import RequestUtils
from app.foundation.url import UrlUtils
from app.modules.ugreen.crypto import UgreenCrypto
from app.runtime.log import logger
@dataclass
class ApiResult:
"""
绿联内部接口的业务响应,保留原始负载供调用方诊断。
:param code: 业务状态码,200 表示成功;响应无有效状态码时为 -1
:param msg: 服务端业务说明,缺失时为空字符串
:param data: 业务数据,形状由具体接口决定;无数据时为 None
:param debug: 可选服务端调试信息
:param raw: 解析后的原始响应字典;响应不是映射时为 None
"""
code: int = -1
msg: str = ""
data: Any = None
debug: Optional[str] = None
raw: Optional[dict[str, Any]] = None
@property
def success(self) -> bool:
"""
判断业务响应是否成功。
:return: 仅 code 等于 200 时返回 True,不按 HTTP 状态码判断
"""
return self.code == 200
class Api:
"""
绿联内部影视接口客户端,管理登录、加密上下文及业务响应。
业务接口统一通过 request 发送明文参数,由加密层处理查询串和请求体,并解密响应。
当前传输实现只实际发送 GET 与 POST;不提供通用 HTTP 方法透传。
"""
__slots__ = (
"_host",
"_request_utils",
"_token",
"_static_token",
"_is_ugk",
"_public_key",
"_crypto",
"_username",
"_client_id",
"_client_version",
"_language",
"_ug_agent",
"_timeout",
"_verify_ssl",
)
def __init__(
self,
host: str,
client_version: str = "76363",
language: str = "zh-CN",
ug_agent: str = "PC/WEB",
timeout: int = 20,
verify_ssl: bool = True,
) -> None:
"""
初始化绿联影视 API 客户端。
:param host: 绿联服务端地址
:param client_version: 绿联 Web 客户端版本号
:param language: 请求语言
:param ug_agent: 绿联客户端标识
:param timeout: HTTP 请求超时时间,单位秒
:param verify_ssl: 是否校验 HTTPS 证书
"""
self._host = self._normalize_base_url(host)
self._request_utils = RequestUtils(
use_session=True,
timeout=timeout,
verify=verify_ssl,
)
self._token: Optional[str] = None
self._static_token: Optional[str] = None
self._is_ugk: bool = False
self._public_key: Optional[str] = None
self._crypto: Optional[UgreenCrypto] = None
self._username: Optional[str] = None
self._client_id = f"{uuid.uuid4()}-WEB"
self._client_version = client_version
self._language = language
self._ug_agent = ug_agent
self._timeout = timeout
# 是否校验证书,默认开启;仅在用户明确配置时才应关闭。
self._verify_ssl = bool(verify_ssl)
@property
def host(self) -> str:
"""
获取规范化后的绿联服务端地址。
"""
return self._host
@property
def token(self) -> Optional[str]:
"""
获取当前登录会话 token。
"""
return self._token
@property
def static_token(self) -> Optional[str]:
"""
获取可用于静态资源访问的 token。
"""
return self._static_token
@property
def is_ugk(self) -> bool:
"""
判断当前会话是否使用 ugk 访问参数。
"""
return self._is_ugk
@property
def public_key(self) -> Optional[str]:
"""
获取当前会话加密公钥。
"""
return self._public_key
def close(self) -> None:
"""
关闭底层 HTTP 会话。
"""
self._request_utils.close()
@staticmethod
def _normalize_base_url(host: str) -> str:
"""
将配置地址规整为服务根地址,移除路径、查询串和片段。
:param host: 绿联地址,空值返回空字符串
:return: 仅包含协议及主机端口的根地址,不带尾部斜杠
"""
if not host:
return ""
host = UrlUtils.standardize_base_url(host).rstrip("/")
parsed = urlsplit(host)
return urlunsplit((parsed.scheme, parsed.netloc, "", "", "")).rstrip("/")
@staticmethod
def _decode_public_key(raw: Optional[str]) -> Optional[str]:
"""
兼容原始 PEM 与 Base64 包裹的公钥文本。
:param raw: 登录响应中的公钥文本
:return: 解码后的公钥;空值或 Base64 解码失败时返回 None
"""
if not raw:
return None
value = str(raw).strip()
if not value:
return None
if "BEGIN" in value:
return value
try:
return base64.b64decode(value).decode("utf-8")
except Exception:
return None
@staticmethod
def _extract_rsa_token(resp_json: dict[str, Any], headers: Mapping[str, str]) -> Optional[str]:
"""
按响应头、顶层负载和 data 的顺序提取登录公钥令牌。
:param resp_json: 登录预检接口解析后的响应字典
:param headers: HTTP 响应头
:return: 首个可用的 RSA 令牌文本;不存在时返回 None
"""
token = headers.get("x-rsa-token") or headers.get("X-Rsa-Token")
if isinstance(token, str) and token:
return token
token = resp_json.get("xRsaToken") or resp_json.get("x-rsa-token")
if isinstance(token, str) and token:
return token
data = resp_json.get("data") if isinstance(resp_json, Mapping) else None
if isinstance(data, Mapping):
token = data.get("xRsaToken") or data.get("x-rsa-token")
return token if isinstance(token, str) else None
return None
def _common_headers(self) -> dict[str, str]:
"""
获取绿联 Web 端通用请求头,兼容新版登录客户端标识。
"""
return {
"Accept": "application/json, text/plain, */*",
"Client-Id": self._client_id,
"Client-Version": self._client_version,
"UG-Agent": self._ug_agent,
"UG-Client-Id": self._client_id,
"X-Specify-Language": self._language,
}
def _request_json(
self,
url: str,
method: str = "GET",
headers: Optional[dict[str, Any]] = None,
params: Optional[dict[str, Any]] = None,
json_data: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]:
"""
发送 HTTP 请求并尝试解析为 JSON。
"""
try:
method = method.upper()
if method == "POST":
resp = self._request_utils.post_res(
url=url,
headers=headers,
params=params,
json=json_data,
timeout=self._timeout,
verify=self._verify_ssl,
)
else:
resp = self._request_utils.get_res(
url=url,
headers=headers,
params=params,
timeout=self._timeout,
verify=self._verify_ssl,
)
if resp is None:
logger.error(f"请求绿联接口失败:{url} 未收到 HTTP 响应")
return None
payload = resp.json()
return payload if isinstance(payload, dict) else None
except Exception as err:
logger.error(f"请求绿联接口失败:{url} {err}")
return None
@staticmethod
def _build_result(payload: Any) -> ApiResult:
"""
将解密后的响应规整为业务结果对象。
:param payload: 预期为包含 code、msg、data 的映射
:return: 统一响应;非法映射或无法转换的状态码使用 code=-1
"""
if not isinstance(payload, Mapping):
return ApiResult(code=-1, msg="响应格式错误", raw=None)
code = payload.get("code")
try:
code = int(code) if isinstance(code, (str, int, float)) else -1
except Exception:
code = -1
return ApiResult(
code=code,
msg=str(payload.get("msg") or ""),
data=payload.get("data"),
debug=payload.get("debug"),
raw=dict(payload),
)
def login(self, username: str, password: str, keepalive: bool = True) -> Optional[str]:
"""
执行公钥预检和账号登录,成功后更新实例的加密会话。
保存访问令牌、静态资源令牌、公钥和客户端标识;公钥损坏或加密库错误会向上传播。
:param username: 绿联登录账号
:param password: 密码明文,发送前使用预检公钥进行 RSA 分块加密
:param keepalive: 是否请求服务端保持登录,默认启用
:return: 成功时返回会话令牌;凭据为空、预检或业务登录失败时返回 None
"""
if not username or not password:
return None
headers = self._common_headers()
try:
check_resp = self._request_utils.post_res(
url=f"{self._host}/ugreen/v1/verify/check",
headers=headers,
json={"username": username},
timeout=self._timeout,
verify=self._verify_ssl,
)
if check_resp is None:
logger.error("绿联获取登录公钥失败:未收到 HTTP 响应")
return None
check_json = check_resp.json()
except Exception as err:
logger.error(f"绿联获取登录公钥失败:{err}")
return None
check_result = self._build_result(check_json)
if not check_result.success:
logger.error(f"绿联获取登录公钥失败:{check_result.msg}")
return None
rsa_token = self._extract_rsa_token(check_json, check_resp.headers)
login_public_key = self._decode_public_key(rsa_token)
if not login_public_key:
logger.error("绿联获取登录公钥失败:公钥为空")
return None
encrypted_password = UgreenCrypto(public_key=login_public_key).rsa_encrypt_long(password)
login_json = self._request_json(
url=f"{self._host}/ugreen/v1/verify/login",
method="POST",
headers=headers,
json_data={
"username": username,
"password": encrypted_password,
"keepalive": keepalive,
"otp": True,
"is_simple": True,
},
)
if not login_json:
return None
login_result = self._build_result(login_json)
if not login_result.success or not isinstance(login_result.data, Mapping):
logger.error(f"绿联登录失败:{login_result.msg}")
return None
token = str(
login_result.data.get("token")
or login_result.data.get("token_id")
or login_result.data.get("tokenId")
or ""
).strip()
public_key = (
self._decode_public_key(
str(
login_result.data.get("public_key")
or login_result.data.get("publicKey")
or ""
)
)
or login_public_key
)
if not token or not public_key:
logger.error("绿联登录失败:未返回 token/token_id 或可用公钥")
return None
self._token = token
static_token = str(
login_result.data.get("static_token")
or login_result.data.get("staticToken")
or ""
).strip()
self._static_token = static_token or self._token
self._is_ugk = bool(login_result.data.get("is_ugk"))
self._public_key = public_key
self._crypto = UgreenCrypto(
public_key=self._public_key,
token=self._token,
client_id=self._client_id,
client_version=self._client_version,
ug_agent=self._ug_agent,
language=self._language,
)
self._username = username
return self._token
def export_session_state(self) -> Optional[dict[str, Any]]:
"""
导出包含认证令牌、客户端身份和 Cookie 的会话快照。
:return: 可供持久化的会话字典;缺少令牌或公钥时返回 None,导出内容应按凭据保护
"""
if not self._token or not self._public_key:
return None
return {
"token": self._token,
"static_token": self._static_token,
"is_ugk": self._is_ugk,
"public_key": self._public_key,
"username": self._username,
"client_id": self._client_id,
"client_version": self._client_version,
"language": self._language,
"ug_agent": self._ug_agent,
"cookies": self._request_utils.get_cookies(),
}
def import_session_state(self, state: Mapping[str, Any]) -> bool:
"""
恢复本地会话和加密上下文,不向服务端验证有效性。
恢复客户端标识以保持服务端会话绑定关系;更新 Cookie 失败时忽略该错误。
:param state: 之前导出的会话映射,必须包含非空 token 和可解码 public_key
:return: 必要字段有效且完成恢复时返回 True,否则返回 False;无效公钥构造异常向上传播
"""
if not isinstance(state, Mapping):
return False
token = str(state.get("token") or "").strip()
public_key = self._decode_public_key(str(state.get("public_key") or ""))
if not token or not public_key:
return False
static_token = str(state.get("static_token") or "").strip()
is_ugk = bool(state.get("is_ugk"))
# 会话可能与 client_id 绑定,需恢复原客户端信息
client_id = str(state.get("client_id") or "").strip()
if client_id:
self._client_id = client_id
client_version = str(state.get("client_version") or "").strip()
if client_version:
self._client_version = client_version
language = str(state.get("language") or "").strip()
if language:
self._language = language
ug_agent = str(state.get("ug_agent") or "").strip()
if ug_agent:
self._ug_agent = ug_agent
username = str(state.get("username") or "").strip()
self._username = username or None
cookies = state.get("cookies")
if isinstance(cookies, Mapping):
try:
self._request_utils.update_cookies(
{
str(k): str(v)
for k, v in cookies.items()
if k is not None and v is not None
}
)
except Exception:
pass
self._token = token
self._static_token = static_token or self._token
self._is_ugk = is_ugk
self._public_key = public_key
self._crypto = UgreenCrypto(
public_key=self._public_key,
token=self._token,
client_id=self._client_id,
client_version=self._client_version,
ug_agent=self._ug_agent,
language=self._language,
)
return True
def logout(self) -> None:
"""
登出并清理本地认证状态。
"""
if not self._token or not self._crypto:
return
try:
req = self._crypto.build_encrypted_request(
url=f"{self._host}/ugreen/v1/verify/logout",
method="GET",
params={},
)
self._request_utils.get_res(
req.url,
headers=req.headers,
params=req.params,
timeout=self._timeout,
verify=self._verify_ssl,
)
except Exception:
pass
self._token = None
self._static_token = None
self._is_ugk = False
self._public_key = None
self._crypto = None
self._username = None
def request(
self,
path: str,
method: str = "GET",
params: Optional[dict[str, Any]] = None,
data: Optional[dict[str, Any]] = None,
) -> ApiResult:
"""
加密查询参数、发送内部业务请求并解密响应。
:param path: ugreen 根路径后的相对接口路径,如 v1/video/homepage/media_list
:param method: 当前仅 POST 会发送为 POST,其他值走 GET;调用方应使用 GET 或 POST
:param params: 明文查询参数,由本方法构造 encrypt_query
:param data: 明文请求体;POST 时加密后发送,其他传输方法当前不发送请求体
:return: 统一业务结果;未登录或 HTTP 请求失败时 code=-1,加解密异常向调用方传播
"""
if not self._crypto:
return ApiResult(code=-1, msg="未登录")
api_path = path.strip("/")
# 由加密工具自动构建 encrypt_query 与加密请求体
req = self._crypto.build_encrypted_request(
url=f"{self._host}/ugreen/{api_path}",
method=method.upper(),
params=params or {},
data=data,
encrypt_body=method.upper() in {"POST", "PUT", "PATCH"},
)
payload = self._request_json(
url=req.url,
method=method,
headers=req.headers,
params=req.params,
json_data=req.json,
)
if payload is None:
return ApiResult(code=-1, msg="接口请求失败")
# 响应若包含 encrypt_resp_body,这里会自动解密
decrypted = self._crypto.decrypt_response(payload, req.aes_key)
return self._build_result(decrypted)
def current_user(self) -> Optional[dict[str, Any]]:
"""
读取内部接口当前用户信息。
:return: 接口 data 中的业务字典;业务失败或 data 不是映射时返回 None
"""
result = self.request("v1/user/current/user")
if not result.success or not isinstance(result.data, Mapping):
return None
return dict(result.data)
def media_list(self) -> Optional[list[dict[str, Any]]]:
"""
读取首页媒体库集合列表。
:return: media_lib_info_list 列表;业务失败或响应结构不符时返回 None
"""
result = self.request("v1/video/homepage/media_list")
if not result.success or not isinstance(result.data, Mapping):
return None
items = result.data.get("media_lib_info_list")
return items if isinstance(items, list) else None
def media_lib_users(self) -> list[dict[str, Any]]:
"""
读取内部接口媒体库用户列表。
:return: user_info_arr 列表;业务失败或响应结构不符时返回空列表
"""
result = self.request("v1/video/media_lib/get_user_list")
if not result.success or not isinstance(result.data, Mapping):
return []
users = result.data.get("user_info_arr")
return users if isinstance(users, list) else []
def recently_played(self, page: int = 1, page_size: int = 12) -> Optional[dict[str, Any]]:
"""
获取继续观看列表。
:param page: 从 1 开始的页码
:param page_size: 每页最多返回条目数量
:return: 接口 data 中的业务字典;业务失败或 data 不是映射时返回 None
"""
result = self.request(
"v1/video/recently_played/get",
params={
"page": page,
"page_size": page_size,
"language": self._language,
"create_time_order": "false",
},
)
return dict(result.data) if result.success and isinstance(result.data, Mapping) else None
def recently_updated(self, page: int = 1, page_size: int = 20) -> Optional[dict[str, Any]]:
"""
获取最近更新列表。
:param page: 从 1 开始的页码
:param page_size: 每页最多返回条目数量
:return: 接口 data 中的业务字典;业务失败或 data 不是映射时返回 None
"""
result = self.request(
"v1/video/recently_update/get",
params={
"page": page,
"page_size": page_size,
"language": self._language,
"create_time_order": "false",
},
)
return dict(result.data) if result.success and isinstance(result.data, Mapping) else None
def recently_played_info(self, item_id: Union[str, int]) -> Optional[dict[str, Any]]:
"""
获取单个视频的播放状态与基础详情信息。
:param item_id: 绿联内部视频条目标识
:return: 视频详情与播放状态字典;业务码 200 或 1303 均接受,其他错误或无数据时返回 None
"""
result = self.request(
"v1/video/recently_played/info",
params={
"ug_video_info_id": item_id,
"version_control": "true",
},
)
if result.code in {200, 1303} and isinstance(result.data, Mapping):
return dict(result.data)
return None
def search(self, keyword: str, offset: int = 0, limit: int = 200) -> Optional[dict[str, Any]]:
"""
搜索媒体(电影/剧集)。
:param keyword: 媒体搜索关键词
:param offset: 结果起始偏移,默认 0
:param limit: 最多返回候选数量,默认 200
:return: 接口 data 中的业务字典;业务失败或 data 不是映射时返回 None
"""
result = self.request(
"v1/video/search",
params={
"language": self._language,
"search_type": 1,
"offset": offset,
"limit": limit,
"keyword": keyword,
},
)
return dict(result.data) if result.success and isinstance(result.data, Mapping) else None
def video_all(self, classification: int, page: int = 1, page_size: int = 20) -> Optional[dict[str, Any]]:
"""
按绿联分类编号查询视频列表。
:param classification: 分类编号,-102 为电影,-103 为电视剧
:param page: 从 1 开始的页码
:param page_size: 每页最多返回条目数量
:return: 接口 data 中的业务字典;业务失败或 data 不是映射时返回 None
"""
result = self.request(
"v1/video/all",
params={
"page": page,
"pageSize": page_size,
"classification": classification,
"sort_type": 2,
"order_type": 2,
"release_date_begin": -9999999999,
"release_date_end": -9999999999,
"identify_status": 0,
"watch_status": -1,
"ug_style_id": 0,
"ug_country_id": 0,
"clarity": -1,
},
)
return dict(result.data) if result.success and isinstance(result.data, Mapping) else None
def poster_wall_get_folder(
self,
path: Optional[str] = None,
page: int = 1,
page_size: int = 100,
sort_type: int = 1,
order_type: int = 1,
) -> Optional[dict[str, Any]]:
"""
获取海报墙文件夹与条目(可按目录路径递归展开)。
:param path: 目标目录路径,空值查询海报墙根目录
:param page: 从 1 开始的页码
:param page_size: 每页最多返回条目数量
:param sort_type: 服务端排序字段编号,默认 1,原样透传
:param order_type: 服务端排序方向编号,默认 1,原样透传
:return: 接口 data 中的业务字典;业务失败或 data 不是映射时返回 None
"""
params: dict[str, Any] = {
"page": page,
"page_size": page_size,
"sort_type": sort_type,
"order_type": order_type,
}
if path:
params["path"] = path
result = self.request("v1/video/poster_wall/media_lib/get_folder", params=params)
return dict(result.data) if result.success and isinstance(result.data, Mapping) else None
def get_movie(
self,
item_id: Union[str, int],
media_lib_set_id: Union[str, int],
path: Optional[str] = None,
folder_path: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""
获取电影详情。
:param item_id: 绿联内部视频条目标识
:param media_lib_set_id: 电影所属媒体库集合标识
:param path: 可选具体文件路径
:param folder_path: 可选所在文件夹路径
:return: 接口 data 中的业务字典;业务失败或 data 不是映射时返回 None
"""
params: dict[str, Any] = {
"id": item_id,
"media_lib_set_id": media_lib_set_id,
"fileVersion": "true",
}
if path:
params["path"] = path
if folder_path:
params["folder_path"] = folder_path
result = self.request("v1/video/details/getMovie", params=params)
return dict(result.data) if result.success and isinstance(result.data, Mapping) else None
def get_tv(self, item_id: Union[str, int], folder_path: str = "ALL") -> Optional[dict[str, Any]]:
"""
获取剧集详情(含季/集信息)。
:param item_id: 绿联内部视频条目标识
:param folder_path: 目录筛选条件,默认 ALL 表示全部目录
:return: 接口 data 中的业务字典;业务失败或 data 不是映射时返回 None
"""
result = self.request(
"v2/video/details/getTV",
params={
"ug_video_info_id": item_id,
"folder_path": folder_path,
},
)
return dict(result.data) if result.success and isinstance(result.data, Mapping) else None
def scan(self, media_lib_set_id: Union[str, int], scan_type: int = 2, op_type: int = 2) -> bool:
"""
触发媒体库扫描。
:param media_lib_set_id: 要扫描的媒体库集合标识
:param scan_type: 1 扫描新建和修改,2 补充缺失,3 覆盖扫描,默认 2
:param op_type: 服务端操作类型,默认使用网页端的 2
:return: 扫描请求业务成功时返回 True,否则 False;不等待扫描完成
"""
result = self.request(
"v1/video/media_lib/scan",
params={
"op_type": op_type,
"media_lib_set_id": media_lib_set_id,
"media_lib_scan_type": scan_type,
},
)
return result.success
def scan_status(self, only_brief: bool = True) -> list[dict[str, Any]]:
"""
获取媒体库扫描状态。
:param only_brief: 是否仅请求扫描概要,默认启用
:return: 扫描状态记录列表;业务失败或响应结构不符时返回空列表
"""
result = self.request(
"v1/video/media_lib/scan/status",
params={"only_brief": "true" if only_brief else "false"},
)
if not result.success or not isinstance(result.data, Mapping):
return []
arr = result.data.get("media_lib_scan_status_arr")
return arr if isinstance(arr, list) else []
def preferences_all(self) -> Optional[Any]:
"""
读取影视偏好设置。
:return: data 原始偏好数据,业务失败时返回 None
"""
result = self.request("v1/video/preferences/all")
return result.data if result.success else None
def history_get(self, num: int = 10) -> Optional[Any]:
"""
获取历史记录(`v1/video/history/get`)。
:param num: 最多返回的历史记录数,默认 10
:return: 服务端 data 原始业务数据;业务失败时返回 None
"""
result = self.request("v1/video/history/get", params={"num": num})
return result.data if result.success else None
def data_source_get_config(self) -> Optional[Any]:
"""
读取元数据来源配置。
:return: data 原始来源配置,业务失败时返回 None
"""
result = self.request("v1/video/data_source/get_config")
return result.data if result.success else None
def homepage_slider(
self, language: Optional[str] = None, app_name: str = "web"
) -> Optional[Any]:
"""
获取首页轮播数据(`v1/video/homepage/slider`)。
:param language: 请求语言,空值使用实例配置语言
:param app_name: 客户端应用名称,默认 web
:return: 服务端 data 原始业务数据;业务失败时返回 None
"""
result = self.request(
"v1/video/homepage/slider",
params={
"language": language or self._language,
"app_name": app_name,
},
)
return result.data if result.success else None
def media_lib_guide_init(self) -> Optional[Any]:
"""
读取媒体库引导初始化信息。
:return: data 原始引导信息,业务失败时返回 None
"""
result = self.request("v1/video/media_lib/guide_init")
return result.data if result.success else None
def media_lib_filter_options(
self, media_type: int = 0, language: Optional[str] = None
) -> Optional[Any]:
"""
获取媒体库筛选项(`v1/video/media_lib/filter/options`)。
:param media_type: 服务端筛选类型编号,默认 0
:param language: 请求语言,空值使用实例配置语言
:return: 服务端 data 原始业务数据;业务失败时返回 None
"""
result = self.request(
"v1/video/media_lib/filter/options",
params={
"type": media_type,
"language": language or self._language,
},
)
return result.data if result.success else None
def guide(self, guide_position: int = 1, client_type: int = 1) -> Optional[Any]:
"""
获取引导位数据(`v1/video/guide`)。
:param guide_position: 服务端引导位置编号,默认 1
:param client_type: 服务端客户端类型编号,默认 1
:return: 服务端 data 原始业务数据;业务失败时返回 None
"""
result = self.request(
"v1/video/guide",
params={
"guide_position": guide_position,
"client_type": client_type,
},
)
return result.data if result.success else None
def homepage_v2(self, language: Optional[str] = None) -> Optional[Any]:
"""
获取新版首页聚合数据(`v2/video/homepage`)。
:param language: 请求语言,空值使用实例配置语言
:return: 服务端 data 原始业务数据;业务失败时返回 None
"""
result = self.request(
"v2/video/homepage",
params={"language": language or self._language},
)
return result.data if result.success else None
def media_lib_init_user_permission(self) -> Optional[Any]:
"""
请求初始化当前用户的媒体库权限。
:return: data 原始业务结果,业务失败时返回 None
"""
result = self.request("v1/video/media_lib/init_user_permission")
return result.data if result.success else None
def media_lib_get_all(
self, req_type: int = 2, language: Optional[str] = None
) -> Optional[Any]:
"""
获取全部媒体库集合(`v1/video/media_lib/get_all`)。
:param req_type: 服务端媒体库集合请求类型,默认 2
:param language: 请求语言,空值使用实例配置语言
:return: 服务端 data 原始业务数据;业务失败时返回 None
"""
result = self.request(
"v1/video/media_lib/get_all",
params={
"mediaLib_get_all_req_type": req_type,
"language": language or self._language,
},
)
return result.data if result.success else None
@dataclass(frozen=True)
class UgreenMediaSession:
"""
绿联媒体库服务认证成功后的不可变会话信息。
:param token: 媒体库服务 AccessToken,访问接口时作为 api_key 使用
:param user_id: 认证用户标识,用于用户范围内的媒体库查询
:param server_id: 可选媒体服务器标识,服务端未提供时为 None
:param user: 认证响应中的原始用户详情;未提供时为 None
"""
token: str
user_id: str
server_id: Optional[str] = None
user: Optional[dict[str, Any]] = None
class UgreenMediaApi:
"""
绿联影视媒体库服务的 Emby 兼容 API 客户端。
媒体库服务只在 HTTPS 9443 端口开放,认证后使用 AccessToken 作为
`api_key` 访问用户媒体库接口,不复用 `/ugreen/v1` 的加密会话。
"""
__slots__ = (
"_host",
"_request_utils",
"_token",
"_user_id",
"_server_id",
"_timeout",
"_verify_ssl",
)
def __init__(self, host: str, timeout: int = 20, verify_ssl: bool = True) -> None:
"""
初始化媒体库服务 API 客户端。
:param host: 绿联媒体库服务地址
:param timeout: HTTP 请求超时时间,单位秒
:param verify_ssl: 是否校验 HTTPS 证书
"""
normalized_host = UrlUtils.standardize_base_url(host).rstrip("/")
parsed = urlsplit(normalized_host)
self._host = urlunsplit((parsed.scheme, parsed.netloc, "", "", "")).rstrip("/")
self._request_utils = RequestUtils(
use_session=True,
timeout=timeout,
verify=verify_ssl,
)
self._token: Optional[str] = None
self._user_id: Optional[str] = None
self._server_id: Optional[str] = None
self._timeout = timeout
self._verify_ssl = bool(verify_ssl)
@property
def host(self) -> str:
"""
获取规范化后的媒体库服务地址。
"""
return self._host
@property
def token(self) -> Optional[str]:
"""
获取当前媒体库服务 AccessToken。
"""
return self._token
@property
def user_id(self) -> Optional[str]:
"""
获取认证响应中的媒体库用户 ID。
"""
return self._user_id
@property
def server_id(self) -> Optional[str]:
"""
获取认证响应中的媒体服务器 ID。
"""
return self._server_id
def close(self) -> None:
"""
关闭媒体库服务的 HTTP 会话。
"""
self._request_utils.close()
def _headers(self) -> dict[str, str]:
"""
构造 Emby 兼容接口所需的客户端请求头。
"""
token = f', Token="{self._token}"' if self._token else ""
return {
"X-Emby-Authorization": (
'MediaBrowser Client="MoviePilot", Device="requests", '
f'DeviceId="moviepilot-ugreen", Version="1.0.0"{token}'
),
"Content-Type": "application/json",
"Accept": "application/json",
}
def _request(
self,
path: str,
method: str = "GET",
params: Optional[dict[str, Any]] = None,
data: Optional[dict[str, Any]] = None,
authenticated: bool = True,
) -> Optional[Any]:
"""
调用媒体库服务 Emby 兼容接口并解析 JSON 响应。
:param path: 接口相对路径
:param method: HTTP 方法
:param params: 查询参数
:param data: JSON 请求体
:param authenticated: 是否附加当前 AccessToken
:return: JSON 响应,连接失败或服务端拒绝时返回 None
"""
request_params = dict(params or {})
if authenticated and self._token:
request_params["api_key"] = self._token
url = f"{self._host}/{path.strip('/')}"
try:
if method.upper() == "POST":
response = self._request_utils.post_res(
url=url,
headers=self._headers(),
params=request_params,
json=data,
timeout=self._timeout,
verify=self._verify_ssl,
)
else:
response = self._request_utils.get_res(
url=url,
headers=self._headers(),
params=request_params,
timeout=self._timeout,
verify=self._verify_ssl,
)
status_code = getattr(response, "status_code", None)
if response is None or not 200 <= (status_code or 0) < 300:
logger.error(f"请求绿联媒体库接口失败:{url} HTTP {status_code or '无响应'}")
return None
return response.json()
except Exception as err:
logger.error(f"请求绿联媒体库接口失败:{url} {err}")
return None
def authenticate(self, username: str, password: str) -> Optional[UgreenMediaSession]:
"""
使用媒体库账号登录 Emby 兼容接口。
:param username: 媒体库账号
:param password: 媒体库密码
:return: 登录会话,认证失败或缺少用户 ID 时返回 None
"""
if not username or not password:
return None
payload = self._request(
"emby/Users/AuthenticateByName",
method="POST",
data={"Username": username, "Pw": password},
authenticated=False,
)
if not isinstance(payload, Mapping):
return None
token = str(payload.get("AccessToken") or "").strip()
user_data = payload.get("User")
user = dict(user_data) if isinstance(user_data, Mapping) else {}
user_id = str(user.get("Id") or payload.get("UserId") or payload.get("UserID") or "").strip()
if not token or not user_id:
logger.error("绿联媒体库服务认证失败:响应缺少 AccessToken 或用户 ID")
return None
self._token = token
self._user_id = user_id
server_id = str(payload.get("ServerId") or "").strip()
self._server_id = server_id or None
return UgreenMediaSession(
token=token,
user_id=user_id,
server_id=self._server_id,
user=user or None,
)
def current_user(self) -> Optional[dict[str, Any]]:
"""
查询认证用户详情,以检查媒体库令牌有效性。
:return: 用户详情字典;未登录、请求失败或响应结构不符时返回 None
"""
if not self._user_id:
return None
payload = self._request(f"emby/Users/{self._user_id}")
return dict(payload) if isinstance(payload, Mapping) else None
def views(self) -> Optional[list[dict[str, Any]]]:
"""
读取认证用户可见的媒体库视图。
:return: 媒体库字典列表;未登录、请求失败或响应结构不符时返回 None
"""
if not self._user_id:
return None
payload = self._request(f"emby/Users/{self._user_id}/Views")
if not isinstance(payload, Mapping):
return None
items = payload.get("Items")
return [dict(item) for item in items if isinstance(item, Mapping)] if isinstance(items, list) else None
def items(
self,
parent_id: Optional[Union[str, int]] = None,
include_item_types: Optional[str] = None,
start_index: int = 0,
limit: Optional[int] = 100,
ids: Optional[str] = None,
search_term: Optional[str] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
fields: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""
查询当前用户媒体库条目。
:param parent_id: 媒体库或父条目 ID
:param include_item_types: Emby 条目类型过滤器
:param start_index: 分页起始位置
:param limit: 最大返回数量,None 表示不传限制
:param ids: 按条目 ID 过滤,多个 ID 用逗号分隔
:param search_term: 搜索词
:param sort_by: 排序字段
:param sort_order: 排序方向
:param fields: 需要返回的字段
:return: 含 Items 和计数等字段的响应字典;未登录、请求失败或结构不符时返回 None
"""
if not self._user_id:
return None
params: dict[str, Any] = {"Recursive": "true"}
if parent_id is not None:
params["ParentId"] = parent_id
if include_item_types:
params["IncludeItemTypes"] = include_item_types
if start_index:
params["StartIndex"] = start_index
if limit is not None:
params["Limit"] = limit
if ids:
params["Ids"] = ids
if search_term:
params["SearchTerm"] = search_term
if sort_by:
params["SortBy"] = sort_by
if sort_order:
params["SortOrder"] = sort_order
if fields:
params["Fields"] = fields
payload = self._request(f"emby/Users/{self._user_id}/Items", params=params)
return dict(payload) if isinstance(payload, Mapping) else None
def item(self, item_id: Union[str, int]) -> Optional[dict[str, Any]]:
"""
通过当前用户的条目查询接口读取单个媒体详情。
:param item_id: 媒体库服务条目标识
:return: 首个匹配条目的字典;标识为空、未登录、无匹配或查询失败时返回 None
"""
if not self._user_id or not item_id:
return None
payload = self.items(ids=str(item_id), limit=1)
if not payload:
return None
items = payload.get("Items")
return dict(items[0]) if isinstance(items, list) and items else None
def counts(self) -> Optional[dict[str, Any]]:
"""
查询媒体库服务的媒体数量统计。
:return: 服务端原始统计字典;请求失败或响应结构不符时返回 None
"""
payload = self._request("emby/Items/Counts")
return dict(payload) if isinstance(payload, Mapping) else None
def resume(self, limit: int = 20) -> Optional[list[dict[str, Any]]]:
"""
查询当前用户的继续观看列表。
:param limit: 最多返回条目数量,默认 20
:return: 播放条目字典列表;未登录、请求失败或响应结构不符时返回 None
"""
if not self._user_id:
return None
payload = self._request(
f"emby/Users/{self._user_id}/Items/Resume",
params={"Limit": limit, "MediaTypes": "Video"},
)
if not isinstance(payload, Mapping):
return None
items = payload.get("Items")
return [dict(item) for item in items if isinstance(item, Mapping)] if isinstance(items, list) else None
def episodes(
self, series_id: Union[str, int], season: Optional[int] = None
) -> Optional[list[dict[str, Any]]]:
"""
获取指定剧集的已入库集数。
:param series_id: 剧集条目 ID
:param season: 可选季号
:return: 分集条目字典列表;剧集标识为空、请求失败或结构不符时返回 None
"""
if not series_id:
return None
params: dict[str, Any] = {"IsMissing": "false"}
if season is not None:
params["Season"] = season
payload = self._request(f"emby/Shows/{series_id}/Episodes", params=params)
if not isinstance(payload, Mapping):
return None
items = payload.get("Items")
return [dict(item) for item in items if isinstance(item, Mapping)] if isinstance(items, list) else None
def image_url(self, item_id: Union[str, int], image_type: str = "Primary") -> Optional[str]:
"""
构造带媒体库访问令牌的图片地址。
:param item_id: 媒体库条目标识
:param image_type: 图片类型,默认 Primary 主封面
:return: 包含 api_key 的图片 URL;缺少条目标识或令牌时返回 None,不验证图片是否存在
"""
if not item_id or not self._token:
return None
return f"{self._host}/emby/Items/{item_id}/Images/{image_type}?api_key={self._token}"