kodi_module.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  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. """播放指定路径的视频文件(异步派发),loop为是否循环播放"""
  77. params = {"item": {"file": video_path}}
  78. def _play_video_worker():
  79. """异步工作线程:播放视频并根据loop参数设置循环"""
  80. try:
  81. response = self._send_request("Player.Open", params)
  82. logger.info(f"[async] Player.Open(video) 响应: {response}")
  83. if loop:
  84. # 如果需要循环播放,等待视频加载后设置循环
  85. if response and response.get('result') == 'OK':
  86. logger.info("[async] Player.Open 调用成功,等待视频加载后设置循环...")
  87. # 等待视频加载 (根据观察到的 5 秒加载时间设置)
  88. wait_time = 5.5
  89. logger.info(f"[async] 等待 {wait_time} 秒让视频加载...")
  90. time.sleep(wait_time)
  91. # 尝试直接使用 playerid: 1,并验证其类型
  92. player_id_to_try = 1
  93. try:
  94. properties_to_get = ["speed", "type"]
  95. get_props_params = {"playerid": player_id_to_try, "properties": properties_to_get}
  96. props_response = self._send_request("Player.GetProperties", get_props_params)
  97. logger.info(f"[async] Player.GetProperties (ID: {player_id_to_try}) 响应: {props_response}")
  98. if props_response and props_response.get('result') and props_response['result'].get('type') == 'video':
  99. logger.info(f"[async] 播放器 {player_id_to_try} 确认为视频播放器。尝试设置循环...")
  100. repeat_params = {"playerid": player_id_to_try, "repeat": "all"}
  101. repeat_response = self._send_request("Player.SetRepeat", repeat_params)
  102. logger.info(f"[async] Player.SetRepeat 响应: {repeat_response}")
  103. if repeat_response and repeat_response.get('result') == 'OK':
  104. logger.info("[async] 循环播放设置成功。")
  105. else:
  106. logger.warning(f"[async] 设置循环播放失败: {repeat_response}")
  107. elif props_response and props_response.get('error'):
  108. logger.warning(f"[async] 获取播放器 {player_id_to_try} 属性时出错: {props_response['error']} - 可能播放器尚未就绪或 ID 不正确")
  109. logger.warning("[async] 无法确认播放器状态,不设置循环。")
  110. else:
  111. logger.warning(f"[async] 播放器 {player_id_to_try} 不是预期的视频播放器或未找到。类型: {props_response.get('result', {}).get('type')},不设置循环。")
  112. except Exception as e:
  113. logger.error(f"[async] 在尝试获取属性或设置循环时发生错误: {str(e)}")
  114. else:
  115. logger.info(f"[async] Player.Open(video) 已派发,不设置循环: {video_path}")
  116. except Exception as e:
  117. logger.warning(f"[async] Player.Open(video) 调用异常: {e}")
  118. t = threading.Thread(target=_play_video_worker, daemon=True)
  119. t.start()
  120. logger.info(f"[async] Player.Open(video) 已派发: {video_path}, 循环: {loop}")
  121. return {"queued": True, "file": video_path, "loop": loop}
  122. def play_playlist_looped(self, video_paths):
  123. """清空播放列表,添加多个视频,并循环播放(整体异步派发)。"""
  124. if not isinstance(video_paths, list) or not video_paths:
  125. logger.error("错误:video_paths 必须是一个非空列表。")
  126. return None
  127. playlist_id = 1
  128. def _playlist_worker():
  129. try:
  130. logger.info(f"[async] Playlist.Clear -> {playlist_id}")
  131. self._send_request("Playlist.Clear", {"playlistid": playlist_id})
  132. for vp in video_paths:
  133. logger.info(f"[async] Playlist.Add -> {vp}")
  134. self._send_request("Playlist.Add", {"playlistid": playlist_id, "item": {"file": vp}})
  135. logger.info(f"[async] Player.Open playlist -> position 0")
  136. self._send_request("Player.Open", {"item": {"playlistid": playlist_id, "position": 0}})
  137. logger.info(f"[async] Player.SetRepeat(all) -> playerid 1")
  138. self._send_request("Player.SetRepeat", {"playerid": 1, "repeat": "all"})
  139. except Exception as e:
  140. logger.warning(f"[async] 播放列表派发异常: {e}")
  141. threading.Thread(target=_playlist_worker, daemon=True).start()
  142. return {"queued": True, "playlist": len(video_paths)}
  143. def _get_active_player_id(self):
  144. """获取当前活动的播放器ID"""
  145. try:
  146. response = self._send_request("Player.GetActivePlayers")
  147. logger.debug(f"Player.GetActivePlayers 响应: {response}")
  148. if response and response.get('result'):
  149. players = response['result']
  150. if players:
  151. return players[0].get('playerid')
  152. logger.warning("未能从响应中找到有效的播放器ID。")
  153. return None
  154. except Exception as e:
  155. logger.error(f"获取活动播放器ID时出错: {str(e)}")
  156. return None
  157. def stop_playback(self):
  158. """停止当前播放(异步派发)。"""
  159. # 直接尝试默认播放器1,避免阻塞
  160. self._send_request_async("Player.Stop", {"playerid": 1})
  161. logger.info("[async] Player.Stop 已派发 (playerid=1)")
  162. return {"queued": True}
  163. def pause_playback(self):
  164. """暂停/继续播放(异步派发)。"""
  165. self._send_request_async("Player.PlayPause", {"playerid": 1})
  166. logger.info("[async] Player.PlayPause 已派发 (playerid=1)")
  167. return {"queued": True}
  168. def set_volume(self, volume):
  169. """设置Kodi音量 (0-100)"""
  170. if not isinstance(volume, int) or not 0 <= volume <= 100:
  171. logger.error("错误:音量必须是 0 到 100 之间的整数。")
  172. return None
  173. params = {"volume": volume}
  174. # 异步发送,不阻塞调用方
  175. self._send_request_async("Application.SetVolume", params)
  176. logger.info(f"Application.SetVolume ({volume}%) 已异步派发")
  177. return {"queued": True, "volume": volume}
  178. def get_player_state(self):
  179. """获取当前播放器状态(为避免阻塞,改为异步派发查询,并返回占位数据)。"""
  180. self._send_request_async('Player.GetActivePlayers')
  181. logger.info("[async] Player.GetActivePlayers 已派发(不阻塞)")
  182. # 占位返回,避免调用方阻塞;如需真实状态,应改造为回调/轮询机制
  183. return {'queued': True}
  184. def set_ready(self):
  185. """设置客户端准备就绪"""
  186. self.ready_event.set()
  187. def wait_for_ready(self, timeout=10):
  188. """等待客户端准备就绪"""
  189. return self.ready_event.wait(timeout)
  190. def set_individual(self, isIndividual=False):
  191. """设置客户端是否为独立客户端"""
  192. self.isIndividual = isIndividual
  193. def get_individual(self):
  194. """获取客户端是否为独立客户端"""
  195. return self.isIndividual
  196. # 启动kodi
  197. def start_kodi(self):
  198. """启动kodi"""
  199. res = requests.get(self.mitv_startapp_url, timeout=3).json()
  200. logger.info(f"启动kodi响应: {res}")
  201. return res
  202. # kodi心跳检测,检查kodi客户端是否在线
  203. def kodi_heartbeat_check(self):
  204. """检查kodi客户端是否在线"""
  205. try:
  206. # 使用JSON-RPC请求进行心跳检测,支持认证
  207. data = {
  208. "jsonrpc": "2.0",
  209. "method": "JSONRPC.Version",
  210. "id": 1
  211. }
  212. response = requests.post(
  213. self.url,
  214. data=json.dumps(data),
  215. headers=self.headers,
  216. auth=self.auth,
  217. timeout=3
  218. )
  219. # 检查HTTP状态码
  220. if response.status_code != 200:
  221. logger.warning(f"kodi心跳检测失败: HTTP状态码 {response.status_code}")
  222. return False
  223. # 检查响应内容是否为空
  224. if not response.text or response.text.strip() == '':
  225. logger.warning(f"kodi心跳检测失败: 响应内容为空")
  226. return False
  227. # 尝试解析JSON
  228. try:
  229. json_data = response.json()
  230. # JSONRPC.Version返回的结果包含version字段表示成功
  231. if json_data.get('result') is not None:
  232. return True
  233. else:
  234. return False
  235. except json.JSONDecodeError as json_err:
  236. logger.warning(f"kodi心跳检测失败: JSON解析错误 - {json_err}, 响应内容: {response.text[:100]}")
  237. return False
  238. except requests.exceptions.Timeout:
  239. logger.warning(f"kodi心跳检测超时: 连接 {self.url} 超时")
  240. return False
  241. except requests.exceptions.ConnectionError:
  242. logger.warning(f"kodi心跳检测失败: 无法连接到 {self.url}")
  243. return False
  244. except Exception as e:
  245. logger.error(f"kodi心跳检测异常: {e}")
  246. return False
  247. class KodiClientManager():
  248. def __init__(self):
  249. self.kodi_clients = []
  250. self.video_infos = []
  251. # 生产环境配置文件路径
  252. self.kodi_config_path = 'kodi_config_prod.yaml'
  253. self.video_config_path = 'video_config_prod.yaml'
  254. # 开发环境配置文件路径
  255. # self.kodi_config_path = 'kodi_config_test.yaml'
  256. # self.video_config_path = 'video_config_test.yaml'
  257. # 只有一台可以有声音,其他没有声音,这是音量
  258. self.volume = 65
  259. self._init_kodi_clients_from_config()
  260. self._init_video_infos_from_config()
  261. def set_volume(self, volume):
  262. # 设置播放视频的音量
  263. self.volume = volume
  264. # 立即应用到符合条件的客户端
  265. # 逻辑与 sync_play_video 中一致:第一台非独立设备设置音量,其他静音
  266. # 注意:这里假设 kodi_clients 的顺序即为物理排列顺序
  267. found_first_non_individual = False
  268. for client in self.kodi_clients:
  269. if client.get_individual():
  270. continue
  271. if not found_first_non_individual:
  272. client.set_volume(self.volume)
  273. found_first_non_individual = True
  274. else:
  275. client.set_volume(0)
  276. def get_volume(self):
  277. """获取当前全局音量"""
  278. return self.volume
  279. def _init_video_infos_from_config(self):
  280. config = self._load_config(self.video_config_path)
  281. video_infos_config = config.get('video_infos', [])
  282. for video_info_config in video_infos_config:
  283. 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'))
  284. logger.info(f"成功加载视频信息: {video_info.name}")
  285. self.video_infos.append(video_info)
  286. def _load_config(self, config_path) -> Dict[str, Any]:
  287. """
  288. 从YAML配置文件加载配置
  289. Returns:
  290. dict: 配置字典
  291. Raises:
  292. FileNotFoundError: 配置文件不存在
  293. yaml.YAMLError: YAML解析错误
  294. Exception: 其他加载错误
  295. """
  296. if not os.path.exists(config_path):
  297. error_msg = f"配置文件不存在: {config_path}"
  298. logger.error(error_msg)
  299. raise FileNotFoundError(error_msg)
  300. try:
  301. with open(config_path, 'r', encoding='utf-8') as file:
  302. config = yaml.safe_load(file)
  303. if config is None:
  304. error_msg = f"配置文件为空或格式错误: {config_path}"
  305. logger.error(error_msg)
  306. raise ValueError(error_msg)
  307. logger.info(f"成功加载配置文件: {config_path}")
  308. return config
  309. except yaml.YAMLError as e:
  310. error_msg = f"YAML解析错误: {e}"
  311. logger.error(error_msg)
  312. raise
  313. except Exception as e:
  314. error_msg = f"加载配置文件失败: {e}"
  315. logger.error(error_msg)
  316. raise
  317. def _init_kodi_clients_from_config(self):
  318. config = self._load_config(self.kodi_config_path)
  319. kodi_servers_config = config.get('kodi_servers', [])
  320. if not kodi_servers_config:
  321. logger.error("未在 config.yaml 中找到有效的 Kodi 服务器配置,脚本将退出。")
  322. exit()
  323. # 创建 Kodi 客户端实例列表
  324. for server_config in kodi_servers_config:
  325. client = KodiClient(
  326. host=server_config.get('ip', 'localhost'),
  327. port=server_config.get('port', 8080),
  328. username=server_config.get('username'),
  329. password=server_config.get('password'),
  330. id=server_config.get('id', 0)
  331. )
  332. try:
  333. if hasattr(client, 'set_volume') and callable(getattr(client, 'set_volume')):
  334. client.set_volume(65)
  335. else:
  336. # 兼容旧构建:直接通过 JSON-RPC 设置音量
  337. client._send_request("Application.SetVolume", {"volume": 65})
  338. except Exception as e:
  339. logger.warning(f"设置音量时出现问题(已忽略以继续):{e}")
  340. self.kodi_clients.append(client)
  341. def _resolve_config_path(self, filename = "kodi_config.yaml"):
  342. """Return absolute path to config file, located next to the script/exe.
  343. When bundled with PyInstaller, sys.frozen is True and sys.executable points to the exe.
  344. In normal execution, use the directory of this file.
  345. """
  346. try:
  347. if getattr(sys, "frozen", False):
  348. base_dir = os.path.dirname(sys.executable)
  349. else:
  350. base_dir = os.path.dirname(os.path.abspath(__file__))
  351. return os.path.join(base_dir, filename)
  352. except Exception:
  353. # Fallback to current working directory
  354. return filename
  355. def sync_play_video(self, clients, video_path, loop=False):
  356. """同步播放视频"""
  357. # 创建一个共享的Event来控制所有客户端同时开始播放
  358. start_event = threading.Event()
  359. logger.info(f"开始同步播放视频: {video_path}")
  360. # 创建播放线程
  361. def play_thread(client):
  362. # 等待开始信号
  363. start_event.wait()
  364. # 执行播放
  365. result = client.play_video(video_path, loop=loop)
  366. logger.info(f"播放视频: {video_path}, 循环: {loop}")
  367. logger.info(f"播放视频结果: {result}")
  368. # 启动所有播放线程
  369. threads = []
  370. client_index = 0
  371. for client in clients:
  372. if client.get_individual():
  373. client_index += 1
  374. continue
  375. # 只对第一台设置音量
  376. if client == clients[client_index]:
  377. client.set_volume(self.volume)
  378. else:
  379. client.set_volume(0)
  380. thread = threading.Thread(target=play_thread, args=(client,))
  381. threads.append(thread)
  382. thread.start()
  383. # 等待所有线程准备就绪
  384. time.sleep(0.1) # 给线程一点时间启动
  385. # 同时触发所有客户端开始播放
  386. start_event.set()
  387. # 等待所有线程完成
  388. for thread in threads:
  389. thread.join()
  390. # 指定某台播放url图片
  391. def play_url_image_on_client(self, client, image_url):
  392. """指定某台播放url图片"""
  393. client.isIndividual = True
  394. return client.play_url_image(image_url)
  395. # 指定某台播放rtsp视频流
  396. def play_rtsp_video_on_client(self, client, rtsp_url,volume=0):
  397. """指定某台播放rtsp视频流"""
  398. client.isIndividual = True
  399. client.set_volume(volume)
  400. return client.play_rtsp_video(rtsp_url)
  401. # 撤销所有客户端的独立状态
  402. def revoke_individual_state(self):
  403. """撤销所有客户端的独立状态"""
  404. for client in self.kodi_clients:
  405. client.isIndividual = False
  406. client.stop_playback()
  407. # 启动所有kodi应用程序
  408. def start_all_kodi_apps(self):
  409. """启动所有kodi应用程序"""
  410. for client in self.kodi_clients:
  411. client.start_kodi()
  412. # 检查所有kodi客户端是否在线,返回所有不在线的client_index集合
  413. def check_all_kodi_clients_online(self):
  414. """检查所有kodi客户端是否在线,返回所有不在线的客户端索引集合"""
  415. offline_indices = set()
  416. client_index = 0
  417. for client in self.kodi_clients:
  418. if not client.kodi_heartbeat_check():
  419. offline_indices.add(client_index)
  420. client_index += 1
  421. return offline_indices