From 9eaa40f809400156753db8539dcf06f39e62f79d Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 11 Jan 2025 22:06:00 +0000 Subject: [PATCH] Implement VLC as a player backend --- microkodi/config.py | 22 +++++ microkodi/helpers.py | 24 +++++ microkodi/jsonrpc.py | 35 ++++++- microkodi/programs/mpv.py | 5 - microkodi/programs/vlc.py | 196 ++++++++++++++++++++++++++++++++++++++ pyproject.toml | 4 +- 6 files changed, 276 insertions(+), 10 deletions(-) create mode 100644 microkodi/programs/vlc.py diff --git a/microkodi/config.py b/microkodi/config.py index 5563365..80efad1 100644 --- a/microkodi/config.py +++ b/microkodi/config.py @@ -2,6 +2,8 @@ from dataclasses import dataclass from pathlib import Path import json +from microkodi.helpers import recursive_dict_merge + @dataclass class Config: host: str @@ -16,12 +18,22 @@ class Config: # Extra args to pass to mpv mpv_args: list[str] + # Path to the vlc binary + vlc: str + + # Extra arguments to pass to vlc + vlc_args: list[str] + # Wallpapers to show wallpapers: list[str] # Additional scripts to load scripts: dict[str, str] + # Player configuration + # URL scheme -> netloc (or '*' for fallback) -> fully-qualified player class + players: dict[str, dict[str, str]] + def load_config(config_path: Path | None) -> Config: if config_path is None: config_data = {} @@ -35,6 +47,16 @@ def load_config(config_path: Path | None) -> Config: mpv=config_data.get("mpv", "/usr/bin/mpv"), mpv_ipc_sock=config_data.get("mpv_ipc_sock", "/tmp/mpv.sock"), mpv_args=config_data.get("mpv_args", []), + vlc=config_data.get("vlc", "/usr/bin/vlc"), + vlc_args=config_data.get("vlc_args", []), wallpapers=config_data.get("wallpapers", []), scripts=config_data.get("scripts", {}), + players=recursive_dict_merge( + { + "https": { + "*": "microkodi.programs.vlc.VlcPlayerProgram", + }, + }, + config_data.get("players", {}), + ), ) diff --git a/microkodi/helpers.py b/microkodi/helpers.py index 9b44f23..723b8a9 100644 --- a/microkodi/helpers.py +++ b/microkodi/helpers.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import TypeVar, Callable @dataclass class KodiTime: @@ -17,3 +18,26 @@ def seconds_to_kodi_format(seconds: int) -> KodiTime: minutes=minutes, seconds=seconds, ) + + +T = TypeVar("T") +def find(l: list[T], pred: Callable[[T], bool]) -> T | None: + for i in l: + if pred(i): + return i + return None + +def recursive_dict_merge(base: dict, update: dict) -> dict: + unique_keys = set([*base.keys(), *update.keys()]) + out = {} + for key in unique_keys: + if key in base and key not in update: + out[key] = base[key] + elif key not in base and key in update: + out[key] = update[key] + else: + if not isinstance(update[key], dict): + out[key] = update[key] + else: + out[key] = recursive_dict_merge(base[key], update[key]) + return out \ No newline at end of file diff --git a/microkodi/jsonrpc.py b/microkodi/jsonrpc.py index 1522543..8c34056 100644 --- a/microkodi/jsonrpc.py +++ b/microkodi/jsonrpc.py @@ -3,12 +3,14 @@ from typing import Any from urllib.parse import urlparse import logging import base64 +import sys from http.server import BaseHTTPRequestHandler from microkodi.repository import I from microkodi.programs import Program, PlayerInfo from microkodi.programs.mpv import MpvProgram +from microkodi.programs.vlc import VlcProgram from microkodi.helpers import seconds_to_kodi_format def jsonrpc_response(id: int, payload: dict[str, Any]) -> dict[str, Any]: @@ -135,18 +137,43 @@ class PlayerRpcObject(JsonRpcObject): def open(self, params: dict[str, Any]) -> Any: url = urlparse(params["item"]["file"]) - program_cls = None - # Handle plugins if url.scheme == "plugin": if url.netloc == "plugin.video.sendtokodi": self.logger.debug("Rewriting provided URL to %s", url.query) url = urlparse(url.query) - if url.scheme == "https": - program_cls = MpvProgram + # Find out what player class to use + config: Config = I.get("Config") + scheme_configuration = config.players.get(url.scheme) + if scheme_configuration is None: + self.logger.warn("Client requested unknown scheme: '%s'", url.scheme) + return { + "error": "invalid protocol" + } + player_class_name = scheme_configuration.get(url.netloc) + if player_class_name is None: + player_class_name = scheme_configuration.get("*") + if player_class_name is None: + self.logger.warn("No player was picked for url '%s'", url) + return { + "error": "invalid protocol" + } + + # Try to import the class + *module_parts, class_name = player_class_name.split(".") + module_name = ".".join(module_parts) + program_cls = None + if module_name not in sys.modules: + self.logger.debug("Trying to import %s to get class %s", module_name, class_name) + spec = importlib.util.find_spec(module) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + program_cls = getattr(sys.modules[module_name], class_name, None) if program_cls is None: + self.logger.warn("Class %s not found in module %s", class_name, module_name) return { "error": "invalid protocol" } diff --git a/microkodi/programs/mpv.py b/microkodi/programs/mpv.py index d0d2c74..3ba40f6 100644 --- a/microkodi/programs/mpv.py +++ b/microkodi/programs/mpv.py @@ -25,7 +25,6 @@ class MpvConfig: def get_ext_transformer(self, ext: str) -> Callable[[ParseResult], tuple[list[str], str]] | None: return self._ext_map.get(ext) - I.register("MpvConfig", MpvConfig()) class MpvProgram(Program): @@ -44,10 +43,6 @@ class MpvProgram(Program): self._mpv_extra_args = config.mpv_args self._mpv_ipc_sock = config.mpv_ipc_sock - def register_url_transformer(self, ext: str, transformer: Callable[[ParseResult], tuple[list[str], str]]): - self.logger.debug("Registering transformer for .%s URLs", ext) - self._ext_map[ext] = transformer - def __mpv_command(self, args: list[str]) -> dict[str, Any]: ipc_logger = logging.getLogger("mpv-ipc") command = json.dumps({ diff --git a/microkodi/programs/vlc.py b/microkodi/programs/vlc.py new file mode 100644 index 0000000..f147a61 --- /dev/null +++ b/microkodi/programs/vlc.py @@ -0,0 +1,196 @@ +from xml.dom.minidom import parseString +from typing import Callable +from urllib.parse import ParseResult, urlunparse +import subprocess +import logging +import secrets + +import requests + +from microkodi.process import nonblocking_run +from microkodi.config import Config +from microkodi.programs import PlayerInfo, Program +from microkodi.repository import I + +EVENT_PLAYER_EXIT = "post-player-exit" + +class VlcConfig: + # Mapping of file extensions to a potential transformer function + _ext_map: dict[str, Callable[[ParseResult], tuple[list[str], str]]] + + # Mapping of domains to a potential transformer function + _domain_map: dict[str, Callable[[ParseResult], tuple[list[str], str]]] + + _event_listeners: dict[str, Callable[[], None]] + + def __init__(self): + self._ext_map = {} + self._event_listeners = {} + self._domain_map = {} + + def register_ext_transformer(self, ext: str, transformer: Callable[[ParseResult], tuple[list[str], str]]): + logging.getLogger("VlcConfig").debug("Registering transformer for .%s URLs", ext) + self._ext_map[ext] = transformer + + def get_ext_transformer(self, ext: str) -> Callable[[ParseResult], tuple[list[str], str]] | None: + return self._ext_map.get(ext) + + def register_domain_transformer(self, domain: str, transformer: Callable[[ParseResult], tuple[list[str], str]]): + logging.getLogger("VlcConfig").debug("Registering transformer for the %s domain", domain) + self._domain_map[domain] = transformer + + def get_domain_transformer(self, domain: str) -> Callable[[ParseResult], tuple[list[str], str]] | None: + return self._domain_map.get(domain) + + def register_event_listener(self, event: str, callback: Callable[[], None]): + if event not in self._event_listeners: + self._event_listeners[event] = [] + logging.getLogger("VlcConfig").debug("Registering event listener for %s", event) + self._event_listeners[event].append(callback) + + def run_event_listeners(self, event: str): + logger = logging.getLogger("VlcConfig") + for listener in self._event_listeners.get(event, []): + logger.debug("Running listener %s for event %s", listener.__name__, event) + listener() +I.register("VlcConfig", VlcConfig()) + +class VlcProgram(Program): + _process: subprocess.Popen + + _vlc_bin_path: str + _vlc_extra_args: list[str] + + _vlc_password: str | None + + def __init__(self): + super().__init__() + + config: Config = I.get("Config") + self._process = None + self._vlc_bin_path = config.vlc + self._vlc_extra_args = config.vlc_args + self._vlc_password = None + + def is_active(self) -> bool: + return self._process is not None and self._process.returncode is None + + def is_playing(self) -> bool: + return self.get_player_info().is_playing + + def resume(self): + self.__vlc_command("pl_pause") + + def play(self, url: ParseResult): + self.stop("mpvplayer.play") + + # Allow transforming the domain + extra_args = [] + domain_transformer = I.get("VlcConfig").get_domain_transformer(url.netloc) + if domain_transformer is not None: + args, new_url = domain_transformer(url) + extra_args += args + url = new_url + + # Allow preprocessing the URL + last_segment = url.path.split("/")[-1] + final_url = "" + if "." in last_segment: + *_, ext = last_segment.split(".") + transformer = I.get("VlcConfig").get_ext_transformer(ext) + if transformer is not None: + self.logger.info("Transforming URL due to extension: %s", ext) + args, final_url = transformer(url) + extra_args += args + self.logger.info("New url: %s", final_url) + else: + final_url = urlunparse(url) + else: + final_url = urlunparse(url) + + # Start vlc + self._vlc_password = secrets.token_hex(32) + cmdline = [ + self._vlc_bin_path, + *self._vlc_extra_args, + "-f", + "--qt-minimal-view", + "--no-osd", + "--intf=qt", + "--extraintf=http", + "--http-host=127.0.0.1", + "--http-port=9090", + f"--http-password={self._vlc_password}", + "--quiet", + *extra_args, + final_url, + ] + self.logger.debug("Cmdline: %s", " ".join(cmdline)) + self._process = nonblocking_run(cmdline, self._when_vlc_exit) + + def _when_vlc_exit(self): + self.logger.info("vlc has exited") + self._process = None + I.get("VlcConfig").run_event_listeners(EVENT_PLAYER_EXIT) + I.get("DataBridge").set_loading(False) + + def __vlc_command(self, command: str) -> str | None: + try: + req = requests.get( + f"http://127.0.0.1:9090/requests/status.xml?command={command}", + auth=("", self._vlc_password), + ) + return req.text + except Exception as ex: + self.logger.warning("Failed to command vlc API: %s", ex) + return None + + def pause(self): + self.__vlc_command("pl_pause") + + def stop(self, src): + if self.is_active(): + self._process.terminate() + self._process = None + + def exit(self): + self.stop("mpvplayer.exit") + + def get_player_info(self) -> PlayerInfo: + try: + req = requests.get( + "http://127.0.0.1:9090/requests/status.xml", + auth=("", self._vlc_password), + ) + dom = parseString(req.text) + except Exception as ex: + self.logger.warning("Failed to query vlc API: %s", ex) + return PlayerInfo( + runtime=0, + position=0, + title="", + playing=False, + ) + + try: + root = dom.getElementsByTagName("root")[0] + time = int(root.getElementsByTagName("time")[0].childNodes[0].data) + length = int(root.getElementsByTagName("length")[0].childNodes[0].data) + playing = root.getElementsByTagName("state")[0].childNodes[0].data == "playing" + except Exception as ex: + self.logger.warning("Failed to parse vlc API response: %s", ex) + self.logger.debug("Response body: %s", req.text) + return PlayerInfo( + runtime=0, + position=0, + title="", + playing=False, + ) + + return PlayerInfo( + runtime=length, + position=time, + # TODO + title="TODO: Find out", + playing=playing, + ) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 59c4fbc..b23d560 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,9 @@ name = "microkodi" version = "0.1.0" dependencies = [ - "pyside6" + "pyside6", + "requests", + "yt-dlp" ] [tools.build]