diff --git a/.gitignore b/.gitignore index 544129f..d73f076 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,7 @@ client.js kino_mpv kino_server +kino_kodi server.conf mpv_client.conf +kodi_client.conf diff --git a/kinoplex.nimble b/kinoplex.nimble index f0cbdf5..053ef9e 100644 --- a/kinoplex.nimble +++ b/kinoplex.nimble @@ -5,7 +5,7 @@ author = "Zed" description = "Server and client for syncing media playback" license = "AGPLv3" srcDir = "src" -bin = @["kino_server", "kino_mpv", "kino_telegram"] +bin = @["kino_server", "kino_mpv", "kino_kodi", "kodi_telegram"] # Dependencies @@ -32,4 +32,4 @@ task windows, " Build static Windows binary": echo "mingw pkg-config not installed, trying fallback" config = fallback let libs = &"--dynlibOverride:ssl {overrides} --passL:\"-Wl,-Bstatic {config} -lssp\"" - exec &"nim c -d:release --opt:size -d:ssl -d:mingw --cpu:amd64 {libs} -o=client.exe src/kino_mpv.nim" + exec &"nim c -d:release --opt:size -d:ssl -d:mingw --cpu:amd64 {libs} -o=client.exe src/kino_mpv.nim" \ No newline at end of file diff --git a/kodi_client.example.conf b/kodi_client.example.conf new file mode 100644 index 0000000..814a48f --- /dev/null +++ b/kodi_client.example.conf @@ -0,0 +1,11 @@ +[User] +username = "guest" +password = "" # only used for admin authentication, leave blank if it's not your server + +[Server] +address = "localhost:9001" # e.g. kino.example.com or example.com/kino +useTLS = false # connect securely over wss instead of ws (requires HTTPS support) + +[Kodi] +auth = "kodi:kodi" # username:password +host = "192.168.0.1:9090" # ip/hostname and port (usually 9090) diff --git a/src/kino_kodi.nim b/src/kino_kodi.nim new file mode 100644 index 0000000..7c3296d --- /dev/null +++ b/src/kino_kodi.nim @@ -0,0 +1,226 @@ +import std/[os, asyncdispatch, json, strformat, strutils] +import ws +import kodi/[kodi, jsonrpc, config], protocol + +type + Server = object + ws: WebSocket + host: string + playlist: seq[string] + index: int + playing: bool + time: float + +let + cfg = getConfig() + +var + name = cfg.username + role = user + server: Server + player: Kodi + messages: seq[string] + loading = false + reloading = false + +proc killKinoplex() = + echo "Leaving" + player.connected = false + close player.ws + close server.ws + +proc send(s: Server; data: Event) = + asyncCheck s.ws.send($(%data)) + +proc showText(text: string) = + let prevMsg = if messages.len > 0: messages[^1] else: "" + player.sendCmd(showMessage(text, prevMsg)) + stdout.write(text, "\n") + messages.add text + +proc showEvent(text: string) = + player.sendCmd(showMessage(text, "System")) + stdout.write(text, "\n") + +proc join(): Future[bool] {.async.} = + echo "Joining.." + await server.ws.send($(%Auth(name, cfg.password))) + + let resp = unpack(await server.ws.receiveStrPacket()) + match resp: + Joined(newName, newRole): + name = newName + if newRole != user: + role = newRole + showEvent(&"Welcome to the kinoplex, {role}!") + else: + showEvent("Welcome to the kinoplex!") + Error(reason): + showEvent("Join failed: " & reason) + result = true + _: discard + +proc clearPlaylist() = + player.sendCmd(stop()) + loading = false + +proc seekTo(time: float) = + if abs(time - player.time) > 3: + showEvent("Syncing time " & $time) + player.sendCmd(seek(time + 0.5)) + +proc setState(playing: bool; time: float; index=(-1)) = + if index > -1: + server.index = index + server.playing = playing + server.time = time + if not loading: + if player.playing != playing: + player.sendCmd(togglePlayer(playing)) + player.playing = playing + seekTo(time) + +proc updateIndex() {.async.} = + while loading: + await sleepAsync(150) + + if server.playlist.len == 0: + return + if server.index > server.playlist.high: + showEvent("Loading went wrong") + return + let url = server.playlist[server.index] + player.sendCmd(playUrl(url, server.time)) + showEvent("Playing " & url) + loading = true + +proc syncPlaying(playing: bool) = + if server.playing != playing: + player.sendCmd(togglePlayer(server.playing)) + player.playing = server.playing + +proc syncTime(time: float) = + player.time = time + seekTo(time) + +proc updateTime() {.async.} = + while player.connected: + if not loading: + player.sendCmd(getTime()) + await sleepAsync(500) + +proc handleKodi() {.async.} = + player.ws.setupPings(5) + while player.ws.readyState == Open: + let raw = await player.ws.receiveStrPacket() + if raw[0] != '{': + continue + + let event = parseJson(raw) + if event.hasKey("error"): + echo "ERROR: ", event + continue + + if event.hasKey("result"): + let res = event["result"] + if res.kind == JObject and res.hasKey("time"): + syncTime(timeToMs(res["time"])) + continue + + echo "notification: ", loading, " ", event["method"] + + case event["method"].getStr + of "Player.OnPlay": + loading = true + player.playing = false + player.time = 0 + of "Player.OnAVStart": + loading = false + setState(server.playing, server.time) + echo "playback started" + of "Player.OnAVChange": + if not loading: + syncPlaying(true) + of "Player.OnResume": + syncPlaying(true) + of "Player.OnPause": + syncPlaying(false) + of "Player.OnStop": + showEvent("stopped") + asyncCheck updateIndex() + of "Player.OnSeek": + let item = event{"params", "data", "player"} + player.time = timeToMs(item["time"]) + of "Playlist.OnClear": discard + of "Playlist.OnAdd": discard + else: + echo "unhandled: ", event + close player.ws + +proc handleServer() {.async.} = + if await join(): return + server.ws.setupPings(5) + while server.ws.readyState == Open: + let event = unpack(await server.ws.receiveStrPacket()) + match event: + Message(name, text): + if name == "server": + showEvent(text) + else: + showText(&"{name}: {text}") + State(playing, time): + echo "setState p:", playing, " t:", time + setState(playing, time) + PlaylistLoad(urls): + if server.playlist.len > 0: + clearPlaylist() + server.playlist = urls + showEvent("Playlist loaded") + PlaylistAdd(url): + server.playlist.add url + if server.playlist.len == 1: + asyncCheck updateIndex() + PlaylistPlay(index): + setState(server.playing, server.time, index=index) + asyncCheck updateIndex() + PlaylistClear: + server.playlist.setLen(0) + clearPlaylist() + setState(false, 0.0) + showEvent("Playlist cleared") + Error(reason): + showEvent(reason) + Joined(name, role): + showEvent(&"{name} joined as {role}") + Left(name): + showEvent(name & " left") + Renamed(oldName, newName): + if oldName == name: name = newName + Janny: discard + Jannies: discard + Clients: discard + Null: discard + Auth: discard + Success: discard + close server.ws + +proc main() {.async.} = + server = Server(host: (if cfg.useTls: "wss://" else: "ws://") & cfg.address & "/ws") + echo "Connecting to ", server.host + + try: + server.ws = await newWebSocket(server.host) + echo "connected to server" + player = await connect(cfg.kodiHost) + echo "connected to kodi" + if player == nil: return + asyncCheck handleKodi() + asyncCheck updateTime() + await handleServer() + except WebSocketError, OSError: + echo "Connection failed" + if player != nil and player.connected: + player.connected = false + close player.ws + +waitFor main() diff --git a/src/kodi/config.nim b/src/kodi/config.nim new file mode 100644 index 0000000..1d8a2b0 --- /dev/null +++ b/src/kodi/config.nim @@ -0,0 +1,22 @@ +import ../config_utils + +type + Config* = object + address*: string + useTls*: bool + username*: string + password*: string + kodiAuth*: string + kodiHost*: string + +proc getConfig*(): Config = + let cfg = loadConfig("kodi_client.conf") + + Config( + address: cfg.get("Server", "address", "localhost:9001/ws"), + useTls: cfg.get("Server", "useTLS", false), + username: cfg.get("User", "username", "guest"), + password: cfg.get("User", "password", ""), + kodiAuth: cfg.get("Kodi", "auth", "kodi:kodi"), + kodiHost: cfg.get("Kodi", "host", "localhost:9090") + ) diff --git a/src/kodi/jsonrpc.nim b/src/kodi/jsonrpc.nim new file mode 100644 index 0000000..cc5a84d --- /dev/null +++ b/src/kodi/jsonrpc.nim @@ -0,0 +1,74 @@ +import json, math, random + +proc msToTime(time: float): JsonNode = + let hours = floor(time / 60 / 60).int + let minutes = floor(time / 60).int - hours * 60 + let seconds = floor(time).int - minutes * 60 - hours * 60 * 60 + let milliseconds = int((time - floor(time)) * 100) + + return %*{ + "hours": hours, + "minutes": minutes, + "seconds": seconds, + "milliseconds": milliseconds + } + +proc timeToMs*(time: JsonNode): float = + let hours = time["hours"].getInt(0) + let minutes = time["minutes"].getInt(0) + let seconds = time["seconds"].getInt(0) + let milliseconds = time["milliseconds"].getInt(0) + return float(hours * 60 * 60 + minutes * 60 + seconds) + milliseconds / 1000 + +proc cmd(function: string; params: JsonNode): JsonNode = + %*{ + "jsonrpc": "2.0", + "method": function, + "params": params, + "id": rand(10000) + } + +proc cmd(function: string): JsonNode = + %*{ + "jsonrpc": "2.0", + "method": function, + "id": rand(10000) + } + +proc showMessage*(title: string; message: string; image="", + displaytime=5000): JsonNode = + cmd("GUI.ShowNotification", %*{ + "title": title, + "message": message, + "image": image, + "displaytime": displaytime + }) + +proc playUrl*(url: string; time: float): JsonNode = + cmd("Player.Open", %*{ + "item": {"file": "plugin://plugin.video.sendtokodi/?" & url}, + "options": {"resume": if time == 0: %false else: msToTime(time)} + }) + +proc togglePlayer*(state: bool): JsonNode = + cmd("Player.PlayPause", %*{ + "playerid": 1, + "play": state + }) + +proc stop*(): JsonNode = + cmd("Player.Stop", %*{ + "playerid": 1 + }) + +proc seek*(time: float): JsonNode = + cmd("Player.Seek", %*{ + "playerid": 1, + "value": {"time": msToTime(time)} + }) + +proc getTime*(): JsonNode = + cmd("Player.GetProperties", %*{ + "playerid": 1, + "properties": ["time"] + }) diff --git a/src/kodi/kodi.nim b/src/kodi/kodi.nim new file mode 100644 index 0000000..d667f2f --- /dev/null +++ b/src/kodi/kodi.nim @@ -0,0 +1,20 @@ +import std/[asyncdispatch, json, strutils] +import ws + +type + Kodi* = ref object + ws*: WebSocket + connected*: bool + playing*: bool + time*: float + +proc sendCmd*(client: Kodi; cmd: JsonNode) = + if cmd["method"].getStr != "Player.GetProperties": + echo "command: ", cmd + asyncCheck client.ws.send($cmd) + +proc connect*(host: string): Future[Kodi] {.async.} = + result = Kodi( + ws: await newWebSocket("ws://" & host & "/jsonrpc"), + connected: true + )