mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-03-20 03:57:30 +08:00
967 lines
33 KiB
Python
967 lines
33 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 import schemas
|
||
from app.db.systemconfig_oper import SystemConfigOper
|
||
from app.log import logger
|
||
from app.modules.ugreen.api import Api
|
||
from app.schemas import MediaType
|
||
from app.schemas.types import SystemConfigKey
|
||
from app.utils.url import UrlUtils
|
||
|
||
|
||
class Ugreen:
|
||
_username: Optional[str] = None
|
||
_password: Optional[str] = None
|
||
|
||
_userinfo: Optional[dict] = None
|
||
_host: Optional[str] = None
|
||
_playhost: Optional[str] = None
|
||
|
||
_libraries: dict[str, dict] = {}
|
||
_library_paths: dict[str, str] = {}
|
||
_sync_libraries: List[str] = []
|
||
_scan_type: int = 2
|
||
_verify_ssl: bool = True
|
||
|
||
_api: Optional[Api] = 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] = None,
|
||
scan_mode: Optional[Union[str, int]] = None,
|
||
scan_type: Optional[Union[str, int]] = None,
|
||
verify_ssl: Optional[Union[bool, str, int]] = True,
|
||
**kwargs,
|
||
):
|
||
if not host or not username or not password:
|
||
logger.error("绿联影视配置不完整!!")
|
||
return
|
||
|
||
self._host = host
|
||
self._username = username
|
||
self._password = password
|
||
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 self._api
|
||
|
||
def close(self):
|
||
self.disconnect()
|
||
|
||
def is_configured(self) -> bool:
|
||
return bool(self._host and self._username and self._password)
|
||
|
||
def is_authenticated(self) -> bool:
|
||
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:
|
||
if not self.is_authenticated():
|
||
return True
|
||
self._userinfo = self._api.current_user() if self._api else None
|
||
return self._userinfo is None
|
||
|
||
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}"
|
||
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:
|
||
sessions = SystemConfigOper().get(SystemConfigKey.UgreenSessionCache)
|
||
return sessions if isinstance(sessions, dict) else {}
|
||
|
||
@staticmethod
|
||
def __save_all_session_cache(sessions: dict):
|
||
SystemConfigOper().set(SystemConfigKey.UgreenSessionCache, sessions)
|
||
|
||
def __remove_persisted_session(self):
|
||
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):
|
||
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) -> bool:
|
||
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=self._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:
|
||
if not self.is_configured():
|
||
return False
|
||
|
||
# 关闭旧连接(不主动登出,避免破坏可复用会话)
|
||
self.disconnect(logout=False)
|
||
|
||
if self.__restore_persisted_session():
|
||
self.get_librarys()
|
||
return True
|
||
|
||
self._api = Api(host=self._host, verify_ssl=self._verify_ssl)
|
||
if self._api.login(self._username, self._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} 成功登录绿联影视")
|
||
self.get_librarys()
|
||
return True
|
||
|
||
def disconnect(self, logout: bool = False):
|
||
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} 已断开绿联影视")
|
||
|
||
@staticmethod
|
||
def __normalize_dir_path(path: Union[str, Path, None]) -> str:
|
||
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:
|
||
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]:
|
||
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) -> Optional[Union[str, int]]:
|
||
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]:
|
||
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, play_status: Optional[dict] = None):
|
||
user_state = schemas.MediaServerItemUserState()
|
||
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 schemas.MediaServerItem(
|
||
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),
|
||
tmdbid=tmdb_id,
|
||
user_state=user_state,
|
||
)
|
||
|
||
def __build_root_url(self) -> str:
|
||
"""
|
||
统一返回 NAS Web 根地址作为跳转链接,避免失效深链。
|
||
"""
|
||
host = self._playhost or (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:
|
||
# 绿联深链在部分版本会失效,统一回落到 NAS 根地址。
|
||
return self.__build_root_url()
|
||
|
||
def __build_play_item_from_wrapper(self, wrapper: dict) -> Optional[schemas.MediaServerPlayItem]:
|
||
video_info = wrapper.get("video_info") if isinstance(wrapper.get("video_info"), 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 = wrapper.get("play_status") if isinstance(wrapper.get("play_status"), dict) else {}
|
||
progress = play_status.get("progress") if isinstance(play_status.get("progress"), (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 schemas.MediaServerPlayItem(
|
||
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:
|
||
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
|
||
return MediaType.UNKNOWN.value
|
||
|
||
def __is_library_blocked(self, library_id: str) -> bool:
|
||
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:
|
||
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:
|
||
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:
|
||
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]:
|
||
if not self._api:
|
||
return {}
|
||
|
||
paths: dict[str, str] = {}
|
||
page = 1
|
||
while True:
|
||
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
|
||
|
||
return paths
|
||
|
||
def get_librarys(self, hidden: Optional[bool] = False) -> List[schemas.MediaServerLibrary]:
|
||
if not self.is_authenticated() or not self._api:
|
||
return []
|
||
|
||
media_libs = self._api.media_list()
|
||
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(
|
||
schemas.MediaServerLibrary(
|
||
server="ugreen",
|
||
id=lib_id,
|
||
name=lib_name,
|
||
type=library_type,
|
||
path=lib_path,
|
||
image_list=image_list,
|
||
link=self.__build_root_url(),
|
||
server_type="ugreen",
|
||
use_cookies=False,
|
||
)
|
||
)
|
||
|
||
return libraries
|
||
|
||
def get_user_count(self) -> int:
|
||
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) -> schemas.Statistic:
|
||
if not self.is_authenticated() or not self._api:
|
||
return schemas.Statistic()
|
||
|
||
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 {}
|
||
|
||
return schemas.Statistic(
|
||
movie_count=int(movie_data.get("total_num") or 0),
|
||
tv_count=int(tv_data.get("total_num") or 0),
|
||
# 绿联当前不统计剧集总数,返回 None 由前端展示“未获取”。
|
||
episode_count=None,
|
||
)
|
||
|
||
def authenticate(self, username: str, password: str) -> Optional[str]:
|
||
if not username or not password or not self._host:
|
||
return None
|
||
|
||
api = Api(self._host, verify_ssl=self._verify_ssl)
|
||
try:
|
||
return api.login(username, password)
|
||
finally:
|
||
api.logout()
|
||
api.close()
|
||
|
||
@staticmethod
|
||
def __extract_video_info_list(bucket: Any) -> list[dict]:
|
||
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_movies(
|
||
self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None
|
||
) -> Optional[List[schemas.MediaServerItem]]:
|
||
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 tmdb_id and tmdb_id != info_tmdb:
|
||
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 __search_tv_item(self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None) -> Optional[dict]:
|
||
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")):
|
||
if tmdb_id and tmdb_id != info.get("tmdb_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,
|
||
tmdb_id: Optional[int] = None,
|
||
season: Optional[int] = None,
|
||
) -> tuple[Optional[str], Optional[Dict[int, list]]]:
|
||
if not self.is_authenticated() or not self._api:
|
||
return None, None
|
||
|
||
if not item_id:
|
||
if not title:
|
||
return None, None
|
||
if not (tv_info := self.__search_tv_item(title, year, tmdb_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:
|
||
return None, {}
|
||
if tmdb_id and item_info.tmdbid and tmdb_id != item_info.tmdbid:
|
||
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] = {}
|
||
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]:
|
||
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]:
|
||
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[schemas.RefreshMediaItem],
|
||
scan_mode: Optional[Union[str, int]] = None,
|
||
) -> Optional[bool]:
|
||
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[schemas.WebhookEventInfo]:
|
||
return None
|
||
|
||
def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]:
|
||
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):
|
||
if not self._api or not root_path:
|
||
return
|
||
|
||
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 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
|
||
|
||
def get_items(
|
||
self,
|
||
parent: Union[str, int],
|
||
start_index: Optional[int] = 0,
|
||
limit: Optional[int] = -1,
|
||
) -> Generator[schemas.MediaServerItem | None | Any, Any, None]:
|
||
if not self.is_authenticated() or not self._api:
|
||
return None
|
||
|
||
library_id = str(parent)
|
||
if not self._library_paths:
|
||
self.get_librarys()
|
||
|
||
root_path = self._library_paths.get(library_id)
|
||
if not root_path:
|
||
return None
|
||
|
||
skip = max(0, start_index or 0)
|
||
remain = -1 if limit in [None, -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]:
|
||
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[schemas.MediaServerPlayItem]]:
|
||
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 not data:
|
||
return []
|
||
|
||
ret_resume = []
|
||
for item in data.get("video_arr") or []:
|
||
if len(ret_resume) == page_size:
|
||
break
|
||
if not isinstance(item, dict):
|
||
continue
|
||
video_info = item.get("video_info") if isinstance(item.get("video_info"), 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[schemas.MediaServerPlayItem]]:
|
||
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 not data:
|
||
return []
|
||
|
||
latest = []
|
||
for item in data.get("video_arr") or []:
|
||
if len(latest) == page_size:
|
||
break
|
||
if not isinstance(item, dict):
|
||
continue
|
||
video_info = item.get("video_info") if isinstance(item.get("video_info"), 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]]:
|
||
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 = item.get("video_info") if isinstance(item.get("video_info"), dict) else {}
|
||
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):
|
||
# 绿联图片流接口依赖加密鉴权头,当前图片代理仅支持Cookie注入。
|
||
return None
|