Files
MoviePilot/app/modules/ugreen/ugreen.py

963 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
)
def __infer_library_type(self, 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") is True:
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()
def __extract_video_info_list(self, 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
def get_webhook_message(self, 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") is True:
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
def get_image_cookies(self, image_url: str):
# 绿联图片流接口依赖加密鉴权头当前图片代理仅支持Cookie注入。
return None