Compare commits

...

10 Commits

12 changed files with 151 additions and 28 deletions

3
.gitignore vendored
View File

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

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,13 +35,12 @@ 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
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: def load_config(config_path: Path | None) -> Config:
if config_path is None: if config_path is None:
config_data = {} config_data = {}
@@ -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,7 +166,6 @@ 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}") I.get("DataBridge").notification.emit(f"No player available for {url.scheme}")
@@ -178,8 +201,7 @@ class PlayerRpcObject(JsonRpcObject):
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,6 +13,7 @@ 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
@@ -23,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...")
@@ -91,10 +97,6 @@ if __name__ == "__main__":
sys.exit(-1) sys.exit(-1)
engine.rootObjects()[0].setProperty("bridge", bridge) engine.rootObjects()[0].setProperty("bridge", bridge)
if config.watch_connector:
logger.info("Will be watching display if it's gone")
exit_code = app.exec() exit_code = app.exec()
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

@@ -120,7 +120,6 @@ class MpvProgram(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 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")
@@ -138,11 +139,15 @@ class VlcProgram(Program):
I.get("DataBridge").notification.emit("VLC exited with an error") I.get("DataBridge").notification.emit("VLC exited with an error")
self._process = None self._process = None
def __vlc_command(self, command: str) -> str | 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:
@@ -155,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

@@ -4,7 +4,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"pyside6", "pyside6",
"requests", "requests",
"yt-dlp" "yt-dlp",
"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)

View File

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