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
|
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", {}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
@ -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
|
@ -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"
|
||||||
}
|
}
|
||||||
|
@ -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
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"
|
name = "microkodi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pyside6"
|
"pyside6",
|
||||||
|
"requests",
|
||||||
|
"yt-dlp"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tools.build]
|
[tools.build]
|
||||||
|
Loading…
Reference in New Issue
Block a user