mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 05:44:44 +08:00
2038 lines
80 KiB
Python
2038 lines
80 KiB
Python
"""
|
||
绿联媒体服务器客户端,协调内部加密接口、媒体库兼容接口及会话缓存。
|
||
"""
|
||
|
||
import hashlib
|
||
from collections import deque
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, Generator, List, Mapping, Optional, Union
|
||
from urllib.parse import parse_qs, urlparse
|
||
|
||
from app.application.configuration import get_configured_system_config
|
||
from app.application.mediaserver import (
|
||
MediaServerIdentityHelper,
|
||
MusicMediaServerHelper,
|
||
format_emby_family_item,
|
||
)
|
||
from app.foundation.url import UrlUtils
|
||
from app.modules.ugreen.api import Api, UgreenMediaApi
|
||
from app.runtime.log import logger
|
||
from app.schemas.dashboard import Statistic as _SchemaStatistic
|
||
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
|
||
from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState
|
||
from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary
|
||
from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem
|
||
from app.schemas.mediaserver import RefreshMediaItem as _SchemaRefreshMediaItem
|
||
from app.schemas.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo
|
||
from app.schemas.types import MediaSource, MediaType, SystemConfigKey
|
||
|
||
|
||
def _media_service_items(data: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
||
"""
|
||
校验媒体库服务列表,保留合法空数组并拒绝未知响应结构。
|
||
"""
|
||
items = data.get("Items")
|
||
if not isinstance(items, list):
|
||
raise ConnectionError("绿联媒体库服务列表响应格式错误")
|
||
return items
|
||
|
||
|
||
class Ugreen:
|
||
"""
|
||
绿联影视媒体服务器客户端。
|
||
"""
|
||
|
||
# 目录索引最多读取 200 页,防止固件错误的分页标志导致无限循环。
|
||
LIBRARY_PATH_PAGE_LIMIT = 200
|
||
# UGOS Pro 独立媒体库服务的固定 HTTPS 端口。
|
||
MEDIA_SERVICE_PORT = 9443
|
||
INTERNAL_CONNECTION_MODE = "internal"
|
||
MEDIA_SERVICE_CONNECTION_MODE = "media_service"
|
||
|
||
_username: Optional[str] = None
|
||
_password: Optional[str] = None
|
||
|
||
# 用户资料、媒体库和会话负载沿用 API 的原始 JSON 字典合同。
|
||
_userinfo: Optional[dict[str, Any]] = None
|
||
_host: Optional[str] = None
|
||
_playhost: Optional[str] = None
|
||
|
||
_libraries: dict[str, dict[str, Any]] = {}
|
||
_library_paths: dict[str, str] = {}
|
||
_sync_libraries: List[str] = []
|
||
_scan_type: int = 2
|
||
_verify_ssl: bool = True
|
||
_connection_mode: str = INTERNAL_CONNECTION_MODE
|
||
|
||
_api: Optional[Api] = None
|
||
_media_api: Optional[UgreenMediaApi] = None
|
||
|
||
def __init__(
|
||
self,
|
||
host: Optional[str] = None,
|
||
username: Optional[str] = None,
|
||
password: Optional[str] = None,
|
||
play_host: Optional[str] = None,
|
||
sync_libraries: Optional[list[str]] = None,
|
||
scan_mode: Optional[Union[str, int]] = None,
|
||
scan_type: Optional[Union[str, int]] = None,
|
||
verify_ssl: Optional[Union[bool, str, int]] = True,
|
||
mode: Optional[str] = None,
|
||
**kwargs: object,
|
||
) -> None:
|
||
"""
|
||
保存连接配置,并按端口或显式模式连接绿联影视服务。
|
||
|
||
地址或用户名密码缺失时不连接;内部接口优先复用持久化会话,媒体库服务重新认证。
|
||
|
||
:param host: 服务地址,9443 端口默认使用媒体库服务兼容接口
|
||
:param username: 登录用户名
|
||
:param password: 登录密码
|
||
:param play_host: 用于播放跳转和远程图片的外网地址
|
||
:param sync_libraries: 同步范围内的媒体库标识,空列表或包含 all 表示不限制
|
||
:param scan_mode: 扫描模式名称或编号,缺省使用补充缺失模式
|
||
:param scan_type: 显式扫描类型编号 1、2 或 3,优先于 scan_mode
|
||
:param verify_ssl: 是否验证证书;None 或无法识别的值默认启用
|
||
:param mode: 连接模式,支持 internal 和 media_service 及兼容别名;无有效显式值时按端口识别
|
||
:param kwargs: 通用服务配置的额外参数,当前不读取
|
||
"""
|
||
if not host or not username or not password:
|
||
logger.error("绿联影视配置不完整!!")
|
||
return
|
||
|
||
self._host = host
|
||
self._username = username
|
||
self._password = password
|
||
self._connection_mode = self.__resolve_connection_mode(host, mode)
|
||
self._sync_libraries = sync_libraries or []
|
||
# 绿联媒体库扫描模式:
|
||
# 1 新添加和修改、2 补充缺失、3 覆盖扫描
|
||
self._scan_type = self.__resolve_scan_type(scan_mode=scan_mode, scan_type=scan_type)
|
||
# HTTPS 证书校验开关:默认开启,仅兼容自签证书等场景下可关闭。
|
||
self._verify_ssl = self.__resolve_verify_ssl(verify_ssl)
|
||
|
||
if play_host:
|
||
self._playhost = UrlUtils.standardize_base_url(play_host).rstrip("/")
|
||
|
||
if not self.reconnect():
|
||
logger.error(f"请检查服务端地址 {host}")
|
||
|
||
@property
|
||
def api(self) -> Optional[Api]:
|
||
"""
|
||
读取内部加密接口客户端。
|
||
|
||
:return: 内部接口客户端;媒体库服务模式或未连接时为 None
|
||
"""
|
||
return self._api
|
||
|
||
def close(self) -> None:
|
||
"""
|
||
关闭绿联影视连接。
|
||
"""
|
||
self.disconnect()
|
||
|
||
def is_configured(self) -> bool:
|
||
"""
|
||
检查绿联影视连接配置是否完整。
|
||
"""
|
||
return bool(self._host and self._username and self._password)
|
||
|
||
def is_authenticated(self) -> bool:
|
||
"""
|
||
检查绿联影视会话是否已认证。
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
return bool(
|
||
self.is_configured()
|
||
and self._media_api is not None
|
||
and self._media_api.token
|
||
and self._media_api.user_id
|
||
)
|
||
return (
|
||
self.is_configured()
|
||
and self._api is not None
|
||
and self._api.token is not None
|
||
and self._userinfo is not None
|
||
)
|
||
|
||
def is_inactive(self) -> bool:
|
||
"""
|
||
验证当前会话,并更新实例缓存的用户资料。
|
||
|
||
:return: 尚未认证或当前用户查询失败时返回 True;此检查会调用服务端接口
|
||
"""
|
||
if not self.is_authenticated():
|
||
return True
|
||
if self.__is_media_service_mode():
|
||
self._userinfo = self._media_api.current_user() if self._media_api else None
|
||
return self._userinfo is None
|
||
self._userinfo = self._api.current_user() if self._api else None
|
||
return self._userinfo is None
|
||
|
||
@staticmethod
|
||
def __resolve_connection_mode(host: str, mode: Optional[str] = None) -> str:
|
||
"""
|
||
解析绿联连接模式。
|
||
|
||
显式配置优先;未配置时按 UGOS Pro 媒体库服务固定使用的 9443 端口
|
||
自动选择 Emby 兼容接口,其他地址继续使用原有内部接口。
|
||
"""
|
||
configured_mode = str(mode or "").strip().lower()
|
||
if configured_mode in {
|
||
Ugreen.MEDIA_SERVICE_CONNECTION_MODE,
|
||
"media-server",
|
||
"media_server",
|
||
"emby",
|
||
}:
|
||
return Ugreen.MEDIA_SERVICE_CONNECTION_MODE
|
||
if configured_mode in {Ugreen.INTERNAL_CONNECTION_MODE, "ugreen"}:
|
||
return Ugreen.INTERNAL_CONNECTION_MODE
|
||
try:
|
||
normalized_host = UrlUtils.standardize_base_url(host)
|
||
if urlparse(normalized_host).port == Ugreen.MEDIA_SERVICE_PORT:
|
||
return Ugreen.MEDIA_SERVICE_CONNECTION_MODE
|
||
except ValueError:
|
||
pass
|
||
return Ugreen.INTERNAL_CONNECTION_MODE
|
||
|
||
def __is_media_service_mode(self) -> bool:
|
||
"""
|
||
判断当前实例是否使用媒体库服务 Emby 兼容接口。
|
||
"""
|
||
return getattr(self, "_connection_mode", self.INTERNAL_CONNECTION_MODE) == self.MEDIA_SERVICE_CONNECTION_MODE
|
||
|
||
def __session_cache_key(self) -> str:
|
||
"""
|
||
生成当前绿联实例的会话缓存键(基于 host、username 和连接模式)。
|
||
"""
|
||
normalized_host = UrlUtils.standardize_base_url(self._host or "").rstrip("/").lower()
|
||
username = (self._username or "").strip().lower()
|
||
raw = f"{normalized_host}|{username}"
|
||
if self.__is_media_service_mode():
|
||
raw = f"{raw}|{self.MEDIA_SERVICE_CONNECTION_MODE}"
|
||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||
|
||
def __password_digest(self) -> str:
|
||
"""
|
||
存储密码摘要用于检测配置是否变更,避免明文落盘。
|
||
"""
|
||
return hashlib.sha256((self._password or "").encode("utf-8")).hexdigest()
|
||
|
||
@staticmethod
|
||
def __load_all_session_cache() -> dict[str, Any]:
|
||
"""
|
||
读取系统配置中保存的所有绿联会话。
|
||
|
||
:return: 会话缓存字典;配置不存在或不是字典时返回空字典
|
||
"""
|
||
sessions = get_configured_system_config().get(SystemConfigKey.UgreenSessionCache)
|
||
return sessions if isinstance(sessions, dict) else {}
|
||
|
||
@staticmethod
|
||
def __save_all_session_cache(sessions: dict[str, Any]) -> None:
|
||
"""
|
||
把所有绿联实例的会话字典写回系统配置。
|
||
|
||
:param sessions: 完整会话缓存映射;本方法替换该配置值,不只更新单个实例
|
||
"""
|
||
get_configured_system_config().set(SystemConfigKey.UgreenSessionCache, sessions)
|
||
|
||
def __remove_persisted_session(self) -> None:
|
||
"""
|
||
从持久化会话映射中删除当前地址和账号对应的记录。
|
||
"""
|
||
cache_key = self.__session_cache_key()
|
||
sessions = self.__load_all_session_cache()
|
||
if cache_key in sessions:
|
||
sessions.pop(cache_key, None)
|
||
self.__save_all_session_cache(sessions)
|
||
|
||
def __save_persisted_session(self) -> None:
|
||
"""
|
||
保存内部接口会话、Cookie、密码摘要和更新时间。
|
||
|
||
仅在内部 API 能导出有效会话时写入系统配置;其他实例的记录保留。
|
||
"""
|
||
if not self._api:
|
||
return
|
||
session_state = self._api.export_session_state()
|
||
if not session_state:
|
||
return
|
||
|
||
sessions = self.__load_all_session_cache()
|
||
cache_key = self.__session_cache_key()
|
||
sessions[cache_key] = {
|
||
**session_state,
|
||
"host": UrlUtils.standardize_base_url(self._host or "").rstrip("/"),
|
||
"username": self._username,
|
||
"password_digest": self.__password_digest(),
|
||
"updated_at": int(datetime.now().timestamp()),
|
||
}
|
||
self.__save_all_session_cache(sessions)
|
||
|
||
def __restore_persisted_session(self, host: str) -> bool:
|
||
"""
|
||
验证缓存会话的密码摘要和服务端用户状态,成功后接管客户端。
|
||
|
||
密码变更或会话失效时删除旧记录;验证失败后关闭临时客户端,调用方可继续密码登录。
|
||
|
||
:param host: 重连入口已校验的服务地址
|
||
:return: 恢复并验证成功时返回 True;记录缺失、配置变更或会话失效时返回 False
|
||
"""
|
||
cache_key = self.__session_cache_key()
|
||
sessions = self.__load_all_session_cache()
|
||
cached = sessions.get(cache_key)
|
||
if not isinstance(cached, Mapping):
|
||
return False
|
||
|
||
# 配置变更(尤其密码变更)后,不复用旧会话
|
||
if cached.get("password_digest") != self.__password_digest():
|
||
logger.info(f"绿联影视 {self._username} 检测到密码变更,清理旧会话缓存")
|
||
self.__remove_persisted_session()
|
||
return False
|
||
|
||
api = Api(host=host, verify_ssl=self._verify_ssl)
|
||
if not api.import_session_state(cached):
|
||
api.close()
|
||
self.__remove_persisted_session()
|
||
return False
|
||
|
||
userinfo = api.current_user()
|
||
if not userinfo:
|
||
# 会话失效,清理缓存并走正常登录
|
||
api.close()
|
||
self.__remove_persisted_session()
|
||
logger.info(f"绿联影视 {self._username} 持久化会话已失效,准备重新登录")
|
||
return False
|
||
|
||
self._api = api
|
||
self._userinfo = userinfo
|
||
logger.debug(f"{self._username} 已复用绿联影视持久化会话")
|
||
return True
|
||
|
||
def reconnect(self) -> bool:
|
||
"""
|
||
关闭旧客户端并按配置重新认证,重置媒体库与目录缓存。
|
||
|
||
关闭旧连接时不主动登出,以保留可复用会话。内部接口成功登录后保存新会话,
|
||
媒体库服务模式不使用内部接口的会话持久化流程。
|
||
|
||
:return: 认证并获取必要用户信息后返回 True;配置缺失或认证失败时返回 False
|
||
"""
|
||
if not self.is_configured():
|
||
return False
|
||
|
||
media_host = self._host
|
||
media_username = self._username
|
||
media_password = self._password
|
||
if not media_host or not media_username or not media_password:
|
||
return False
|
||
|
||
self._libraries = {}
|
||
self._library_paths = {}
|
||
|
||
# 关闭旧连接(不主动登出,避免破坏可复用会话)
|
||
self.disconnect(logout=False)
|
||
|
||
if self.__is_media_service_mode():
|
||
media_api = UgreenMediaApi(
|
||
host=media_host,
|
||
verify_ssl=self._verify_ssl,
|
||
)
|
||
session = media_api.authenticate(media_username, media_password)
|
||
if session is None:
|
||
media_api.close()
|
||
return False
|
||
self._media_api = media_api
|
||
self._userinfo = session.user or {
|
||
"Id": session.user_id,
|
||
"Name": media_username,
|
||
}
|
||
logger.debug(f"{self._username} 成功登录绿联媒体库服务")
|
||
return True
|
||
|
||
if self.__restore_persisted_session(media_host):
|
||
return True
|
||
|
||
self._api = Api(host=media_host, verify_ssl=self._verify_ssl)
|
||
if self._api.login(media_username, media_password) is None:
|
||
self.__remove_persisted_session()
|
||
return False
|
||
|
||
self._userinfo = self._api.current_user()
|
||
if not self._userinfo:
|
||
self.__remove_persisted_session()
|
||
return False
|
||
|
||
# 登录成功后持久化参数,下次优先复用
|
||
self.__save_persisted_session()
|
||
logger.debug(f"{self._username} 成功登录绿联影视")
|
||
return True
|
||
|
||
def disconnect(self, logout: bool = False) -> None:
|
||
"""
|
||
关闭客户端并清空内存中的用户、媒体库与路径状态。
|
||
|
||
媒体库服务模式只关闭 HTTP 会话,不调用服务端登出接口。
|
||
|
||
:param logout: 是否注销内部接口会话并删除持久化记录;默认保留供重连复用
|
||
"""
|
||
if self._media_api:
|
||
self._media_api.close()
|
||
self._media_api = None
|
||
self._userinfo = None
|
||
logger.debug(f"{self._username} 已断开绿联媒体库服务")
|
||
if self._api:
|
||
if logout:
|
||
# 显式登出时同步清理本地缓存
|
||
self._api.logout()
|
||
self.__remove_persisted_session()
|
||
self._api.close()
|
||
self._api = None
|
||
self._userinfo = None
|
||
logger.debug(f"{self._username} 已断开绿联影视")
|
||
self._libraries = {}
|
||
self._library_paths = {}
|
||
|
||
@staticmethod
|
||
def __normalize_dir_path(path: Union[str, Path, None]) -> str:
|
||
"""
|
||
将目录分隔符统一为斜杠,并去除尾部斜杠。
|
||
|
||
:param path: 字符串或路径对象;None 转为空字符串
|
||
:return: 用于字符串层级匹配的路径,不访问文件系统或解析符号链接
|
||
"""
|
||
if path is None:
|
||
return ""
|
||
value = str(path).replace("\\", "/").rstrip("/")
|
||
return value
|
||
|
||
@staticmethod
|
||
def __is_subpath(path: Union[str, Path, None], parent: Union[str, Path, None]) -> bool:
|
||
"""
|
||
按路径分隔符判断目标是否等于或位于父目录之下。
|
||
|
||
:param path: 待判断的目标路径
|
||
:param parent: 父目录路径
|
||
:return: 两个路径都非空且目标属于父目录时返回 True;比较区分大小写
|
||
"""
|
||
path_str = Ugreen.__normalize_dir_path(path)
|
||
parent_str = Ugreen.__normalize_dir_path(parent)
|
||
if not path_str or not parent_str:
|
||
return False
|
||
return path_str == parent_str or path_str.startswith(parent_str + "/")
|
||
|
||
def __build_image_stream_url(self, source_url: str, size: int = 1) -> Optional[str]:
|
||
"""
|
||
通过绿联 getImaStream 中转图片,规避 scraper.ugnas.com 403 问题。
|
||
"""
|
||
if not self._api:
|
||
return None
|
||
|
||
auth_token = self._api.static_token or self._api.token
|
||
if not auth_token:
|
||
return None
|
||
|
||
params = {
|
||
"app_name": "web",
|
||
"name": source_url,
|
||
"size": size,
|
||
}
|
||
if self._api.is_ugk:
|
||
params["ugk"] = auth_token
|
||
else:
|
||
params["token"] = auth_token
|
||
|
||
return UrlUtils.combine_url(
|
||
host=self._api.host,
|
||
path="/ugreen/v2/video/getImaStream",
|
||
query=params,
|
||
)
|
||
|
||
def __resolve_image(self, path: Optional[str]) -> Optional[str]:
|
||
"""
|
||
将图片地址转换为可用的外链或绿联图片中转地址。
|
||
|
||
scraper.ugnas.com 图片优先经本机 getImaStream 代理;普通外部 HTTP 地址保持原样。
|
||
|
||
:param path: 服务端原始图片地址或路径
|
||
:return: 可用 URL;过期签名且无法中转、相对路径或空值时返回 None
|
||
"""
|
||
if not path:
|
||
return None
|
||
if path.startswith("http://") or path.startswith("https://"):
|
||
parsed = urlparse(path)
|
||
if parsed.netloc.lower() == "scraper.ugnas.com":
|
||
# scraper 链接优先改为本机 getImaStream,避免签名过期导致 403
|
||
if image_stream_url := self.__build_image_stream_url(path):
|
||
return image_stream_url
|
||
|
||
# 绿联返回的 scraper.ugnas.com 图片常带 auth_key 时效签名,
|
||
# 过期后会直接 403。这里提前过滤,避免前端出现裂图。
|
||
if self.__is_expired_signed_image(path):
|
||
return None
|
||
return path
|
||
# 绿联本地图片路径需要额外鉴权头,MP图片代理当前仅支持Cookie,故先忽略本地路径。
|
||
return None
|
||
|
||
@staticmethod
|
||
def __is_expired_signed_image(url: str) -> bool:
|
||
"""
|
||
判断绿联 scraper 签名图是否已过期。
|
||
|
||
auth_key 结构通常为:
|
||
`{过期时间戳}-{随机串}-...`
|
||
"""
|
||
try:
|
||
parsed = urlparse(url)
|
||
if parsed.netloc.lower() != "scraper.ugnas.com":
|
||
return False
|
||
auth_key = parse_qs(parsed.query).get("auth_key", [None])[0]
|
||
if not auth_key:
|
||
return False
|
||
expire_part = str(auth_key).split("-", 1)[0]
|
||
expire_ts = int(expire_part)
|
||
now_ts = int(datetime.now().timestamp())
|
||
return expire_ts <= now_ts
|
||
except Exception:
|
||
return False
|
||
|
||
@staticmethod
|
||
def __parse_year(video_info: dict[str, Any]) -> Optional[Union[str, int]]:
|
||
"""
|
||
读取有效年份,缺失时从发行时间戳推断。
|
||
|
||
:param video_info: 内部接口的视频信息字典
|
||
:return: 正整数年份;只有正的发行秒时间戳时按本地时区取年,无法转换时返回 None
|
||
"""
|
||
year = video_info.get("year")
|
||
if isinstance(year, int) and year > 0:
|
||
return year
|
||
release_date = video_info.get("release_date")
|
||
if isinstance(release_date, (int, float)) and release_date > 0:
|
||
try:
|
||
return datetime.fromtimestamp(release_date).year
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
@staticmethod
|
||
def __map_item_type(video_type: Any) -> Optional[str]:
|
||
"""
|
||
将绿联内部数值类型映射为统一条目类型。
|
||
|
||
:param video_type: 0 为目录,1 为电影,2 为剧集,3 为合集
|
||
:return: Folder、Movie、Series 或 Collection;其他值统一返回 Video
|
||
"""
|
||
if video_type == 2:
|
||
return "Series"
|
||
if video_type == 1:
|
||
return "Movie"
|
||
if video_type == 3:
|
||
return "Collection"
|
||
if video_type == 0:
|
||
return "Folder"
|
||
return "Video"
|
||
|
||
@staticmethod
|
||
def __build_media_server_item(
|
||
video_info: dict[str, Any], play_status: Optional[dict[str, Any]] = None,
|
||
) -> Optional[_SchemaMediaServerItem]:
|
||
"""
|
||
将内部视频信息和播放状态转换为统一媒体模型。
|
||
|
||
:param video_info: 含 ug_video_info_id、名称、类型及可选 TMDB 身份的视频信息
|
||
:param play_status: 可选播放状态,progress 为 0 至 1 的比例,watch_status=2 表示已看完
|
||
:return: 统一媒体条目;缺少 ug_video_info_id 时返回 None,仅正整数 tmdb_id 被接受为来源身份
|
||
"""
|
||
user_state = _SchemaMediaServerItemUserState()
|
||
if isinstance(play_status, dict):
|
||
progress = play_status.get("progress")
|
||
watch_status = play_status.get("watch_status")
|
||
if watch_status == 2:
|
||
user_state.played = True
|
||
if isinstance(progress, (int, float)) and progress > 0:
|
||
user_state.resume = progress < 1
|
||
user_state.percentage = progress * 100.0
|
||
last_play_time = play_status.get("last_access_time") or play_status.get("LastPlayTime")
|
||
if isinstance(last_play_time, (int, float)) and last_play_time > 0:
|
||
user_state.last_played_date = str(int(last_play_time))
|
||
|
||
tmdb_id = video_info.get("tmdb_id")
|
||
if not isinstance(tmdb_id, int) or tmdb_id <= 0:
|
||
tmdb_id = None
|
||
|
||
item_id = video_info.get("ug_video_info_id")
|
||
if item_id is None:
|
||
return None
|
||
|
||
return _SchemaMediaServerItem(
|
||
server="ugreen",
|
||
library=video_info.get("media_lib_set_id"),
|
||
item_id=str(item_id),
|
||
item_type=Ugreen.__map_item_type(video_info.get("type")),
|
||
title=video_info.get("name"),
|
||
original_title=video_info.get("original_name"),
|
||
year=Ugreen.__parse_year(video_info),
|
||
media_source=MediaSource.TMDB if tmdb_id else None,
|
||
media_id=str(tmdb_id) if tmdb_id else None,
|
||
user_state=user_state,
|
||
)
|
||
|
||
def __build_root_url(self) -> str:
|
||
"""
|
||
统一返回 NAS Web 根地址作为跳转链接,避免失效深链。
|
||
"""
|
||
host = self._playhost or (
|
||
self._media_api.host
|
||
if self._media_api
|
||
else self._api.host
|
||
if self._api
|
||
else ""
|
||
)
|
||
if not host:
|
||
return ""
|
||
return f"{host.rstrip('/')}/"
|
||
|
||
def __build_play_url(self, item_id: Union[str, int], video_type: Any, media_lib_set_id: Any) -> str:
|
||
"""
|
||
按连接模式生成播放跳转地址。
|
||
|
||
:param item_id: 媒体库服务的条目标识
|
||
:param video_type: 兼容内部调用的类型参数,当前不参与链接构造
|
||
:param media_lib_set_id: 兼容内部调用的媒体库标识,当前不参与链接构造
|
||
:return: 媒体库模式返回条目网页链接;内部模式因深链不稳定回退 NAS 根地址
|
||
"""
|
||
if self.__is_media_service_mode() and self._media_api:
|
||
host = self._playhost or self._media_api.host
|
||
server_id = self._media_api.server_id
|
||
server_query = f"&serverId={server_id}" if server_id else ""
|
||
return f"{host.rstrip('/')}/web/index.html#!/item?id={item_id}&context=home{server_query}"
|
||
# 绿联深链在部分版本会失效,统一回落到 NAS 根地址。
|
||
return self.__build_root_url()
|
||
|
||
def __format_media_service_item(
|
||
self,
|
||
item: Mapping[str, Any],
|
||
) -> Optional[_SchemaMediaServerItem]:
|
||
"""
|
||
把媒体库服务返回的 Emby 条目转换为绿联媒体条目模型。
|
||
"""
|
||
if not isinstance(item, Mapping):
|
||
return None
|
||
media_item = format_emby_family_item(
|
||
item,
|
||
server="ugreen",
|
||
include_server_id=True,
|
||
)
|
||
if media_item and self._media_api and not media_item.server_id:
|
||
media_item.server_id = self._media_api.server_id
|
||
return media_item
|
||
|
||
@staticmethod
|
||
def __media_service_library_type(collection_type: Any) -> str:
|
||
"""
|
||
把 Emby 媒体库集合类型转换为 MoviePilot 媒体类型。
|
||
"""
|
||
normalized_type = str(collection_type or "").strip().lower()
|
||
if normalized_type == "movies":
|
||
return MediaType.MOVIE.value
|
||
if normalized_type in {"tvshows", "tv", "series"}:
|
||
return MediaType.TV.value
|
||
if normalized_type in {"music", "musicvideos", "audio"}:
|
||
return MediaType.MUSIC.value
|
||
return MediaType.UNKNOWN.value
|
||
|
||
def __build_media_service_library_link(self, library_id: Union[str, int]) -> str:
|
||
"""
|
||
生成媒体库服务媒体库视图的 Emby 网页链接。
|
||
"""
|
||
if not self._media_api:
|
||
return self.__build_root_url()
|
||
host = self._playhost or self._media_api.host
|
||
server_id = self._media_api.server_id
|
||
server_query = f"serverId={server_id}&" if server_id else ""
|
||
return f"{host.rstrip('/')}/web/index.html#!/videos?{server_query}parentId={library_id}"
|
||
|
||
def __get_media_service_librarys(self, hidden: Optional[bool] = False) -> Optional[List[_SchemaMediaServerLibrary]]:
|
||
"""
|
||
获取媒体库服务 Emby 兼容接口返回的媒体库列表。
|
||
|
||
绿联媒体库服务不实现 `GET /Users`,因此直接使用认证响应中的用户 ID
|
||
查询 `Users/{id}/Views`,避免沿用 Emby 适配器的全局用户枚举前置条件。
|
||
"""
|
||
if not self.is_authenticated() or not self._media_api:
|
||
return None
|
||
source_libraries = self._media_api.views()
|
||
if source_libraries is None:
|
||
return None
|
||
|
||
self._libraries = {}
|
||
libraries: List[_SchemaMediaServerLibrary] = []
|
||
for library in source_libraries:
|
||
library_id = str(library.get("Id") or "").strip()
|
||
if not library_id or (hidden and self.__is_library_blocked(library_id)):
|
||
continue
|
||
library_path = library.get("Path") or library.get("Locations")
|
||
library_type = self.__media_service_library_type(library.get("CollectionType"))
|
||
if library_type == MediaType.UNKNOWN.value:
|
||
library_type = self.__infer_library_type(library.get("Name") or "", str(library_path or ""))
|
||
self._libraries[library_id] = {
|
||
"id": library_id,
|
||
"name": library.get("Name") or "",
|
||
"path": library_path,
|
||
"type": library_type,
|
||
"video_count": 0,
|
||
}
|
||
item_count = self.get_items_count(library_id)
|
||
self._libraries[library_id]["video_count"] = item_count or 0
|
||
libraries.append(
|
||
_SchemaMediaServerLibrary(
|
||
server="ugreen",
|
||
id=library_id,
|
||
item_id=library_id,
|
||
server_id=library.get("ServerId") or self._media_api.server_id,
|
||
name=library.get("Name"),
|
||
path=library_path,
|
||
type=library_type,
|
||
item_count=item_count,
|
||
image=self.__build_media_service_image_url(library_id),
|
||
link=self.__build_media_service_library_link(library_id),
|
||
server_type="ugreen",
|
||
use_cookies=False,
|
||
)
|
||
)
|
||
return libraries
|
||
|
||
def __build_media_service_play_item(self, item: Mapping[str, Any]) -> Optional[_SchemaMediaServerPlayItem]:
|
||
"""
|
||
把媒体库服务的继续观看或最近入库条目转换为播放模型。
|
||
"""
|
||
if not self._media_api or not isinstance(item, Mapping):
|
||
return None
|
||
item_id = item.get("Id")
|
||
item_type = item.get("Type")
|
||
if not item_id or item_type not in {"Movie", "Series", "Episode"}:
|
||
return None
|
||
|
||
server_id = item.get("ServerId") or self._media_api.server_id
|
||
user_data_value = item.get("UserData")
|
||
user_data: Mapping[str, Any] = user_data_value if isinstance(user_data_value, Mapping) else {}
|
||
subtitle: Optional[str]
|
||
if item_type == "Episode":
|
||
title = item.get("SeriesName") or item.get("Name")
|
||
subtitle = f"S{item.get('ParentIndexNumber')}:{item.get('IndexNumber')} - {item.get('Name')}"
|
||
image_id = item.get("SeriesId") or item_id
|
||
else:
|
||
title = item.get("Name")
|
||
subtitle = str(item.get("ProductionYear")) if item.get("ProductionYear") else None
|
||
image_id = item_id
|
||
image_type = "Backdrop" if item.get("BackdropImageTags") else "Primary"
|
||
item_media_type = MediaType.TV.value if item_type in {"Series", "Episode"} else MediaType.MOVIE.value
|
||
return _SchemaMediaServerPlayItem(
|
||
id=item_id,
|
||
item_id=item_id,
|
||
server_id=server_id,
|
||
title=title,
|
||
subtitle=subtitle,
|
||
type=item_media_type,
|
||
image=self.__build_media_service_image_url(image_id, image_type, remote=True),
|
||
link=self.__build_play_url(item_id, item_type, item.get("ParentId")),
|
||
percent=user_data.get("PlayedPercentage"),
|
||
BackdropImageTags=item.get("BackdropImageTags") or [],
|
||
server_type="ugreen",
|
||
use_cookies=False,
|
||
)
|
||
|
||
def __build_media_service_image_url(
|
||
self,
|
||
item_id: Optional[Union[str, int]],
|
||
image_type: str = "Primary",
|
||
remote: bool = False,
|
||
) -> Optional[str]:
|
||
"""
|
||
生成媒体库服务条目的图片地址,并在远程场景使用播放地址。
|
||
"""
|
||
if not self._media_api or not item_id:
|
||
return None
|
||
image_url = self._media_api.image_url(item_id, image_type)
|
||
if remote and image_url and self._playhost:
|
||
return image_url.replace(
|
||
f"{self._media_api.host}/",
|
||
f"{self._playhost.rstrip('/')}/",
|
||
1,
|
||
)
|
||
return image_url
|
||
|
||
def __build_play_item_from_wrapper(self, wrapper: dict[str, Any]) -> Optional[_SchemaMediaServerPlayItem]:
|
||
"""
|
||
将内部接口的视频与播放状态包装转换为仪表盘条目。
|
||
|
||
:param wrapper: 包含 video_info、play_status 的包装,或直接传入视频信息字典
|
||
:return: 播放条目;缺少视频标识时返回 None,进度转换并限制在 0 至 100 之间
|
||
"""
|
||
video_info_value = wrapper.get("video_info")
|
||
video_info = video_info_value if isinstance(video_info_value, dict) else wrapper
|
||
if not isinstance(video_info, dict):
|
||
return None
|
||
|
||
item_id = video_info.get("ug_video_info_id")
|
||
if item_id is None:
|
||
return None
|
||
|
||
play_status_value = wrapper.get("play_status")
|
||
play_status = play_status_value if isinstance(play_status_value, dict) else {}
|
||
progress_value = play_status.get("progress")
|
||
progress = progress_value if isinstance(progress_value, (int, float)) else 0
|
||
|
||
if video_info.get("type") == 2:
|
||
subtitle = play_status.get("tv_name") or "剧集"
|
||
media_type = MediaType.TV.value
|
||
else:
|
||
subtitle = "电影" if video_info.get("type") == 1 else "视频"
|
||
media_type = MediaType.MOVIE.value
|
||
|
||
image = self.__resolve_image(video_info.get("poster_path")) or self.__resolve_image(
|
||
video_info.get("backdrop_path")
|
||
)
|
||
|
||
return _SchemaMediaServerPlayItem(
|
||
id=str(item_id),
|
||
title=video_info.get("name"),
|
||
subtitle=subtitle,
|
||
type=media_type,
|
||
image=image,
|
||
link=self.__build_play_url(item_id, video_info.get("type"), video_info.get("media_lib_set_id")),
|
||
percent=max(0.0, min(100.0, progress * 100.0)),
|
||
server_type="ugreen",
|
||
use_cookies=False,
|
||
)
|
||
|
||
@staticmethod
|
||
def __infer_library_type(name: str, path: Optional[str]) -> str:
|
||
"""
|
||
在接口缺少媒体库类型时按名称与目录关键词推断。
|
||
|
||
:param name: 媒体库名称
|
||
:param path: 可选媒体库目录
|
||
:return: 优先判定剧集,再判定电影与音乐;无匹配关键词时返回未知类型
|
||
"""
|
||
name = name or ""
|
||
path = path or ""
|
||
if "电视剧" in path or any(key in name for key in ["剧", "综艺", "动漫", "纪录片"]):
|
||
return MediaType.TV.value
|
||
if "电影" in path or "电影" in name:
|
||
return MediaType.MOVIE.value
|
||
if any(key in name.lower() for key in ["music", "audio", "音乐"]):
|
||
return MediaType.MUSIC.value
|
||
return MediaType.UNKNOWN.value
|
||
|
||
def __is_library_blocked(self, library_id: str) -> bool:
|
||
"""
|
||
判断媒体库是否被当前同步范围排除。
|
||
|
||
:param library_id: 媒体库标识,比较时转换为字符串
|
||
:return: 非空同步列表不含 all 且未选中该库时返回 True
|
||
"""
|
||
return (
|
||
True
|
||
if (
|
||
self._sync_libraries
|
||
and "all" not in self._sync_libraries
|
||
and str(library_id) not in self._sync_libraries
|
||
)
|
||
else False
|
||
)
|
||
|
||
@staticmethod
|
||
def __resolve_scan_type(
|
||
scan_mode: Optional[Union[str, int]] = None,
|
||
scan_type: Optional[Union[str, int]] = None,
|
||
) -> int:
|
||
"""
|
||
解析绿联扫描模式并转为 `media_lib_scan_type`。
|
||
|
||
支持值:
|
||
- 1 / new_and_modified: 新添加和修改
|
||
- 2 / supplement_missing: 补充缺失
|
||
- 3 / full_override: 覆盖扫描
|
||
"""
|
||
# 优先使用显式 scan_type 数值配置。
|
||
for value in (scan_type, scan_mode):
|
||
try:
|
||
# None 等非法输入由紧邻的异常分支处理,兼容旧配置值类型。
|
||
parsed = int(value) # type: ignore[arg-type]
|
||
if parsed in (1, 2, 3):
|
||
return parsed
|
||
except Exception:
|
||
pass
|
||
|
||
mode = str(scan_mode or "").strip().lower()
|
||
mode_map = {
|
||
"new_and_modified": 1,
|
||
"new_modified": 1,
|
||
"add": 1,
|
||
"added": 1,
|
||
"new": 1,
|
||
"scan_new_modified": 1,
|
||
"supplement_missing": 2,
|
||
"supplement": 2,
|
||
"additional": 2,
|
||
"missing": 2,
|
||
"scan_missing": 2,
|
||
"full_override": 3,
|
||
"override": 3,
|
||
"cover": 3,
|
||
"replace": 3,
|
||
"scan_override": 3,
|
||
}
|
||
return mode_map.get(mode, 2)
|
||
|
||
@staticmethod
|
||
def __resolve_verify_ssl(verify_ssl: Optional[Union[bool, str, int]]) -> bool:
|
||
"""
|
||
解析证书验证开关,未知配置默认启用验证。
|
||
|
||
:param verify_ssl: 布尔值或 1/0、true/false、yes/no、on/off 文本
|
||
:return: 规范布尔开关,None 或无法识别的文本返回 True
|
||
"""
|
||
if isinstance(verify_ssl, bool):
|
||
return verify_ssl
|
||
if verify_ssl is None:
|
||
return True
|
||
value = str(verify_ssl).strip().lower()
|
||
if value in {"1", "true", "yes", "on"}:
|
||
return True
|
||
if value in {"0", "false", "no", "off"}:
|
||
return False
|
||
return True
|
||
|
||
def __scan_library(self, library_id: str, scan_type: Optional[int] = None) -> bool:
|
||
"""
|
||
向内部接口发送单个媒体库的扫描请求。
|
||
|
||
:param library_id: 媒体库集合标识
|
||
:param scan_type: 扫描类型,空值使用实例配置
|
||
:return: 请求业务成功时返回 True;内部 API 不存在或请求失败时返回 False,不等待扫描完成
|
||
"""
|
||
if not self._api:
|
||
return False
|
||
return self._api.scan(
|
||
media_lib_set_id=library_id,
|
||
scan_type=scan_type or self._scan_type,
|
||
op_type=2,
|
||
)
|
||
|
||
def __load_library_paths(self) -> dict[str, str]:
|
||
"""
|
||
分页收集内部接口的媒体库目录,限制最多 200 页。
|
||
|
||
:return: 媒体库标识到路径的映射;接口失败保留此前结果,达到页数上限时记录警告并停止
|
||
"""
|
||
if not self._api:
|
||
return {}
|
||
|
||
paths: dict[str, str] = {}
|
||
page = 1
|
||
while page <= self.LIBRARY_PATH_PAGE_LIMIT:
|
||
data = self._api.poster_wall_get_folder(page=page, page_size=100)
|
||
if not data:
|
||
break
|
||
|
||
for folder in data.get("folder_arr") or []:
|
||
lib_id = folder.get("media_lib_set_id")
|
||
lib_path = folder.get("path")
|
||
if lib_id is not None and lib_path:
|
||
paths[str(lib_id)] = str(lib_path)
|
||
|
||
if data.get("is_last_page"):
|
||
break
|
||
page += 1
|
||
|
||
if page > self.LIBRARY_PATH_PAGE_LIMIT:
|
||
# 部分固件分页标志异常时会无限返回下一页,这里加硬限制避免阻塞调用方。
|
||
logger.warning(
|
||
f"绿联影视 {self._username} 媒体库目录分页超过上限 {self.LIBRARY_PATH_PAGE_LIMIT} 页,停止继续加载"
|
||
)
|
||
|
||
return paths
|
||
|
||
def get_librarys(self, hidden: Optional[bool] = False) -> Optional[List[_SchemaMediaServerLibrary]]:
|
||
"""
|
||
查询当前连接模式的媒体库,并刷新内存中的库及路径缓存。
|
||
|
||
:param hidden: 是否过滤同步范围外的媒体库,默认不过滤
|
||
:return: 统一媒体库列表;未认证或查询失败时返回 None
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
return self.__get_media_service_librarys(hidden=hidden)
|
||
if not self.is_authenticated() or not self._api:
|
||
return None
|
||
|
||
media_libs = self._api.media_list()
|
||
if media_libs is None:
|
||
return None
|
||
self._library_paths = self.__load_library_paths()
|
||
libraries = []
|
||
self._libraries = {}
|
||
|
||
for lib in media_libs:
|
||
lib_id = str(lib.get("media_lib_set_id"))
|
||
if hidden and self.__is_library_blocked(lib_id):
|
||
continue
|
||
|
||
lib_name = lib.get("media_name") or ""
|
||
lib_path = self._library_paths.get(lib_id)
|
||
library_type = self.__infer_library_type(lib_name, lib_path)
|
||
|
||
poster_paths = lib.get("poster_paths") or []
|
||
backdrop_paths = lib.get("backdrop_paths") or []
|
||
image_list = list(
|
||
filter(
|
||
None,
|
||
[self.__resolve_image(p) for p in [*poster_paths, *backdrop_paths]],
|
||
)
|
||
)
|
||
|
||
self._libraries[lib_id] = {
|
||
"id": lib_id,
|
||
"name": lib_name,
|
||
"path": lib_path,
|
||
"type": library_type,
|
||
"video_count": lib.get("video_count") or 0,
|
||
}
|
||
|
||
libraries.append(
|
||
_SchemaMediaServerLibrary(
|
||
server="ugreen",
|
||
id=lib_id,
|
||
name=lib_name,
|
||
type=library_type,
|
||
path=lib_path,
|
||
item_count=lib.get("video_count") or 0,
|
||
image_list=image_list,
|
||
link=self.__build_root_url(),
|
||
server_type="ugreen",
|
||
use_cookies=False,
|
||
)
|
||
)
|
||
|
||
return libraries
|
||
|
||
def get_user_count(self) -> int:
|
||
"""
|
||
查询媒体库用户数量。
|
||
|
||
:return: 内部模式返回接口用户数;媒体库服务不支持枚举,已认证时记 1、否则记 0
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
# 媒体库服务不开放用户枚举,当前实例只代表一个已认证媒体库账号。
|
||
return 1 if self.is_authenticated() else 0
|
||
if not self.is_authenticated() or not self._api:
|
||
return 0
|
||
users = self._api.media_lib_users()
|
||
return len(users)
|
||
|
||
def get_medias_count(self) -> _SchemaStatistic:
|
||
"""
|
||
查询电影、电视剧和音乐数量,按服务能力提供分集统计。
|
||
|
||
:return: 统一统计对象;未认证或查询失败时对应计数为空或零,内部模式的分集总数固定为 None
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
if not self.is_authenticated() or not self._media_api:
|
||
return _SchemaStatistic()
|
||
counts = self._media_api.counts()
|
||
if not counts:
|
||
return _SchemaStatistic()
|
||
episode_count = counts.get("EpisodeCount")
|
||
return _SchemaStatistic(
|
||
movie_count=int(counts.get("MovieCount") or 0),
|
||
tv_count=int(counts.get("SeriesCount") or 0),
|
||
episode_count=(int(episode_count) if episode_count is not None else None),
|
||
music_count=int(
|
||
counts.get("MusicAlbumCount") or counts.get("AlbumCount") or counts.get("SongCount") or 0
|
||
),
|
||
)
|
||
if not self.is_authenticated() or not self._api:
|
||
return _SchemaStatistic()
|
||
|
||
movie_data = self._api.video_all(classification=-102, page=1, page_size=1) or {}
|
||
tv_data = self._api.video_all(classification=-103, page=1, page_size=1) or {}
|
||
music_data = self._api.video_all(classification=-104, page=1, page_size=1) or {}
|
||
|
||
return _SchemaStatistic(
|
||
movie_count=int(movie_data.get("total_num") or 0),
|
||
tv_count=int(tv_data.get("total_num") or 0),
|
||
# 绿联当前不统计剧集总数,返回 None 由前端展示“未获取”。
|
||
episode_count=None,
|
||
music_count=int(music_data.get("total_num") or 0),
|
||
)
|
||
|
||
def authenticate(self, username: str, password: str) -> Optional[str]:
|
||
"""
|
||
使用独立临时客户端验证账号,不替换当前实例会话。
|
||
|
||
媒体库模式验证后关闭临时连接;内部模式还会立即登出,因此返回令牌仅用于认证结果,不能据此长期复用该会话。
|
||
|
||
:param username: 待验证的登录用户名
|
||
:param password: 待验证的密码
|
||
:return: 成功时返回认证令牌,输入不完整或认证失败时返回 None
|
||
"""
|
||
host = self._host
|
||
if not username or not password or not host:
|
||
return None
|
||
|
||
if self.__is_media_service_mode():
|
||
media_api = UgreenMediaApi(host, verify_ssl=self._verify_ssl)
|
||
try:
|
||
session = media_api.authenticate(username, password)
|
||
return session.token if session else None
|
||
finally:
|
||
media_api.close()
|
||
|
||
api = Api(host, verify_ssl=self._verify_ssl)
|
||
try:
|
||
return api.login(username, password)
|
||
finally:
|
||
api.logout()
|
||
api.close()
|
||
|
||
@staticmethod
|
||
def __extract_video_info_list(bucket: object) -> list[dict[str, Any]]:
|
||
"""
|
||
从搜索分类桶的 video_arr 中提取有效 video_info 字典。
|
||
|
||
:param bucket: 按媒体类型分组的搜索结果
|
||
:return: 视频信息字典列表;分组或列表形状不符时返回空列表
|
||
"""
|
||
if not isinstance(bucket, Mapping):
|
||
return []
|
||
video_arr = bucket.get("video_arr")
|
||
if not isinstance(video_arr, list):
|
||
return []
|
||
result = []
|
||
for item in video_arr:
|
||
if not isinstance(item, Mapping):
|
||
continue
|
||
info = item.get("video_info")
|
||
if isinstance(info, Mapping):
|
||
result.append(dict(info))
|
||
return result
|
||
|
||
def __get_media_service_movies(
|
||
self,
|
||
title: str,
|
||
year: Optional[str] = None,
|
||
media_source: Optional[MediaSource] = None,
|
||
media_id: Optional[str] = None,
|
||
) -> Optional[List[_SchemaMediaServerItem]]:
|
||
"""
|
||
通过媒体库服务用户条目接口查找电影。
|
||
"""
|
||
if not self.is_authenticated() or not self._media_api or not title:
|
||
return None
|
||
data = self._media_api.items(
|
||
include_item_types="Movie",
|
||
search_term=title,
|
||
limit=10,
|
||
fields=("ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId"),
|
||
)
|
||
if data is None:
|
||
return None
|
||
|
||
movies: List[_SchemaMediaServerItem] = []
|
||
for item in data.get("Items") or []:
|
||
media_item = self.__format_media_service_item(item)
|
||
if not media_item or not MediaServerIdentityHelper.is_compatible(media_item, media_source, media_id):
|
||
continue
|
||
if title not in [media_item.title, media_item.original_title]:
|
||
continue
|
||
if year and str(media_item.year) != str(year):
|
||
continue
|
||
movies.append(media_item)
|
||
return movies
|
||
|
||
def __get_media_service_music(
|
||
self,
|
||
title: Optional[str] = None,
|
||
artist: Optional[str] = None,
|
||
album: Optional[str] = None,
|
||
) -> List[_SchemaMediaServerItem]:
|
||
"""
|
||
通过媒体库服务用户条目接口查找音乐。
|
||
"""
|
||
if not self.is_authenticated() or not self._media_api:
|
||
return []
|
||
query = " ".join(filter(None, [title, artist, album])).strip()
|
||
if not query:
|
||
return []
|
||
data = self._media_api.items(
|
||
include_item_types="MusicAlbum,Audio",
|
||
search_term=query,
|
||
limit=20,
|
||
fields=(
|
||
"Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount,"
|
||
"ProviderIds,OriginalTitle,ProductionYear,Path,ParentId"
|
||
),
|
||
)
|
||
if not data:
|
||
return []
|
||
results: List[_SchemaMediaServerItem] = []
|
||
for item in data.get("Items") or []:
|
||
media_item = self.__format_media_service_item(item)
|
||
if media_item:
|
||
results.append(media_item)
|
||
return results
|
||
|
||
def __search_media_service_tv_item(
|
||
self,
|
||
title: str,
|
||
year: Optional[str] = None,
|
||
media_source: Optional[MediaSource] = None,
|
||
media_id: Optional[str] = None,
|
||
) -> Optional[_SchemaMediaServerItem]:
|
||
"""
|
||
通过媒体库服务用户条目接口查找电视剧。
|
||
"""
|
||
if not self.is_authenticated() or not self._media_api or not title:
|
||
return None
|
||
data = self._media_api.items(
|
||
include_item_types="Series",
|
||
search_term=title,
|
||
limit=10,
|
||
fields="ProviderIds,OriginalTitle,ProductionYear,Path,ParentId",
|
||
)
|
||
if not data:
|
||
return None
|
||
for item in data.get("Items") or []:
|
||
media_item = self.__format_media_service_item(item)
|
||
if not media_item or not MediaServerIdentityHelper.is_compatible(media_item, media_source, media_id):
|
||
continue
|
||
if title not in [media_item.title, media_item.original_title]:
|
||
continue
|
||
if year and str(media_item.year) != str(year):
|
||
continue
|
||
return media_item
|
||
return None
|
||
|
||
def __get_media_service_tv_episodes(
|
||
self,
|
||
item_id: Optional[str] = None,
|
||
title: Optional[str] = None,
|
||
year: Optional[str] = None,
|
||
media_source: Optional[MediaSource] = None,
|
||
media_id: Optional[str] = None,
|
||
season: Optional[int] = None,
|
||
) -> tuple[Optional[str], Optional[Dict[int, List[int]]]]:
|
||
"""
|
||
通过媒体库服务的 Shows/Episodes 接口查询已入库剧集。
|
||
"""
|
||
if not self.is_authenticated() or not self._media_api:
|
||
return None, None
|
||
cached_item_id = item_id
|
||
if not item_id:
|
||
if not title:
|
||
return None, None
|
||
tv_item = self.__search_media_service_tv_item(title, year, media_source, media_id)
|
||
if not tv_item or not tv_item.item_id:
|
||
return None, None
|
||
item_id = tv_item.item_id
|
||
item_id = str(item_id)
|
||
|
||
item_info = self.get_iteminfo(item_id)
|
||
if not item_info and cached_item_id and title:
|
||
logger.warning(f"绿联媒体库服务缓存的电视剧媒体ID {cached_item_id} 已失效,尝试按标题重新搜索:{title}")
|
||
tv_item = self.__search_media_service_tv_item(title, year, media_source, media_id)
|
||
if not tv_item or not tv_item.item_id:
|
||
return None, {}
|
||
item_id = str(tv_item.item_id)
|
||
item_info = self.get_iteminfo(item_id)
|
||
if not item_info:
|
||
return None, {}
|
||
if not MediaServerIdentityHelper.is_compatible(item_info, media_source, media_id):
|
||
return None, {}
|
||
|
||
episodes = self._media_api.episodes(item_id, season=season)
|
||
if episodes is None:
|
||
return None, None
|
||
season_episodes: Dict[int, List[int]] = {}
|
||
for episode in episodes:
|
||
season_number = episode.get("ParentIndexNumber")
|
||
episode_number = episode.get("IndexNumber")
|
||
if not isinstance(season_number, int) or not isinstance(episode_number, int):
|
||
continue
|
||
season_episodes.setdefault(season_number, []).append(episode_number)
|
||
for season_number in list(season_episodes):
|
||
season_episodes[season_number] = sorted(set(season_episodes[season_number]))
|
||
return item_id, season_episodes
|
||
|
||
def __get_media_service_latest_items(self, num: int = 20) -> Optional[List[dict[str, Any]]]:
|
||
"""
|
||
按创建时间获取媒体库服务中的最近电影和剧集条目。
|
||
"""
|
||
if not self.is_authenticated() or not self._media_api:
|
||
return None
|
||
page_size = max(1, num)
|
||
data = self._media_api.items(
|
||
include_item_types="Movie,Series",
|
||
limit=page_size,
|
||
sort_by="DateCreated",
|
||
sort_order="Descending",
|
||
fields=(
|
||
"ProviderIds,OriginalTitle,ProductionYear,Path,ParentId,"
|
||
"BackdropImageTags,UserDataPlayCount,UserDataLastPlayedDate"
|
||
),
|
||
)
|
||
if data is None:
|
||
return None
|
||
return [dict(item) for item in data.get("Items") or [] if isinstance(item, Mapping)]
|
||
|
||
def get_movies(
|
||
self, title: str, year: Optional[str] = None,
|
||
media_source: Optional[MediaSource] = None,
|
||
media_id: Optional[str] = None,
|
||
) -> Optional[List[_SchemaMediaServerItem]]:
|
||
"""
|
||
按连接模式搜索电影,并核对名称、年份及来源身份。
|
||
|
||
:param title: 电影标题,内部模式同时匹配名称与原始名称
|
||
:param year: 可选年份,空值不限制年份
|
||
:param media_source: 用于排除身份冲突的来源
|
||
:param media_id: 对应来源内的媒体标识
|
||
:return: 匹配的统一电影列表;未认证或标题为空时返回 None,无结果时返回空列表
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
return self.__get_media_service_movies(title, year, media_source, media_id)
|
||
if not self.is_authenticated() or not self._api or not title:
|
||
return None
|
||
|
||
data = self._api.search(title)
|
||
if not data:
|
||
return []
|
||
|
||
movies = []
|
||
for info in self.__extract_video_info_list(data.get("movies_list")):
|
||
info_tmdb = info.get("tmdb_id")
|
||
if not MediaServerIdentityHelper.are_compatible(
|
||
MediaSource.TMDB if info_tmdb else None,
|
||
str(info_tmdb) if info_tmdb else None,
|
||
media_source,
|
||
media_id,
|
||
):
|
||
continue
|
||
if title not in [info.get("name"), info.get("original_name")]:
|
||
continue
|
||
item_year = info.get("year")
|
||
if year and str(item_year) != str(year):
|
||
continue
|
||
media_item = self.__build_media_server_item(info)
|
||
if media_item:
|
||
movies.append(media_item)
|
||
return movies
|
||
|
||
def get_music(
|
||
self,
|
||
title: Optional[str] = None,
|
||
artist: Optional[str] = None,
|
||
album: Optional[str] = None,
|
||
) -> List[_SchemaMediaServerItem]:
|
||
"""
|
||
按专辑、歌曲或艺术家名称查询音乐候选。
|
||
|
||
:param title: 歌曲标题
|
||
:param artist: 艺术家名称
|
||
:param album: 专辑名称,内部模式优先作为搜索词
|
||
:return: 统一音乐候选列表;无查询词、未认证或请求失败时返回空列表
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
return self.__get_media_service_music(title, artist, album)
|
||
if not self.is_authenticated() or not self._api:
|
||
return []
|
||
query = album or title or artist
|
||
if not query:
|
||
return []
|
||
|
||
data = self._api.search(query)
|
||
if not data:
|
||
return []
|
||
|
||
# 绿联搜索按媒体类型分桶返回,音乐相关桶名在不同固件版本上并不统一
|
||
music_buckets = ("music_list", "audio_list", "album_list", "songs_list")
|
||
results: List[_SchemaMediaServerItem] = []
|
||
for bucket in music_buckets:
|
||
for info in self.__extract_video_info_list(data.get(bucket)):
|
||
media_item = self.__build_media_server_item(info)
|
||
if media_item:
|
||
media_item.note = MusicMediaServerHelper.build_note(info)
|
||
results.append(media_item)
|
||
return results
|
||
|
||
def __search_tv_item(
|
||
self, title: str, year: Optional[str] = None,
|
||
media_source: Optional[MediaSource] = None,
|
||
media_id: Optional[str] = None,
|
||
) -> Optional[dict[str, Any]]:
|
||
"""
|
||
按标题、年份与统一媒体身份查找电视剧条目。
|
||
"""
|
||
if not self._api:
|
||
return None
|
||
data = self._api.search(title)
|
||
if not data:
|
||
return None
|
||
|
||
for info in self.__extract_video_info_list(data.get("tv_list")):
|
||
info_tmdb = info.get("tmdb_id")
|
||
if not MediaServerIdentityHelper.are_compatible(
|
||
MediaSource.TMDB if info_tmdb else None,
|
||
str(info_tmdb) if info_tmdb else None,
|
||
media_source,
|
||
media_id,
|
||
):
|
||
continue
|
||
if title not in [info.get("name"), info.get("original_name")]:
|
||
continue
|
||
item_year = info.get("year")
|
||
if year and str(item_year) != str(year):
|
||
continue
|
||
return info
|
||
return None
|
||
|
||
def get_tv_episodes(
|
||
self,
|
||
item_id: Optional[str] = None,
|
||
title: Optional[str] = None,
|
||
year: Optional[str] = None,
|
||
media_source: Optional[MediaSource] = None,
|
||
media_id: Optional[str] = None,
|
||
season: Optional[int] = None,
|
||
) -> tuple[Optional[str], Optional[Dict[int, list[int]]]]:
|
||
"""
|
||
根据标题、年份、媒体身份和季号查询绿联媒体库中的电视剧已入库集数。
|
||
|
||
:param item_id: 绿联媒体库中的剧集ID,存在缓存ID时优先使用
|
||
:param title: 标题
|
||
:param year: 年份
|
||
:param media_source: 媒体来源
|
||
:param media_id: 媒体来源原生ID
|
||
:param season: 季号
|
||
:return: 命中的剧集ID及每季已入库集数
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
return self.__get_media_service_tv_episodes(
|
||
item_id=item_id,
|
||
title=title,
|
||
year=year,
|
||
media_source=media_source,
|
||
media_id=media_id,
|
||
season=season,
|
||
)
|
||
if not self.is_authenticated() or not self._api:
|
||
return None, None
|
||
|
||
cached_item_id = item_id
|
||
if not item_id:
|
||
if not title:
|
||
return None, None
|
||
if not (tv_info := self.__search_tv_item(
|
||
title, year, media_source, media_id
|
||
)):
|
||
return None, None
|
||
found_item_id = tv_info.get("ug_video_info_id")
|
||
if found_item_id is None:
|
||
return None, None
|
||
item_id = str(found_item_id)
|
||
else:
|
||
item_id = str(item_id)
|
||
|
||
item_info = self.get_iteminfo(item_id)
|
||
if not item_info and cached_item_id and title:
|
||
# 媒体删除后重新入库会导致缓存ID失效,回退到标题搜索避免误判整部剧缺失。
|
||
logger.warning(f"绿联缓存的电视剧媒体ID {cached_item_id} 已失效,尝试按标题重新搜索:{title}")
|
||
if not (tv_info := self.__search_tv_item(
|
||
title, year, media_source, media_id
|
||
)):
|
||
return None, {}
|
||
found_item_id = tv_info.get("ug_video_info_id")
|
||
if found_item_id is None:
|
||
return None, {}
|
||
item_id = str(found_item_id)
|
||
item_info = self.get_iteminfo(item_id)
|
||
if not item_info:
|
||
return None, {}
|
||
if not MediaServerIdentityHelper.is_compatible(item_info, media_source, media_id):
|
||
return None, {}
|
||
|
||
tv_detail = self._api.get_tv(item_id, folder_path="ALL")
|
||
if not tv_detail:
|
||
return None, {}
|
||
|
||
season_map = {}
|
||
for info in tv_detail.get("season_info") or []:
|
||
if not isinstance(info, dict):
|
||
continue
|
||
category_id = info.get("category_id")
|
||
season_num = info.get("season_num")
|
||
if category_id and isinstance(season_num, int):
|
||
season_map[str(category_id)] = season_num
|
||
|
||
season_episodes: Dict[int, list[int]] = {}
|
||
for ep in tv_detail.get("tv_info") or []:
|
||
if not isinstance(ep, dict):
|
||
continue
|
||
episode = ep.get("episode")
|
||
if not isinstance(episode, int):
|
||
continue
|
||
season_num = season_map.get(str(ep.get("category_id")), 1)
|
||
if season is not None and season_num != season:
|
||
continue
|
||
season_episodes.setdefault(season_num, []).append(episode)
|
||
|
||
for season_num in list(season_episodes.keys()):
|
||
season_episodes[season_num] = sorted(set(season_episodes[season_num]))
|
||
|
||
return item_id, season_episodes
|
||
|
||
def refresh_root_library(self, scan_mode: Optional[Union[str, int]] = None) -> Optional[bool]:
|
||
"""
|
||
请求刷新缓存中的全部媒体库;媒体库服务模式暂不支持。
|
||
|
||
未加载媒体库时先查询目录;只等待扫描请求被接受,不等待扫描结束。
|
||
|
||
:param scan_mode: 本次扫描模式,省略时使用实例配置
|
||
:return: 全部请求成功或没有媒体库时返回 True;未认证时返回 None,媒体库服务模式返回 False
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
if not self.is_authenticated():
|
||
return None
|
||
logger.debug("绿联媒体库服务未实现媒体库刷新接口,跳过刷新")
|
||
return False
|
||
if not self.is_authenticated() or not self._api:
|
||
return None
|
||
|
||
if not self._libraries:
|
||
self.get_librarys()
|
||
|
||
scan_type = (
|
||
self.__resolve_scan_type(scan_mode=scan_mode)
|
||
if scan_mode is not None
|
||
else self._scan_type
|
||
)
|
||
results = []
|
||
for lib_id in self._libraries.keys():
|
||
logger.info(
|
||
f"刷新媒体库:{self._libraries[lib_id].get('name')}(扫描模式: {scan_type})"
|
||
)
|
||
results.append(self.__scan_library(library_id=lib_id, scan_type=scan_type))
|
||
|
||
return all(results) if results else True
|
||
|
||
def __match_library_id_by_path(self, path: Optional[Path]) -> Optional[str]:
|
||
"""
|
||
按目录层级查找目标路径所属的第一个媒体库。
|
||
|
||
:param path: 媒体整理后的目标路径
|
||
:return: 匹配的媒体库标识;路径为空或没有匹配时返回 None,路径缓存为空时先查询媒体库
|
||
"""
|
||
if path is None:
|
||
return None
|
||
|
||
path_str = self.__normalize_dir_path(path)
|
||
if not self._library_paths:
|
||
self.get_librarys()
|
||
|
||
for lib_id, lib_path in self._library_paths.items():
|
||
if self.__is_subpath(path_str, lib_path):
|
||
return lib_id
|
||
return None
|
||
|
||
def refresh_library_by_items(
|
||
self,
|
||
items: List[_SchemaRefreshMediaItem],
|
||
scan_mode: Optional[Union[str, int]] = None,
|
||
) -> Optional[bool]:
|
||
"""
|
||
合并媒体目标路径所属的库,并请求扫描。
|
||
|
||
无法定位任一目标或局部扫描失败时回退到刷新全部媒体库;空输入无需扫描并返回 True。
|
||
|
||
:param items: 包含目标路径的媒体刷新列表
|
||
:param scan_mode: 本次扫描模式,省略时使用实例配置
|
||
:return: 刷新结果;未认证时返回 None,媒体库服务模式不支持并返回 False
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
if not self.is_authenticated():
|
||
return None
|
||
logger.debug("绿联媒体库服务未实现媒体库刷新接口,跳过刷新")
|
||
return False
|
||
if not self.is_authenticated() or not self._api:
|
||
return None
|
||
|
||
scan_type = (
|
||
self.__resolve_scan_type(scan_mode=scan_mode)
|
||
if scan_mode is not None
|
||
else self._scan_type
|
||
)
|
||
library_ids = set()
|
||
for item in items:
|
||
library_id = self.__match_library_id_by_path(item.target_path)
|
||
if library_id is None:
|
||
return self.refresh_root_library(scan_mode=scan_mode)
|
||
library_ids.add(library_id)
|
||
|
||
for library_id in library_ids:
|
||
lib_name = self._libraries.get(library_id, {}).get("name", library_id)
|
||
logger.info(f"刷新媒体库:{lib_name}(扫描模式: {scan_type})")
|
||
if not self.__scan_library(library_id=library_id, scan_type=scan_type):
|
||
return self.refresh_root_library(scan_mode=scan_mode)
|
||
|
||
return True
|
||
|
||
@staticmethod
|
||
def get_webhook_message(body: Any) -> Optional[_SchemaWebhookEventInfo]:
|
||
"""
|
||
保留统一 Webhook 解析入口,绿联当前不提供事件解析。
|
||
|
||
:param body: 兼容统一接口的请求体,当前不读取
|
||
:return: 始终返回 None
|
||
"""
|
||
return None
|
||
|
||
def get_iteminfo(self, itemid: str) -> Optional[_SchemaMediaServerItem]:
|
||
"""
|
||
按当前连接模式查询单个媒体条目。
|
||
|
||
:param itemid: 对应服务的条目标识
|
||
:return: 统一媒体详情;未认证、标识为空、查询失败或缺少视频身份时返回 None
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
if not self.is_authenticated() or not self._media_api or not itemid:
|
||
return None
|
||
item = self._media_api.item(itemid)
|
||
return self.__format_media_service_item(item) if item else None
|
||
if not self.is_authenticated() or not self._api or not itemid:
|
||
return None
|
||
|
||
info = self._api.recently_played_info(itemid)
|
||
if not info:
|
||
return None
|
||
|
||
video_info = info.get("video_info") if isinstance(info.get("video_info"), dict) else None
|
||
if not video_info or not video_info.get("ug_video_info_id"):
|
||
return None
|
||
|
||
return self.__build_media_server_item(video_info, info.get("play_status"))
|
||
|
||
def _iter_library_videos(
|
||
self, root_path: str, page_size: int = 100,
|
||
) -> Generator[dict[str, Any], None, None]:
|
||
"""
|
||
广度优先分页遍历海报墙目录,最多访问 20000 个不同路径。
|
||
|
||
重复路径只访问一次。任意目录或分页失败都中止,不能将部分结果视为完整媒体库。
|
||
无视频或子目录时,接口列表字段可为 null,按空列表继续遍历。
|
||
|
||
:param root_path: 遍历起始目录
|
||
:param page_size: 每个目录的分页大小,默认 100
|
||
:yields: 服务端 video_arr 中的有效视频字典
|
||
:raises ConnectionError: 目录读取失败或超过完整遍历的路径数量上限
|
||
"""
|
||
if not self._api or not root_path:
|
||
raise ConnectionError("绿联内部服务尚未连接或媒体库路径为空")
|
||
|
||
queue = deque([root_path])
|
||
visited: set[str] = set()
|
||
max_paths = 20000
|
||
|
||
while queue and len(visited) < max_paths:
|
||
current_path = queue.popleft()
|
||
if current_path in visited:
|
||
continue
|
||
visited.add(current_path)
|
||
|
||
page = 1
|
||
while True:
|
||
data = self._api.poster_wall_get_folder(
|
||
path=current_path,
|
||
page=page,
|
||
page_size=page_size,
|
||
sort_type=1,
|
||
order_type=1,
|
||
)
|
||
if data is None:
|
||
raise ConnectionError("读取绿联海报墙目录失败")
|
||
if any(data.get(field) is not None and not isinstance(data[field], list)
|
||
for field in ("video_arr", "folder_arr")):
|
||
raise ConnectionError("绿联海报墙目录响应格式错误")
|
||
if not data:
|
||
break
|
||
|
||
for video in data.get("video_arr") or []:
|
||
if isinstance(video, dict):
|
||
yield video
|
||
|
||
for folder in data.get("folder_arr") or []:
|
||
if not isinstance(folder, dict):
|
||
continue
|
||
sub_path = folder.get("path")
|
||
if sub_path and sub_path not in visited:
|
||
queue.append(str(sub_path))
|
||
|
||
if data.get("is_last_page"):
|
||
break
|
||
page += 1
|
||
if queue:
|
||
raise ConnectionError("绿联媒体库目录数量超过遍历上限")
|
||
|
||
def __iter_media_service_items(
|
||
self,
|
||
parent: Union[str, int],
|
||
start_index: Optional[int] = 0,
|
||
limit: Optional[int] = -1,
|
||
) -> Generator[_SchemaMediaServerItem, None, None]:
|
||
"""
|
||
分页遍历媒体库服务用户可见的电影、剧集和音乐专辑。
|
||
|
||
:raises ConnectionError: 未认证、分页失败或列表结构错误,调用方须保留旧缓存
|
||
"""
|
||
if not self.is_authenticated() or not self._media_api or not parent:
|
||
raise ConnectionError("绿联媒体库服务尚未连接或媒体库标识为空")
|
||
if self.__is_library_blocked(str(parent)):
|
||
return
|
||
|
||
remaining = None if limit in (None, -1) else max(0, limit or 0)
|
||
if remaining == 0:
|
||
return
|
||
offset = max(0, start_index or 0)
|
||
fields = (
|
||
"ProviderIds,OriginalTitle,ProductionYear,Path,ParentId,"
|
||
"BackdropImageTags,Album,AlbumArtist,AlbumArtists,Artists,"
|
||
"ArtistItems,ChildCount,UserDataPlayCount,UserDataLastPlayedDate"
|
||
)
|
||
|
||
while True:
|
||
page_size = min(100, remaining) if remaining is not None else 100
|
||
data = self._media_api.items(
|
||
parent_id=parent,
|
||
include_item_types="Movie,Series,MusicAlbum",
|
||
start_index=offset,
|
||
limit=page_size,
|
||
fields=fields,
|
||
)
|
||
if data is None:
|
||
raise ConnectionError("读取绿联媒体库服务条目失败")
|
||
page_items = _media_service_items(data)
|
||
if not page_items:
|
||
return
|
||
|
||
for raw_item in page_items:
|
||
if not isinstance(raw_item, Mapping):
|
||
continue
|
||
if raw_item.get("Type") not in {"Movie", "Series", "MusicAlbum"}:
|
||
continue
|
||
media_item = self.__format_media_service_item(raw_item)
|
||
if media_item:
|
||
yield media_item
|
||
if remaining is not None:
|
||
remaining -= 1
|
||
if remaining <= 0:
|
||
return
|
||
|
||
offset += len(page_items)
|
||
total_count = data.get("TotalRecordCount")
|
||
try:
|
||
reached_total = total_count is not None and offset >= int(total_count)
|
||
except TypeError, ValueError:
|
||
reached_total = False
|
||
if len(page_items) < page_size or reached_total:
|
||
return
|
||
|
||
def get_items_count(self, parent: Union[str, int]) -> Optional[int]:
|
||
"""
|
||
获取指定媒体库可同步的媒体条目总数。
|
||
|
||
:param parent: 媒体库ID
|
||
:return: 媒体条目总数,查询失败时返回None
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
if not self.is_authenticated() or not self._media_api or not parent:
|
||
return None
|
||
media_library = self._libraries.get(str(parent), {})
|
||
library_type = str(media_library.get("type") or "")
|
||
include_item_types = {
|
||
MediaType.MOVIE.value: "Movie",
|
||
MediaType.TV.value: "Series",
|
||
MediaType.MUSIC.value: "MusicAlbum,Audio",
|
||
}.get(library_type, "Movie,Series,MusicAlbum")
|
||
data = self._media_api.items(
|
||
parent_id=parent,
|
||
include_item_types=include_item_types,
|
||
limit=0,
|
||
)
|
||
if data is None:
|
||
return None
|
||
total_count = data.get("TotalRecordCount")
|
||
try:
|
||
return int(total_count) if total_count is not None else None
|
||
except TypeError, ValueError:
|
||
return None
|
||
if not self.is_authenticated() or not self._api:
|
||
return None
|
||
if not self._libraries:
|
||
self.get_librarys()
|
||
library = self._libraries.get(str(parent))
|
||
if not library:
|
||
return None
|
||
return int(library.get("video_count") or 0)
|
||
|
||
def get_items(
|
||
self,
|
||
parent: Union[str, int],
|
||
start_index: Optional[int] = 0,
|
||
limit: Optional[int] = -1,
|
||
) -> Generator[_SchemaMediaServerItem, None, None]:
|
||
"""
|
||
按连接模式逐项遍历媒体库中可同步的媒体。
|
||
|
||
内部模式按目录遍历并只返回电影、剧集,媒体库模式还支持音乐专辑。
|
||
当前内部模式对 limit=0 仍可能产生首个条目,媒体库模式不产生条目。
|
||
底层读取失败抛出异常,调用方不得清理未读取到的旧缓存。
|
||
|
||
:param parent: 媒体库标识
|
||
:param start_index: 跳过的条目数量,负值或空值按 0 处理
|
||
:param limit: 期望返回的条目上限,None 或 -1 表示不限;调用方应提供正数或不限标记
|
||
:yields: 统一媒体条目
|
||
:raises ConnectionError: 服务未连接、媒体库路径缺失或任意目录分页失败
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
yield from self.__iter_media_service_items(parent, start_index, limit)
|
||
return None
|
||
if not self.is_authenticated() or not self._api:
|
||
raise ConnectionError("绿联内部服务尚未认证")
|
||
|
||
library_id = str(parent)
|
||
if not self._library_paths:
|
||
self.get_librarys()
|
||
|
||
root_path = self._library_paths.get(library_id)
|
||
if not root_path:
|
||
raise ConnectionError("绿联媒体库路径不可用")
|
||
|
||
skip = max(0, start_index or 0)
|
||
remain = -1 if limit is None or limit == -1 else max(0, limit)
|
||
|
||
for video in self._iter_library_videos(root_path=root_path):
|
||
video_type = video.get("type")
|
||
if video_type not in [1, 2]:
|
||
continue
|
||
|
||
if skip > 0:
|
||
skip -= 1
|
||
continue
|
||
|
||
item = self.__build_media_server_item(video)
|
||
if item:
|
||
yield item
|
||
if remain != -1:
|
||
remain -= 1
|
||
if remain <= 0:
|
||
break
|
||
|
||
return None
|
||
|
||
def get_play_url(self, item_id: str) -> Optional[str]:
|
||
"""
|
||
查询条目后生成当前连接模式的网页跳转链接。
|
||
|
||
:param item_id: 对应服务的媒体条目标识
|
||
:return: 媒体库模式返回条目链接,内部模式返回 NAS 根地址;未认证、条目无效或查询失败时返回 None
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
if not self.is_authenticated() or not self._media_api or not item_id:
|
||
return None
|
||
item = self._media_api.item(item_id)
|
||
if not item:
|
||
return None
|
||
return self.__build_play_url(
|
||
item_id=item_id,
|
||
video_type=item.get("Type"),
|
||
media_lib_set_id=item.get("ParentId"),
|
||
)
|
||
if not self.is_authenticated() or not self._api:
|
||
return None
|
||
|
||
info = self._api.recently_played_info(item_id)
|
||
if not info:
|
||
return None
|
||
|
||
video_info = info.get("video_info") if isinstance(info.get("video_info"), dict) else None
|
||
if not video_info:
|
||
return None
|
||
|
||
return self.__build_play_url(
|
||
item_id=item_id,
|
||
video_type=video_info.get("type"),
|
||
media_lib_set_id=video_info.get("media_lib_set_id"),
|
||
)
|
||
|
||
def get_resume(self, num: Optional[int] = 12) -> Optional[List[_SchemaMediaServerPlayItem]]:
|
||
"""
|
||
获取当前账号的继续观看条目。
|
||
|
||
:param num: 期望数量,空值或 0 使用 12,最少请求 1 条
|
||
:return: 播放条目列表;未认证或查询失败时返回 None,内部模式会排除同步范围外的媒体库
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
if not self.is_authenticated() or not self._media_api:
|
||
return None
|
||
page_size = max(1, num or 12)
|
||
items = self._media_api.resume(limit=page_size)
|
||
if items is None:
|
||
return None
|
||
media_resume: List[_SchemaMediaServerPlayItem] = []
|
||
for item in items:
|
||
if len(media_resume) == page_size:
|
||
break
|
||
play_item = self.__build_media_service_play_item(item)
|
||
if play_item:
|
||
media_resume.append(play_item)
|
||
return media_resume
|
||
if not self.is_authenticated() or not self._api:
|
||
return None
|
||
|
||
page_size = max(1, num or 12)
|
||
data = self._api.recently_played(page=1, page_size=page_size)
|
||
if data is None:
|
||
return None
|
||
|
||
ret_resume: list[_SchemaMediaServerPlayItem] = []
|
||
for item in data.get("video_arr") or []:
|
||
if len(ret_resume) == page_size:
|
||
break
|
||
if not isinstance(item, dict):
|
||
continue
|
||
video_info_value = item.get("video_info")
|
||
video_info = video_info_value if isinstance(video_info_value, dict) else {}
|
||
library_id = str(video_info.get("media_lib_set_id") or "")
|
||
if self.__is_library_blocked(library_id):
|
||
continue
|
||
play_item = self.__build_play_item_from_wrapper(item)
|
||
if play_item:
|
||
ret_resume.append(play_item)
|
||
|
||
return ret_resume
|
||
|
||
def get_latest(self, num: int = 20) -> Optional[List[_SchemaMediaServerPlayItem]]:
|
||
"""
|
||
查询最近入库的电影和电视剧。
|
||
|
||
:param num: 期望条目数,最少请求 1 条
|
||
:return: 最新条目列表;未认证或查询失败时返回 None,内部模式会排除同步范围外的媒体库
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
items = self.__get_media_service_latest_items(num=num)
|
||
if items is None:
|
||
return None
|
||
page_size = max(1, num)
|
||
media_latest: List[_SchemaMediaServerPlayItem] = []
|
||
for item in items:
|
||
if len(media_latest) == page_size:
|
||
break
|
||
play_item = self.__build_media_service_play_item(item)
|
||
if play_item:
|
||
media_latest.append(play_item)
|
||
return media_latest
|
||
if not self.is_authenticated() or not self._api:
|
||
return None
|
||
|
||
page_size = max(1, num)
|
||
data = self._api.recently_updated(page=1, page_size=page_size)
|
||
if data is None:
|
||
return None
|
||
|
||
latest: list[_SchemaMediaServerPlayItem] = []
|
||
for item in data.get("video_arr") or []:
|
||
if len(latest) == page_size:
|
||
break
|
||
if not isinstance(item, dict):
|
||
continue
|
||
video_info_value = item.get("video_info")
|
||
video_info = video_info_value if isinstance(video_info_value, dict) else {}
|
||
library_id = str(video_info.get("media_lib_set_id") or "")
|
||
if self.__is_library_blocked(library_id):
|
||
continue
|
||
play_item = self.__build_play_item_from_wrapper(item)
|
||
if play_item:
|
||
latest.append(play_item)
|
||
|
||
return latest
|
||
|
||
def get_latest_backdrops(self, num: int = 20, remote: bool = False) -> Optional[List[str]]:
|
||
"""
|
||
查询最近入库媒体可用的背景图,缺少时回退主封面。
|
||
|
||
:param num: 期望图片数量,默认 20
|
||
:param remote: 媒体库服务模式是否优先使用外网地址;内部模式不使用此参数
|
||
:return: 图片 URL 列表;未认证或媒体库服务查询失败时返回 None,内部模式无查询结果时返回空列表
|
||
"""
|
||
if self.__is_media_service_mode():
|
||
items = self.__get_media_service_latest_items(num=num)
|
||
if items is None:
|
||
return None
|
||
media_images: List[str] = []
|
||
for item in items:
|
||
if len(media_images) == num:
|
||
break
|
||
item_id = item.get("Id")
|
||
if not item_id:
|
||
continue
|
||
image_type = "Backdrop" if item.get("BackdropImageTags") else "Primary"
|
||
image = self.__build_media_service_image_url(item_id, image_type, remote=remote)
|
||
if image:
|
||
media_images.append(image)
|
||
return media_images
|
||
if not self.is_authenticated() or not self._api:
|
||
return None
|
||
|
||
data = self._api.recently_updated(page=1, page_size=max(1, num))
|
||
if not data:
|
||
return []
|
||
|
||
images: List[str] = []
|
||
for item in data.get("video_arr") or []:
|
||
if len(images) == num:
|
||
break
|
||
if not isinstance(item, dict):
|
||
continue
|
||
|
||
video_info_value = item.get("video_info")
|
||
video_info = video_info_value if isinstance(video_info_value, dict) else {}
|
||
video_type = video_info.get("type") if isinstance(video_info, dict) else None
|
||
if video_type != 1 and video_type != 2:
|
||
continue
|
||
library_id = str(video_info.get("media_lib_set_id") or "")
|
||
if self.__is_library_blocked(library_id):
|
||
continue
|
||
|
||
image = self.__resolve_image(video_info.get("backdrop_path")) or self.__resolve_image(
|
||
video_info.get("poster_path")
|
||
)
|
||
if image:
|
||
images.append(image)
|
||
|
||
return images
|
||
|
||
@staticmethod
|
||
def get_image_cookies(image_url: str) -> None:
|
||
# 绿联图片流接口依赖加密鉴权头,当前图片代理仅支持Cookie注入。
|
||
"""
|
||
保留图片 Cookie 接口;绿联图片认证不通过 Cookie 提供。
|
||
|
||
:param image_url: 兼容统一接口的图片地址,当前不读取
|
||
:return: 始终返回 None
|
||
"""
|
||
return None
|