mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-03-20 03:57:30 +08:00
660 lines
22 KiB
Python
660 lines
22 KiB
Python
from pathlib import Path
|
||
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
||
|
||
import app.modules.trimemedia.api as fnapi
|
||
from app import schemas
|
||
from app.log import logger
|
||
from app.schemas import MediaType
|
||
from app.utils.security import SecurityUtils
|
||
from app.utils.url import UrlUtils
|
||
|
||
|
||
class TrimeMedia:
|
||
_username: Optional[str] = None
|
||
_password: Optional[str] = None
|
||
|
||
_userinfo: Optional[fnapi.User] = None
|
||
_host: Optional[str] = None
|
||
_playhost: Optional[str] = None
|
||
|
||
_libraries: dict[str, fnapi.MediaDb] = {}
|
||
_sync_libraries: List[str] = []
|
||
|
||
_api: Optional[fnapi.Api] = None
|
||
_version: Optional[fnapi.Version] = 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,
|
||
**kwargs,
|
||
):
|
||
if not host or not username or not password:
|
||
logger.error("飞牛影视配置不完整!!")
|
||
return
|
||
self._username = username
|
||
self._password = password
|
||
self._host = host
|
||
self._sync_libraries = sync_libraries or []
|
||
|
||
if not self.reconnect():
|
||
logger.error(f"请检查服务端地址 {host}")
|
||
return
|
||
if result := self.__create_api(play_host):
|
||
self._playhost = result.api.host
|
||
result.api.close()
|
||
elif play_host:
|
||
logger.warning(f"请检查外网播放地址 {play_host}")
|
||
self._playhost = UrlUtils.standardize_base_url(play_host).rstrip("/")
|
||
|
||
@property
|
||
def api(self) -> Optional[fnapi.Api]:
|
||
"""
|
||
获得飞牛API
|
||
"""
|
||
return self._api
|
||
|
||
@property
|
||
def version(self) -> Optional[fnapi.Version]:
|
||
"""
|
||
获得飞牛API的版本
|
||
"""
|
||
return self._version
|
||
|
||
class _ApiCreateResult:
|
||
api: fnapi.Api
|
||
version: fnapi.Version
|
||
|
||
@staticmethod
|
||
def __create_api(host: Optional[str]) -> Optional["TrimeMedia._ApiCreateResult"]:
|
||
"""
|
||
创建一个飞牛API
|
||
|
||
:param host: 服务端地址
|
||
:return: 如果地址无效、不可访问则返回None
|
||
"""
|
||
|
||
if not host:
|
||
return None
|
||
api_key = "16CCEB3D-AB42-077D-36A1-F355324E4237"
|
||
host = UrlUtils.standardize_base_url(host).rstrip("/")
|
||
|
||
if not host.endswith("/v"):
|
||
# 尝试补上结尾的/v 测试能否正常访问
|
||
res = TrimeMedia._ApiCreateResult()
|
||
res.api = fnapi.Api(host + "/v", api_key)
|
||
if fnver := res.api.sys_version():
|
||
res.version = fnver
|
||
return res
|
||
# 测试用户配置的地址
|
||
res = TrimeMedia._ApiCreateResult()
|
||
res.api = fnapi.Api(host, api_key)
|
||
if fnver := res.api.sys_version():
|
||
res.version = fnver
|
||
return res
|
||
return None
|
||
|
||
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.user_info()
|
||
return self._userinfo is None
|
||
|
||
def reconnect(self):
|
||
"""
|
||
重连
|
||
"""
|
||
if not self.is_configured():
|
||
return False
|
||
self.disconnect()
|
||
if result := self.__create_api(self._host):
|
||
self._api = result.api
|
||
self._version = result.version
|
||
# 版本号:0.8.53, 服务版本:0.8.23
|
||
# 版本号:0.8.56, 服务版本:0.8.23 接口/memory/user/list改为/manager/user/list
|
||
logger.debug(
|
||
f"版本号:{result.version.frontend}, 服务版本:{result.version.backend}"
|
||
)
|
||
else:
|
||
return False
|
||
if self._api.login(self._username, self._password) is None:
|
||
return False
|
||
self._userinfo = self._api.user_info()
|
||
if self._userinfo is None:
|
||
return False
|
||
logger.debug(f"{self._username} 成功登录飞牛影视")
|
||
# 刷新媒体库列表
|
||
self.get_librarys()
|
||
return True
|
||
|
||
def disconnect(self):
|
||
"""
|
||
断开与飞牛的连接
|
||
"""
|
||
if self._api:
|
||
self._api.logout()
|
||
self._api.close()
|
||
self._api = None
|
||
self._userinfo = None
|
||
logger.debug(f"{self._username} 已断开飞牛影视")
|
||
|
||
def get_librarys(
|
||
self, hidden: Optional[bool] = False
|
||
) -> List[schemas.MediaServerLibrary]:
|
||
"""
|
||
获取媒体服务器所有媒体库列表
|
||
"""
|
||
if not self.is_authenticated():
|
||
return []
|
||
if self._userinfo.is_admin == 1:
|
||
mdb_list = self._api.mdb_list() or []
|
||
else:
|
||
mdb_list = self._api.mediadb_list() or []
|
||
self._libraries = {lib.guid: lib for lib in mdb_list}
|
||
libraries = []
|
||
for library in self._libraries.values():
|
||
if hidden and self.__is_library_blocked(library.guid):
|
||
continue
|
||
if library.category == fnapi.Category.MOVIE:
|
||
library_type = MediaType.MOVIE.value
|
||
elif library.category == fnapi.Category.TV:
|
||
library_type = MediaType.TV.value
|
||
elif library.category == fnapi.Category.OTHERS:
|
||
# 忽略这个库
|
||
continue
|
||
else:
|
||
library_type = MediaType.UNKNOWN.value
|
||
libraries.append(
|
||
schemas.MediaServerLibrary(
|
||
server="trimemedia",
|
||
id=library.guid,
|
||
name=library.name,
|
||
type=library_type,
|
||
path=library.dir_list,
|
||
image_list=[
|
||
f"{self._api.host}{img_path}?w=256"
|
||
for img_path in library.posters or []
|
||
],
|
||
link=f"{self._playhost or self._api.host}/library/{library.guid}",
|
||
server_type="trimemedia",
|
||
use_cookies=True,
|
||
)
|
||
)
|
||
return libraries
|
||
|
||
def get_user_count(self) -> int:
|
||
"""
|
||
获取用户数量(非管理员不能调用)
|
||
"""
|
||
if not self.is_authenticated():
|
||
return 0
|
||
if not self._userinfo or self._userinfo.is_admin != 1:
|
||
return 0
|
||
return len(self._api.user_list() or [])
|
||
|
||
def get_medias_count(self) -> schemas.Statistic:
|
||
"""
|
||
获取媒体数量
|
||
|
||
:return: MovieCount SeriesCount
|
||
"""
|
||
if not self.is_authenticated():
|
||
return schemas.Statistic()
|
||
if (info := self._api.mediadb_sum()) is None:
|
||
return schemas.Statistic()
|
||
return schemas.Statistic(
|
||
movie_count=info.movie,
|
||
tv_count=info.tv,
|
||
)
|
||
|
||
def authenticate(self, username: str, password: str) -> Optional[str]:
|
||
"""
|
||
用户认证
|
||
|
||
:param username: 用户名
|
||
:param password: 密码
|
||
:return: 认证成功返回token,否则返回None
|
||
"""
|
||
if not username or not password:
|
||
return None
|
||
if not self.is_configured():
|
||
return None
|
||
if result := self.__create_api(self._host):
|
||
try:
|
||
return result.api.login(username, password)
|
||
finally:
|
||
result.api.logout()
|
||
result.api.close()
|
||
|
||
def get_movies(
|
||
self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None
|
||
) -> Optional[List[schemas.MediaServerItem]]:
|
||
"""
|
||
根据标题和年份,检查电影是否在飞牛中存在,存在则返回列表
|
||
|
||
:param title: 标题
|
||
:param year: 年份,为空则不过滤
|
||
:param tmdb_id: TMDB ID
|
||
:return: 含title、year属性的字典列表
|
||
"""
|
||
if not self.is_authenticated():
|
||
return None
|
||
movies = []
|
||
items = self._api.search_list(keywords=title) or []
|
||
for item in items:
|
||
if item.type != fnapi.Type.MOVIE:
|
||
continue
|
||
if (
|
||
(not tmdb_id or tmdb_id == item.tmdb_id)
|
||
and title in [item.title, item.original_title]
|
||
and (not year or (item.release_date and item.release_date[:4] == year))
|
||
):
|
||
movies.append(self.__build_media_server_item(item))
|
||
return movies
|
||
|
||
def __get_series_id_by_name(self, name: str, year: str) -> Optional[str]:
|
||
items = self._api.search_list(keywords=name) or []
|
||
for item in items:
|
||
if item.type != fnapi.Type.TV:
|
||
continue
|
||
# 可惜搜索接口不下发original_title 也不能指定分类、年份
|
||
if name in [item.title, item.original_title]:
|
||
if not year or (item.air_date and item.air_date[:4] == year):
|
||
return item.guid
|
||
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]]]:
|
||
"""
|
||
根据标题和年份和季,返回飞牛中的剧集列表
|
||
|
||
:param item_id: 飞牛影视中的guid
|
||
:param title: 标题
|
||
:param year: 年份
|
||
:param tmdb_id: TMDBID
|
||
:param season: 季
|
||
:return: 集号的列表
|
||
"""
|
||
if not self.is_authenticated():
|
||
return None, None
|
||
|
||
if not item_id:
|
||
item_id = self.__get_series_id_by_name(title, year)
|
||
if item_id is None:
|
||
return None, None
|
||
|
||
item_info = self.get_iteminfo(item_id)
|
||
if not item_info:
|
||
return None, {}
|
||
|
||
if tmdb_id and item_info.tmdbid:
|
||
if tmdb_id != item_info.tmdbid:
|
||
return None, {}
|
||
|
||
seasons = self._api.season_list(item_id)
|
||
if not seasons:
|
||
# 季列表获取失败
|
||
return None, {}
|
||
|
||
if season is not None:
|
||
for item in seasons:
|
||
if item.season_number == season:
|
||
seasons = [item]
|
||
break
|
||
else:
|
||
# 没有匹配的季
|
||
return None, {}
|
||
|
||
season_episodes = {}
|
||
for item in seasons:
|
||
episodes = self._api.episode_list(item.guid)
|
||
for episode in episodes or []:
|
||
if episode.season_number not in season_episodes:
|
||
season_episodes[episode.season_number] = []
|
||
season_episodes[episode.season_number].append(episode.episode_number)
|
||
return item_id, season_episodes
|
||
|
||
def refresh_root_library(self) -> Optional[bool]:
|
||
"""
|
||
通知飞牛刷新整个媒体库(非管理员不能调用)
|
||
"""
|
||
if not self.is_authenticated():
|
||
return None
|
||
if not self._userinfo or self._userinfo.is_admin != 1:
|
||
logger.error("飞牛仅支持管理员账号刷新媒体库")
|
||
return False
|
||
|
||
# 必须调用 否则容易误报 -14 Task duplicate
|
||
self._api.task_running()
|
||
logger.info("刷新所有媒体库")
|
||
return self._api.mdb_scanall()
|
||
|
||
def refresh_library_by_items(
|
||
self, items: List[schemas.RefreshMediaItem]
|
||
) -> Optional[bool]:
|
||
"""
|
||
按路径刷新所在的媒体库(非管理员不能调用)
|
||
|
||
:param items: 已识别的需要刷新媒体库的媒体信息列表
|
||
"""
|
||
if not self.is_authenticated():
|
||
return None
|
||
if not self._userinfo or self._userinfo.is_admin != 1:
|
||
logger.error("飞牛仅支持管理员账号刷新媒体库")
|
||
return False
|
||
|
||
libraries = set()
|
||
for item in items:
|
||
lib = self.__match_library_by_path(item.target_path)
|
||
if lib is None:
|
||
# 如果有匹配失败的,刷新整个库
|
||
return self.refresh_root_library()
|
||
# 媒体库去重
|
||
libraries.add(lib.guid)
|
||
|
||
# 必须调用 否则容易误报 -14 Task duplicate
|
||
self._api.task_running()
|
||
for lib_guid in libraries:
|
||
# 逐个刷新
|
||
lib = self._libraries[lib_guid]
|
||
logger.info(f"刷新媒体库:{lib.name}")
|
||
if not self._api.mdb_scan(lib):
|
||
# 如果失败,刷新整个库
|
||
return self.refresh_root_library()
|
||
return True
|
||
|
||
def __match_library_by_path(self, path: Path) -> Optional[fnapi.MediaDb]:
|
||
def is_subpath(_path: Path, _parent: Path) -> bool:
|
||
"""
|
||
判断_path是否是_parent的子目录下
|
||
"""
|
||
_path = _path.resolve()
|
||
_parent = _parent.resolve()
|
||
return _path.parts[: len(_parent.parts)] == _parent.parts
|
||
|
||
if path is None:
|
||
return None
|
||
for lib in self._libraries.values():
|
||
for d in lib.dir_list or []:
|
||
if is_subpath(path, Path(d)):
|
||
return lib
|
||
return None
|
||
|
||
def get_webhook_message(self, body: any) -> Optional[schemas.WebhookEventInfo]:
|
||
pass
|
||
|
||
def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]:
|
||
"""
|
||
获取单个项目详情
|
||
"""
|
||
if not self.is_authenticated():
|
||
return None
|
||
if item := self._api.item(guid=itemid):
|
||
return self.__build_media_server_item(item)
|
||
return None
|
||
|
||
@staticmethod
|
||
def __build_media_server_item(item: fnapi.Item):
|
||
if item.air_date and item.type == fnapi.Type.TV:
|
||
year = item.air_date[:4]
|
||
elif item.release_date:
|
||
year = item.release_date[:4]
|
||
else:
|
||
year = None
|
||
|
||
user_state = schemas.MediaServerItemUserState()
|
||
if item.watched:
|
||
user_state.played = True
|
||
if item.duration and item.ts is not None:
|
||
user_state.percentage = item.ts / item.duration * 100
|
||
user_state.resume = True
|
||
if item.type is None:
|
||
item_type = None
|
||
else:
|
||
# 将飞牛的媒体类型转为MP能识别的
|
||
item_type = "Series" if item.type == fnapi.Type.TV else item.type.value
|
||
return schemas.MediaServerItem(
|
||
server="trimemedia",
|
||
library=item.ancestor_guid,
|
||
item_id=item.guid,
|
||
item_type=item_type,
|
||
title=item.title,
|
||
original_title=item.original_title,
|
||
year=year,
|
||
tmdbid=item.tmdb_id,
|
||
imdbid=item.imdb_id,
|
||
user_state=user_state,
|
||
)
|
||
|
||
@staticmethod
|
||
def __build_play_url(host: str, item: fnapi.Item) -> str:
|
||
"""
|
||
拼装播放链接
|
||
"""
|
||
if item.type == fnapi.Type.EPISODE:
|
||
return f"{host}/tv/episode/{item.guid}"
|
||
elif item.type == fnapi.Type.SEASON:
|
||
return f"{host}/tv/season/{item.guid}"
|
||
elif item.type == fnapi.Type.MOVIE:
|
||
return f"{host}/movie/{item.guid}"
|
||
elif item.type == fnapi.Type.TV:
|
||
return f"{host}/tv/{item.guid}"
|
||
else:
|
||
# 其它类型走通用页面,由飞牛来判断
|
||
return f"{host}/other/{item.guid}"
|
||
|
||
def __build_media_server_play_item(
|
||
self, item: fnapi.Item
|
||
) -> schemas.MediaServerPlayItem:
|
||
if item.type == fnapi.Type.EPISODE:
|
||
title = item.tv_title
|
||
subtitle = f"S{item.season_number}:{item.episode_number} - {item.title}"
|
||
else:
|
||
title = item.title
|
||
subtitle = "电影" if item.type == fnapi.Type.MOVIE else "视频"
|
||
types = (
|
||
MediaType.MOVIE.value
|
||
if item.type in [fnapi.Type.MOVIE, fnapi.Type.VIDEO]
|
||
else MediaType.TV.value
|
||
)
|
||
return schemas.MediaServerPlayItem(
|
||
id=item.guid,
|
||
title=title,
|
||
subtitle=subtitle,
|
||
type=types,
|
||
image=f"{self._api.host}{item.poster}",
|
||
link=self.__build_play_url(self._playhost or self._api.host, item),
|
||
percent=(
|
||
item.ts / item.duration * 100.0
|
||
if item.duration and item.ts is not None
|
||
else 0
|
||
),
|
||
server_type="trimemedia",
|
||
use_cookies=True,
|
||
)
|
||
|
||
def get_items(
|
||
self,
|
||
parent: Union[str, int],
|
||
start_index: Optional[int] = 0,
|
||
limit: Optional[int] = -1,
|
||
) -> Generator[schemas.MediaServerItem | None | Any, Any, None]:
|
||
"""
|
||
获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据
|
||
|
||
:param parent: 媒体库ID,用于标识要获取的媒体库
|
||
:param start_index: 起始索引,用于分页获取数据。默认为 0,即从第一个项目开始获取
|
||
:param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1,则表示一次性获取所有数据,默认为 -1
|
||
|
||
:return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目
|
||
"""
|
||
if not self.is_authenticated():
|
||
return None
|
||
if (page_size := limit) is None:
|
||
page_size = -1
|
||
items = (
|
||
self._api.item_list(
|
||
guid=parent,
|
||
page=start_index + 1,
|
||
page_size=page_size,
|
||
types=[fnapi.Type.MOVIE, fnapi.Type.TV, fnapi.Type.DIRECTORY],
|
||
)
|
||
or []
|
||
)
|
||
for item in items:
|
||
if item.type == fnapi.Type.DIRECTORY:
|
||
for items in self.get_items(parent=item.guid):
|
||
yield items
|
||
elif item.type in [fnapi.Type.MOVIE, fnapi.Type.TV]:
|
||
yield self.__build_media_server_item(item)
|
||
return None
|
||
|
||
def get_play_url(self, item_id: str) -> Optional[str]:
|
||
"""
|
||
获取媒体的外网播放链接
|
||
|
||
:param item_id: 媒体ID
|
||
"""
|
||
if not self.is_authenticated():
|
||
return None
|
||
if (item := self._api.item(item_id)) is None:
|
||
return None
|
||
# 根据查询到的信息拼装出播放链接
|
||
return self.__build_play_url(self._playhost or self._api.host, item)
|
||
|
||
def get_resume(
|
||
self, num: Optional[int] = 12
|
||
) -> Optional[List[schemas.MediaServerPlayItem]]:
|
||
"""
|
||
获取继续观看列表
|
||
|
||
:param num: 列表大小,None不限制数量
|
||
"""
|
||
if not self.is_authenticated():
|
||
return None
|
||
ret_resume = []
|
||
for item in self._api.play_list() or []:
|
||
if len(ret_resume) == num:
|
||
break
|
||
if self.__is_library_blocked(item.ancestor_guid):
|
||
continue
|
||
ret_resume.append(self.__build_media_server_play_item(item))
|
||
return ret_resume
|
||
|
||
def get_latest(self, num=20) -> Optional[List[schemas.MediaServerPlayItem]]:
|
||
"""
|
||
获取最近更新列表
|
||
"""
|
||
if not self.is_authenticated():
|
||
return None
|
||
items = (
|
||
self._api.item_list(
|
||
page=1,
|
||
page_size=max(100, num * 5),
|
||
types=[fnapi.Type.MOVIE, fnapi.Type.TV],
|
||
)
|
||
or []
|
||
)
|
||
latest = []
|
||
for item in items:
|
||
if len(latest) == num:
|
||
break
|
||
if self.__is_library_blocked(item.ancestor_guid):
|
||
continue
|
||
latest.append(self.__build_media_server_play_item(item))
|
||
return latest
|
||
|
||
def get_latest_backdrops(self, num=20, remote=False) -> Optional[List[str]]:
|
||
"""
|
||
获取最近更新的媒体Backdrop图片
|
||
"""
|
||
if not self.is_authenticated():
|
||
return None
|
||
items = (
|
||
self._api.item_list(
|
||
page=1,
|
||
page_size=max(100, num * 5),
|
||
types=[fnapi.Type.MOVIE, fnapi.Type.TV],
|
||
)
|
||
or []
|
||
)
|
||
backdrops = []
|
||
for item in items:
|
||
if len(backdrops) == num:
|
||
break
|
||
if self.__is_library_blocked(item.ancestor_guid):
|
||
continue
|
||
if (item_details := self._api.item(item.guid)) is None:
|
||
continue
|
||
if remote:
|
||
# FIXME 新版飞牛的壁纸无法直接在浏览器中访问
|
||
img_host = self._playhost or self._api.host
|
||
else:
|
||
img_host = self._api.host
|
||
if item_details.backdrops:
|
||
item_image = item_details.backdrops
|
||
else:
|
||
item_image = (
|
||
item_details.posters
|
||
if item_details.posters
|
||
else item_details.poster
|
||
)
|
||
backdrops.append(f"{img_host}{item_image}")
|
||
return backdrops
|
||
|
||
def __is_library_blocked(self, library_guid: str):
|
||
if library := self._libraries.get(library_guid):
|
||
if library.category == fnapi.Category.OTHERS:
|
||
# 忽略这个库
|
||
return True
|
||
return (
|
||
True
|
||
if (
|
||
self._sync_libraries
|
||
and "all" not in self._sync_libraries
|
||
and library_guid not in self._sync_libraries
|
||
)
|
||
else False
|
||
)
|
||
|
||
def get_image_cookies(self, image_url: str):
|
||
"""
|
||
获得指定图片的Cookies
|
||
"""
|
||
if not self.is_authenticated():
|
||
return None
|
||
if not image_url or not SecurityUtils.is_safe_url(
|
||
image_url, [self._api.host], strict=True
|
||
):
|
||
return None
|
||
return {"Trim-MC-token": self._api.token}
|