Merge pull request #4110 from cddjr/trimemedia

提升飞牛服务端地址的兼容性
This commit is contained in:
jxxghp
2025-04-07 21:15:38 +08:00
committed by GitHub
3 changed files with 196 additions and 86 deletions

View File

@@ -62,7 +62,9 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
server.reconnect()
def stop(self):
pass
for server in self.get_instances().values():
if server.is_authenticated():
server.disconnect()
def test(self) -> Optional[Tuple[bool, str]]:
"""
@@ -73,7 +75,7 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
for name, server in self.get_instances().items():
if not server.is_configured():
return False, f"飞牛影视配置不完整:{name}"
if server.is_inactive() and server.reconnect() != True:
if server.is_inactive() and not server.reconnect():
return False, f"无法连接飞牛影视:{name}"
return True, ""

View File

@@ -4,7 +4,7 @@ import random
import time
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Union, List
from typing import List, Optional, Union
from app.core.config import settings
from app.log import logger
@@ -19,27 +19,27 @@ class User:
class Category(Enum):
Movie = "Movie"
MOVIE = "Movie"
TV = "TV"
Mix = "Mix"
Others = "Others"
MIX = "Mix"
OTHERS = "Others"
@classmethod
def _missing_(cls, value):
return cls.Others
return cls.OTHERS
class Type(Enum):
Movie = "Movie"
MOVIE = "Movie"
TV = "TV"
Season = "Season"
Episode = "Episode"
Video = "Video"
Directory = "Directory"
SEASON = "Season"
EPISODE = "Episode"
VIDEO = "Video"
DIRECTORY = "Directory"
@classmethod
def _missing_(cls, value):
return cls.Video
return cls.VIDEO
@dataclass
@@ -60,6 +60,13 @@ class MediaDbSummary:
total: int = 0
@dataclass
class Version:
# 飞牛影视版本
frontend: Optional[str] = None
backend: Optional[str] = None
@dataclass
class Item:
guid: str
@@ -103,6 +110,7 @@ class Api:
"_apikey",
"_api_path",
"_request_utils",
"_version",
)
@property
@@ -117,13 +125,34 @@ class Api:
def apikey(self) -> str:
return self._apikey
@property
def version(self) -> Optional[Version]:
return self._version
def __init__(self, host: str, apikey: str):
self._api_path = "/v/api/v1"
"""
:param host: 飞牛服务端地址如http://127.0.0.1:5666/v
"""
self._api_path = "/api/v1"
self._host = host.rstrip("/")
self._apikey = apikey
self._token = None
self._token: Optional[str] = None
self._version: Optional[Version] = None
self._request_utils = RequestUtils(session=requests.Session())
def sys_version(self) -> Optional[Version]:
"""
飞牛影视版本号
"""
if (res := self.__request_api("/sys/version")) and res.success:
if res.data:
self._version = Version(
frontend=res.data.get("version"),
backend=res.data.get("mediasrvVersion"),
)
return self._version
return None
def login(self, username, password) -> Optional[str]:
"""
登录飞牛影视
@@ -131,14 +160,14 @@ class Api:
:return: 成功返回token 否则返回None
"""
if (
res := self.__request_api(
"/login",
data={
"username": username,
"password": password,
"app_name": "trimemedia-web",
},
)
res := self.__request_api(
"/login",
data={
"username": username,
"password": password,
"app_name": "trimemedia-web",
},
)
) and res.success:
self._token = res.data.get("token")
return self._token
@@ -250,7 +279,7 @@ class Api:
扫描指定媒体库
"""
if (
res := self.__request_api(f"/mdb/scan/{mdb.guid}", data={})
res := self.__request_api(f"/mdb/scan/{mdb.guid}", data={})
) and res.success:
if res.data:
return True
@@ -272,22 +301,22 @@ class Api:
return item
def item_list(
self,
guid: Optional[str] = None,
type=None,
exclude_grouped_video=True,
page=1,
page_size=22,
sort_by="create_time",
sort="DESC",
self,
guid: Optional[str] = None,
types=None,
exclude_grouped_video=True,
page=1,
page_size=22,
sort_by="create_time",
sort="DESC",
) -> Optional[list[Item]]:
"""
媒体列表
"""
if type is None:
type = [Type.Movie, Type.TV, Type.Directory, Type.Video]
if types is None:
types = [Type.MOVIE, Type.TV, Type.DIRECTORY, Type.VIDEO]
post = {
"tags": {"type": type} if type else {},
"tags": {"type": types} if types else {},
"sort_type": sort,
"sort_column": sort_by,
"page": page,
@@ -307,25 +336,48 @@ class Api:
搜索影片、演员
"""
if (
res := self.__request_api("/search/list", params={"q": keywords})
res := self.__request_api("/search/list", params={"q": keywords})
) and res.success:
return [self.__build_item(info) for info in res.data]
return None
def item(self, guid: str) -> Optional[Item]:
""" """
"""
查询媒体详情
"""
if (res := self.__request_api(f"/item/{guid}")) and res.success:
return self.__build_item(res.data)
return None
def del_item(self, guid: str, delete_file: bool) -> bool:
"""
删除媒体
:param delete_file: True删除媒体文件False仅从媒体库移除
"""
if (
res := self.__request_api(
f"/item/{guid}",
method="delete",
data={"delete_file": 1 if delete_file else 0, "media_guids": []},
)
) and res.success:
if res.data:
return True
return False
def season_list(self, tv_guid: str) -> Optional[list[Item]]:
""" """
"""
查询季列表
"""
if (res := self.__request_api(f"/season/list/{tv_guid}")) and res.success:
return [self.__build_item(info) for info in res.data]
return None
def episode_list(self, season_guid: str) -> Optional[list[Item]]:
""" """
"""
查询剧集列表
"""
if (res := self.__request_api(f"/episode/list/{season_guid}")) and res.success:
return [self.__build_item(info) for info in res.data]
return None
@@ -338,12 +390,12 @@ class Api:
return [self.__build_item(info) for info in res.data]
return None
def __get_authx(self, api_path, body: Optional[str]):
def __get_authx(self, api_path: str, body: Optional[str]):
"""
计算消息签名
"""
if api_path[0] != "/":
api_path = "/" + api_path
if not api_path.startswith("/v"):
api_path = "/v" + api_path
nonce = str(random.randint(100000, 999999))
ts = str(int(time.time() * 1000))
md5 = hashlib.md5()
@@ -366,10 +418,17 @@ class Api:
return f"nonce={nonce}&timestamp={ts}&sign={sign}"
def __request_api(
self, api: str, method: str = None, params: dict = None, data: dict = None
self,
api: str,
method: Optional[str] = None,
params: Optional[dict] = None,
data: Optional[dict] = None,
suppress_log=False,
):
"""
请求飞牛影视API
:param suppress_log: 是否禁止日志
"""
@dataclass
@@ -397,7 +456,7 @@ class Api:
url = self._host + api_path
if method is None:
method = "get" if data is None else "post"
if method == "post":
if method != "get":
json_body = (
json.dumps(data, allow_nan=False, cls=JsonEncoder) if data else ""
)
@@ -422,11 +481,13 @@ class Api:
resp = res.json()
msg = resp.get("msg")
if code := int(resp.get("code", -1)):
logger.error(f"请求接口 {api_path} 失败,错误码:{code} {msg}")
if not suppress_log:
logger.error(f"请求接口 {url} 失败,错误码:{code} {msg}")
return Result(code, msg)
return Result(0, msg, resp.get("data"))
else:
logger.error(f"请求接口 {api_path} 失败")
elif not suppress_log:
logger.error(f"请求接口 {url} 失败")
except Exception as e:
logger.error(f"请求接口 {api_path} 异常:" + str(e))
if not suppress_log:
logger.error(f"请求接口 {url} 异常:" + str(e))
return None

View File

@@ -26,21 +26,58 @@ class TrimeMedia:
username: Optional[str] = None,
password: Optional[str] = None,
play_host: Optional[str] = None,
sync_libraries: list = None,
sync_libraries: Optional[list] = None,
**kwargs,
):
if not host or not username or not password:
logger.error("飞牛影视配置不完整!!")
return
host = UrlUtils.standardize_base_url(host).rstrip("/")
if play_host:
self._playhost = UrlUtils.standardize_base_url(play_host).rstrip("/")
self._username = username
self._password = password
self._sync_libraries = sync_libraries or []
self._api = fnapi.Api(host, apikey="16CCEB3D-AB42-077D-36A1-F355324E4237")
if (api := self.__create_api(host)) is None:
logger.error(f"请检查服务端地址 {host}")
return
self._api = api
if play_api := self.__create_api(play_host):
self._playhost = play_api.host
elif play_host:
logger.warning(f"请检查外网播放地址 {play_host}")
self.reconnect()
@property
def api(self) -> Optional[fnapi.Api]:
"""
获得飞牛API
"""
return self._api
def __create_api(self, host: Optional[str]) -> Optional[fnapi.Api]:
"""
创建一个飞牛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 测试能否正常访问
api = fnapi.Api(host + "/v", api_key)
if api.sys_version():
return api
# 测试用户配置的地址
api = fnapi.Api(host, api_key)
return api if api.sys_version() else None
def __del__(self):
self.disconnect()
def is_configured(self) -> bool:
return self._api is not None
@@ -62,14 +99,27 @@ class TrimeMedia:
"""
if not self.is_configured():
return False
if (fnver := self._api.sys_version()) is None:
return False
# 版本号:0.8.36, 服务版本:0.8.19
logger.debug(f"版本号:{fnver.frontend}, 服务版本:{fnver.backend}")
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._userinfo.username} 成功登录飞牛影视")
logger.debug(f"{self._username} 成功登录飞牛影视")
return True
def disconnect(self):
"""
断开与飞牛的连接
"""
if self.is_authenticated():
self._api.logout()
self._userinfo = None
logger.debug(f"{self._username} 已断开飞牛影视")
def get_librarys(
self, hidden: Optional[bool] = False
) -> List[schemas.MediaServerLibrary]:
@@ -87,11 +137,11 @@ class TrimeMedia:
for library in self._libraries.values():
if hidden and self.__is_library_blocked(library.guid):
continue
if library.category == fnapi.Category.Movie:
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:
elif library.category == fnapi.Category.OTHERS:
# 忽略这个库
continue
else:
@@ -107,7 +157,7 @@ class TrimeMedia:
f"{self._api.host}{img_path}?w=256"
for img_path in library.posters or []
],
link=f"{self._playhost or self._api.host}/v/library/{library.guid}",
link=f"{self._playhost or self._api.host}/library/{library.guid}",
)
)
return libraries
@@ -170,7 +220,7 @@ class TrimeMedia:
movies = []
items = self._api.search_list(keywords=title) or []
for item in items:
if item.type != fnapi.Type.Movie:
if item.type != fnapi.Type.MOVIE:
continue
if (
(not tmdb_id or tmdb_id == item.tmdb_id)
@@ -280,7 +330,7 @@ class TrimeMedia:
lib = self.__match_library_by_path(item.target_path)
if lib is None:
# 如果有匹配失败的,刷新整个库
return self._api.mdb_scanall()
return self.refresh_root_library()
# 媒体库去重
libraries.add(lib.guid)
@@ -290,7 +340,7 @@ class TrimeMedia:
logger.info(f"刷新媒体库:{lib.name}")
if not self._api.mdb_scan(lib):
# 如果失败,刷新整个库
return self._api.mdb_scanall()
return self.refresh_root_library()
return True
def __match_library_by_path(self, path: Path) -> Optional[fnapi.MediaDb]:
@@ -336,7 +386,7 @@ class TrimeMedia:
if item.watched:
user_state.played = True
if item.duration and item.ts is not None:
user_state.percentage = item.ts / item.duration
user_state.percentage = item.ts / item.duration * 100
user_state.resume = True
if item.type is None:
item_type = None
@@ -361,40 +411,37 @@ class TrimeMedia:
"""
拼装播放链接
"""
if item.type == fnapi.Type.Episode:
return f"{host}/v/tv/episode/{item.guid}"
elif item.type == fnapi.Type.Season:
return f"{host}/v/tv/season/{item.guid}"
elif item.type == fnapi.Type.Movie:
return f"{host}/v/movie/{item.guid}"
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}/v/tv/{item.guid}"
return f"{host}/tv/{item.guid}"
else:
# 其它类型走通用页面,由飞牛来判断
return f"{host}/v/other/{item.guid}"
return f"{host}/other/{item.guid}"
def __build_media_server_play_item(
self, item: fnapi.Item
) -> schemas.MediaServerPlayItem:
"""
:params use_backdrop: 是否优先使用Backdrop类型的图片
"""
if item.type == fnapi.Type.Episode:
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 "视频"
type = (
subtitle = "电影" if item.type == fnapi.Type.MOVIE else "视频"
types = (
MediaType.MOVIE.value
if item.type in [fnapi.Type.Movie, fnapi.Type.Video]
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=type,
type=types,
image=f"{self._api.host}{item.poster}",
link=self.__build_play_url(self._playhost or self._api.host, item),
percent=(
@@ -421,22 +468,22 @@ class TrimeMedia:
"""
if not self.is_authenticated():
return None
if (SIZE := limit) is None:
SIZE = -1
if (page_size := limit) is None:
page_size = -1
items = (
self._api.item_list(
guid=parent,
page=start_index + 1,
page_size=SIZE,
type=[fnapi.Type.Movie, fnapi.Type.TV, fnapi.Type.Directory],
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:
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]:
elif item.type in [fnapi.Type.MOVIE, fnapi.Type.TV]:
yield self.__build_media_server_item(item)
return None
@@ -482,7 +529,7 @@ class TrimeMedia:
self._api.item_list(
page=1,
page_size=max(100, num * 5),
type=[fnapi.Type.Movie, fnapi.Type.TV],
types=[fnapi.Type.MOVIE, fnapi.Type.TV],
)
or []
)
@@ -505,7 +552,7 @@ class TrimeMedia:
self._api.item_list(
page=1,
page_size=max(100, num * 5),
type=[fnapi.Type.Movie, fnapi.Type.TV],
types=[fnapi.Type.MOVIE, fnapi.Type.TV],
)
or []
)
@@ -534,7 +581,7 @@ class TrimeMedia:
def __is_library_blocked(self, library_guid: str):
if library := self._libraries.get(library_guid):
if library.category == fnapi.Category.Others:
if library.category == fnapi.Category.OTHERS:
# 忽略这个库
return True
return (