Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@
client.js
kino_mpv
kino_server
kino_kodi
server.conf
mpv_client.conf
kodi_client.conf
4 changes: 2 additions & 2 deletions kinoplex.nimble
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
11 changes: 11 additions & 0 deletions kodi_client.example.conf
Original file line number Diff line number Diff line change
@@ -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)
226 changes: 226 additions & 0 deletions src/kino_kodi.nim
Original file line number Diff line number Diff line change
@@ -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()
22 changes: 22 additions & 0 deletions src/kodi/config.nim
Original file line number Diff line number Diff line change
@@ -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")
)
74 changes: 74 additions & 0 deletions src/kodi/jsonrpc.nim
Original file line number Diff line number Diff line change
@@ -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"]
})
20 changes: 20 additions & 0 deletions src/kodi/kodi.nim
Original file line number Diff line number Diff line change
@@ -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
)