mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
618 lines
19 KiB
Python
618 lines
19 KiB
Python
import hashlib
|
|
import json
|
|
import random
|
|
import time
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from typing import List, Optional, Union
|
|
from urllib.parse import quote
|
|
|
|
from app.runtime.settings import get_runtime_setting
|
|
|
|
from app.runtime.log import logger
|
|
from app.adapters.network.http import RequestUtils, requests
|
|
|
|
|
|
@dataclass
|
|
class User:
|
|
guid: str
|
|
username: str
|
|
is_admin: int = 0
|
|
|
|
|
|
class Category(Enum):
|
|
MOVIE = "Movie"
|
|
TV = "TV"
|
|
MIX = "Mix"
|
|
OTHERS = "Others"
|
|
|
|
@classmethod
|
|
def _missing_(cls, value):
|
|
return cls.OTHERS
|
|
|
|
|
|
class Type(Enum):
|
|
MOVIE = "Movie"
|
|
TV = "TV"
|
|
SEASON = "Season"
|
|
EPISODE = "Episode"
|
|
VIDEO = "Video"
|
|
DIRECTORY = "Directory"
|
|
|
|
@classmethod
|
|
def _missing_(cls, value):
|
|
return cls.VIDEO
|
|
|
|
|
|
@dataclass
|
|
class MediaDb:
|
|
guid: str
|
|
category: Category
|
|
name: Optional[str] = None
|
|
posters: Optional[list[str]] = None
|
|
dir_list: Optional[list[str]] = None
|
|
|
|
|
|
@dataclass
|
|
class MediaDbSummary:
|
|
favorite: int = 0
|
|
movie: int = 0
|
|
tv: int = 0
|
|
video: int = 0
|
|
total: int = 0
|
|
|
|
|
|
@dataclass
|
|
class Version:
|
|
# 飞牛影视版本
|
|
frontend: Optional[str] = None
|
|
backend: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class Item:
|
|
guid: str
|
|
ancestor_guid: str = ""
|
|
type: Optional[Type] = None
|
|
# 当type为Episode时是剧名,parent_title是季名,title作为分集名称
|
|
tv_title: Optional[str] = None
|
|
parent_title: Optional[str] = None
|
|
title: Optional[str] = None
|
|
original_title: Optional[str] = None
|
|
overview: Optional[str] = None
|
|
poster: Optional[str] = None
|
|
backdrops: Optional[str] = None
|
|
posters: Optional[str] = None
|
|
douban_id: Optional[int] = None
|
|
imdb_id: Optional[str] = None
|
|
trim_id: Optional[str] = None
|
|
release_date: Optional[str] = None
|
|
air_date: Optional[str] = None
|
|
vote_average: Optional[str] = None
|
|
season_number: Optional[int] = None
|
|
episode_number: Optional[int] = None
|
|
duration: Optional[int] = None # 片长(秒)
|
|
ts: Optional[int] = None # 已播放(秒)
|
|
watched: Optional[int] = None # 1:已看完
|
|
|
|
@property
|
|
def tmdb_id(self) -> Optional[int]:
|
|
if self.trim_id is None:
|
|
return None
|
|
if self.trim_id.startswith("tt") or self.trim_id.startswith("tm"):
|
|
# 飞牛给tmdbid加了前缀用以区分tv或movie
|
|
return int(self.trim_id[2:])
|
|
return None
|
|
|
|
|
|
class Api:
|
|
__slots__ = (
|
|
"_host",
|
|
"_token",
|
|
"_apikey",
|
|
"_access_code",
|
|
"_api_path",
|
|
"_request_utils",
|
|
"_version",
|
|
"_session",
|
|
)
|
|
|
|
@property
|
|
def token(self) -> Optional[str]:
|
|
return self._token
|
|
|
|
@property
|
|
def host(self) -> str:
|
|
return self._host
|
|
|
|
@property
|
|
def apikey(self) -> str:
|
|
return self._apikey
|
|
|
|
@property
|
|
def version(self) -> Optional[Version]:
|
|
return self._version
|
|
|
|
@property
|
|
def cookies(self) -> dict:
|
|
"""
|
|
当前会话的Cookies,开启访问码后包含访问码校验凭证
|
|
"""
|
|
return self._session.cookies.get_dict()
|
|
|
|
def __init__(self, host: str, apikey: str, access_code: Optional[str] = None):
|
|
"""
|
|
:param host: 飞牛服务端地址,如http://127.0.0.1:5666/v
|
|
:param access_code: 访问码,未开启时为空
|
|
"""
|
|
self._api_path = "/api/v1"
|
|
self._host = host.rstrip("/")
|
|
self._apikey = apikey
|
|
self._access_code = access_code
|
|
self._token: Optional[str] = None
|
|
self._version: Optional[Version] = None
|
|
self._session = requests.Session()
|
|
self._request_utils = RequestUtils(session=self._session, timeout=10)
|
|
|
|
def verify_access_code(self) -> bool:
|
|
"""
|
|
校验访问码,通过后会话获得访问凭证,否则无法访问登录页和各应用接口
|
|
|
|
:return: 未配置访问码或校验通过返回True
|
|
"""
|
|
if not self._access_code:
|
|
return True
|
|
# 访问码校验地址位于设备根路径,不在/v下
|
|
root = self._host[: -len("/v")] if self._host.endswith("/v") else self._host
|
|
url = f"{root}/c/{quote(self._access_code, safe='')}"
|
|
res = self._request_utils.get_res(url, allow_redirects=True)
|
|
if res is None:
|
|
logger.error(f"校验飞牛访问码失败,无法访问 {url}")
|
|
return False
|
|
if res.status_code == 404:
|
|
# 访问码错误或校验失败时返回404
|
|
logger.error("飞牛访问码校验失败,请检查访问码是否正确")
|
|
return False
|
|
if not res.ok:
|
|
logger.error(f"飞牛访问码校验失败,状态码:{res.status_code}")
|
|
return False
|
|
return True
|
|
|
|
def sys_version(self) -> Optional[Version]:
|
|
"""
|
|
飞牛影视版本号
|
|
"""
|
|
if (res := self.request("/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]:
|
|
"""
|
|
登录飞牛影视
|
|
|
|
新版服务端已废弃 v1 明文登录接口,优先使用 v2 协议登录(密码传输 SHA256 摘要),
|
|
v2 接口不存在时回退旧版 v1 明文登录
|
|
|
|
:return: 成功返回token 否则返回None
|
|
"""
|
|
# 开启访问码后需先通过访问码校验,否则无法访问登录接口
|
|
if not self.verify_access_code():
|
|
return None
|
|
# v2 协议要求密码为明文密码的 SHA256 十六进制小写摘要
|
|
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
|
res = self.request(
|
|
"/user/loginByPassword",
|
|
data={
|
|
"username": username,
|
|
"password": password_hash,
|
|
"app_name": "trimemedia-web",
|
|
},
|
|
base_path="/api/v2",
|
|
suppress_log=True,
|
|
)
|
|
if res and res.success:
|
|
self._token = res.data.get("token")
|
|
return self._token
|
|
if res:
|
|
# v2 接口存在但登录失败(如账号密码错误),回退 v1 也无法成功
|
|
logger.error(f"飞牛影视登录失败,错误码:{res.code} {res.msg}")
|
|
return None
|
|
# v2 接口不可用(旧版服务端),回退 v1 明文登录
|
|
if (
|
|
res := self.request(
|
|
"/login",
|
|
data={
|
|
"username": username,
|
|
"password": password,
|
|
"app_name": "trimemedia-web",
|
|
},
|
|
)
|
|
) and res.success:
|
|
self._token = res.data.get("token")
|
|
return self._token
|
|
|
|
def logout(self) -> bool:
|
|
"""
|
|
退出账号
|
|
"""
|
|
if not self._token:
|
|
return True
|
|
if (res := self.request("/user/logout", method="post")) and res.success:
|
|
if res.data:
|
|
self._token = None
|
|
return True
|
|
return False
|
|
|
|
def user_list(self) -> Optional[list[User]]:
|
|
"""
|
|
用户列表(仅管理员有权访问)
|
|
"""
|
|
if (res := self.request("/manager/user/list")) and res.success:
|
|
if not res.data:
|
|
return []
|
|
return [
|
|
User(
|
|
guid=info.get("guid"),
|
|
username=info.get("username"),
|
|
is_admin=info.get("is_admin", 0),
|
|
)
|
|
for info in res.data
|
|
]
|
|
return None
|
|
|
|
def user_info(self) -> Optional[User]:
|
|
"""
|
|
当前用户信息
|
|
"""
|
|
if (res := self.request("/user/info")) and res.success:
|
|
_user = User("", "")
|
|
_user.__dict__.update(res.data)
|
|
return _user
|
|
return None
|
|
|
|
def mediadb_sum(self) -> Optional[MediaDbSummary]:
|
|
"""
|
|
媒体数量统计
|
|
"""
|
|
if (res := self.request("/mediadb/sum")) and res.success:
|
|
sums = MediaDbSummary()
|
|
sums.__dict__.update(res.data)
|
|
return sums
|
|
return None
|
|
|
|
def mediadb_list(self) -> Optional[List[MediaDb]]:
|
|
"""
|
|
媒体库列表(普通用户)
|
|
"""
|
|
if (res := self.request("/mediadb/list")) and res.success:
|
|
_items = []
|
|
for info in res.data or []:
|
|
mdb = MediaDb(
|
|
guid=info.get("guid"),
|
|
category=Category(info.get("category")),
|
|
name=info.get("title", ""),
|
|
posters=[
|
|
self.__build_img_api_url(poster)
|
|
for poster in info.get("posters", [])
|
|
],
|
|
)
|
|
_items.append(mdb)
|
|
return _items
|
|
return None
|
|
|
|
def __build_img_api_url(self, img_path: Optional[str]) -> Optional[str]:
|
|
if not img_path:
|
|
return None
|
|
if img_path[0] != "/":
|
|
img_path = "/" + img_path
|
|
return f"{self._api_path}/sys/img{img_path}"
|
|
|
|
def mdb_list(self) -> Optional[list[MediaDb]]:
|
|
"""
|
|
媒体库列表(管理员)
|
|
"""
|
|
if (res := self.request("/mdb/list")) and res.success:
|
|
_items = []
|
|
for info in res.data or []:
|
|
mdb = MediaDb(
|
|
guid=info.get("guid"),
|
|
category=Category(info.get("category")),
|
|
name=info.get("name", ""),
|
|
posters=[
|
|
self.__build_img_api_url(poster)
|
|
for poster in info.get("posters", [])
|
|
],
|
|
dir_list=info.get("dir_list"),
|
|
)
|
|
_items.append(mdb)
|
|
return _items
|
|
return None
|
|
|
|
def mdb_scanall(self) -> bool:
|
|
"""
|
|
扫描所有媒体库
|
|
"""
|
|
if (res := self.request("/mdb/scanall", method="post")) and res.success:
|
|
if res.data:
|
|
return True
|
|
return False
|
|
|
|
def mdb_scan(self, mdb: MediaDb) -> bool:
|
|
"""
|
|
扫描指定媒体库
|
|
"""
|
|
if (res := self.request(f"/mdb/scan/{mdb.guid}", data={})) and res.success:
|
|
if res.data:
|
|
return True
|
|
return False
|
|
|
|
def task_running(self):
|
|
"""
|
|
当前正在运行的任务
|
|
"""
|
|
if (res := self.request("/task/running")) and res.success:
|
|
if res.data:
|
|
# TODO 具体正在运行的任务
|
|
return True
|
|
return False
|
|
|
|
def __build_item(self, info: dict) -> Item:
|
|
"""
|
|
构造媒体Item
|
|
"""
|
|
item = Item(guid="")
|
|
item.__dict__.update(info)
|
|
item.type = Type(info.get("type"))
|
|
# Item详情接口才有posters和backdrops
|
|
item.posters = self.__build_img_api_url(item.posters)
|
|
item.backdrops = self.__build_img_api_url(item.backdrops)
|
|
item.poster = (
|
|
self.__build_img_api_url(item.poster) if item.poster else item.posters
|
|
)
|
|
return item
|
|
|
|
def item_list(
|
|
self,
|
|
guid: Optional[str] = None,
|
|
types=None,
|
|
exclude_grouped_video=True,
|
|
page=1,
|
|
page_size=20,
|
|
sort_by="create_time",
|
|
sort="DESC",
|
|
) -> Optional[list[Item]]:
|
|
"""
|
|
媒体列表
|
|
"""
|
|
if types is None:
|
|
types = [Type.MOVIE, Type.TV, Type.DIRECTORY, Type.VIDEO]
|
|
post = {
|
|
"tags": {"type": types} if types else {},
|
|
"sort_type": sort,
|
|
"sort_column": sort_by,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
}
|
|
if guid:
|
|
post["ancestor_guid"] = guid
|
|
if exclude_grouped_video:
|
|
post["exclude_grouped_video"] = 1
|
|
|
|
if (res := self.request("/item/list", data=post)) and res.success:
|
|
if not res.data:
|
|
return []
|
|
return [self.__build_item(info) for info in res.data.get("list", [])]
|
|
return None
|
|
|
|
def item_count(self, guid: str, types=None) -> Optional[int]:
|
|
"""
|
|
获取指定媒体库的媒体条目总数
|
|
|
|
:param guid: 媒体库GUID
|
|
:param types: 需要统计的媒体类型
|
|
:return: 媒体条目总数,查询失败时返回None
|
|
"""
|
|
if types is None:
|
|
types = [Type.MOVIE, Type.TV]
|
|
post = {
|
|
"ancestor_guid": guid,
|
|
"tags": {"type": types},
|
|
"exclude_grouped_video": 1,
|
|
"page": 1,
|
|
"page_size": 1,
|
|
}
|
|
if (res := self.request("/item/list", data=post)) and res.success:
|
|
if not res.data:
|
|
return 0
|
|
total_count = res.data.get("total")
|
|
if total_count is None:
|
|
total_count = res.data.get("total_count")
|
|
return int(total_count) if total_count is not None else None
|
|
return None
|
|
|
|
def search_list(self, keywords: str) -> Optional[list[Item]]:
|
|
"""
|
|
搜索影片、演员
|
|
"""
|
|
if (
|
|
res := self.request("/search/list", params={"q": keywords})
|
|
) and res.success:
|
|
if not res.data:
|
|
return []
|
|
return [self.__build_item(info) for info in res.data]
|
|
return None
|
|
|
|
def item(self, guid: str) -> Optional[Item]:
|
|
"""
|
|
查询媒体详情
|
|
"""
|
|
if (res := self.request(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 guid: 媒体GUID
|
|
:param delete_file: True删除媒体文件,False仅从媒体库移除
|
|
"""
|
|
if (
|
|
res := self.request(
|
|
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(f"/season/list/{tv_guid}")) and res.success:
|
|
if not res.data:
|
|
return []
|
|
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(f"/episode/list/{season_guid}")) and res.success:
|
|
if not res.data:
|
|
return []
|
|
return [self.__build_item(info) for info in res.data]
|
|
return None
|
|
|
|
def play_list(self) -> Optional[list[Item]]:
|
|
"""
|
|
继续观看列表
|
|
"""
|
|
if (res := self.request("/play/list")) and res.success:
|
|
if not res.data:
|
|
return []
|
|
return [self.__build_item(info) for info in res.data]
|
|
return None
|
|
|
|
def __get_authx(self, api_path: str, body: Optional[str]):
|
|
"""
|
|
计算消息签名
|
|
"""
|
|
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()
|
|
md5.update((body or "").encode())
|
|
data_hash = md5.hexdigest()
|
|
md5 = hashlib.md5()
|
|
md5.update(
|
|
"_".join(
|
|
[
|
|
"NDzZTVxnRKP8Z0jXg1VAMonaG8akvh",
|
|
api_path,
|
|
nonce,
|
|
ts,
|
|
data_hash,
|
|
self._apikey,
|
|
]
|
|
).encode()
|
|
)
|
|
sign = md5.hexdigest()
|
|
return f"nonce={nonce}×tamp={ts}&sign={sign}"
|
|
|
|
def request(
|
|
self,
|
|
api: str,
|
|
method: Optional[str] = None,
|
|
params: Optional[dict] = None,
|
|
data: Optional[dict] = None,
|
|
base_path: Optional[str] = None,
|
|
suppress_log=False,
|
|
):
|
|
"""
|
|
请求飞牛影视API
|
|
|
|
:param base_path: 接口路径前缀(如 /api/v2),默认使用 v1 路径
|
|
:param suppress_log: 是否禁止日志
|
|
"""
|
|
|
|
@dataclass
|
|
class Result:
|
|
@property
|
|
def success(self) -> bool:
|
|
return code == 0
|
|
|
|
code: int
|
|
msg: Optional[str] = None
|
|
data: Optional[Union[dict, list, str, bool]] = None
|
|
|
|
class JsonEncoder(json.JSONEncoder):
|
|
def default(self, obj):
|
|
if isinstance(obj, Type):
|
|
return obj.value
|
|
return super().default(obj)
|
|
|
|
if not self._host or not api:
|
|
return None
|
|
prefix = base_path if base_path is not None else self._api_path
|
|
if not api.startswith("/"):
|
|
api_path = f"{prefix}/{api}"
|
|
else:
|
|
api_path = prefix + api
|
|
url = self._host + api_path
|
|
if method is None:
|
|
method = "get" if data is None else "post"
|
|
if method != "get":
|
|
json_body = (
|
|
json.dumps(data, allow_nan=False, cls=JsonEncoder) if data else ""
|
|
)
|
|
else:
|
|
json_body = None
|
|
if params:
|
|
queries_unquoted = "&".join([f"{k}={v}" for k, v in params.items()])
|
|
else:
|
|
queries_unquoted = None
|
|
headers = {
|
|
"User-Agent": get_runtime_setting('USER_AGENT'),
|
|
"Accept": "application/json",
|
|
"Referer": self._host,
|
|
"Authorization": self._token,
|
|
"authx": self.__get_authx(api_path, json_body or queries_unquoted),
|
|
}
|
|
if json_body is not None:
|
|
headers["Content-Type"] = "application/json"
|
|
try:
|
|
res = self._request_utils.request(
|
|
method=method, url=url, headers=headers, params=params, data=json_body
|
|
)
|
|
if res:
|
|
resp = res.json()
|
|
msg = resp.get("msg")
|
|
if code := int(resp.get("code", -1)):
|
|
if not suppress_log:
|
|
logger.error(f"请求接口 {url} 失败,错误码:{code} {msg}")
|
|
return Result(code, msg)
|
|
return Result(0, msg, resp.get("data"))
|
|
elif not suppress_log:
|
|
logger.error(f"请求接口 {url} 失败")
|
|
except Exception as e:
|
|
if not suppress_log:
|
|
logger.error(f"请求接口 {url} 异常:" + str(e))
|
|
return None
|
|
|
|
def close(self):
|
|
"""
|
|
关闭API会话
|
|
"""
|
|
if self._session:
|
|
self._session.close()
|