Implement VLC as a player backend

This commit is contained in:
PapaTutuWawa 2025-01-11 22:06:00 +00:00
parent 312238b35a
commit 9eaa40f809
6 changed files with 276 additions and 10 deletions

View File

@ -2,6 +2,8 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import json import json
from microkodi.helpers import recursive_dict_merge
@dataclass @dataclass
class Config: class Config:
host: str host: str
@ -16,12 +18,22 @@ class Config:
# Extra args to pass to mpv # Extra args to pass to mpv
mpv_args: list[str] 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 to show
wallpapers: list[str] wallpapers: list[str]
# Additional scripts to load # Additional scripts to load
scripts: dict[str, str] 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: def load_config(config_path: Path | None) -> Config:
if config_path is None: if config_path is None:
config_data = {} config_data = {}
@ -35,6 +47,16 @@ def load_config(config_path: Path | None) -> Config:
mpv=config_data.get("mpv", "/usr/bin/mpv"), mpv=config_data.get("mpv", "/usr/bin/mpv"),
mpv_ipc_sock=config_data.get("mpv_ipc_sock", "/tmp/mpv.sock"), mpv_ipc_sock=config_data.get("mpv_ipc_sock", "/tmp/mpv.sock"),
mpv_args=config_data.get("mpv_args", []), 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", []), wallpapers=config_data.get("wallpapers", []),
scripts=config_data.get("scripts", {}), scripts=config_data.get("scripts", {}),
players=recursive_dict_merge(
{
"https": {
"*": "microkodi.programs.vlc.VlcPlayerProgram",
},
},
config_data.get("players", {}),
),
) )

View File

@ -1,4 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import TypeVar, Callable
@dataclass @dataclass
class KodiTime: class KodiTime:
@ -17,3 +18,26 @@ def seconds_to_kodi_format(seconds: int) -> KodiTime:
minutes=minutes, minutes=minutes,
seconds=seconds, 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

View File

@ -3,12 +3,14 @@ from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
import logging import logging
import base64 import base64
import sys
from http.server import BaseHTTPRequestHandler from http.server import BaseHTTPRequestHandler
from microkodi.repository import I from microkodi.repository import I
from microkodi.programs import Program, PlayerInfo from microkodi.programs import Program, PlayerInfo
from microkodi.programs.mpv import MpvProgram from microkodi.programs.mpv import MpvProgram
from microkodi.programs.vlc import VlcProgram
from microkodi.helpers import seconds_to_kodi_format from microkodi.helpers import seconds_to_kodi_format
def jsonrpc_response(id: int, payload: dict[str, Any]) -> dict[str, Any]: 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: def open(self, params: dict[str, Any]) -> Any:
url = urlparse(params["item"]["file"]) url = urlparse(params["item"]["file"])
program_cls = None
# Handle plugins # Handle plugins
if url.scheme == "plugin": if url.scheme == "plugin":
if url.netloc == "plugin.video.sendtokodi": if url.netloc == "plugin.video.sendtokodi":
self.logger.debug("Rewriting provided URL to %s", url.query) self.logger.debug("Rewriting provided URL to %s", url.query)
url = urlparse(url.query) url = urlparse(url.query)
if url.scheme == "https": # Find out what player class to use
program_cls = MpvProgram 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: if program_cls is None:
self.logger.warn("Class %s not found in module %s", class_name, module_name)
return { return {
"error": "invalid protocol" "error": "invalid protocol"
} }

View File

@ -25,7 +25,6 @@ class MpvConfig:
def get_ext_transformer(self, ext: str) -> Callable[[ParseResult], tuple[list[str], str]] | None: def get_ext_transformer(self, ext: str) -> Callable[[ParseResult], tuple[list[str], str]] | None:
return self._ext_map.get(ext) return self._ext_map.get(ext)
I.register("MpvConfig", MpvConfig()) I.register("MpvConfig", MpvConfig())
class MpvProgram(Program): class MpvProgram(Program):
@ -44,10 +43,6 @@ class MpvProgram(Program):
self._mpv_extra_args = config.mpv_args self._mpv_extra_args = config.mpv_args
self._mpv_ipc_sock = config.mpv_ipc_sock 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]: def __mpv_command(self, args: list[str]) -> dict[str, Any]:
ipc_logger = logging.getLogger("mpv-ipc") ipc_logger = logging.getLogger("mpv-ipc")
command = json.dumps({ command = json.dumps({

196
microkodi/programs/vlc.py Normal file
View File

@ -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,
)

View File

@ -2,7 +2,9 @@
name = "microkodi" name = "microkodi"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"pyside6" "pyside6",
"requests",
"yt-dlp"
] ]
[tools.build] [tools.build]