feat: 完成绿联影视接入并补齐扫描模式与统计展示

This commit is contained in:
doumao
2026-02-28 21:58:35 +08:00
parent 383371dd6f
commit 296261da8a
10 changed files with 2548 additions and 7 deletions
+944
View File
@@ -0,0 +1,944 @@
import hashlib
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
_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,
**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)
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)
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)
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)
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)
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: List[str] = [root_path]
visited: set[str] = set()
max_paths = 20000
while queue and len(visited) < max_paths:
current_path = queue.pop(0)
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