Initial commit
This commit is contained in:
commit
312238b35a
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
**/*.egg-info
|
||||||
|
**/__pycache__/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
config.json
|
||||||
|
scripts/
|
0
microkodi/__init__.py
Normal file
0
microkodi/__init__.py
Normal file
40
microkodi/config.py
Normal file
40
microkodi/config.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
|
||||||
|
# Path to the mpv binary
|
||||||
|
mpv: str
|
||||||
|
|
||||||
|
# Path to where the mpv IPC sock should be put
|
||||||
|
mpv_ipc_sock: str
|
||||||
|
|
||||||
|
# Extra args to pass to mpv
|
||||||
|
mpv_args: list[str]
|
||||||
|
|
||||||
|
# Wallpapers to show
|
||||||
|
wallpapers: list[str]
|
||||||
|
|
||||||
|
# Additional scripts to load
|
||||||
|
scripts: dict[str, str]
|
||||||
|
|
||||||
|
def load_config(config_path: Path | None) -> Config:
|
||||||
|
if config_path is None:
|
||||||
|
config_data = {}
|
||||||
|
else:
|
||||||
|
with open(config_path, "r") as f:
|
||||||
|
config_data = json.load(f)
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
host=config_data.get("host", "0.0.0.0"),
|
||||||
|
port=config_data.get("port", 8080),
|
||||||
|
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", []),
|
||||||
|
wallpapers=config_data.get("wallpapers", []),
|
||||||
|
scripts=config_data.get("scripts", {}),
|
||||||
|
)
|
19
microkodi/helpers.py
Normal file
19
microkodi/helpers.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KodiTime:
|
||||||
|
hours: int
|
||||||
|
minutes: int
|
||||||
|
seconds: int
|
||||||
|
|
||||||
|
def seconds_to_kodi_format(seconds: int) -> KodiTime:
|
||||||
|
"""Convert seconds into hours, minutes, and seconds as Kodi wants that."""
|
||||||
|
hours = seconds // 3600
|
||||||
|
seconds -= hours * 3600
|
||||||
|
minutes = seconds // 60
|
||||||
|
seconds -= minutes * 60
|
||||||
|
return KodiTime(
|
||||||
|
hours=hours,
|
||||||
|
minutes=minutes,
|
||||||
|
seconds=seconds,
|
||||||
|
)
|
345
microkodi/jsonrpc.py
Normal file
345
microkodi/jsonrpc.py
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import logging
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from http.server import BaseHTTPRequestHandler
|
||||||
|
|
||||||
|
from microkodi.repository import I
|
||||||
|
from microkodi.programs import Program, PlayerInfo
|
||||||
|
from microkodi.programs.mpv import MpvProgram
|
||||||
|
from microkodi.helpers import seconds_to_kodi_format
|
||||||
|
|
||||||
|
def jsonrpc_response(id: int, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"result": payload,
|
||||||
|
"id": id,
|
||||||
|
}
|
||||||
|
|
||||||
|
class JsonRpcObject:
|
||||||
|
logger: logging.Logger
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
|
def handle(self, method: str, params: dict[str, Any]) -> Any:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class JSONRPCRpcObject:
|
||||||
|
def handle(self, method: str, params: dict[str, Any]) -> Any:
|
||||||
|
if method == "Ping":
|
||||||
|
return "pong"
|
||||||
|
else:
|
||||||
|
return "Err"
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationRpcObject:
|
||||||
|
def handle(self, method: str, params: dict[str, Any]) -> Any:
|
||||||
|
if method == "Ping":
|
||||||
|
return "pong"
|
||||||
|
elif method == "GetProperties":
|
||||||
|
return self.get_properties(params)
|
||||||
|
else:
|
||||||
|
return "Err"
|
||||||
|
|
||||||
|
def get_properties(self, params: dict[str, Any]) -> Any:
|
||||||
|
def _get_property(name: str) -> str | bool | dict[str, Any]:
|
||||||
|
if name == "muted":
|
||||||
|
return False
|
||||||
|
elif name == "version":
|
||||||
|
return {
|
||||||
|
"major": 20,
|
||||||
|
"minor": 5,
|
||||||
|
"revision": "20240501-8c8d7afa26",
|
||||||
|
"tag": "stable",
|
||||||
|
}
|
||||||
|
elif name == "volume":
|
||||||
|
return 0
|
||||||
|
elif name == "partymode":
|
||||||
|
return False
|
||||||
|
elif name == "index":
|
||||||
|
return 0
|
||||||
|
elif name == "isdefault":
|
||||||
|
return True
|
||||||
|
elif name == "language":
|
||||||
|
return "und"
|
||||||
|
elif name == "name":
|
||||||
|
return "LOL"
|
||||||
|
else:
|
||||||
|
return "Unknown"
|
||||||
|
return {
|
||||||
|
prop: _get_property(prop) for prop in params["properties"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerRpcObject(JsonRpcObject):
|
||||||
|
_active_program: Program | None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._active_program = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return self._active_program is not None and self._active_program.is_active()
|
||||||
|
|
||||||
|
def handle(self, method: str, params: dict[str, Any]) -> Any:
|
||||||
|
if method == "GetProperties":
|
||||||
|
return self.get_properties(params)
|
||||||
|
elif method == "GetItem":
|
||||||
|
return self.get_item(params)
|
||||||
|
elif method == "GetActivePlayers":
|
||||||
|
return self.get_active_players(params)
|
||||||
|
elif method == "Stop":
|
||||||
|
return self.stop(params)
|
||||||
|
elif method == "Open":
|
||||||
|
return self.open(params)
|
||||||
|
elif method == "PlayPause":
|
||||||
|
return self.play_pause(params)
|
||||||
|
else:
|
||||||
|
return "Unknown method"
|
||||||
|
|
||||||
|
def get_active_players(self, params: dict[str, Any]) -> Any:
|
||||||
|
if not self.is_active:
|
||||||
|
return []
|
||||||
|
return [{"playerid":1,"playertype":"internal","type":"video"}]
|
||||||
|
|
||||||
|
def play_pause(self, params: dict[str, Any]) -> Any:
|
||||||
|
if not self.is_active:
|
||||||
|
self.logger.warn("Trying to toggle player that is not running")
|
||||||
|
return "OK"
|
||||||
|
if params["play"] != "toggle":
|
||||||
|
self.logger.warn("Trying to call PlayPause with unknown play: '%s'", params["play"])
|
||||||
|
return "Err"
|
||||||
|
|
||||||
|
playing = self._active_program.is_playing()
|
||||||
|
if playing:
|
||||||
|
self._active_program.pause()
|
||||||
|
else:
|
||||||
|
self._active_program.resume()
|
||||||
|
return {
|
||||||
|
"speed": 1 if not playing else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def stop(self, params: dict[str, Any]) -> Any:
|
||||||
|
if not self.is_active:
|
||||||
|
self.logger.warn("Trying to stop player that is not running")
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
self._active_program.stop("playerrpcobject.stop")
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if program_cls is None:
|
||||||
|
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")
|
||||||
|
else:
|
||||||
|
self._active_program.exit()
|
||||||
|
self._active_program = program_cls()
|
||||||
|
else:
|
||||||
|
self._active_program = program_cls()
|
||||||
|
self._active_program.play(url)
|
||||||
|
|
||||||
|
def get_item(self, params: dict[str, Any]) -> Any:
|
||||||
|
def _get_property(name: str, player_info: PlayerInfo) -> Any:
|
||||||
|
if name == "title":
|
||||||
|
return player_info.title
|
||||||
|
elif name == "label":
|
||||||
|
return player_info.title
|
||||||
|
elif name == "art":
|
||||||
|
return {
|
||||||
|
"icon": "image://DefaultVideo.png/",
|
||||||
|
}
|
||||||
|
return name if self.is_active else ""
|
||||||
|
|
||||||
|
player_info = self._active_program.get_player_info()
|
||||||
|
item = {
|
||||||
|
prop: _get_property(prop, player_info) for prop in params["properties"]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"item": {
|
||||||
|
**item,
|
||||||
|
"label": player_info.title,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_properties(self, params: dict[str, Any]) -> Any:
|
||||||
|
def _get_property(name: str, player_info: PlayerInfo) -> str | bool | dict[str, Any]:
|
||||||
|
if name == "currentsubtitle":
|
||||||
|
return None
|
||||||
|
elif name == "partymode":
|
||||||
|
return False
|
||||||
|
elif name == "percentage":
|
||||||
|
return player_info.position / player_info.runtime if player_info.runtime > 0 else 0.0
|
||||||
|
elif name == "playlistid":
|
||||||
|
return 0
|
||||||
|
elif name == "position":
|
||||||
|
return 0
|
||||||
|
elif name == "repeat":
|
||||||
|
return "off"
|
||||||
|
elif name == "shuffled":
|
||||||
|
return False
|
||||||
|
elif name == "speed":
|
||||||
|
return 1 if player_info.playing else 0
|
||||||
|
elif name == "subtitleenabled":
|
||||||
|
return False
|
||||||
|
elif name == "subtitles":
|
||||||
|
return []
|
||||||
|
elif name == "time":
|
||||||
|
kodi_time = seconds_to_kodi_format(player_info.position)
|
||||||
|
return {
|
||||||
|
"hours": kodi_time.hours,
|
||||||
|
"milliseconds": 0,
|
||||||
|
"minutes": kodi_time.minutes,
|
||||||
|
"seconds": kodi_time.seconds,
|
||||||
|
}
|
||||||
|
elif name == "totaltime":
|
||||||
|
kodi_time = seconds_to_kodi_format(player_info.runtime)
|
||||||
|
return {
|
||||||
|
"hours": kodi_time.hours,
|
||||||
|
"milliseconds": 0,
|
||||||
|
"minutes": kodi_time.minutes,
|
||||||
|
"seconds": kodi_time.seconds,
|
||||||
|
}
|
||||||
|
elif name == "audiostreams":
|
||||||
|
return []
|
||||||
|
elif name == "currentaudiostream":
|
||||||
|
return {}
|
||||||
|
elif name == "isdefault":
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.logger.error("Unknown param '%s'", name)
|
||||||
|
return "Unknown"
|
||||||
|
player_info = self._active_program.get_player_info()
|
||||||
|
return {
|
||||||
|
prop: _get_property(prop, player_info) for prop in params["properties"]
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlaylistRpcObject(JsonRpcObject):
|
||||||
|
def handle(self, method: str, params: dict[str, Any]) -> Any:
|
||||||
|
if method == "Clear":
|
||||||
|
return self.clear(params)
|
||||||
|
elif method == "GetPlaylists":
|
||||||
|
return self.get_playlists(params)
|
||||||
|
elif method == "GetItems":
|
||||||
|
return self.get_items(params)
|
||||||
|
|
||||||
|
def clear(self, params: dict[str, Any]) -> Any:
|
||||||
|
playlistid: int = params["playlistid"]
|
||||||
|
self.logger.warn("Asked to empty playlist %d but we're just pretending to do so", playlistid)
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
def get_playlists(self, params: dict[str, Any]) -> Any:
|
||||||
|
return [{"playlistid":1,"type":"video"}]
|
||||||
|
|
||||||
|
def get_items(self, params: dict[str, Any]) -> Any:
|
||||||
|
return "ERR"
|
||||||
|
|
||||||
|
class GlobalMethodHandler:
|
||||||
|
objects: dict[str, JsonRpcObject]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.objects = {}
|
||||||
|
|
||||||
|
# Register default objects
|
||||||
|
self.register("JSONRPC", JSONRPCRpcObject())
|
||||||
|
self.register("Application", ApplicationRpcObject())
|
||||||
|
self.register("Player", PlayerRpcObject())
|
||||||
|
self.register("Playlist", PlaylistRpcObject())
|
||||||
|
|
||||||
|
def register(self, name: str, obj: JsonRpcObject):
|
||||||
|
self.objects[name] = obj
|
||||||
|
I.register(name, obj)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonRpcHandler(BaseHTTPRequestHandler):
|
||||||
|
logger: logging.Logger
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
logger = logging.getLogger("JsonRpcHandler")
|
||||||
|
logger.debug("GET %s", self.path)
|
||||||
|
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
logger = logging.getLogger("JsonRpcHandler")
|
||||||
|
if self.path != "/jsonrpc":
|
||||||
|
logger.error("POST on invalid path %s", self.path)
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
|
||||||
|
body = self.rfile.read(int(self.headers["Content-Length"])).decode()
|
||||||
|
payload = json.loads(body)
|
||||||
|
|
||||||
|
# Only allow version 2
|
||||||
|
if payload["jsonrpc"] != "2.0":
|
||||||
|
self.send_error(400)
|
||||||
|
return
|
||||||
|
|
||||||
|
object_name, method_name = payload["method"].split(".")
|
||||||
|
call_id: int = payload["id"]
|
||||||
|
params = payload.get("params") or {}
|
||||||
|
|
||||||
|
obj = I.get("GlobalMethodHandler").objects.get(object_name)
|
||||||
|
if obj is None:
|
||||||
|
logger.debug("Payload: %s", body)
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO: Handle authentication
|
||||||
|
authenticated = False
|
||||||
|
if authenticated:
|
||||||
|
auth: str | None = self.headers.get("Authorization")
|
||||||
|
if auth is None:
|
||||||
|
logger.warn("Client provided no Authorization header. Method: %s", method)
|
||||||
|
self.send_error(401)
|
||||||
|
return
|
||||||
|
|
||||||
|
auth_type, auth_payload = auth.split(" ")
|
||||||
|
if auth_type != "Basic":
|
||||||
|
logger.warn("Client provided no Basic authorization. Method: %s", method)
|
||||||
|
self.send_error(401)
|
||||||
|
return
|
||||||
|
credentials = base64.b64decode(auth_payload).decode("utf-8")
|
||||||
|
if credentials != "kodi:1234":
|
||||||
|
logger.warn("Rejecting request due to wrong credentials. Method: %s", method)
|
||||||
|
self.send_error(403)
|
||||||
|
return
|
||||||
|
|
||||||
|
response = jsonrpc_response(
|
||||||
|
call_id,
|
||||||
|
obj.handle(method_name, params),
|
||||||
|
)
|
||||||
|
response_body = json.dumps(response).encode()
|
||||||
|
|
||||||
|
logger.debug("%s.%s: %s", object_name, method_name, payload)
|
||||||
|
logger.debug("-> %s", response_body)
|
||||||
|
self.send_response_only(200)
|
||||||
|
self.send_header("Content-Length", len(response_body))
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
self.wfile.write(response_body)
|
||||||
|
self.wfile.flush()
|
||||||
|
self.log_request(code="200")
|
88
microkodi/main.py
Normal file
88
microkodi/main.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
from http.server import HTTPServer
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
from PySide6.QtGui import QGuiApplication
|
||||||
|
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.repository import I
|
||||||
|
|
||||||
|
|
||||||
|
def run_kodi_server():
|
||||||
|
config: Config = I.get("Config")
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
logger = logging.getLogger("JsonRPCServer")
|
||||||
|
method_handler = GlobalMethodHandler()
|
||||||
|
I.register("GlobalMethodHandler", method_handler)
|
||||||
|
|
||||||
|
# Load extra plugins
|
||||||
|
if config.scripts:
|
||||||
|
logger.info("Loading scripts...")
|
||||||
|
|
||||||
|
for name, path in config.scripts.items():
|
||||||
|
logger.debug("Trying to load %s (%s)", name, path)
|
||||||
|
if not Path(path).exists():
|
||||||
|
logger.warning("Failed to load %s: File does not exist", name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
# Inject special globals
|
||||||
|
setattr(module, "I", I)
|
||||||
|
setattr(module, "logger", logging.getLogger(name))
|
||||||
|
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
if not hasattr(module, "init"):
|
||||||
|
logger.warning("Failed to load %s: No init function", name)
|
||||||
|
continue
|
||||||
|
init_method = getattr(module, "init")
|
||||||
|
init_method()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
server = HTTPServer
|
||||||
|
httpd = HTTPServer((config.host, config.port,), JsonRpcHandler)
|
||||||
|
logger.info("Starting server on %s:%i", config.host, config.port)
|
||||||
|
httpd.serve_forever()
|
||||||
|
logger.info("Shutting down server")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--config", "-c", type=Path, help="Location of the config file")
|
||||||
|
options = parser.parse_args()
|
||||||
|
|
||||||
|
# Load the config
|
||||||
|
config = load_config(options.config)
|
||||||
|
I.register("Config", config)
|
||||||
|
bridge = DataBridge()
|
||||||
|
I.register("DataBridge", bridge)
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
server_thread = threading.Thread(target=run_kodi_server, daemon=True)
|
||||||
|
server_thread.start()
|
||||||
|
|
||||||
|
# Setup the UI
|
||||||
|
app = QGuiApplication(sys.argv)
|
||||||
|
engine = QQmlApplicationEngine()
|
||||||
|
engine.addImportPath(sys.path[0])
|
||||||
|
engine.rootContext().setContextProperty(
|
||||||
|
"wallpapers",
|
||||||
|
config.wallpapers,
|
||||||
|
)
|
||||||
|
engine.loadFromModule("qml", "Main")
|
||||||
|
if not engine.rootObjects():
|
||||||
|
sys.exit(-1)
|
||||||
|
|
||||||
|
engine.rootObjects()[0].setProperty("bridge", bridge)
|
||||||
|
exit_code = app.exec()
|
||||||
|
del engine
|
||||||
|
sys.exit(exit_code)
|
13
microkodi/process.py
Normal file
13
microkodi/process.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from threading import Thread
|
||||||
|
import subprocess
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
def nonblocking_run(args: list[str], when_done: Callable[[], None]) -> subprocess.Popen:
|
||||||
|
def _run(process: subprocess.Popen):
|
||||||
|
process.wait()
|
||||||
|
when_done()
|
||||||
|
|
||||||
|
process = subprocess.Popen(args)
|
||||||
|
t = Thread(target=_run, args=(process,))
|
||||||
|
t.start()
|
||||||
|
return process
|
40
microkodi/programs/__init__.py
Normal file
40
microkodi/programs/__init__.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import ParseResult
|
||||||
|
import logging
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlayerInfo:
|
||||||
|
runtime: int
|
||||||
|
position: int
|
||||||
|
title: str
|
||||||
|
playing: bool
|
||||||
|
|
||||||
|
class Program:
|
||||||
|
logger: logging.Logger
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def is_playing(self) -> bool:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def play(self, url: ParseResult):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def resume(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def exit(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def get_player_info(self) -> PlayerInfo:
|
||||||
|
raise NotImplementedError()
|
144
microkodi/programs/mpv.py
Normal file
144
microkodi/programs/mpv.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import subprocess
|
||||||
|
from typing import Any, Callable
|
||||||
|
from urllib.parse import ParseResult, urlunparse, urldefrag, unquote
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from microkodi.process import nonblocking_run
|
||||||
|
from microkodi.config import Config
|
||||||
|
from microkodi.programs import PlayerInfo, Program
|
||||||
|
from microkodi.repository import I
|
||||||
|
|
||||||
|
class MpvConfig:
|
||||||
|
# Mapping of file extensions to a potential transformer function
|
||||||
|
_ext_map: dict[str, Callable[[ParseResult], tuple[list[str], str]]]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._ext_map = {}
|
||||||
|
|
||||||
|
def register_ext_transformer(self, ext: str, transformer: Callable[[ParseResult], tuple[list[str], str]]):
|
||||||
|
logging.getLogger("MpvConfig").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)
|
||||||
|
|
||||||
|
I.register("MpvConfig", MpvConfig())
|
||||||
|
|
||||||
|
class MpvProgram(Program):
|
||||||
|
_process: subprocess.Popen
|
||||||
|
|
||||||
|
_mpv_bin_path: str
|
||||||
|
_mpv_extra_args: list[str]
|
||||||
|
_mpv_ipc_sock: str
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
config: Config = I.get("Config")
|
||||||
|
self._process = None
|
||||||
|
self._mpv_bin_path = config.mpv
|
||||||
|
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({
|
||||||
|
"command": args,
|
||||||
|
}) + "\n"
|
||||||
|
ipc_logger.debug("-> %s", command)
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
||||||
|
try:
|
||||||
|
client.connect(self._mpv_ipc_sock)
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
ipc_logger.warn("Connection refused")
|
||||||
|
return {}
|
||||||
|
client.send(command.encode("utf-8"))
|
||||||
|
|
||||||
|
resp_raw = client.recv(4096).decode()
|
||||||
|
ipc_logger.debug("<- %s", resp_raw)
|
||||||
|
return json.loads(resp_raw)
|
||||||
|
|
||||||
|
|
||||||
|
def __read_prop(self, prop: str, default: Any) -> Any:
|
||||||
|
return self.__mpv_command(["get_property", prop]).get("data", default)
|
||||||
|
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return self._process is not None and self._process.returncode is None
|
||||||
|
|
||||||
|
def is_playing(self) -> bool:
|
||||||
|
return not self.__read_prop("pause", False)
|
||||||
|
|
||||||
|
def resume(self):
|
||||||
|
self.__mpv_command(["set_property", "pause", False])
|
||||||
|
|
||||||
|
def play(self, url: ParseResult):
|
||||||
|
self.stop("mpvplayer.play")
|
||||||
|
|
||||||
|
# Allow preprocessing the URL
|
||||||
|
last_segment = url.path.split("/")[-1]
|
||||||
|
final_url = ""
|
||||||
|
extra_args = []
|
||||||
|
if "." in last_segment:
|
||||||
|
*_, ext = last_segment.split(".")
|
||||||
|
transformer = I.get("MpvConfig").get_ext_transformer(ext)
|
||||||
|
if transformer is not None:
|
||||||
|
self.logger.info("Transforming URL due to extension: %s", ext)
|
||||||
|
extra_args, final_url = transformer(url)
|
||||||
|
self.logger.info("New url: %s", final_url)
|
||||||
|
else:
|
||||||
|
final_url = urlunparse(url)
|
||||||
|
else:
|
||||||
|
final_url = urlunparse(url)
|
||||||
|
|
||||||
|
# Start mpv
|
||||||
|
cmdline = [
|
||||||
|
self._mpv_bin_path,
|
||||||
|
f"--input-ipc-server={self._mpv_ipc_sock}",
|
||||||
|
"--fs",
|
||||||
|
"--quiet",
|
||||||
|
*self._mpv_extra_args,
|
||||||
|
*extra_args,
|
||||||
|
final_url,
|
||||||
|
]
|
||||||
|
self.logger.debug("Cmdline: %s", " ".join(cmdline))
|
||||||
|
self._process = nonblocking_run(cmdline, self._when_mpv_exit)
|
||||||
|
|
||||||
|
def _when_mpv_exit(self):
|
||||||
|
self.logger.info("MPV has exited")
|
||||||
|
self._process = None
|
||||||
|
I.get("DataBridge").set_loading(False)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
def get_player_info(self) -> PlayerInfo:
|
||||||
|
if not os.path.exists(self._mpv_ipc_sock):
|
||||||
|
return PlayerInfo(
|
||||||
|
runtime=0,
|
||||||
|
position=0,
|
||||||
|
title="",
|
||||||
|
playing=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return PlayerInfo(
|
||||||
|
runtime=int(self.__read_prop("duration", "0")),
|
||||||
|
position=int(self.__read_prop("playback-time", "0")),
|
||||||
|
title=self.__read_prop("media-title", ""),
|
||||||
|
playing=not self.__read_prop("pause", False),
|
||||||
|
)
|
52
microkodi/qml/Main.qml
Normal file
52
microkodi/qml/Main.qml
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
|
||||||
|
Window {
|
||||||
|
id: window
|
||||||
|
visible: true
|
||||||
|
title: "Hello World"
|
||||||
|
visibility: Window.Maximized
|
||||||
|
flags: Qt.FramelessWindowHint
|
||||||
|
|
||||||
|
property var wallpaperIndex: 0
|
||||||
|
property var isLoading: false
|
||||||
|
property var bridge
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: bridge
|
||||||
|
|
||||||
|
function onIsLoading(loading) {
|
||||||
|
isLoading = loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Image {
|
||||||
|
height: window.height
|
||||||
|
width: window.width
|
||||||
|
visible: true
|
||||||
|
source: wallpapers[wallpaperIndex]
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
}
|
||||||
|
|
||||||
|
Popup {
|
||||||
|
visible: isLoading
|
||||||
|
x: Math.round((parent.width - width) / 2)
|
||||||
|
y: Math.round((parent.height - height) / 2)
|
||||||
|
width: 100
|
||||||
|
height: 100
|
||||||
|
|
||||||
|
background: BusyIndicator {
|
||||||
|
running: isLoading
|
||||||
|
width: 100
|
||||||
|
height: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 5 * 60 * 1000
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: wallpaperIndex = (wallpaperIndex + 1) % wallpapers.length
|
||||||
|
}
|
||||||
|
}
|
2
microkodi/qml/qmldir
Normal file
2
microkodi/qml/qmldir
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
module qml
|
||||||
|
Main 254.0 Main.qml
|
15
microkodi/repository.py
Normal file
15
microkodi/repository.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
class _Repository:
|
||||||
|
_data: dict[str, Any]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._data = {}
|
||||||
|
|
||||||
|
def register(self, key: str, value: Any):
|
||||||
|
self._data[key] = value
|
||||||
|
|
||||||
|
def get(self, key: str) -> Any | None:
|
||||||
|
return self._data.get(key)
|
||||||
|
|
||||||
|
I = _Repository()
|
14
microkodi/ui/bridge.py
Normal file
14
microkodi/ui/bridge.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
|
class DataBridge(QObject):
|
||||||
|
"""Bridge between the QML app and the Python "host"."""
|
||||||
|
|
||||||
|
# Indicates whether we're currently loading something or not
|
||||||
|
isLoading = Signal(bool, arguments=["loading"])
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def set_loading(self, state: bool):
|
||||||
|
"""Set the loading state in the UI."""
|
||||||
|
self.isLoading.emit(state)
|
9
pyproject.toml
Normal file
9
pyproject.toml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
[project]
|
||||||
|
name = "microkodi"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"pyside6"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tools.build]
|
||||||
|
packages = ["microkodi"]
|
Loading…
Reference in New Issue
Block a user