Compare commits

..

13 Commits

16 changed files with 227 additions and 73 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
# Testing
config.json
scripts/
!scripts/youtube.py

12
contrib/sway.cfg Normal file
View File

@@ -0,0 +1,12 @@
# Prevent window resizing when we open the player apps
workspace_layout stacking
# Hide the window titlebar
default_border none
default_floating_border none
font pango:monospace 0
titlebar_padding 1
titlebar_border_thickness 0
# Start microkodi
exec bash <repo path>/start-microkodi.sh

10
microkodi/cec_handler.py Normal file
View File

@@ -0,0 +1,10 @@
import cec
class CECHandler:
def __init__(self):
cec.init()
def power_on_if_needed(self):
tc = cec.Device(cec.CECDEVICE_TV)
if not tc.is_on():
tc.power_on()

View File

@@ -1,6 +1,7 @@
from dataclasses import dataclass
from pathlib import Path
import json
from typing import Any
from microkodi.helpers import recursive_dict_merge
@@ -34,12 +35,11 @@ class Config:
# URL scheme -> netloc (or '*' for fallback) -> fully-qualified player class
players: dict[str, dict[str, str]]
card: str | None
connector: str | None
# The entire configuration file for use in user scripts
options: dict[str, Any]
@property
def watch_connector(self) -> bool:
return self.card is not None and self.connector is not None
# Enables the use of CEC
cec: bool
def load_config(config_path: Path | None) -> Config:
if config_path is None:
@@ -66,6 +66,6 @@ def load_config(config_path: Path | None) -> Config:
},
config_data.get("players", {}),
),
card=config_data.get("card"),
connector=config_data.get("connector"),
options=config_data.get("options", {}),
cec=config_data.get("cec", False),
)

View File

@@ -1,5 +1,18 @@
from dataclasses import dataclass
from typing import TypeVar, Callable
from typing import TypeVar, ParamSpec, Callable
from functools import wraps
P = ParamSpec("P")
T = TypeVar("T")
def after(func: Callable[[], None]) -> Callable[P, T]:
"""Runs @func after the decorated function exits."""
def decorator(f: Callable[P, T]) -> Callable[P, T]:
def inner(*args: P.args, **kwargs: P.kwargs) -> T:
ret = f(*args, **kwargs)
func()
return ret
return inner
return decorator
@dataclass
class KodiTime:
@@ -7,6 +20,9 @@ class KodiTime:
minutes: int
seconds: int
def to_seconds(self) -> int:
return self.hours * 3600 + self.minutes * 59 + self.seconds
def seconds_to_kodi_format(seconds: int) -> KodiTime:
"""Convert seconds into hours, minutes, and seconds as Kodi wants that."""
hours = seconds // 3600
@@ -20,7 +36,6 @@ def seconds_to_kodi_format(seconds: int) -> KodiTime:
)
T = TypeVar("T")
def find(l: list[T], pred: Callable[[T], bool]) -> T | None:
for i in l:
if pred(i):

View File

@@ -1,5 +1,6 @@
import json
from typing import Any
from typing import Any, Callable
from dataclasses import dataclass
from urllib.parse import urlparse
import logging
import base64
@@ -9,7 +10,7 @@ from http.server import BaseHTTPRequestHandler
from microkodi.repository import I
from microkodi.programs import Program, PlayerInfo
from microkodi.helpers import seconds_to_kodi_format
from microkodi.helpers import KodiTime, seconds_to_kodi_format, after
def jsonrpc_response(id: int, payload: dict[str, Any]) -> dict[str, Any]:
return {
@@ -99,9 +100,25 @@ class PlayerRpcObject(JsonRpcObject):
return self.open(params)
elif method == "PlayPause":
return self.play_pause(params)
elif method == "Seek":
return self.seek(params)
else:
return "Unknown method"
def seek(self, params: dict[str, Any]) -> Any:
if not self.is_active:
self.logger.warn("Trying to seek player that is not active")
return "ERR"
time_raw = params["value"]["time"]
kodi_time = KodiTime(
hours=time_raw["hours"],
minutes=time_raw["minutes"],
seconds=time_raw["seconds"],
)
self._active_program.seek(kodi_time)
return "Ok"
def get_active_players(self, params: dict[str, Any]) -> Any:
if not self.is_active:
return []
@@ -132,7 +149,14 @@ class PlayerRpcObject(JsonRpcObject):
self._active_program.stop("playerrpcobject.stop")
return "OK"
@after(lambda: I.get("DataBridge").set_loading(False))
def open(self, params: dict[str, Any]) -> Any:
# Turn on the TV
config: Config = I.get("Config")
if config.cec:
I.get("CECHandler").power_on_if_needed()
I.get("DataBridge").set_loading(True)
url = urlparse(params["item"]["file"])
# Handle plugins
@@ -142,9 +166,9 @@ class PlayerRpcObject(JsonRpcObject):
url = urlparse(url.query)
# Find out what player class to use
config: Config = I.get("Config")
scheme_configuration = config.players.get(url.scheme)
if scheme_configuration is None:
I.get("DataBridge").notification.emit(f"No player available for {url.scheme}")
self.logger.warn("Client requested unknown scheme: '%s'", url.scheme)
return {
"error": "invalid protocol"
@@ -153,6 +177,7 @@ class PlayerRpcObject(JsonRpcObject):
if player_class_name is None:
player_class_name = scheme_configuration.get("*")
if player_class_name is None:
I.get("DataBridge").notification.emit(f"No player available for {url.netloc}")
self.logger.warn("No player was picked for url '%s'", url)
return {
"error": "invalid protocol"
@@ -171,12 +196,12 @@ class PlayerRpcObject(JsonRpcObject):
program_cls = getattr(sys.modules[module_name], class_name, None)
if program_cls is None:
I.get("DataBridge").notification.emit("Could not start player")
self.logger.warn("Class %s not found in module %s", class_name, module_name)
return {
"error": "invalid protocol"
}
I.get("DataBridge").set_loading(True)
if self._active_program is not None:
if isinstance(self._active_program, program_cls):
self._active_program.stop("playerrpcobject.open")

View File

@@ -13,8 +13,8 @@ from PySide6.QtQml import QQmlApplicationEngine
from microkodi.jsonrpc import JsonRpcHandler, GlobalMethodHandler
from microkodi.ui.bridge import DataBridge
from microkodi.config import Config, load_config
from microkodi.cec_handler import CECHandler
from microkodi.repository import I
from microkodi.udev import is_display_connected, block_until_display_connected
def run_kodi_server():
@@ -24,6 +24,11 @@ def run_kodi_server():
method_handler = GlobalMethodHandler()
I.register("GlobalMethodHandler", method_handler)
# Setup CEC
if config.cec:
I.register("CECHandler", CECHandler())
logger.info("Enabling CEC support")
# Load extra plugins
if config.scripts:
logger.info("Loading scripts...")
@@ -92,25 +97,6 @@ if __name__ == "__main__":
sys.exit(-1)
engine.rootObjects()[0].setProperty("bridge", bridge)
if config.watch_connector:
logger.info("Will be watching display if it's gone")
exit_code = 0
while True:
exit_code = app.exec()
if not config.watch_connector:
break
# Exit if the display is still connected
if is_display_connected(config.card, config.connector):
break
logger.info("Display is gone. Waiting until it's back")
block_until_display_connected(config.card, config.connector)
logger.info("Display is back. Waiting 500ms...")
time.sleep(0.5)
del engine
sys.exit(exit_code)

View File

@@ -2,6 +2,8 @@ from dataclasses import dataclass
from urllib.parse import ParseResult
import logging
from microkodi.helpers import KodiTime
@dataclass
class PlayerInfo:
runtime: int
@@ -38,3 +40,6 @@ class Program:
def get_player_info(self) -> PlayerInfo:
raise NotImplementedError()
def seek(self, timestamp: KodiTime):
raise NotImplementedError()

View File

@@ -108,16 +108,18 @@ class MpvProgram(Program):
def _when_mpv_exit(self):
self.logger.info("MPV has exited")
self._process = None
I.get("DataBridge").set_loading(False)
if self._process.returncode != 0:
I.get("DataBridge").notification.emit("mpv exited with an error")
self._process = None
def pause(self):
self.__mpv_command(["set_property", "pause", True])
def stop(self, src):
if self.is_active():
self._process.terminate()
self._process = None
def exit(self):
self.stop("mpvplayer.exit")

View File

@@ -1,5 +1,5 @@
from xml.dom.minidom import parseString
from typing import Callable
from typing import Any, Callable
from urllib.parse import ParseResult, urlunparse
import subprocess
import logging
@@ -11,6 +11,7 @@ from microkodi.process import nonblocking_run
from microkodi.config import Config
from microkodi.programs import PlayerInfo, Program
from microkodi.repository import I
from microkodi.helpers import KodiTime
EVENT_PLAYER_EXIT = "post-player-exit"
@@ -76,7 +77,7 @@ class VlcProgram(Program):
return self._process is not None and self._process.returncode is None
def is_playing(self) -> bool:
return self.get_player_info().is_playing
return self.get_player_info().playing
def resume(self):
self.__vlc_command("pl_pause")
@@ -122,6 +123,7 @@ class VlcProgram(Program):
"--http-port=9090",
f"--http-password={self._vlc_password}",
"--quiet",
"--play-and-exit",
*extra_args,
final_url,
]
@@ -130,15 +132,22 @@ class VlcProgram(Program):
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:
if self._process.returncode != 0:
I.get("DataBridge").notification.emit("VLC exited with an error")
self._process = None
def __vlc_command(self, command: str, params: dict[str, Any] | None = None) -> str | None:
try:
req = requests.get(
f"http://127.0.0.1:9090/requests/status.xml?command={command}",
f"http://127.0.0.1:9090/requests/status.xml",
auth=("", self._vlc_password),
params={
"command": command,
**(params or {}),
},
)
return req.text
except Exception as ex:
@@ -151,7 +160,14 @@ class VlcProgram(Program):
def stop(self, src):
if self.is_active():
self._process.terminate()
self._process = None
def seek(self, timestamp: KodiTime):
self.__vlc_command(
"seek",
{
"val": timestamp.to_seconds(),
}
)
def exit(self):
self.stop("mpvplayer.exit")

View File

@@ -20,6 +20,10 @@ Window {
function onIsLoading(loading) {
isLoading = loading
}
function onNotification(text) {
notificationModel.append({"message": text})
}
}
Image {
@@ -67,4 +71,47 @@ Window {
anchors.rightMargin: 20
font.pixelSize: window.height * 0.1
}
ListModel {
id: notificationModel
}
Timer {
interval: 8 * 1000
running: notificationModel.count > 0
repeat: true
onTriggered: notificationModel.remove(0, 1)
}
Component {
id: notificationDelegate
Rectangle {
color: "#37474F"
width: 400
height: 100
radius: 10
Label {
text: message
color: "white"
font.pixelSize: 20
anchors.fill: parent
anchors.margins: 10
}
}
}
ListView {
anchors.right: wallpaperImage.right
anchors.top: wallpaperImage.top
anchors.topMargin: 20 * 2 + window.height * 0.1
anchors.rightMargin: 20
width: 400
height: window.height * 0.9 - 20 * 2
spacing: 50
model: notificationModel
delegate: notificationDelegate
}
}

View File

@@ -1,27 +0,0 @@
import logging
import pyudev
def is_display_connected(card: str, connector: str) -> bool:
logger = logging.getLogger("udev")
status_file = f"/sys/class/drm/{card}-{connector}/status"
logger.debug("Reading file %s", status_file)
with open(status_file, "r") as f:
result = f.read().strip()
logger.debug("Result: '%s'", result)
return result == "connected"
def block_until_display_connected(card: str, connector: str):
ctx = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(ctx)
monitor.filter_by("drm")
for device in iter(monitor.poll, None):
if not "DEVNAME" in device:
continue
if device.get("DEVNAME") != f"/dev/dri/{card}":
continue
if not is_display_connected(card, connector):
continue
break

View File

@@ -8,6 +8,8 @@ class DataBridge(QObject):
# Indicates whether we're currently loading something or not
isLoading = Signal(bool, arguments=["loading"])
notification = Signal(str, arguments=["text"])
def __init__(self):
super().__init__()

View File

@@ -5,7 +5,7 @@ dependencies = [
"pyside6",
"requests",
"yt-dlp",
"pyudev"
"cec"
]
[tools.build]

56
scripts/youtube.py Normal file
View File

@@ -0,0 +1,56 @@
from urllib.parse import ParseResult, urlunparse, urlparse
import logging
from microkodi.repository import I
from microkodi.config import Config
from microkodi.helpers import recursive_dict_merge
import yt_dlp
DEFAULT_CONFIG = {
"format": "bestvideo[width<=1920]+bestaudio",
"ytdlp_options": {}
}
def youtube_url_transformer(url: ParseResult) -> tuple[list[str], str]:
logger = logging.getLogger("Youtube")
youtube_config = I.get("YoutubeConfig")
opts = {
"format": youtube_config["format"],
**youtube_config["ytdlp_options"],
}
logger.debug("Using config for yt-dlp: %s", opts)
with yt_dlp.YoutubeDL(opts) as ytdl:
info = ytdl.extract_info(urlunparse(url), download=False)
user_agent = None
audio_url = None
video_url = None
for format in info["requested_formats"]:
if format["width"] is None:
audio_url = format["url"]
else:
user_agent = format["http_headers"]["User-Agent"]
video_url = format["url"]
args = [
f'--input-slave={audio_url}',
] if audio_url else None
return args, urlparse(video_url)
def init():
# Create the config
config: Config = I.get("Config")
youtube_config = recursive_dict_merge(
DEFAULT_CONFIG,
config.options.get(
"me.polynom.microkodi.youtube",
{},
),
)
I.register("YoutubeConfig", youtube_config)
# Register the transformers
I.get("VlcConfig").register_domain_transformer("youtu.be", youtube_url_transformer)
I.get("VlcConfig").register_domain_transformer("www.youtube.com", youtube_url_transformer)

4
start-microkodi.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
cd `dirname $0`
source .venv/bin/activate
python3 microkodi/main.py -c ./config.json $@