Implement VLC as a player backend
This commit is contained in:
parent
312238b35a
commit
9eaa40f809
@ -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", {}),
|
||||
),
|
||||
)
|
||||
|
@ -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
|
@ -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"
|
||||
}
|
||||
|
@ -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
196
microkodi/programs/vlc.py
Normal 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,
|
||||
)
|
@ -2,7 +2,9 @@
|
||||
name = "microkodi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"pyside6"
|
||||
"pyside6",
|
||||
"requests",
|
||||
"yt-dlp"
|
||||
]
|
||||
|
||||
[tools.build]
|
||||
|
Loading…
Reference in New Issue
Block a user