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
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", {}),
),
)

View File

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

View File

@ -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"
}

View File

@ -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({

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"
version = "0.1.0"
dependencies = [
"pyside6"
"pyside6",
"requests",
"yt-dlp"
]
[tools.build]