Compare commits

..

7 Commits

9 changed files with 111 additions and 23 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
# 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,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:
@@ -8,7 +21,7 @@ class KodiTime:
seconds: int seconds: int
def to_seconds(self) -> int: def to_seconds(self) -> int:
return self.hours * 3600 + self.minutes * 60 + self.seconds 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."""
@@ -23,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 KodiTime, 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 {
@@ -148,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
@@ -158,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}")
@@ -195,7 +202,6 @@ class PlayerRpcObject(JsonRpcObject):
"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

@@ -77,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")

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)