kodi_module.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. import json
  2. from typing import Any, Dict
  3. import requests
  4. import time
  5. import threading
  6. import yaml # Add yaml import
  7. import os
  8. import sys
  9. from utils.logger_config import logger
  10. class VideoInfo:
  11. def __init__(self, name, formula, description, video_duration, video_path, id=0):
  12. self.name = name
  13. self.formula = formula
  14. self.description = description
  15. self.video_duration = video_duration
  16. self.video_path = video_path
  17. self.id = id
  18. class KodiClient:
  19. def __init__(self, host="localhost", port=8080, username=None, password=None, id=0):
  20. self.host = host
  21. self.port = port
  22. self.id = id
  23. self.url = f"http://{host}:{port}/jsonrpc"
  24. self.headers = {'Content-Type': 'application/json'}
  25. # 非独立的客户端默认播放展品视频,独立情况下可能会播放图片或监控
  26. self.isIndividual = False
  27. # 启动kodi的url
  28. self.my_app_name = "org.xbmc.kodi" # kodi的包名
  29. self.mitv_startapp_url = f"http://{host}:6095/controller?action=startapp&&type=packagename&packagename={self.my_app_name}"
  30. if username and password:
  31. self.auth = (username, password)
  32. else:
  33. self.auth = None
  34. self.ready_event = threading.Event()
  35. def _send_request(self, method, params=None):
  36. data = {
  37. "jsonrpc": "2.0",
  38. "method": method,
  39. "id": 1,
  40. }
  41. if params:
  42. data["params"] = params
  43. response = requests.post(
  44. self.url,
  45. data=json.dumps(data),
  46. headers=self.headers,
  47. auth=self.auth
  48. )
  49. return response.json()
  50. def _send_request_async(self, method, params=None):
  51. """在后台线程中发送JSON-RPC请求,立即返回,不阻塞当前调用。"""
  52. def _worker():
  53. try:
  54. resp = self._send_request(method, params)
  55. logger.debug(f"[async] {method} 响应: {resp}")
  56. except Exception as e:
  57. logger.warning(f"[async] {method} 调用异常: {e}")
  58. t = threading.Thread(target=_worker, daemon=True)
  59. t.start()
  60. return True
  61. # 播放url图片
  62. def play_url_image(self, image_url):
  63. """播放指定url的图片文件(异步派发)。"""
  64. params = {"item": {"file": image_url}}
  65. self._send_request_async("Player.Open", params)
  66. logger.info(f"[async] Player.Open(url image) 已派发: {image_url}")
  67. return {"queued": True, "file": image_url}
  68. # 播放rtsp视频流
  69. def play_rtsp_video(self, rtsp_url):
  70. """播放指定rtsp视频流(异步派发)。"""
  71. params = {"item": {"file": rtsp_url}}
  72. self._send_request_async("Player.Open", params)
  73. logger.info(f"[async] Player.Open(rtsp) 已派发: {rtsp_url}")
  74. return {"queued": True, "file": rtsp_url}
  75. def play_video(self, video_path, loop=False):
  76. """播放指定路径的视频文件(异步派发)。"""
  77. params = {"item": {"file": video_path}}
  78. self._send_request_async("Player.Open", params)
  79. logger.info(f"[async] Player.Open(video) 已派发: {video_path}")
  80. return {"queued": True, "file": video_path}
  81. def play_playlist_looped(self, video_paths):
  82. """清空播放列表,添加多个视频,并循环播放(整体异步派发)。"""
  83. if not isinstance(video_paths, list) or not video_paths:
  84. logger.error("错误:video_paths 必须是一个非空列表。")
  85. return None
  86. playlist_id = 1
  87. def _playlist_worker():
  88. try:
  89. logger.info(f"[async] Playlist.Clear -> {playlist_id}")
  90. self._send_request("Playlist.Clear", {"playlistid": playlist_id})
  91. for vp in video_paths:
  92. logger.info(f"[async] Playlist.Add -> {vp}")
  93. self._send_request("Playlist.Add", {"playlistid": playlist_id, "item": {"file": vp}})
  94. logger.info(f"[async] Player.Open playlist -> position 0")
  95. self._send_request("Player.Open", {"item": {"playlistid": playlist_id, "position": 0}})
  96. logger.info(f"[async] Player.SetRepeat(all) -> playerid 1")
  97. self._send_request("Player.SetRepeat", {"playerid": 1, "repeat": "all"})
  98. except Exception as e:
  99. logger.warning(f"[async] 播放列表派发异常: {e}")
  100. threading.Thread(target=_playlist_worker, daemon=True).start()
  101. return {"queued": True, "playlist": len(video_paths)}
  102. def _get_active_player_id(self):
  103. """获取当前活动的播放器ID"""
  104. try:
  105. response = self._send_request("Player.GetActivePlayers")
  106. logger.debug(f"Player.GetActivePlayers 响应: {response}")
  107. if response and response.get('result'):
  108. players = response['result']
  109. if players:
  110. return players[0].get('playerid')
  111. logger.warning("未能从响应中找到有效的播放器ID。")
  112. return None
  113. except Exception as e:
  114. logger.error(f"获取活动播放器ID时出错: {str(e)}")
  115. return None
  116. def stop_playback(self):
  117. """停止当前播放(异步派发)。"""
  118. # 直接尝试默认播放器1,避免阻塞
  119. self._send_request_async("Player.Stop", {"playerid": 1})
  120. logger.info("[async] Player.Stop 已派发 (playerid=1)")
  121. return {"queued": True}
  122. def pause_playback(self):
  123. """暂停/继续播放(异步派发)。"""
  124. self._send_request_async("Player.PlayPause", {"playerid": 1})
  125. logger.info("[async] Player.PlayPause 已派发 (playerid=1)")
  126. return {"queued": True}
  127. def set_volume(self, volume):
  128. """设置Kodi音量 (0-100)"""
  129. if not isinstance(volume, int) or not 0 <= volume <= 100:
  130. logger.error("错误:音量必须是 0 到 100 之间的整数。")
  131. return None
  132. params = {"volume": volume}
  133. # 异步发送,不阻塞调用方
  134. self._send_request_async("Application.SetVolume", params)
  135. logger.info(f"Application.SetVolume ({volume}%) 已异步派发")
  136. return {"queued": True, "volume": volume}
  137. def get_player_state(self):
  138. """获取当前播放器状态(为避免阻塞,改为异步派发查询,并返回占位数据)。"""
  139. self._send_request_async('Player.GetActivePlayers')
  140. logger.info("[async] Player.GetActivePlayers 已派发(不阻塞)")
  141. # 占位返回,避免调用方阻塞;如需真实状态,应改造为回调/轮询机制
  142. return {'queued': True}
  143. def set_ready(self):
  144. """设置客户端准备就绪"""
  145. self.ready_event.set()
  146. def wait_for_ready(self, timeout=10):
  147. """等待客户端准备就绪"""
  148. return self.ready_event.wait(timeout)
  149. def set_individual(self, isIndividual=False):
  150. """设置客户端是否为独立客户端"""
  151. self.isIndividual = isIndividual
  152. def get_individual(self):
  153. """获取客户端是否为独立客户端"""
  154. return self.isIndividual
  155. # 启动kodi
  156. def start_kodi(self):
  157. """启动kodi"""
  158. res = requests.get(self.mitv_startapp_url, timeout=3).json()
  159. logger.info(f"启动kodi响应: {res}")
  160. return res
  161. # kodi心跳检测,检查kodi客户端是否在线
  162. def kodi_heartbeat_check(self):
  163. """检查kodi客户端是否在线"""
  164. try:
  165. # 使用JSON-RPC请求进行心跳检测,支持认证
  166. data = {
  167. "jsonrpc": "2.0",
  168. "method": "JSONRPC.Version",
  169. "id": 1
  170. }
  171. response = requests.post(
  172. self.url,
  173. data=json.dumps(data),
  174. headers=self.headers,
  175. auth=self.auth,
  176. timeout=3
  177. )
  178. # 检查HTTP状态码
  179. if response.status_code != 200:
  180. logger.warning(f"kodi心跳检测失败: HTTP状态码 {response.status_code}")
  181. return False
  182. # 检查响应内容是否为空
  183. if not response.text or response.text.strip() == '':
  184. logger.warning(f"kodi心跳检测失败: 响应内容为空")
  185. return False
  186. # 尝试解析JSON
  187. try:
  188. json_data = response.json()
  189. # JSONRPC.Version返回的结果包含version字段表示成功
  190. if json_data.get('result') is not None:
  191. return True
  192. else:
  193. return False
  194. except json.JSONDecodeError as json_err:
  195. logger.warning(f"kodi心跳检测失败: JSON解析错误 - {json_err}, 响应内容: {response.text[:100]}")
  196. return False
  197. except requests.exceptions.Timeout:
  198. logger.warning(f"kodi心跳检测超时: 连接 {self.url} 超时")
  199. return False
  200. except requests.exceptions.ConnectionError:
  201. logger.warning(f"kodi心跳检测失败: 无法连接到 {self.url}")
  202. return False
  203. except Exception as e:
  204. logger.error(f"kodi心跳检测异常: {e}")
  205. return False
  206. class KodiClientManager():
  207. def __init__(self):
  208. self.kodi_clients = []
  209. self.video_infos = []
  210. # 生产环境配置文件路径
  211. self.kodi_config_path = 'kodi_config_prod.yaml'
  212. self.video_config_path = 'video_config_prod.yaml'
  213. # 开发环境配置文件路径
  214. # self.kodi_config_path = 'kodi_config_test.yaml'
  215. # self.video_config_path = 'video_config_test.yaml'
  216. # 只有一台可以有声音,其他没有声音,这是音量
  217. self.volume = 65
  218. self._init_kodi_clients_from_config()
  219. self._init_video_infos_from_config()
  220. def set_volume(self, volume):
  221. # 设置播放视频的音量
  222. self.volume = volume
  223. def _init_video_infos_from_config(self):
  224. config = self._load_config(self.video_config_path)
  225. video_infos_config = config.get('video_infos', [])
  226. for video_info_config in video_infos_config:
  227. video_info = VideoInfo(video_info_config.get('name'), video_info_config.get('formula'), video_info_config.get('description'), video_info_config.get('video_duration'), video_info_config.get('video_path'), video_info_config.get('id'))
  228. logger.info(f"成功加载视频信息: {video_info.name}")
  229. self.video_infos.append(video_info)
  230. def _load_config(self, config_path) -> Dict[str, Any]:
  231. """
  232. 从YAML配置文件加载配置
  233. Returns:
  234. dict: 配置字典
  235. Raises:
  236. FileNotFoundError: 配置文件不存在
  237. yaml.YAMLError: YAML解析错误
  238. Exception: 其他加载错误
  239. """
  240. if not os.path.exists(config_path):
  241. error_msg = f"配置文件不存在: {config_path}"
  242. logger.error(error_msg)
  243. raise FileNotFoundError(error_msg)
  244. try:
  245. with open(config_path, 'r', encoding='utf-8') as file:
  246. config = yaml.safe_load(file)
  247. if config is None:
  248. error_msg = f"配置文件为空或格式错误: {config_path}"
  249. logger.error(error_msg)
  250. raise ValueError(error_msg)
  251. logger.info(f"成功加载配置文件: {config_path}")
  252. return config
  253. except yaml.YAMLError as e:
  254. error_msg = f"YAML解析错误: {e}"
  255. logger.error(error_msg)
  256. raise
  257. except Exception as e:
  258. error_msg = f"加载配置文件失败: {e}"
  259. logger.error(error_msg)
  260. raise
  261. def _init_kodi_clients_from_config(self):
  262. config = self._load_config(self.kodi_config_path)
  263. kodi_servers_config = config.get('kodi_servers', [])
  264. if not kodi_servers_config:
  265. logger.error("未在 config.yaml 中找到有效的 Kodi 服务器配置,脚本将退出。")
  266. exit()
  267. # 创建 Kodi 客户端实例列表
  268. for server_config in kodi_servers_config:
  269. client = KodiClient(
  270. host=server_config.get('ip', 'localhost'),
  271. port=server_config.get('port', 8080),
  272. username=server_config.get('username'),
  273. password=server_config.get('password'),
  274. id=server_config.get('id', 0)
  275. )
  276. try:
  277. if hasattr(client, 'set_volume') and callable(getattr(client, 'set_volume')):
  278. client.set_volume(65)
  279. else:
  280. # 兼容旧构建:直接通过 JSON-RPC 设置音量
  281. client._send_request("Application.SetVolume", {"volume": 65})
  282. except Exception as e:
  283. logger.warning(f"设置音量时出现问题(已忽略以继续):{e}")
  284. self.kodi_clients.append(client)
  285. def _resolve_config_path(self, filename = "kodi_config.yaml"):
  286. """Return absolute path to config file, located next to the script/exe.
  287. When bundled with PyInstaller, sys.frozen is True and sys.executable points to the exe.
  288. In normal execution, use the directory of this file.
  289. """
  290. try:
  291. if getattr(sys, "frozen", False):
  292. base_dir = os.path.dirname(sys.executable)
  293. else:
  294. base_dir = os.path.dirname(os.path.abspath(__file__))
  295. return os.path.join(base_dir, filename)
  296. except Exception:
  297. # Fallback to current working directory
  298. return filename
  299. def sync_play_video(self, clients, video_path, loop=False):
  300. """同步播放视频"""
  301. # 创建一个共享的Event来控制所有客户端同时开始播放
  302. start_event = threading.Event()
  303. logger.info(f"开始同步播放视频: {video_path}")
  304. # 创建播放线程
  305. def play_thread(client):
  306. # 等待开始信号
  307. start_event.wait()
  308. # 执行播放
  309. result = client.play_video(video_path, loop=loop)
  310. logger.info(f"播放结果: {result}")
  311. # 启动所有播放线程
  312. threads = []
  313. client_index = 0
  314. for client in clients:
  315. if client.get_individual():
  316. client_index += 1
  317. continue
  318. # 只对第一台设置音量
  319. if client == clients[client_index]:
  320. client.set_volume(self.volume)
  321. else:
  322. client.set_volume(0)
  323. thread = threading.Thread(target=play_thread, args=(client,))
  324. threads.append(thread)
  325. thread.start()
  326. # 等待所有线程准备就绪
  327. time.sleep(0.1) # 给线程一点时间启动
  328. # 同时触发所有客户端开始播放
  329. start_event.set()
  330. # 等待所有线程完成
  331. for thread in threads:
  332. thread.join()
  333. # 指定某台播放url图片
  334. def play_url_image_on_client(self, client, image_url):
  335. """指定某台播放url图片"""
  336. client.isIndividual = True
  337. return client.play_url_image(image_url)
  338. # 指定某台播放rtsp视频流
  339. def play_rtsp_video_on_client(self, client, rtsp_url,volume=0):
  340. """指定某台播放rtsp视频流"""
  341. client.isIndividual = True
  342. client.set_volume(volume)
  343. return client.play_rtsp_video(rtsp_url)
  344. # 撤销所有客户端的独立状态
  345. def revoke_individual_state(self):
  346. """撤销所有客户端的独立状态"""
  347. for client in self.kodi_clients:
  348. client.isIndividual = False
  349. client.stop_playback()
  350. # 启动所有kodi应用程序
  351. def start_all_kodi_apps(self):
  352. """启动所有kodi应用程序"""
  353. for client in self.kodi_clients:
  354. client.start_kodi()
  355. # 检查所有kodi客户端是否在线,如果有不在线返回不在线的client_index
  356. def check_all_kodi_clients_online(self):
  357. """检查所有kodi客户端是否在线"""
  358. client_index = 0
  359. for client in self.kodi_clients:
  360. if not client.kodi_heartbeat_check():
  361. return client_index
  362. client_index += 1
  363. return -1