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
+4 -2
View File
@@ -62,7 +62,9 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
server.reconnect() server.reconnect()
def stop(self): def stop(self):
pass for server in self.get_instances().values():
if server.is_authenticated():
server.disconnect()
def test(self) -> Optional[Tuple[bool, str]]: def test(self) -> Optional[Tuple[bool, str]]:
""" """
@@ -73,7 +75,7 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
for name, server in self.get_instances().items(): for name, server in self.get_instances().items():
if not server.is_configured(): if not server.is_configured():
return False, f"飞牛影视配置不完整:{name}" 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 False, f"无法连接飞牛影视:{name}"
return True, "" return True, ""
+107 -46
View File
@@ -4,7 +4,7 @@ import random
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Optional, Union, List from typing import List, Optional, Union
from app.core.config import settings from app.core.config import settings
from app.log import logger from app.log import logger
@@ -19,27 +19,27 @@ class User:
class Category(Enum): class Category(Enum):
Movie = "Movie" MOVIE = "Movie"
TV = "TV" TV = "TV"
Mix = "Mix" MIX = "Mix"
Others = "Others" OTHERS = "Others"
@classmethod @classmethod
def _missing_(cls, value): def _missing_(cls, value):
return cls.Others return cls.OTHERS
class Type(Enum): class Type(Enum):
Movie = "Movie" MOVIE = "Movie"
TV = "TV" TV = "TV"
Season = "Season" SEASON = "Season"
Episode = "Episode" EPISODE = "Episode"
Video = "Video" VIDEO = "Video"
Directory = "Directory" DIRECTORY = "Directory"
@classmethod @classmethod
def _missing_(cls, value): def _missing_(cls, value):
return cls.Video return cls.VIDEO
@dataclass @dataclass
@@ -60,6 +60,13 @@ class MediaDbSummary:
total: int = 0 total: int = 0
@dataclass
class Version:
# 飞牛影视版本
frontend: Optional[str] = None
backend: Optional[str] = None
@dataclass @dataclass
class Item: class Item:
guid: str guid: str
@@ -103,6 +110,7 @@ class Api:
"_apikey", "_apikey",
"_api_path", "_api_path",
"_request_utils", "_request_utils",
"_version",
) )
@property @property
@@ -117,13 +125,34 @@ class Api:
def apikey(self) -> str: def apikey(self) -> str:
return self._apikey return self._apikey
@property
def version(self) -> Optional[Version]:
return self._version
def __init__(self, host: str, apikey: str): 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._host = host.rstrip("/")
self._apikey = apikey self._apikey = apikey
self._token = None self._token: Optional[str] = None
self._version: Optional[Version] = None
self._request_utils = RequestUtils(session=requests.Session()) 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]: def login(self, username, password) -> Optional[str]:
""" """
登录飞牛影视 登录飞牛影视
@@ -131,14 +160,14 @@ class Api:
:return: 成功返回token 否则返回None :return: 成功返回token 否则返回None
""" """
if ( if (
res := self.__request_api( res := self.__request_api(
"/login", "/login",
data={ data={
"username": username, "username": username,
"password": password, "password": password,
"app_name": "trimemedia-web", "app_name": "trimemedia-web",
}, },
) )
) and res.success: ) and res.success:
self._token = res.data.get("token") self._token = res.data.get("token")
return self._token return self._token
@@ -250,7 +279,7 @@ class Api:
扫描指定媒体库 扫描指定媒体库
""" """
if ( if (
res := self.__request_api(f"/mdb/scan/{mdb.guid}", data={}) res := self.__request_api(f"/mdb/scan/{mdb.guid}", data={})
) and res.success: ) and res.success:
if res.data: if res.data:
return True return True
@@ -272,22 +301,22 @@ class Api:
return item return item
def item_list( def item_list(
self, self,
guid: Optional[str] = None, guid: Optional[str] = None,
type=None, types=None,
exclude_grouped_video=True, exclude_grouped_video=True,
page=1, page=1,
page_size=22, page_size=22,
sort_by="create_time", sort_by="create_time",
sort="DESC", sort="DESC",
) -> Optional[list[Item]]: ) -> Optional[list[Item]]:
""" """
媒体列表 媒体列表
""" """
if type is None: if types is None:
type = [Type.Movie, Type.TV, Type.Directory, Type.Video] types = [Type.MOVIE, Type.TV, Type.DIRECTORY, Type.VIDEO]
post = { post = {
"tags": {"type": type} if type else {}, "tags": {"type": types} if types else {},
"sort_type": sort, "sort_type": sort,
"sort_column": sort_by, "sort_column": sort_by,
"page": page, "page": page,
@@ -307,25 +336,48 @@ class Api:
搜索影片、演员 搜索影片、演员
""" """
if ( if (
res := self.__request_api("/search/list", params={"q": keywords}) res := self.__request_api("/search/list", params={"q": keywords})
) and res.success: ) and res.success:
return [self.__build_item(info) for info in res.data] return [self.__build_item(info) for info in res.data]
return None return None
def item(self, guid: str) -> Optional[Item]: def item(self, guid: str) -> Optional[Item]:
""" """ """
查询媒体详情
"""
if (res := self.__request_api(f"/item/{guid}")) and res.success: if (res := self.__request_api(f"/item/{guid}")) and res.success:
return self.__build_item(res.data) return self.__build_item(res.data)
return None 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]]: def season_list(self, tv_guid: str) -> Optional[list[Item]]:
""" """ """
查询季列表
"""
if (res := self.__request_api(f"/season/list/{tv_guid}")) and res.success: if (res := self.__request_api(f"/season/list/{tv_guid}")) and res.success:
return [self.__build_item(info) for info in res.data] return [self.__build_item(info) for info in res.data]
return None return None
def episode_list(self, season_guid: str) -> Optional[list[Item]]: def episode_list(self, season_guid: str) -> Optional[list[Item]]:
""" """ """
查询剧集列表
"""
if (res := self.__request_api(f"/episode/list/{season_guid}")) and res.success: if (res := self.__request_api(f"/episode/list/{season_guid}")) and res.success:
return [self.__build_item(info) for info in res.data] return [self.__build_item(info) for info in res.data]
return None return None
@@ -338,12 +390,12 @@ class Api:
return [self.__build_item(info) for info in res.data] return [self.__build_item(info) for info in res.data]
return None 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] != "/": if not api_path.startswith("/v"):
api_path = "/" + api_path api_path = "/v" + api_path
nonce = str(random.randint(100000, 999999)) nonce = str(random.randint(100000, 999999))
ts = str(int(time.time() * 1000)) ts = str(int(time.time() * 1000))
md5 = hashlib.md5() md5 = hashlib.md5()
@@ -366,10 +418,17 @@ class Api:
return f"nonce={nonce}&timestamp={ts}&sign={sign}" return f"nonce={nonce}&timestamp={ts}&sign={sign}"
def __request_api( 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 请求飞牛影视API
:param suppress_log: 是否禁止日志
""" """
@dataclass @dataclass
@@ -397,7 +456,7 @@ class Api:
url = self._host + api_path url = self._host + api_path
if method is None: if method is None:
method = "get" if data is None else "post" method = "get" if data is None else "post"
if method == "post": if method != "get":
json_body = ( json_body = (
json.dumps(data, allow_nan=False, cls=JsonEncoder) if data else "" json.dumps(data, allow_nan=False, cls=JsonEncoder) if data else ""
) )
@@ -422,11 +481,13 @@ class Api:
resp = res.json() resp = res.json()
msg = resp.get("msg") msg = resp.get("msg")
if code := int(resp.get("code", -1)): 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(code, msg)
return Result(0, msg, resp.get("data")) return Result(0, msg, resp.get("data"))
else: elif not suppress_log:
logger.error(f"请求接口 {api_path} 失败") logger.error(f"请求接口 {url} 失败")
except Exception as e: except Exception as e:
logger.error(f"请求接口 {api_path} 异常:" + str(e)) if not suppress_log:
logger.error(f"请求接口 {url} 异常:" + str(e))
return None return None
+85 -38
View File
@@ -26,21 +26,58 @@ class TrimeMedia:
username: Optional[str] = None, username: Optional[str] = None,
password: Optional[str] = None, password: Optional[str] = None,
play_host: Optional[str] = None, play_host: Optional[str] = None,
sync_libraries: list = None, sync_libraries: Optional[list] = None,
**kwargs, **kwargs,
): ):
if not host or not username or not password: if not host or not username or not password:
logger.error("飞牛影视配置不完整!!") logger.error("飞牛影视配置不完整!!")
return return
host = UrlUtils.standardize_base_url(host).rstrip("/")
if play_host:
self._playhost = UrlUtils.standardize_base_url(play_host).rstrip("/")
self._username = username self._username = username
self._password = password self._password = password
self._sync_libraries = sync_libraries or [] 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() 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: def is_configured(self) -> bool:
return self._api is not None return self._api is not None
@@ -62,14 +99,27 @@ class TrimeMedia:
""" """
if not self.is_configured(): if not self.is_configured():
return False 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: if self._api.login(self._username, self._password) is None:
return False return False
self._userinfo = self._api.user_info() self._userinfo = self._api.user_info()
if self._userinfo is None: if self._userinfo is None:
return False return False
logger.debug(f"{self._userinfo.username} 成功登录飞牛影视") logger.debug(f"{self._username} 成功登录飞牛影视")
return True return True
def disconnect(self):
"""
断开与飞牛的连接
"""
if self.is_authenticated():
self._api.logout()
self._userinfo = None
logger.debug(f"{self._username} 已断开飞牛影视")
def get_librarys( def get_librarys(
self, hidden: Optional[bool] = False self, hidden: Optional[bool] = False
) -> List[schemas.MediaServerLibrary]: ) -> List[schemas.MediaServerLibrary]:
@@ -87,11 +137,11 @@ class TrimeMedia:
for library in self._libraries.values(): for library in self._libraries.values():
if hidden and self.__is_library_blocked(library.guid): if hidden and self.__is_library_blocked(library.guid):
continue continue
if library.category == fnapi.Category.Movie: if library.category == fnapi.Category.MOVIE:
library_type = MediaType.MOVIE.value library_type = MediaType.MOVIE.value
elif library.category == fnapi.Category.TV: elif library.category == fnapi.Category.TV:
library_type = MediaType.TV.value library_type = MediaType.TV.value
elif library.category == fnapi.Category.Others: elif library.category == fnapi.Category.OTHERS:
# 忽略这个库 # 忽略这个库
continue continue
else: else:
@@ -107,7 +157,7 @@ class TrimeMedia:
f"{self._api.host}{img_path}?w=256" f"{self._api.host}{img_path}?w=256"
for img_path in library.posters or [] 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 return libraries
@@ -170,7 +220,7 @@ class TrimeMedia:
movies = [] movies = []
items = self._api.search_list(keywords=title) or [] items = self._api.search_list(keywords=title) or []
for item in items: for item in items:
if item.type != fnapi.Type.Movie: if item.type != fnapi.Type.MOVIE:
continue continue
if ( if (
(not tmdb_id or tmdb_id == item.tmdb_id) (not tmdb_id or tmdb_id == item.tmdb_id)
@@ -280,7 +330,7 @@ class TrimeMedia:
lib = self.__match_library_by_path(item.target_path) lib = self.__match_library_by_path(item.target_path)
if lib is None: if lib is None:
# 如果有匹配失败的,刷新整个库 # 如果有匹配失败的,刷新整个库
return self._api.mdb_scanall() return self.refresh_root_library()
# 媒体库去重 # 媒体库去重
libraries.add(lib.guid) libraries.add(lib.guid)
@@ -290,7 +340,7 @@ class TrimeMedia:
logger.info(f"刷新媒体库:{lib.name}") logger.info(f"刷新媒体库:{lib.name}")
if not self._api.mdb_scan(lib): if not self._api.mdb_scan(lib):
# 如果失败,刷新整个库 # 如果失败,刷新整个库
return self._api.mdb_scanall() return self.refresh_root_library()
return True return True
def __match_library_by_path(self, path: Path) -> Optional[fnapi.MediaDb]: def __match_library_by_path(self, path: Path) -> Optional[fnapi.MediaDb]:
@@ -336,7 +386,7 @@ class TrimeMedia:
if item.watched: if item.watched:
user_state.played = True user_state.played = True
if item.duration and item.ts is not None: 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 user_state.resume = True
if item.type is None: if item.type is None:
item_type = None item_type = None
@@ -361,40 +411,37 @@ class TrimeMedia:
""" """
拼装播放链接 拼装播放链接
""" """
if item.type == fnapi.Type.Episode: if item.type == fnapi.Type.EPISODE:
return f"{host}/v/tv/episode/{item.guid}" return f"{host}/tv/episode/{item.guid}"
elif item.type == fnapi.Type.Season: elif item.type == fnapi.Type.SEASON:
return f"{host}/v/tv/season/{item.guid}" return f"{host}/tv/season/{item.guid}"
elif item.type == fnapi.Type.Movie: elif item.type == fnapi.Type.MOVIE:
return f"{host}/v/movie/{item.guid}" return f"{host}/movie/{item.guid}"
elif item.type == fnapi.Type.TV: elif item.type == fnapi.Type.TV:
return f"{host}/v/tv/{item.guid}" return f"{host}/tv/{item.guid}"
else: else:
# 其它类型走通用页面,由飞牛来判断 # 其它类型走通用页面,由飞牛来判断
return f"{host}/v/other/{item.guid}" return f"{host}/other/{item.guid}"
def __build_media_server_play_item( def __build_media_server_play_item(
self, item: fnapi.Item self, item: fnapi.Item
) -> schemas.MediaServerPlayItem: ) -> schemas.MediaServerPlayItem:
""" if item.type == fnapi.Type.EPISODE:
:params use_backdrop: 是否优先使用Backdrop类型的图片
"""
if item.type == fnapi.Type.Episode:
title = item.tv_title title = item.tv_title
subtitle = f"S{item.season_number}:{item.episode_number} - {item.title}" subtitle = f"S{item.season_number}:{item.episode_number} - {item.title}"
else: else:
title = item.title title = item.title
subtitle = "电影" if item.type == fnapi.Type.Movie else "视频" subtitle = "电影" if item.type == fnapi.Type.MOVIE else "视频"
type = ( types = (
MediaType.MOVIE.value 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 else MediaType.TV.value
) )
return schemas.MediaServerPlayItem( return schemas.MediaServerPlayItem(
id=item.guid, id=item.guid,
title=title, title=title,
subtitle=subtitle, subtitle=subtitle,
type=type, type=types,
image=f"{self._api.host}{item.poster}", image=f"{self._api.host}{item.poster}",
link=self.__build_play_url(self._playhost or self._api.host, item), link=self.__build_play_url(self._playhost or self._api.host, item),
percent=( percent=(
@@ -421,22 +468,22 @@ class TrimeMedia:
""" """
if not self.is_authenticated(): if not self.is_authenticated():
return None return None
if (SIZE := limit) is None: if (page_size := limit) is None:
SIZE = -1 page_size = -1
items = ( items = (
self._api.item_list( self._api.item_list(
guid=parent, guid=parent,
page=start_index + 1, page=start_index + 1,
page_size=SIZE, page_size=page_size,
type=[fnapi.Type.Movie, fnapi.Type.TV, fnapi.Type.Directory], types=[fnapi.Type.MOVIE, fnapi.Type.TV, fnapi.Type.DIRECTORY],
) )
or [] or []
) )
for item in items: 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): for items in self.get_items(parent=item.guid):
yield items 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) yield self.__build_media_server_item(item)
return None return None
@@ -482,7 +529,7 @@ class TrimeMedia:
self._api.item_list( self._api.item_list(
page=1, page=1,
page_size=max(100, num * 5), page_size=max(100, num * 5),
type=[fnapi.Type.Movie, fnapi.Type.TV], types=[fnapi.Type.MOVIE, fnapi.Type.TV],
) )
or [] or []
) )
@@ -505,7 +552,7 @@ class TrimeMedia:
self._api.item_list( self._api.item_list(
page=1, page=1,
page_size=max(100, num * 5), page_size=max(100, num * 5),
type=[fnapi.Type.Movie, fnapi.Type.TV], types=[fnapi.Type.MOVIE, fnapi.Type.TV],
) )
or [] or []
) )
@@ -534,7 +581,7 @@ class TrimeMedia:
def __is_library_blocked(self, library_guid: str): def __is_library_blocked(self, library_guid: str):
if library := self._libraries.get(library_guid): if library := self._libraries.get(library_guid):
if library.category == fnapi.Category.Others: if library.category == fnapi.Category.OTHERS:
# 忽略这个库 # 忽略这个库
return True return True
return ( return (