Compare commits

..

13 Commits

16 changed files with 227 additions and 73 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,5 +1,18 @@
from dataclasses import dataclass 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 @dataclass
class KodiTime: class KodiTime:
@@ -7,6 +20,9 @@ class KodiTime:
minutes: int minutes: int
seconds: 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: def seconds_to_kodi_format(seconds: int) -> KodiTime:
"""Convert seconds into hours, minutes, and seconds as Kodi wants that.""" """Convert seconds into hours, minutes, and seconds as Kodi wants that."""
hours = seconds // 3600 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: def find(l: list[T], pred: Callable[[T], bool]) -> T | None:
for i in l: for i in l:
if pred(i): if pred(i):

View File

@@ -1,5 +1,6 @@
import json import json
from typing import Any from typing import Any, Callable
from dataclasses import dataclass
from urllib.parse import urlparse from urllib.parse import urlparse
import logging import logging
import base64 import base64
@@ -9,7 +10,7 @@ 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.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]: def jsonrpc_response(id: int, payload: dict[str, Any]) -> dict[str, Any]:
return { return {
@@ -99,9 +100,25 @@ class PlayerRpcObject(JsonRpcObject):
return self.open(params) return self.open(params)
elif method == "PlayPause": elif method == "PlayPause":
return self.play_pause(params) return self.play_pause(params)
elif method == "Seek":
return self.seek(params)
else: else:
return "Unknown method" 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: def get_active_players(self, params: dict[str, Any]) -> Any:
if not self.is_active: if not self.is_active:
return [] return []
@@ -132,7 +149,14 @@ class PlayerRpcObject(JsonRpcObject):
self._active_program.stop("playerrpcobject.stop") self._active_program.stop("playerrpcobject.stop")
return "OK" return "OK"
@after(lambda: I.get("DataBridge").set_loading(False))
def open(self, params: dict[str, Any]) -> Any: 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"]) url = urlparse(params["item"]["file"])
# Handle plugins # Handle plugins
@@ -142,9 +166,9 @@ class PlayerRpcObject(JsonRpcObject):
url = urlparse(url.query) url = urlparse(url.query)
# Find out what player class to use # Find out what player class to use
config: Config = I.get("Config")
scheme_configuration = config.players.get(url.scheme) scheme_configuration = config.players.get(url.scheme)
if scheme_configuration is None: 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) self.logger.warn("Client requested unknown scheme: '%s'", url.scheme)
return { return {
"error": "invalid protocol" "error": "invalid protocol"
@@ -153,6 +177,7 @@ class PlayerRpcObject(JsonRpcObject):
if player_class_name is None: if player_class_name is None:
player_class_name = scheme_configuration.get("*") player_class_name = scheme_configuration.get("*")
if player_class_name is None: 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) self.logger.warn("No player was picked for url '%s'", url)
return { return {
"error": "invalid protocol" "error": "invalid protocol"
@@ -171,12 +196,12 @@ class PlayerRpcObject(JsonRpcObject):
program_cls = getattr(sys.modules[module_name], class_name, None) program_cls = getattr(sys.modules[module_name], class_name, None)
if program_cls is 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) self.logger.warn("Class %s not found in module %s", class_name, module_name)
return { return {
"error": "invalid protocol" "error": "invalid protocol"
} }
I.get("DataBridge").set_loading(True)
if self._active_program is not None: if self._active_program is not None:
if isinstance(self._active_program, program_cls): if isinstance(self._active_program, program_cls):
self._active_program.stop("playerrpcobject.open") 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.jsonrpc import JsonRpcHandler, GlobalMethodHandler
from microkodi.ui.bridge import DataBridge from microkodi.ui.bridge import DataBridge
from microkodi.config import Config, load_config from microkodi.config import Config, load_config
from microkodi.cec_handler import CECHandler
from microkodi.repository import I from microkodi.repository import I
from microkodi.udev import is_display_connected, block_until_display_connected
def run_kodi_server(): def run_kodi_server():
@@ -24,6 +24,11 @@ def run_kodi_server():
method_handler = GlobalMethodHandler() method_handler = GlobalMethodHandler()
I.register("GlobalMethodHandler", method_handler) I.register("GlobalMethodHandler", method_handler)
# Setup CEC
if config.cec:
I.register("CECHandler", CECHandler())
logger.info("Enabling CEC support")
# Load extra plugins # Load extra plugins
if config.scripts: if config.scripts:
logger.info("Loading scripts...") logger.info("Loading scripts...")
@@ -92,25 +97,6 @@ if __name__ == "__main__":
sys.exit(-1) sys.exit(-1)
engine.rootObjects()[0].setProperty("bridge", bridge) engine.rootObjects()[0].setProperty("bridge", bridge)
exit_code = app.exec()
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 del engine
sys.exit(exit_code) sys.exit(exit_code)

View File

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

View File

@@ -108,16 +108,18 @@ class MpvProgram(Program):
def _when_mpv_exit(self): def _when_mpv_exit(self):
self.logger.info("MPV has exited") self.logger.info("MPV has exited")
self._process = None
I.get("DataBridge").set_loading(False) 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): def pause(self):
self.__mpv_command(["set_property", "pause", True]) self.__mpv_command(["set_property", "pause", True])
def stop(self, src): def stop(self, src):
if self.is_active(): if self.is_active():
self._process.terminate() self._process.terminate()
self._process = None
def exit(self): def exit(self):
self.stop("mpvplayer.exit") self.stop("mpvplayer.exit")

View File

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

View File

@@ -20,6 +20,10 @@ Window {
function onIsLoading(loading) { function onIsLoading(loading) {
isLoading = loading isLoading = loading
} }
function onNotification(text) {
notificationModel.append({"message": text})
}
} }
Image { Image {
@@ -67,4 +71,47 @@ Window {
anchors.rightMargin: 20 anchors.rightMargin: 20
font.pixelSize: window.height * 0.1 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 # Indicates whether we're currently loading something or not
isLoading = Signal(bool, arguments=["loading"]) isLoading = Signal(bool, arguments=["loading"])
notification = Signal(str, arguments=["text"])
def __init__(self): def __init__(self):
super().__init__() super().__init__()

View File

@@ -5,7 +5,7 @@ dependencies = [
"pyside6", "pyside6",
"requests", "requests",
"yt-dlp", "yt-dlp",
"pyudev" "cec"
] ]
[tools.build] [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 $@