3 Commits
Author SHA1 Message Date
nathan 3a3ba861dc Phase 5a: persistent websocket with request ids and reconnect
continuous-integration/drone/push Build encountered an error
Client (ts/network_ts.ts): one WebSocket per page. Commands queue and go
out one at a time with a client-chosen id; the response is matched on the
echoed id (or to the in-flight request for older servers). 12 s timeout
per request, exponential-backoff reconnect (0.5 s to 10 s), the in-flight
request is retried after a reconnect, and log polls are de-duplicated so
they cannot pile up behind a stalled connection. The old global flag that
silently dropped any request made while another was in flight is gone.

Server (src/webserver.go): the /data/ handler now serves any number of
commands on one connection (it used to break out of its loop after the
first reply without closing the socket, leaving it open and deaf; the old
client papered over that by opening a new socket per request). Connection
closed on exit, request id echoed in the response.

Test: TestWSServesMultipleCommandsPerConnection.
2026-09-26 13:19:38 +10:00
nathan cbc5bc5da2 Merge branch 'worktree-agent-a93010566d356e58c' into improvements 2026-09-26 13:18:03 +10:00
nathan 3bbea8e952 Phase 2b: server/client timeouts, atomic state writes, log mutex, panic and shutdown fixes
- http.Server with ReadHeaderTimeout/IdleTimeout (no read/write timeouts: /stream/ is long-lived)
- shared outbound clients: providerHTTPClient (5m), apiHTTPClient (30s), imgcache client (30s)
- imgcache: download outside the lock, per-item helper, cache URL uses the file name not the fs path
- writeFileAtomic (temp + fsync + rename) for settings/xepg/pms/urls/authentication JSON
- one package-level logMu for WebScreenLog and notifications; ring buffer keeps the newest lines
- notifications evict the oldest instead of random map entries
- xepg XMLTV file removal rebuilt after the loop; data.go range-mutation removed
- API handler returns after error body; WS request/response fresh per command
- checked type assertions in data/backup/provider/screen
- SIGINT/SIGTERM handled in main via src.Shutdown(); fatal paths exit 1
2026-09-26 13:17:07 +10:00
24 changed files with 1222 additions and 400 deletions

No files matched your search

+193 -94
View File
@@ -1,97 +1,197 @@
// Websocket transport between the web UI and xTeVe.
//
// One socket stays open for the life of the page. Requests are queued and
// sent one at a time; each carries an id that the server echoes back, and
// the response is matched on it (or, for older servers that close after a
// single reply, to the oldest in-flight request). Connection loss triggers
// a reconnect with exponential backoff, and queued requests survive it.
class WSClient {
constructor() {
this.socket = null;
this.queue = [];
this.inFlight = null;
this.nextID = 1;
this.reconnectDelay = 500;
this.reconnectTimer = 0;
this.closedByUs = false;
this.requestTimeoutMs = 12000;
}
url() {
var protocol = window.location.protocol == "https:" ? "wss://" : "ws://";
var host = window.location.host;
if (host == undefined || host.length < 1) {
host = window.location.hostname;
}
return protocol + host + "/data/";
}
// send queues a command and resolves with the parsed response.
send(payload) {
var client = this;
return new Promise(function (resolve, reject) {
var req = {
id: String(client.nextID++),
payload: payload,
resolve: resolve,
reject: reject,
timer: 0,
sent: false
};
client.queue.push(req);
client.pump();
});
}
// hasQueued reports whether a command of this kind is already waiting.
hasQueued(cmd) {
if (this.inFlight != null && this.inFlight.payload["cmd"] == cmd) {
return true;
}
for (var i = 0; i < this.queue.length; i++) {
if (this.queue[i].payload["cmd"] == cmd) {
return true;
}
}
return false;
}
connect() {
if (this.socket != null && (this.socket.readyState == WebSocket.OPEN || this.socket.readyState == WebSocket.CONNECTING)) {
return;
}
var client = this;
var socket = new WebSocket(this.url());
this.socket = socket;
socket.onopen = function () {
WS_AVAILABLE = true;
client.reconnectDelay = 500;
client.pump();
};
socket.onmessage = function (e) {
client.handleMessage(e.data);
};
socket.onerror = function () {
// onclose follows; nothing to do here.
};
socket.onclose = function () {
if (client.socket == socket) {
client.socket = null;
}
// An in-flight request that never got its answer goes back to the
// front of the queue and is retried on the next connection.
if (client.inFlight != null) {
var req = client.inFlight;
client.inFlight = null;
req.sent = false;
client.queue.unshift(req);
}
client.scheduleReconnect();
};
}
scheduleReconnect() {
if (this.closedByUs || this.reconnectTimer != 0) {
return;
}
if (this.queue.length == 0) {
// Idle: reconnect lazily on the next request.
return;
}
var client = this;
this.reconnectTimer = window.setTimeout(function () {
client.reconnectTimer = 0;
client.connect();
}, this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 10000);
}
// pump sends the next queued request if the socket is open and idle.
pump() {
if (this.inFlight != null || this.queue.length == 0) {
return;
}
if (this.socket == null || this.socket.readyState != WebSocket.OPEN) {
this.connect();
return;
}
var req = this.queue.shift();
this.inFlight = req;
req.sent = true;
req.payload["id"] = req.id;
var client = this;
req.timer = window.setTimeout(function () {
if (client.inFlight == req) {
client.inFlight = null;
req.reject(new Error("timeout"));
// A socket that stopped answering is not trusted any more.
client.resetSocket();
client.pump();
}
}, this.requestTimeoutMs);
try {
this.socket.send(JSON.stringify(req.payload));
}
catch (err) {
window.clearTimeout(req.timer);
this.inFlight = null;
req.sent = false;
this.queue.unshift(req);
this.resetSocket();
}
}
resetSocket() {
var socket = this.socket;
this.socket = null;
if (socket != null) {
socket.onclose = null;
try {
socket.close();
}
catch (err) { /* ignore */ }
}
this.scheduleReconnect();
}
handleMessage(raw) {
var response;
try {
response = JSON.parse(raw);
}
catch (err) {
return;
}
var req = this.inFlight;
if (req == null) {
return;
}
// Servers that echo the id let us drop stray late answers.
if (response.hasOwnProperty("id") && response["id"] != req.id) {
return;
}
window.clearTimeout(req.timer);
this.inFlight = null;
req.resolve(response);
this.pump();
}
}
var WS = new WSClient();
class Server { class Server {
constructor(cmd) { constructor(cmd) {
this.cmd = cmd; this.cmd = cmd;
} }
request(data) { request(data) {
if (SERVER_CONNECTION == true) { var isLogUpdate = this.cmd == "updateLog";
// Log polling must never pile up behind a stalled connection.
if (isLogUpdate && WS.hasQueued("updateLog")) {
return; return;
} }
SERVER_CONNECTION = true; if (!isLogUpdate) {
if (this.cmd != "updateLog") {
showElement("loading", true); showElement("loading", true);
UNDO = new Object(); UNDO = new Object();
setConnectionState("busy"); setConnectionState("busy");
} }
switch (window.location.protocol) {
case "http:":
this.protocol = "ws://";
break;
case "https:":
this.protocol = "wss://";
break;
}
var wsHost = window.location.host;
if (wsHost == undefined || wsHost.length < 1) {
wsHost = window.location.hostname;
}
// The session cookie (HttpOnly) is sent with the websocket handshake.
var url = this.protocol + wsHost + "/data/";
data["cmd"] = this.cmd; data["cmd"] = this.cmd;
var requestCmd = data["cmd"]; var requestCmd = this.cmd;
var ws = new WebSocket(url); WS.send(data).then(function (response) {
var isLogUpdate = data["cmd"] == "updateLog";
var responseReceived = false;
var requestFinished = false;
var timeoutMs = 12000;
var requestTimeout;
var finishRequest = function (state, responseSuccess = false) {
if (requestFinished == true) {
return;
}
requestFinished = true;
SERVER_CONNECTION = false;
window.clearTimeout(requestTimeout);
if (responseSuccess == true) {
if (state == "online") {
WS_FAILURE_COUNT = 0; WS_FAILURE_COUNT = 0;
} if (!isLogUpdate) {
}
else {
WS_FAILURE_COUNT++;
}
if (isLogUpdate == false) {
showElement("loading", false); showElement("loading", false);
} }
if (state != "") { setConnectionState("online");
setConnectionState(state);
}
};
requestTimeout = window.setTimeout(function () {
console.log("Websocket request timed out.");
var timeoutState = "offline";
if (isLogUpdate == true && WS_FAILURE_COUNT < 2) {
timeoutState = "idle";
}
finishRequest(timeoutState, false);
try {
ws.close();
}
catch (err) {
console.log(err);
}
}, timeoutMs);
ws.onopen = function () {
WS_AVAILABLE = true;
if (data["cmd"] != "updateLog") {
setConnectionState("busy");
}
this.send(JSON.stringify(data));
};
ws.onerror = function (e) {
console.log("No websocket connection to xTeVe could be established. Check your network configuration.");
var errorState = "offline";
if (isLogUpdate == true && WS_FAILURE_COUNT < 2) {
errorState = "idle";
}
finishRequest(errorState, false);
if (WS_AVAILABLE == false && isLogUpdate == false && requestCmd != "getServerConfig") {
alert("No websocket connection to xTeVe could be established. Check your network configuration.");
}
};
ws.onmessage = function (e) {
responseReceived = true;
finishRequest("online", true);
var response = JSON.parse(e.data);
if (response["status"] == false) { if (response["status"] == false) {
setConnectionState("offline"); setConnectionState("offline");
alert(response["err"]); alert(response["err"]);
@@ -106,16 +206,14 @@ class Server {
div.className = "changed"; div.className = "changed";
return; return;
} }
switch (data["cmd"]) { switch (requestCmd) {
case "updateLog": case "updateLog":
SERVER["log"] = response["log"]; SERVER["log"] = response["log"];
if (document.getElementById("content_log")) { if (document.getElementById("content_log")) {
showLogs(false); showLogs(false);
} }
return; return;
break;
default: default:
SERVER = new Object();
SERVER = response; SERVER = response;
break; break;
} }
@@ -139,17 +237,20 @@ class Server {
return; return;
} }
createLayout(); createLayout();
}; }, function (err) {
ws.onclose = function () { WS_FAILURE_COUNT++;
if (responseReceived == true) { if (!isLogUpdate) {
return; showElement("loading", false);
} }
var closeState = "offline"; var state = "offline";
if (isLogUpdate == true && WS_FAILURE_COUNT < 2) { if (isLogUpdate && WS_FAILURE_COUNT < 2) {
closeState = "idle"; state = "idle";
} }
finishRequest(closeState, false); setConnectionState(state);
}; if (WS_AVAILABLE == false && !isLogUpdate && requestCmd != "getServerConfig") {
alert("No websocket connection to xTeVe could be established. Check your network configuration.");
}
});
} }
} }
var WS_FAILURE_COUNT = 0; var WS_FAILURE_COUNT = 0;
@@ -961,7 +1062,7 @@ function PageReady() {
window.clearInterval(bootstrapTimer); window.clearInterval(bootstrapTimer);
return; return;
} }
if (SERVER_CONNECTION == true) { if (WS.hasQueued("getServerConfig")) {
return; return;
} }
bootstrapAttempts++; bootstrapAttempts++;
@@ -2688,7 +2789,6 @@ var BULK_EDIT = false;
var COLUMN_TO_SORT; var COLUMN_TO_SORT;
var SEARCH_MAPPING = new Object(); var SEARCH_MAPPING = new Object();
var UNDO = new Object(); var UNDO = new Object();
var SERVER_CONNECTION = false;
var WS_AVAILABLE = false; var WS_AVAILABLE = false;
var ACTIVE_MENU_ID = ""; var ACTIVE_MENU_ID = "";
var LAST_BULK_CHECKBOX = null; var LAST_BULK_CHECKBOX = null;
@@ -3236,7 +3336,6 @@ function sortSelect(elem) {
return; return;
} }
function updateLog() { function updateLog() {
console.log("TOKEN");
var server = new Server("updateLog"); var server = new Server("updateLog");
server.request(new Object()); server.request(new Object());
} }
+7 -3
View File
@@ -148,8 +148,8 @@ func xteveRestore(archive string) (newWebURL string, err error) {
return return
} }
backupVersion = newConfig["version"].(string) backupVersion, ok := newConfig["version"].(string)
if backupVersion < System.Compatibility { if !ok || backupVersion < System.Compatibility {
err = errors.New(getErrMsg(1013)) err = errors.New(getErrMsg(1013))
return return
} }
@@ -167,7 +167,11 @@ func xteveRestore(archive string) (newWebURL string, err error) {
return return
} }
newPort = newConfig["port"].(string) newPort, ok = newConfig["port"].(string)
if !ok {
err = errors.New(getErrMsg(1030))
return
}
oldPort = Settings.Port oldPort = Settings.Port
if newPort == oldPort { if newPort == oldPort {
+47 -15
View File
@@ -63,17 +63,31 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e
// Leerzeichen aus den Werten entfernen und Formatierung der Uhrzeit überprüfen (0000 - 2359) // Leerzeichen aus den Werten entfernen und Formatierung der Uhrzeit überprüfen (0000 - 2359)
var newUpdateTimes = make([]string, 0) var newUpdateTimes = make([]string, 0)
for _, v := range value.([]any) { updateTimes, ok := value.([]any)
if !ok {
err = errors.New(getErrMsg(1012))
ShowError(err, 1012)
return Settings, err
}
v = strings.Replace(v.(string), " ", "", -1) for _, v := range updateTimes {
_, err := time.Parse("1504", v.(string)) t, ok := v.(string)
if !ok {
err = errors.New(getErrMsg(1012))
ShowError(err, 1012)
return Settings, err
}
t = strings.Replace(t, " ", "", -1)
_, err := time.Parse("1504", t)
if err != nil { if err != nil {
ShowError(err, 1012) ShowError(err, 1012)
return Settings, err return Settings, err
} }
newUpdateTimes = append(newUpdateTimes, v.(string)) newUpdateTimes = append(newUpdateTimes, t)
} }
@@ -320,21 +334,30 @@ func saveFiles(request RequestStruct, fileType string) (err error) {
for dataID, data := range newData { for dataID, data := range newData {
dataMap, ok := data.(map[string]any)
if !ok {
err = errors.New(getErrMsg(1020))
return
}
if dataID == "-" { if dataID == "-" {
// Neue Providerdatei // Neue Providerdatei
dataID = indicator + randomString(19) dataID = indicator + randomString(19)
data.(map[string]any)["new"] = true dataMap["new"] = true
filesMap[dataID] = data filesMap[dataID] = dataMap
} else { } else {
// Bereits vorhandene Providerdatei // Bereits vorhandene Providerdatei
for key, value := range data.(map[string]any) { oldData, ok := filesMap[dataID].(map[string]any)
if !ok {
// Unknown file ID: nothing to update.
continue
}
var oldData = filesMap[dataID].(map[string]any) for key, value := range dataMap {
oldData[key] = value oldData[key] = value
} }
} }
@@ -353,11 +376,11 @@ func saveFiles(request RequestStruct, fileType string) (err error) {
} }
// Neue Providerdatei // Neue Providerdatei
if _, ok := data.(map[string]any)["new"]; ok { if _, ok := dataMap["new"]; ok {
reloadData = true reloadData = true
err = getProviderData(fileType, dataID) err = getProviderData(fileType, dataID)
delete(data.(map[string]any), "new") delete(dataMap, "new")
if err != nil { if err != nil {
delete(filesMap, dataID) delete(filesMap, dataID)
@@ -661,8 +684,16 @@ func saveUserData(request RequestStruct) (err error) {
func saveNewUser(request RequestStruct) (err error) { func saveNewUser(request RequestStruct) (err error) {
var data = request.UserData var data = request.UserData
var username = data["username"].(string)
var password = data["password"].(string) username, ok := data["username"].(string)
if !ok {
return errors.New("Username is missing")
}
password, ok := data["password"].(string)
if !ok {
return errors.New("Password is missing")
}
delete(data, "password") delete(data, "password")
delete(data, "confirm") delete(data, "confirm")
@@ -855,7 +886,7 @@ func buildDatabaseDVR() (err error) {
var playlistFile = getLocalProviderFiles(fileType) var playlistFile = getLocalProviderFiles(fileType)
for n, i := range playlistFile { for _, i := range playlistFile {
var channels []any var channels []any
var groupTitle, tvgID, uuid = 0, 0, 0 var groupTitle, tvgID, uuid = 0, 0, 0
@@ -878,7 +909,8 @@ func buildDatabaseDVR() (err error) {
ShowError(err, 1005) ShowError(err, 1005)
err = errors.New(playlistName + ": Local copy of the file no longer exists") err = errors.New(playlistName + ": Local copy of the file no longer exists")
ShowError(err, 0) ShowError(err, 0)
playlistFile = append(playlistFile[:n], playlistFile[n+1:]...) // playlistFile is not used after this loop; removing the entry
// while ranging over it only skipped the following file.
} }
// Streams analysieren // Streams analysieren
+14
View File
@@ -0,0 +1,14 @@
package src
import (
"net/http"
"time"
)
// providerHTTPClient : Downloads of provider files (M3U, XMLTV, HDHR lineup).
// Playlists and EPG files can be large, so the overall timeout is generous;
// it only exists so a stalled provider cannot hang an update forever.
var providerHTTPClient = &http.Client{Timeout: 5 * time.Minute}
// apiHTTPClient : Short request/response calls to other services (Plex API).
var apiHTTPClient = &http.Client{Timeout: 30 * time.Second}
@@ -0,0 +1,43 @@
package authentication
import (
"os"
"path/filepath"
"testing"
)
func TestWriteFileAtomic(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "authentication.json")
if err := writeFileAtomic(file, []byte("{\"a\":1}"), 0600); err != nil {
t.Fatal(err)
}
if err := writeFileAtomic(file, []byte("{\"a\":2}"), 0600); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if string(got) != "{\"a\":2}" {
t.Errorf("content = %q", got)
}
fi, err := os.Stat(file)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0600 {
t.Errorf("mode = %o, want 0600", fi.Mode().Perm())
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Errorf("expected only the target file, found %d entries", len(entries))
}
}
+37 -1
View File
@@ -411,11 +411,47 @@ func saveDatabase(tmpMap any) (err error) {
return return
} }
err = os.WriteFile(database, []byte(jsonString), 0600) err = writeFileAtomic(database, []byte(jsonString), 0600)
return
}
// writeFileAtomic : Temp file + fsync + rename, so authentication.json is
// never observed half-written. (Private copy; this package cannot import src.)
func writeFileAtomic(name string, data []byte, perm os.FileMode) (err error) {
tmp, err := os.CreateTemp(filepath.Dir(name), ".tmp-*")
if err != nil { if err != nil {
return return
} }
var tmpName = tmp.Name()
defer func() {
if err != nil {
tmp.Close()
os.Remove(tmpName)
}
}()
if _, err = tmp.Write(data); err != nil {
return
}
if err = tmp.Sync(); err != nil {
return
}
if err = tmp.Close(); err != nil {
return
}
if err = os.Chmod(tmpName, perm); err != nil {
return
}
err = os.Rename(tmpName, name)
return return
} }
+62 -36
View File
@@ -9,8 +9,14 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"time"
) )
// httpClient : Client for logo downloads. Providers occasionally serve
// images from hosts that never answer; without a timeout the caching
// goroutine would hang forever.
var httpClient = &http.Client{Timeout: 30 * time.Second}
// Cache : Cache strcut // Cache : Cache strcut
type Cache struct { type Cache struct {
path string path string
@@ -41,8 +47,6 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
c.Queue = []string{} c.Queue = []string{}
c.Cache = []string{} c.Cache = []string{}
var queue []string
c.Image.GetURL = func(src string) (cacheURL string) { c.Image.GetURL = func(src string) (cacheURL string) {
c.Lock() c.Lock()
@@ -89,50 +93,30 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
return src return src
} }
// Caching downloads every queued image. The lock is only held while the
// queue is copied and while the map / queue are updated per image; the
// HTTP download and file write happen unlocked so GetURL callers (M3U /
// XMLTV generation) are not blocked for the duration of the downloads.
c.Image.Caching = func() { c.Image.Caching = func() {
c.Lock() c.Lock()
defer c.Unlock() var queue = make([]string, len(c.Queue))
copy(queue, c.Queue)
c.Unlock()
var filename string for _, src := range queue {
for _, src := range c.Queue { filename, err := c.download(src)
resp, err := http.Get(src)
if err != nil { if err != nil {
continue // Stays in the queue and is retried on the next run.
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
continue continue
} }
filename = fmt.Sprintf("%s%s%s%s", c.path, string(os.PathSeparator), strToMD5(src), filepath.Ext(src)) c.Lock()
c.images[filename] = c.cacheURL + filename
c.Queue = removeStringFromSlice(src, c.Queue)
c.Unlock()
file, err := os.Create(filename)
if err != nil {
continue
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
continue
}
u, err := url.Parse(src)
if err == nil {
c.images[fmt.Sprintf("%s%s", strToMD5(src), filepath.Ext(u.Path))] = c.cacheURL + filename
}
queue = append(queue, src)
}
for _, q := range queue {
c.Queue = removeStringFromSlice(q, c.Queue)
} }
} }
@@ -175,3 +159,45 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
return return
} }
// download : Fetches src into the cache folder and returns the cached file
// name (md5 of the URL plus the extension of the URL path). Must be called
// without holding the lock.
func (c *Cache) download(src string) (filename string, err error) {
u, err := url.Parse(src)
if err != nil {
return
}
filename = fmt.Sprintf("%s%s", strToMD5(src), filepath.Ext(u.Path))
resp, err := httpClient.Get(src)
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
err = fmt.Errorf("%s: %s", src, resp.Status)
return
}
var path = filepath.Join(c.path, filename)
file, err := os.Create(path)
if err != nil {
return
}
_, err = io.Copy(file, resp.Body)
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
os.Remove(path)
}
return
}
+1 -5
View File
@@ -342,11 +342,7 @@ func doPlexRequest(method, baseURL, endpoint, token string) (status int, body []
req.Header.Set("X-Plex-Version", System.Version) req.Header.Set("X-Plex-Version", System.Version)
} }
client := &http.Client{ resp, err := apiHTTPClient.Do(req)
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
if err != nil { if err != nil {
return return
} }
+28 -11
View File
@@ -142,8 +142,16 @@ func getProviderData(fileType, fileID string) (err error) {
for dataID, d := range dataMap { for dataID, d := range dataMap {
var data = d.(map[string]any) data, ok := d.(map[string]any)
var fileSource = data["file.source"].(string) if !ok {
continue
}
fileSource, ok := data["file.source"].(string)
if !ok {
continue
}
newProvider = false newProvider = false
if _, ok := data["new"]; ok { if _, ok := data["new"]; ok {
@@ -224,8 +232,8 @@ func getProviderData(fileType, fileID string) (err error) {
if value, ok := dataMap[dataID].(map[string]any); ok { if value, ok := dataMap[dataID].(map[string]any); ok {
data = value data = value
data["counter.error"] = data["counter.error"].(float64) + 1 data["counter.error"] = toFloat64(data["counter.error"]) + 1
data["counter.download"] = data["counter.download"].(float64) + 1 data["counter.download"] = toFloat64(data["counter.download"]) + 1
} }
@@ -243,10 +251,13 @@ func getProviderData(fileType, fileID string) (err error) {
var data = make(map[string]any) var data = make(map[string]any)
data = value data = value
if data["counter.error"].(float64) == 0 { var errCount = toFloat64(data["counter.error"])
var dlCount = toFloat64(data["counter.download"])
if errCount == 0 || dlCount == 0 {
data["provider.availability"] = 100 data["provider.availability"] = 100
} else { } else {
data["provider.availability"] = int(data["counter.error"].(float64)*100/data["counter.download"].(float64)*-1 + 100) data["provider.availability"] = int(errCount*100/dlCount*-1 + 100)
} }
} }
@@ -289,8 +300,7 @@ func downloadFileFromServer(providerURL string) (filename string, body []byte, e
req.Header.Set("User-Agent", getUserAgent()) req.Header.Set("User-Agent", getUserAgent())
client := &http.Client{} resp, err := providerHTTPClient.Do(req)
resp, err := client.Do(req)
if err != nil { if err != nil {
return return
} }
@@ -307,15 +317,22 @@ func downloadFileFromServer(providerURL string) (filename string, body []byte, e
if index > -1 { if index > -1 {
var headerFilename = resp.Header.Get("Content-Disposition")[index:len(resp.Header.Get("Content-Disposition"))] var headerFilename = resp.Header.Get("Content-Disposition")[index:]
var value = strings.Split(headerFilename, `=`) var value = strings.SplitN(headerFilename, `=`, 2)
if len(value) == 2 {
var f = strings.Replace(value[1], `"`, "", -1) var f = strings.Replace(value[1], `"`, "", -1)
f = strings.Replace(f, `;`, "", -1) f = strings.Replace(f, `;`, "", -1)
filename = f filename = f
showInfo("Header filename:" + filename) showInfo("Header filename:" + filename)
} else { }
}
if len(filename) == 0 {
var cleanFilename = strings.SplitN(getFilenameFromPath(providerURL), "?", 2) var cleanFilename = strings.SplitN(getFilenameFromPath(providerURL), "?", 2)
filename = cleanFilename[0] filename = cleanFilename[0]
+145 -40
View File
@@ -10,6 +10,82 @@ import (
"time" "time"
) )
// logMu guards WebScreenLog (its slice and counters) and System.Notification.
// Log lines are appended from every goroutine (buffer, maintenance, web
// handlers) and read by the websocket handler.
var logMu sync.Mutex
// logAppend : Appends a timestamped line to the in-memory log and trims it
// to Settings.LogEntriesRAM.
func logAppend(logMsg string) {
logMu.Lock()
defer logMu.Unlock()
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
logCleanUpLocked()
}
// logAppendCounted : Appends a warning / error line and bumps the matching
// counter. Like the original code this does not trim the log; the next
// info / debug line does.
func logAppendCounted(logMsg string, isError bool) {
logMu.Lock()
defer logMu.Unlock()
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
if isError {
WebScreenLog.Errors++
} else {
WebScreenLog.Warnings++
}
}
// resetWebScreenLog : Clears the in-memory log and its counters.
func resetWebScreenLog() {
logMu.Lock()
defer logMu.Unlock()
WebScreenLog.Log = make([]string, 0)
WebScreenLog.Errors = 0
WebScreenLog.Warnings = 0
}
// webScreenLogSnapshot : Copy of the log for the web client. The slice is
// copied so the JSON encoder never races with a concurrent append.
func webScreenLogSnapshot() (snapshot WebScreenLogStruct) {
logMu.Lock()
defer logMu.Unlock()
snapshot = WebScreenLog
snapshot.Log = make([]string, len(WebScreenLog.Log))
copy(snapshot.Log, WebScreenLog.Log)
return
}
// notificationsSnapshot : Copy of the notifications map for the web client.
func notificationsSnapshot() (snapshot map[string]Notification) {
logMu.Lock()
defer logMu.Unlock()
if System.Notification == nil {
return nil
}
snapshot = make(map[string]Notification, len(System.Notification))
for k, v := range System.Notification {
snapshot[k] = v
}
return
}
func showInfo(str string) { func showInfo(str string) {
if System.Flag.Info { if System.Flag.Info {
@@ -33,9 +109,7 @@ func showInfo(str string) {
printLogOnScreen(logMsg, "info") printLogOnScreen(logMsg, "info")
logMsg = strings.Replace(logMsg, " ", "&nbsp;", -1) logAppend(strings.Replace(logMsg, " ", "&nbsp;", -1))
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
logCleanUp()
} }
@@ -51,7 +125,6 @@ func showDebug(str string, level int) {
var msg = strings.SplitN(str, ":", 2) var msg = strings.SplitN(str, ":", 2)
var length = len(msg[0]) var length = len(msg[0])
var space string var space string
var mutex = sync.RWMutex{}
if len(msg) == 2 { if len(msg) == 2 {
@@ -64,11 +137,7 @@ func showDebug(str string, level int) {
printLogOnScreen(logMsg, "debug") printLogOnScreen(logMsg, "debug")
mutex.Lock() logAppend(strings.Replace(logMsg, " ", "&nbsp;", -1))
logMsg = strings.Replace(logMsg, " ", "&nbsp;", -1)
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
logCleanUp()
mutex.Unlock()
} }
@@ -99,7 +168,8 @@ func showHighlight(str string) {
} }
notification.Type = "info" notification.Type = "info"
notification.Message = msg[1] // Messages without a "key:value" prefix are shown as they are.
notification.Message = msg[len(msg)-1]
addNotification(notification) addNotification(notification)
@@ -109,31 +179,22 @@ func showWarning(errCode int) {
var errMsg = getErrMsg(errCode) var errMsg = getErrMsg(errCode)
var logMsg = fmt.Sprintf("[%s] [WARNING] %s", System.Name, errMsg) var logMsg = fmt.Sprintf("[%s] [WARNING] %s", System.Name, errMsg)
var mutex = sync.RWMutex{}
printLogOnScreen(logMsg, "warning") printLogOnScreen(logMsg, "warning")
mutex.Lock() logAppendCounted(logMsg, false)
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
WebScreenLog.Warnings++
mutex.Unlock()
} }
// ShowError : Zeigt die Fehlermeldungen in der Konsole // ShowError : Zeigt die Fehlermeldungen in der Konsole
func ShowError(err error, errCode int) { func ShowError(err error, errCode int) {
var mutex = sync.RWMutex{}
var errMsg = getErrMsg(errCode) var errMsg = getErrMsg(errCode)
var logMsg = fmt.Sprintf("[%s] [ERROR] %s (%s) - EC: %d", System.Name, err, errMsg, errCode) var logMsg = fmt.Sprintf("[%s] [ERROR] %s (%s) - EC: %d", System.Name, err, errMsg, errCode)
printLogOnScreen(logMsg, "error") printLogOnScreen(logMsg, "error")
mutex.Lock() logAppendCounted(logMsg, true)
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
WebScreenLog.Errors++
mutex.Unlock()
} }
@@ -174,24 +235,17 @@ func printLogOnScreen(logMsg string, logType string) {
} }
func logCleanUp() { // logCleanUpLocked : Trims the log to the newest Settings.LogEntriesRAM
// lines and recounts warnings / errors. The caller must hold logMu.
func logCleanUpLocked() {
WebScreenLog.Log = keepLastEntries(WebScreenLog.Log, Settings.LogEntriesRAM)
var logEntriesRAM = Settings.LogEntriesRAM
var logs = WebScreenLog.Log var logs = WebScreenLog.Log
WebScreenLog.Warnings = 0 WebScreenLog.Warnings = 0
WebScreenLog.Errors = 0 WebScreenLog.Errors = 0
if len(logs) > logEntriesRAM {
var tmp = make([]string, 0)
for i := len(logs) - logEntriesRAM; i < logEntriesRAM; i++ {
tmp = append(tmp, logs[i])
}
logs = tmp
}
for _, log := range logs { for _, log := range logs {
if strings.Contains(log, "WARNING") { if strings.Contains(log, "WARNING") {
@@ -204,8 +258,25 @@ func logCleanUp() {
} }
WebScreenLog.Log = logs }
// keepLastEntries : Returns the last max entries of logs (a copy, so the
// old backing array does not keep growing). A max <= 0 keeps nothing, which
// matches the original behaviour for an unset LogEntriesRAM.
func keepLastEntries(logs []string, max int) []string {
if max < 0 {
max = 0
}
if len(logs) <= max {
return logs
}
var tmp = make([]string, max)
copy(tmp, logs[len(logs)-max:])
return tmp
} }
// Fehlercodes // Fehlercodes
@@ -386,30 +457,64 @@ func getErrMsg(errCode int) (errMsg string) {
return errMsg return errMsg
} }
// maxNotifications : Number of notifications kept for the web interface.
const maxNotifications = 10
func addNotification(notification Notification) (err error) { func addNotification(notification Notification) (err error) {
var i int
var t = time.Now().UnixNano() / (int64(time.Millisecond) / int64(time.Nanosecond)) var t = time.Now().UnixNano() / (int64(time.Millisecond) / int64(time.Nanosecond))
notification.Time = strconv.FormatInt(t, 10) notification.Time = strconv.FormatInt(t, 10)
insertNotification(notification)
return
}
// insertNotification : Stores the notification under its Time key and evicts
// the oldest ones once more than maxNotifications are kept.
func insertNotification(notification Notification) {
notification.New = true notification.New = true
if len(notification.Headline) == 0 { if len(notification.Headline) == 0 {
notification.Headline = strings.ToUpper(notification.Type) notification.Headline = strings.ToUpper(notification.Type)
} }
logMu.Lock()
defer logMu.Unlock()
if len(System.Notification) == 0 { if len(System.Notification) == 0 {
System.Notification = make(map[string]Notification) System.Notification = make(map[string]Notification)
} }
System.Notification[notification.Time] = notification System.Notification[notification.Time] = notification
for key := range System.Notification { for len(System.Notification) > maxNotifications {
delete(System.Notification, oldestNotificationKey(System.Notification))
if i < len(System.Notification)-10 {
delete(System.Notification, key)
} }
i++ }
// oldestNotificationKey : Key of the notification with the smallest
// timestamp. Keys are millisecond timestamps as decimal strings; anything
// that does not parse sorts before everything that does.
func oldestNotificationKey(notifications map[string]Notification) (oldest string) {
var oldestTime int64
var found bool
for key, n := range notifications {
t, err := strconv.ParseInt(n.Time, 10, 64)
if err != nil {
return key
}
if !found || t < oldestTime {
oldest = key
oldestTime = t
found = true
}
} }
+108
View File
@@ -0,0 +1,108 @@
package src
import (
"fmt"
"reflect"
"strconv"
"strings"
"testing"
)
func TestKeepLastEntries(t *testing.T) {
logs := []string{"1", "2", "3", "4", "5"}
got := keepLastEntries(logs, 3)
if want := []string{"3", "4", "5"}; !reflect.DeepEqual(got, want) {
t.Errorf("keepLastEntries(5 entries, 3) = %v, want %v", got, want)
}
if got := keepLastEntries(logs, 10); !reflect.DeepEqual(got, logs) {
t.Errorf("keepLastEntries below the limit = %v, want %v", got, logs)
}
if got := keepLastEntries(logs, 0); len(got) != 0 {
t.Errorf("keepLastEntries(.., 0) = %v, want empty", got)
}
}
// The ring buffer must keep the newest lines once the log is full; the old
// loop bound dropped them.
func TestLogCleanUpKeepsNewest(t *testing.T) {
oldLimit := Settings.LogEntriesRAM
resetWebScreenLog()
t.Cleanup(func() {
Settings.LogEntriesRAM = oldLimit
resetWebScreenLog()
})
Settings.LogEntriesRAM = 3
for i := 1; i <= 5; i++ {
logAppend(fmt.Sprintf("line-%d", i))
}
snapshot := webScreenLogSnapshot()
if len(snapshot.Log) != 3 {
t.Fatalf("log has %d entries, want 3: %v", len(snapshot.Log), snapshot.Log)
}
for i, want := range []string{"line-3", "line-4", "line-5"} {
if !strings.HasSuffix(snapshot.Log[i], want) {
t.Errorf("entry %d = %q, want suffix %q", i, snapshot.Log[i], want)
}
}
// Counters are recomputed from the retained lines.
logAppendCounted("[ERROR] boom", true)
logAppend("[WARNING] careful")
snapshot = webScreenLogSnapshot()
if snapshot.Errors != 1 || snapshot.Warnings != 1 {
t.Errorf("errors/warnings = %d/%d, want 1/1 (%v)", snapshot.Errors, snapshot.Warnings, snapshot.Log)
}
}
func TestInsertNotificationEvictsOldest(t *testing.T) {
old := System.Notification
System.Notification = nil
t.Cleanup(func() { System.Notification = old })
// Insert out of order so eviction cannot rely on insertion order.
order := []int64{5, 1, 12, 3, 9, 2, 11, 7, 4, 10, 8, 6}
for _, ts := range order {
insertNotification(Notification{Type: "info", Message: "n", Time: strconv.FormatInt(1000+ts, 10)})
}
got := notificationsSnapshot()
if len(got) != maxNotifications {
t.Fatalf("kept %d notifications, want %d", len(got), maxNotifications)
}
for _, ts := range []int64{1, 2} {
if _, ok := got[strconv.FormatInt(1000+ts, 10)]; ok {
t.Errorf("oldest notification %d should have been evicted", ts)
}
}
for ts := int64(3); ts <= 12; ts++ {
if _, ok := got[strconv.FormatInt(1000+ts, 10)]; !ok {
t.Errorf("notification %d missing", ts)
}
}
// The web-client copy must be independent of the live map.
delete(got, "1012")
if _, ok := notificationsSnapshot()["1012"]; !ok {
t.Error("snapshot is not a copy")
}
}
func TestShowHighlightWithoutColon(t *testing.T) {
old := System.Notification
System.Notification = nil
t.Cleanup(func() { System.Notification = old })
showHighlight("no separator here") // used to index msg[1] and panic
for _, n := range notificationsSnapshot() {
if n.Message != "no separator here" {
t.Errorf("message = %q", n.Message)
}
}
}
+3 -8
View File
@@ -124,12 +124,7 @@ func maskSettings(s SettingsStruct) SettingsStruct {
// writePrivateFile : like writeByteToFile but readable only by the owner. // writePrivateFile : like writeByteToFile but readable only by the owner.
func writePrivateFile(file string, data []byte) error { func writePrivateFile(file string, data []byte) error {
var filename = getPlatformFile(file) // The temp file is chmod'ed before the rename, so an existing
// world-readable file is replaced by a 0600 one.
if err := os.WriteFile(filename, data, 0600); err != nil { return writeFileAtomic(getPlatformFile(file), data, 0600)
return err
}
// WriteFile keeps the mode of an existing file; tighten it.
return os.Chmod(filename, 0600)
} }
+49 -18
View File
@@ -4,12 +4,38 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"os/signal" "sync"
"time" "time"
"github.com/koron/go-ssdp" "github.com/koron/go-ssdp"
) )
// ssdpState : The running advertiser, so Shutdown can say goodbye to the
// network before the process exits.
var ssdpState struct {
sync.Mutex
adv *ssdp.Advertiser
done chan struct{}
}
// Shutdown : Stops background services (currently the SSDP advertiser).
// Called from main on SIGINT / SIGTERM.
func Shutdown() {
ssdpState.Lock()
defer ssdpState.Unlock()
if ssdpState.adv == nil {
return
}
close(ssdpState.done)
ssdpState.adv.Bye()
ssdpState.adv.Close()
ssdpState.adv = nil
}
// SSDP : SSPD / DLNA Server // SSDP : SSPD / DLNA Server
func SSDP() (err error) { func SSDP() (err error) {
@@ -19,9 +45,6 @@ func SSDP() (err error) {
showInfo(fmt.Sprintf("SSDP / DLNA:%t", Settings.SSDP)) showInfo(fmt.Sprintf("SSDP / DLNA:%t", Settings.SSDP))
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
ad, err := ssdp.Advertise( ad, err := ssdp.Advertise(
"upnp:rootdevice", // send as "ST" "upnp:rootdevice", // send as "ST"
fmt.Sprintf("uuid:%s::upnp:rootdevice", System.DeviceID), // send as "USN" fmt.Sprintf("uuid:%s::upnp:rootdevice", System.DeviceID), // send as "USN"
@@ -38,35 +61,43 @@ func SSDP() (err error) {
ssdp.Logger = log.New(os.Stderr, "[SSDP] ", log.LstdFlags) ssdp.Logger = log.New(os.Stderr, "[SSDP] ", log.LstdFlags)
} }
go func(adv *ssdp.Advertiser) { var done = make(chan struct{})
aliveTick := time.Tick(300 * time.Second) ssdpState.Lock()
ssdpState.adv = ad
ssdpState.done = done
ssdpState.Unlock()
go func(adv *ssdp.Advertiser, done chan struct{}) {
aliveTick := time.NewTicker(300 * time.Second)
defer aliveTick.Stop()
loop:
for { for {
select { select {
case <-aliveTick: case <-aliveTick.C:
err = adv.Alive() if aliveErr := adv.Alive(); aliveErr != nil {
if err != nil { ShowError(aliveErr, 0)
ShowError(err, 0) ssdpState.Lock()
if ssdpState.adv == adv {
ssdpState.adv = nil
}
ssdpState.Unlock()
adv.Bye() adv.Bye()
adv.Close() adv.Close()
break loop return
} }
case <-quit: case <-done:
adv.Bye() return
adv.Close()
os.Exit(0)
break loop
} }
} }
}(ad) }(ad, done)
return return
} }
+6
View File
@@ -5,6 +5,10 @@ type RequestStruct struct {
// Befehle an xTeVe // Befehle an xTeVe
Cmd string `json:"cmd"` Cmd string `json:"cmd"`
// Client-chosen request id, echoed in the response so the web UI can
// match answers on a long-lived connection.
ID string `json:"id,omitempty"`
// Benutzer // Benutzer
DeleteUser bool `json:"deleteUser,omitempty"` DeleteUser bool `json:"deleteUser,omitempty"`
UserData map[string]any `json:"userData,omitempty"` UserData map[string]any `json:"userData,omitempty"`
@@ -73,6 +77,8 @@ type RequestStruct struct {
// ResponseStruct : Antworten an den Client (WEB) // ResponseStruct : Antworten an den Client (WEB)
type ResponseStruct struct { type ResponseStruct struct {
ID string `json:"id,omitempty"`
ClientInfo struct { ClientInfo struct {
ARCH string `json:"arch"` ARCH string `json:"arch"`
Branch string `json:"branch,omitempty"` Branch string `json:"branch,omitempty"`
+74 -7
View File
@@ -204,12 +204,7 @@ func saveMapToJSONFile(file string, tmpMap any) error {
return err return err
} }
err = os.WriteFile(filename, []byte(jsonString), 0644) return writeFileAtomic(filename, []byte(jsonString), 0644)
if err != nil {
return err
}
return nil
} }
func loadJSONFileToMap(file string) (tmpMap map[string]any, err error) { func loadJSONFileToMap(file string) (tmpMap map[string]any, err error) {
@@ -229,6 +224,37 @@ func loadJSONFileToMap(file string) (tmpMap map[string]any, err error) {
return return
} }
// toFloat64 : Numeric value of a JSON decoded field; 0 for anything that
// is not a number (missing key, wrong type).
func toFloat64(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
}
return 0
}
// removeStrings : Copy of list without the entries present in remove.
// Order of the remaining entries is kept.
func removeStrings(list []string, remove map[string]bool) []string {
var result = make([]string, 0, len(list))
for _, s := range list {
if !remove[s] {
result = append(result, s)
}
}
return result
}
// Binary // Binary
func readByteFromFile(file string) (content []byte, err error) { func readByteFromFile(file string) (content []byte, err error) {
@@ -246,7 +272,48 @@ func readByteFromFile(file string) (content []byte, err error) {
func writeByteToFile(file string, data []byte) (err error) { func writeByteToFile(file string, data []byte) (err error) {
var filename = getPlatformFile(file) var filename = getPlatformFile(file)
err = os.WriteFile(filename, data, 0644) err = writeFileAtomic(filename, data, 0644)
return
}
// writeFileAtomic : Writes data to a temporary file in the target directory,
// fsyncs it and renames it over name, so readers (and a crash mid-write)
// never see a truncated or half-written file. The temp file is removed on
// any error.
func writeFileAtomic(name string, data []byte, perm os.FileMode) (err error) {
tmp, err := os.CreateTemp(filepath.Dir(name), ".tmp-*")
if err != nil {
return
}
var tmpName = tmp.Name()
defer func() {
if err != nil {
tmp.Close()
os.Remove(tmpName)
}
}()
if _, err = tmp.Write(data); err != nil {
return
}
if err = tmp.Sync(); err != nil {
return
}
if err = tmp.Close(); err != nil {
return
}
if err = os.Chmod(tmpName, perm); err != nil {
return
}
err = os.Rename(tmpName, name)
return return
} }
+86
View File
@@ -0,0 +1,86 @@
package src
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestWriteFileAtomic(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "settings.json")
if err := writeFileAtomic(file, []byte("first"), 0644); err != nil {
t.Fatal(err)
}
if err := writeFileAtomic(file, []byte("second"), 0600); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if string(got) != "second" {
t.Errorf("content = %q, want %q", got, "second")
}
fi, err := os.Stat(file)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0600 {
t.Errorf("mode = %o, want 0600", fi.Mode().Perm())
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.Name() != "settings.json" {
t.Errorf("unexpected leftover file %q", e.Name())
}
}
}
func TestWriteFileAtomicMissingDir(t *testing.T) {
file := filepath.Join(t.TempDir(), "missing", "settings.json")
if err := writeFileAtomic(file, []byte("x"), 0644); err == nil {
t.Fatal("expected an error for a missing directory")
}
}
func TestRemoveStrings(t *testing.T) {
list := []string{"a.xml", "b.xml", "c.xml", "d.xml"}
got := removeStrings(list, map[string]bool{"b.xml": true, "d.xml": true})
want := []string{"a.xml", "c.xml"}
if !reflect.DeepEqual(got, want) {
t.Errorf("removeStrings = %v, want %v", got, want)
}
// Nothing to remove: same content, original untouched.
got = removeStrings(list, nil)
if !reflect.DeepEqual(got, list) {
t.Errorf("removeStrings(nil) = %v, want %v", got, list)
}
}
func TestToFloat64(t *testing.T) {
cases := []struct {
in any
want float64
}{
{float64(3), 3},
{int(2), 2},
{int64(1), 1},
{"x", 0},
{nil, 0},
}
for _, c := range cases {
if got := toFloat64(c.in); got != c.want {
t.Errorf("toFloat64(%v) = %v, want %v", c.in, got, c.want)
}
}
}
+33 -27
View File
@@ -9,6 +9,7 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
"xteve/src/internal/authentication" "xteve/src/internal/authentication"
@@ -73,7 +74,17 @@ func StartWebserver() (err error) {
} }
if err = http.ListenAndServe(":"+port, nil); err != nil { // No ReadTimeout / WriteTimeout on purpose: /stream/ responses are
// long-lived. ReadHeaderTimeout still protects against slowloris
// clients and IdleTimeout reaps keep-alive connections.
var server = &http.Server{
Addr: ":" + port,
Handler: nil,
ReadHeaderTimeout: 15 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err = server.ListenAndServe(); err != nil {
ShowError(err, 1001) ShowError(err, 1001)
return return
} }
@@ -317,36 +328,36 @@ func DataImages(w http.ResponseWriter, r *http.Request) {
// WS : Web Sockets /ws/ // WS : Web Sockets /ws/
func WS(w http.ResponseWriter, r *http.Request) { func WS(w http.ResponseWriter, r *http.Request) {
var request RequestStruct
var response ResponseStruct
response.Status = true
var newToken string var newToken string
/*
if r.Header.Get("Origin") != "http://"+r.Host {
httpStatusError(w, r, 403)
return
}
*/
// Upgrade writes its own error response (e.g. 403 for a bad Origin). // Upgrade writes its own error response (e.g. 403 for a bad Origin).
conn, err := wsUpgrader.Upgrade(w, r, nil) conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
ShowError(err, 0) ShowError(err, 0)
return return
} }
defer conn.Close()
setGlobalDomain(r.Host) setGlobalDomain(r.Host)
// One connection serves any number of commands, one at a time, until the
// client goes away or a write fails.
for { for {
// Fresh structs per command: a failed command must not leak its
// Status / Error / Base64 into the next one on the same connection.
var request RequestStruct
var response ResponseStruct
response.Status = true
err = conn.ReadJSON(&request) err = conn.ReadJSON(&request)
if err != nil { if err != nil {
return return
} }
response.ID = request.ID
if !System.ConfigurationWizard { if !System.ConfigurationWizard {
switch Settings.AuthenticationWEB { switch Settings.AuthenticationWEB {
@@ -385,10 +396,9 @@ func WS(w http.ResponseWriter, r *http.Request) {
response = setDefaultResponseData(response, false) response = setDefaultResponseData(response, false)
if err = conn.WriteJSON(response); err != nil { if err = conn.WriteJSON(response); err != nil {
ShowError(err, 1022) ShowError(err, 1022)
} else {
return return
} }
return continue
case "loadFiles": case "loadFiles":
//response.Response = Settings.Files //response.Response = Settings.Files
@@ -465,9 +475,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
} }
case "resetLogs": case "resetLogs":
WebScreenLog.Log = make([]string, 0) resetWebScreenLog()
WebScreenLog.Errors = 0
WebScreenLog.Warnings = 0
response.OpenMenu = strconv.Itoa(indexOfString("log", System.WEB.Menu)) response.OpenMenu = strconv.Itoa(indexOfString("log", System.WEB.Menu))
case "xteveBackup": case "xteveBackup":
@@ -478,9 +486,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
} }
case "xteveRestore": case "xteveRestore":
WebScreenLog.Log = make([]string, 0) resetWebScreenLog()
WebScreenLog.Errors = 0
WebScreenLog.Warnings = 0
if len(request.Base64) > 0 { if len(request.Base64) > 0 {
@@ -563,8 +569,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
if err = conn.WriteJSON(response); err != nil { if err = conn.WriteJSON(response); err != nil {
ShowError(err, 1022) ShowError(err, 1022)
} else { return
break
} }
} }
@@ -855,8 +860,8 @@ func API(w http.ResponseWriter, r *http.Request) {
default: default:
token, err = tokenAuthentication(request.Token) token, err = tokenAuthentication(request.Token)
fmt.Println(err)
if err != nil { if err != nil {
ShowError(err, 0)
responseAPIError(err) responseAPIError(err)
return return
} }
@@ -926,6 +931,7 @@ func API(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
responseAPIError(err) responseAPIError(err)
return
} }
w.Write([]byte(mapToJSON(response))) w.Write([]byte(mapToJSON(response)))
@@ -977,10 +983,10 @@ func setDefaultResponseData(response ResponseStruct, data bool) (defaults Respon
defaults.ClientInfo.OS = System.OS defaults.ClientInfo.OS = System.OS
defaults.ClientInfo.Streams = fmt.Sprintf("%d / %d", len(Data.Streams.Active), len(Data.Streams.All)) defaults.ClientInfo.Streams = fmt.Sprintf("%d / %d", len(Data.Streams.Active), len(Data.Streams.All))
defaults.ClientInfo.UUID = Settings.UUID defaults.ClientInfo.UUID = Settings.UUID
defaults.ClientInfo.Errors = WebScreenLog.Errors defaults.Log = webScreenLogSnapshot()
defaults.ClientInfo.Warnings = WebScreenLog.Warnings defaults.ClientInfo.Errors = defaults.Log.Errors
defaults.Notification = System.Notification defaults.ClientInfo.Warnings = defaults.Log.Warnings
defaults.Log = WebScreenLog defaults.Notification = notificationsSnapshot()
switch System.Branch { switch System.Branch {
+31
View File
@@ -5,6 +5,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@@ -137,3 +138,33 @@ func TestSetCookieTokenFlags(t *testing.T) {
t.Error("logout must clear the cookie (negative MaxAge)") t.Error("logout must clear the cookie (negative MaxAge)")
} }
} }
// A browser keeps one socket open and sends commands one after another; the
// server must answer each on the same connection and echo the request id.
func TestWSServesMultipleCommandsPerConnection(t *testing.T) {
srv, token := newAuthenticatedWSServer(t)
conn, _, err := websocket.DefaultDialer.Dial(wsURL(srv), http.Header{"Cookie": {"Token=" + token}})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
for i := 1; i <= 3; i++ {
id := strings.Repeat("x", i)
if err := conn.WriteJSON(map[string]any{"cmd": "noop", "id": id}); err != nil {
t.Fatalf("write %d: %v", i, err)
}
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
var response ResponseStruct
if err := conn.ReadJSON(&response); err != nil {
t.Fatalf("read %d: %v (server stopped answering on the same connection)", i, err)
}
if !response.Status {
t.Fatalf("command %d refused: %s", i, response.Error)
}
if response.ID != id {
t.Fatalf("command %d: id %q not echoed, got %q", i, id, response.ID)
}
}
}
+7 -1
View File
@@ -172,6 +172,11 @@ func createXEPGMapping() {
if len(Data.XMLTV.Files) > 0 { if len(Data.XMLTV.Files) > 0 {
// Files that could not be read are collected here and removed after
// the loop; mutating Data.XMLTV.Files while iterating over it used
// to duplicate entries instead of removing them.
var failedFiles = make(map[string]bool)
for i := len(Data.XMLTV.Files) - 1; i >= 0; i-- { for i := len(Data.XMLTV.Files) - 1; i >= 0; i-- {
var file = Data.XMLTV.Files[i] var file = Data.XMLTV.Files[i]
@@ -185,7 +190,7 @@ func createXEPGMapping() {
err = getLocalXMLTV(file, &xmltv) err = getLocalXMLTV(file, &xmltv)
if err != nil { if err != nil {
Data.XMLTV.Files = append(Data.XMLTV.Files, Data.XMLTV.Files[i+1:]...) failedFiles[file] = true
var errMsg = err.Error() var errMsg = err.Error()
err = errors.New(getProviderParameter(fileID, "xmltv", "name") + ": " + errMsg) err = errors.New(getProviderParameter(fileID, "xmltv", "name") + ": " + errMsg)
ShowError(err, 000) ShowError(err, 000)
@@ -215,6 +220,7 @@ func createXEPGMapping() {
} }
Data.XMLTV.Files = removeStrings(Data.XMLTV.Files, failedFiles)
Data.XMLTV.Mapping = tmpMap Data.XMLTV.Mapping = tmpMap
} else { } else {
+8 -8
View File
@@ -1,7 +1,7 @@
# xTeVe improvement checklist # xTeVe improvement checklist
Detailed rationale, file references and effort estimates: `tasks/improvement-plan.md`. Detailed rationale, file references and effort estimates: `tasks/improvement-plan.md`.
Status: Phase 0 committed on branch `improvements` 2026-09-26. Status: Phases 0, 1, 2, 3 done on branch `improvements` (2026-09-26); Phase 5 in progress.
## Phase 0: Hygiene ## Phase 0: Hygiene
- [x] `.gitignore` (`.gocache/`), deleted `.gocache/`, extended `.dockerignore` - [x] `.gitignore` (`.gocache/`), deleted `.gocache/`, extended `.dockerignore`
@@ -35,12 +35,12 @@ Status: Phase 0 committed on branch `improvements` 2026-09-26.
- [x] `streamingURLsMu` around `Data.Cache.StreamingURLS`; persisted from a snapshot - [x] `streamingURLsMu` around `Data.Cache.StreamingURLS`; persisted from a snapshot
- [x] No more `defer` inside the buffer read/redirect loops (compression done in Phase 1; imgcache in 2b) - [x] No more `defer` inside the buffer read/redirect loops (compression done in Phase 1; imgcache in 2b)
- [x] `thirdPartyBuffer` rewritten: `exec.CommandContext` cancelled when the last client leaves or the 20 s startup watchdog fires; `buildFFmpegArgs`/`buildVLCArgs` tested; no panic, `cmd.Start` checked, one file handle per segment. Client loop uses `r.Context()` instead of `CloseNotifier` - [x] `thirdPartyBuffer` rewritten: `exec.CommandContext` cancelled when the last client leaves or the 20 s startup watchdog fires; `buildFFmpegArgs`/`buildVLCArgs` tested; no panic, `cmd.Start` checked, one file handle per segment. Client loop uses `r.Context()` instead of `CloseNotifier`
- [ ] Real mutex for screen log - [x] `logMu` guards `WebScreenLog` and notifications; accessors `logAppend`/`webScreenLogSnapshot`/`notificationsSnapshot`; ring buffer keeps the newest N (was dropping them)
- [ ] `http.Server` with timeouts; timeouts on all outbound clients - [x] `http.Server` with `ReadHeaderTimeout` 15 s and `IdleTimeout` 120 s (no write timeout, streams are long-lived); `providerHTTPClient` 5 min, `apiHTTPClient` 30 s, imgcache client 30 s, stream client with dial/TLS/header timeouts
- [ ] imgcache: download outside the lock - [x] imgcache downloads outside the lock; cache URL bug fixed (was a filesystem path); query-string URLs no longer produce unservable file names
- [ ] Atomic write helper (temp + fsync + rename) - [x] `writeFileAtomic` (temp + fsync + rename) behind `writeByteToFile`, `saveMapToJSONFile`, `writePrivateFile` and the auth database
- [x] Buffer: headers set before `WriteHeader`, bogus `Content-Length:` header gone, segments flushed to the client as they arrive. Others in 2b - [x] Buffer: headers set before `WriteHeader`, bogus `Content-Length:` header gone, segments flushed as they arrive. xepg file-removal bug, dead range mutation, API double write, WS struct reuse, unchecked assertions in data/backup/provider/screen, notification eviction by age: all fixed with tests
- [ ] Error hygiene: no-op closures, `os.Exit`/`panic` outside main, ignored results - [x] Error hygiene: no `panic`/`os.Exit`/`log.Fatal` outside `main`; SIGINT/SIGTERM handled in main via `src.Shutdown()`; fatal start-up errors exit 1
## Phase 3: Build, embed, CI, Docker ## Phase 3: Build, embed, CI, Docker
- [x] `//go:embed` via `html/embed.go`; deleted `webUI.go`, `html-build.go`, `cmd/webui-gen`; ETag + Cache-Control on static assets - [x] `//go:embed` via `html/embed.go`; deleted `webUI.go`, `html-build.go`, `cmd/webui-gen`; ETag + Cache-Control on static assets
@@ -58,7 +58,7 @@ Status: Phase 0 committed on branch `improvements` 2026-09-26.
Not planned (lineup is ~170 channels, decided 2026-09-25). See plan for the reference list. Not planned (lineup is ~170 channels, decided 2026-09-25). See plan for the reference list.
## Phase 5: Frontend architecture ## Phase 5: Frontend architecture
- [ ] Persistent websocket with request IDs, queue, backoff reconnect - [x] Persistent websocket: one socket per page, queued requests with ids echoed by the server, 12 s per-request timeout, exponential-backoff reconnect, log-poll dedupe. Server keeps serving on one connection and closes it on exit (upstream leaked one open socket per request). Test: `TestWSServesMultipleCommandsPerConnection`
- [ ] Section-level re-render instead of full `createLayout()` - [ ] Section-level re-render instead of full `createLayout()`
- [ ] Virtualised mapping table - [ ] Virtualised mapping table
- [ ] Split `menu_ts.ts`; `addEventListener`; data-driven settings rows - [ ] Split `menu_ts.ts`; `addEventListener`; data-driven settings rows
-2
View File
@@ -3,7 +3,6 @@ var BULK_EDIT:Boolean = false
var COLUMN_TO_SORT:number var COLUMN_TO_SORT:number
var SEARCH_MAPPING = new Object() var SEARCH_MAPPING = new Object()
var UNDO = new Object() var UNDO = new Object()
var SERVER_CONNECTION = false
var WS_AVAILABLE = false var WS_AVAILABLE = false
var ACTIVE_MENU_ID:string = "" var ACTIVE_MENU_ID:string = ""
var LAST_BULK_CHECKBOX:HTMLInputElement = null var LAST_BULK_CHECKBOX:HTMLInputElement = null
@@ -761,7 +760,6 @@ function sortSelect(elem) {
function updateLog() { function updateLog() {
console.log("TOKEN")
var server:Server = new Server("updateLog") var server:Server = new Server("updateLog")
server.request(new Object()) server.request(new Object())
+1 -1
View File
@@ -1023,7 +1023,7 @@ function PageReady() {
return return
} }
if (SERVER_CONNECTION == true) { if (WS.hasQueued("getServerConfig")) {
return return
} }
+220 -117
View File
@@ -1,5 +1,199 @@
// Websocket transport between the web UI and xTeVe.
//
// One socket stays open for the life of the page. Requests are queued and
// sent one at a time; each carries an id that the server echoes back, and
// the response is matched on it (or, for older servers that close after a
// single reply, to the oldest in-flight request). Connection loss triggers
// a reconnect with exponential backoff, and queued requests survive it.
interface PendingRequest {
id: string
payload: Object
resolve: (response: any) => void
reject: (err: Error) => void
timer: number
sent: boolean
}
class WSClient {
private socket: WebSocket = null
private queue: PendingRequest[] = []
private inFlight: PendingRequest = null
private nextID: number = 1
private reconnectDelay: number = 500
private reconnectTimer: number = 0
private closedByUs: boolean = false
readonly requestTimeoutMs: number = 12000
private url(): string {
var protocol = window.location.protocol == "https:" ? "wss://" : "ws://"
var host = window.location.host
if (host == undefined || host.length < 1) {
host = window.location.hostname
}
return protocol + host + "/data/"
}
// send queues a command and resolves with the parsed response.
send(payload: Object): Promise<any> {
var client = this
return new Promise(function(resolve, reject) {
var req: PendingRequest = {
id: String(client.nextID++),
payload: payload,
resolve: resolve,
reject: reject,
timer: 0,
sent: false
}
client.queue.push(req)
client.pump()
})
}
// hasQueued reports whether a command of this kind is already waiting.
hasQueued(cmd: string): boolean {
if (this.inFlight != null && this.inFlight.payload["cmd"] == cmd) {
return true
}
for (var i = 0; i < this.queue.length; i++) {
if (this.queue[i].payload["cmd"] == cmd) {
return true
}
}
return false
}
private connect(): void {
if (this.socket != null && (this.socket.readyState == WebSocket.OPEN || this.socket.readyState == WebSocket.CONNECTING)) {
return
}
var client = this
var socket = new WebSocket(this.url())
this.socket = socket
socket.onopen = function() {
WS_AVAILABLE = true
client.reconnectDelay = 500
client.pump()
}
socket.onmessage = function(e: MessageEvent) {
client.handleMessage(e.data)
}
socket.onerror = function() {
// onclose follows; nothing to do here.
}
socket.onclose = function() {
if (client.socket == socket) {
client.socket = null
}
// An in-flight request that never got its answer goes back to the
// front of the queue and is retried on the next connection.
if (client.inFlight != null) {
var req = client.inFlight
client.inFlight = null
req.sent = false
client.queue.unshift(req)
}
client.scheduleReconnect()
}
}
private scheduleReconnect(): void {
if (this.closedByUs || this.reconnectTimer != 0) {
return
}
if (this.queue.length == 0) {
// Idle: reconnect lazily on the next request.
return
}
var client = this
this.reconnectTimer = window.setTimeout(function() {
client.reconnectTimer = 0
client.connect()
}, this.reconnectDelay)
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 10000)
}
// pump sends the next queued request if the socket is open and idle.
private pump(): void {
if (this.inFlight != null || this.queue.length == 0) {
return
}
if (this.socket == null || this.socket.readyState != WebSocket.OPEN) {
this.connect()
return
}
var req = this.queue.shift()
this.inFlight = req
req.sent = true
req.payload["id"] = req.id
var client = this
req.timer = window.setTimeout(function() {
if (client.inFlight == req) {
client.inFlight = null
req.reject(new Error("timeout"))
// A socket that stopped answering is not trusted any more.
client.resetSocket()
client.pump()
}
}, this.requestTimeoutMs)
try {
this.socket.send(JSON.stringify(req.payload))
} catch (err) {
window.clearTimeout(req.timer)
this.inFlight = null
req.sent = false
this.queue.unshift(req)
this.resetSocket()
}
}
private resetSocket(): void {
var socket = this.socket
this.socket = null
if (socket != null) {
socket.onclose = null
try { socket.close() } catch (err) { /* ignore */ }
}
this.scheduleReconnect()
}
private handleMessage(raw: string): void {
var response: any
try {
response = JSON.parse(raw)
} catch (err) {
return
}
var req = this.inFlight
if (req == null) {
return
}
// Servers that echo the id let us drop stray late answers.
if (response.hasOwnProperty("id") && response["id"] != req.id) {
return
}
window.clearTimeout(req.timer)
this.inFlight = null
req.resolve(response)
this.pump()
}
}
var WS = new WSClient()
class Server { class Server {
protocol:string
cmd: string cmd: string
constructor(cmd: string) { constructor(cmd: string) {
@@ -8,129 +202,39 @@ class Server {
request(data: Object): any { request(data: Object): any {
if (SERVER_CONNECTION == true) { var isLogUpdate: boolean = this.cmd == "updateLog"
// Log polling must never pile up behind a stalled connection.
if (isLogUpdate && WS.hasQueued("updateLog")) {
return return
} }
SERVER_CONNECTION = true if (!isLogUpdate) {
if (this.cmd != "updateLog") {
showElement("loading", true) showElement("loading", true)
UNDO = new Object() UNDO = new Object()
setConnectionState("busy") setConnectionState("busy")
} }
switch(window.location.protocol) {
case "http:":
this.protocol = "ws://"
break
case "https:":
this.protocol = "wss://"
break
}
var wsHost:string = window.location.host
if (wsHost == undefined || wsHost.length < 1) {
wsHost = window.location.hostname
}
// The session cookie (HttpOnly) is sent with the websocket handshake.
var url = this.protocol + wsHost + "/data/"
data["cmd"] = this.cmd data["cmd"] = this.cmd
var requestCmd:string = data["cmd"] var requestCmd: string = this.cmd
var ws = new WebSocket(url)
var isLogUpdate:boolean = data["cmd"] == "updateLog"
var responseReceived:boolean = false
var requestFinished:boolean = false
var timeoutMs:number = 12000
var requestTimeout:number
var finishRequest = function(state:string, responseSuccess:boolean = false):void { WS.send(data).then(function(response) {
if (requestFinished == true) {
return
}
requestFinished = true
SERVER_CONNECTION = false
window.clearTimeout(requestTimeout)
if (responseSuccess == true) {
if (state == "online") {
WS_FAILURE_COUNT = 0 WS_FAILURE_COUNT = 0
} if (!isLogUpdate) {
} else {
WS_FAILURE_COUNT++
}
if (isLogUpdate == false) {
showElement("loading", false) showElement("loading", false)
} }
setConnectionState("online")
if (state != "") {
setConnectionState(state)
}
}
requestTimeout = window.setTimeout(function() {
console.log("Websocket request timed out.")
var timeoutState:string = "offline"
if (isLogUpdate == true && WS_FAILURE_COUNT < 2) {
timeoutState = "idle"
}
finishRequest(timeoutState, false)
try {
ws.close()
} catch (err) {
console.log(err)
}
}, timeoutMs)
ws.onopen = function() {
WS_AVAILABLE = true
if (data["cmd"] != "updateLog") {
setConnectionState("busy")
}
this.send(JSON.stringify(data));
}
ws.onerror = function(e) {
console.log("No websocket connection to xTeVe could be established. Check your network configuration.")
var errorState:string = "offline"
if (isLogUpdate == true && WS_FAILURE_COUNT < 2) {
errorState = "idle"
}
finishRequest(errorState, false)
if (WS_AVAILABLE == false && isLogUpdate == false && requestCmd != "getServerConfig") {
alert("No websocket connection to xTeVe could be established. Check your network configuration.")
}
}
ws.onmessage = function (e) {
responseReceived = true
finishRequest("online", true)
var response = JSON.parse(e.data);
if (response["status"] == false) { if (response["status"] == false) {
setConnectionState("offline") setConnectionState("offline")
alert(response["err"]) alert(response["err"])
if (response.hasOwnProperty("reload")) { if (response.hasOwnProperty("reload")) {
location.reload() location.reload()
} }
return return
} }
if (response.hasOwnProperty("logoURL")) { if (response.hasOwnProperty("logoURL")) {
var div = (document.getElementById("channel-icon") as HTMLInputElement) var div = (document.getElementById("channel-icon") as HTMLInputElement)
div.value = response["logoURL"] div.value = response["logoURL"]
@@ -138,19 +242,17 @@ class Server {
return return
} }
switch (data["cmd"]) { switch (requestCmd) {
case "updateLog": case "updateLog":
SERVER["log"] = response["log"] SERVER["log"] = response["log"]
if (document.getElementById("content_log")) { if (document.getElementById("content_log")) {
showLogs(false) showLogs(false)
} }
return return
break;
default: default:
SERVER = new Object()
SERVER = response SERVER = response
break; break
} }
if (response.hasOwnProperty("openMenu")) { if (response.hasOwnProperty("openMenu")) {
@@ -171,7 +273,6 @@ class Server {
location.reload() location.reload()
} }
if (response.hasOwnProperty("wizard")) { if (response.hasOwnProperty("wizard")) {
createLayout() createLayout()
configurationWizard[response["wizard"]].createWizard() configurationWizard[response["wizard"]].createWizard()
@@ -180,23 +281,25 @@ class Server {
createLayout() createLayout()
}, function(err) {
WS_FAILURE_COUNT++
if (!isLogUpdate) {
showElement("loading", false)
} }
ws.onclose = function() { var state: string = "offline"
if (responseReceived == true) { if (isLogUpdate && WS_FAILURE_COUNT < 2) {
return state = "idle"
}
setConnectionState(state)
if (WS_AVAILABLE == false && !isLogUpdate && requestCmd != "getServerConfig") {
alert("No websocket connection to xTeVe could be established. Check your network configuration.")
} }
var closeState:string = "offline" })
if (isLogUpdate == true && WS_FAILURE_COUNT < 2) {
closeState = "idle"
} }
finishRequest(closeState, false)
}
}
} }
var WS_FAILURE_COUNT: number = 0 var WS_FAILURE_COUNT: number = 0
+19 -6
View File
@@ -9,9 +9,11 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"syscall"
"xteve/src" "xteve/src"
) )
@@ -111,7 +113,7 @@ func main() {
err := src.Init() err := src.Init()
if err != nil { if err != nil {
src.ShowError(err, 0) src.ShowError(err, 0)
os.Exit(0) os.Exit(1)
} }
src.ShowSystemInfo() src.ShowSystemInfo()
@@ -150,12 +152,13 @@ func main() {
err := src.Init() err := src.Init()
if err != nil { if err != nil {
src.ShowError(err, 0) src.ShowError(err, 0)
os.Exit(0) os.Exit(1)
} }
err = src.XteveRestoreFromCLI(*restore) err = src.XteveRestoreFromCLI(*restore)
if err != nil { if err != nil {
src.ShowError(err, 0) src.ShowError(err, 0)
os.Exit(1)
} }
os.Exit(0) os.Exit(0)
@@ -164,25 +167,35 @@ func main() {
err := src.Init() err := src.Init()
if err != nil { if err != nil {
src.ShowError(err, 0) src.ShowError(err, 0)
os.Exit(0) os.Exit(1)
} }
err = src.StartSystem(false) err = src.StartSystem(false)
if err != nil { if err != nil {
src.ShowError(err, 0) src.ShowError(err, 0)
os.Exit(0) os.Exit(1)
} }
// Graceful stop on SIGINT / SIGTERM: say goodbye on the network (SSDP)
// and exit cleanly instead of letting the runtime kill the process.
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
src.Shutdown()
os.Exit(0)
}()
err = src.InitMaintenance() err = src.InitMaintenance()
if err != nil { if err != nil {
src.ShowError(err, 0) src.ShowError(err, 0)
os.Exit(0) os.Exit(1)
} }
err = src.StartWebserver() err = src.StartWebserver()
if err != nil { if err != nil {
src.ShowError(err, 0) src.ShowError(err, 0)
os.Exit(0) os.Exit(1)
} }
} }