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 1234 additions and 412 deletions

No files matched your search

+194 -95
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 {
constructor(cmd) {
this.cmd = cmd;
}
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;
}
SERVER_CONNECTION = true;
if (this.cmd != "updateLog") {
if (!isLogUpdate) {
showElement("loading", true);
UNDO = new Object();
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;
var requestCmd = data["cmd"];
var ws = new WebSocket(url);
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;
}
}
else {
WS_FAILURE_COUNT++;
}
if (isLogUpdate == false) {
var requestCmd = this.cmd;
WS.send(data).then(function (response) {
WS_FAILURE_COUNT = 0;
if (!isLogUpdate) {
showElement("loading", false);
}
if (state != "") {
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);
setConnectionState("online");
if (response["status"] == false) {
setConnectionState("offline");
alert(response["err"]);
@@ -106,16 +206,14 @@ class Server {
div.className = "changed";
return;
}
switch (data["cmd"]) {
switch (requestCmd) {
case "updateLog":
SERVER["log"] = response["log"];
if (document.getElementById("content_log")) {
showLogs(false);
}
return;
break;
default:
SERVER = new Object();
SERVER = response;
break;
}
@@ -139,17 +237,20 @@ class Server {
return;
}
createLayout();
};
ws.onclose = function () {
if (responseReceived == true) {
return;
}, function (err) {
WS_FAILURE_COUNT++;
if (!isLogUpdate) {
showElement("loading", false);
}
var closeState = "offline";
if (isLogUpdate == true && WS_FAILURE_COUNT < 2) {
closeState = "idle";
var state = "offline";
if (isLogUpdate && WS_FAILURE_COUNT < 2) {
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;
@@ -961,7 +1062,7 @@ function PageReady() {
window.clearInterval(bootstrapTimer);
return;
}
if (SERVER_CONNECTION == true) {
if (WS.hasQueued("getServerConfig")) {
return;
}
bootstrapAttempts++;
@@ -2688,7 +2789,6 @@ var BULK_EDIT = false;
var COLUMN_TO_SORT;
var SEARCH_MAPPING = new Object();
var UNDO = new Object();
var SERVER_CONNECTION = false;
var WS_AVAILABLE = false;
var ACTIVE_MENU_ID = "";
var LAST_BULK_CHECKBOX = null;
@@ -3236,7 +3336,6 @@ function sortSelect(elem) {
return;
}
function updateLog() {
console.log("TOKEN");
var server = new Server("updateLog");
server.request(new Object());
}
+7 -3
View File
@@ -148,8 +148,8 @@ func xteveRestore(archive string) (newWebURL string, err error) {
return
}
backupVersion = newConfig["version"].(string)
if backupVersion < System.Compatibility {
backupVersion, ok := newConfig["version"].(string)
if !ok || backupVersion < System.Compatibility {
err = errors.New(getErrMsg(1013))
return
}
@@ -167,7 +167,11 @@ func xteveRestore(archive string) (newWebURL string, err error) {
return
}
newPort = newConfig["port"].(string)
newPort, ok = newConfig["port"].(string)
if !ok {
err = errors.New(getErrMsg(1030))
return
}
oldPort = Settings.Port
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)
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 {
ShowError(err, 1012)
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 {
dataMap, ok := data.(map[string]any)
if !ok {
err = errors.New(getErrMsg(1020))
return
}
if dataID == "-" {
// Neue Providerdatei
dataID = indicator + randomString(19)
data.(map[string]any)["new"] = true
filesMap[dataID] = data
dataMap["new"] = true
filesMap[dataID] = dataMap
} else {
// 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
}
}
@@ -353,11 +376,11 @@ func saveFiles(request RequestStruct, fileType string) (err error) {
}
// Neue Providerdatei
if _, ok := data.(map[string]any)["new"]; ok {
if _, ok := dataMap["new"]; ok {
reloadData = true
err = getProviderData(fileType, dataID)
delete(data.(map[string]any), "new")
delete(dataMap, "new")
if err != nil {
delete(filesMap, dataID)
@@ -661,8 +684,16 @@ func saveUserData(request RequestStruct) (err error) {
func saveNewUser(request RequestStruct) (err error) {
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, "confirm")
@@ -855,7 +886,7 @@ func buildDatabaseDVR() (err error) {
var playlistFile = getLocalProviderFiles(fileType)
for n, i := range playlistFile {
for _, i := range playlistFile {
var channels []any
var groupTitle, tvgID, uuid = 0, 0, 0
@@ -878,7 +909,8 @@ func buildDatabaseDVR() (err error) {
ShowError(err, 1005)
err = errors.New(playlistName + ": Local copy of the file no longer exists")
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
+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
}
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 {
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
}
+62 -36
View File
@@ -9,8 +9,14 @@ import (
"path/filepath"
"strings"
"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
type Cache struct {
path string
@@ -41,8 +47,6 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
c.Queue = []string{}
c.Cache = []string{}
var queue []string
c.Image.GetURL = func(src string) (cacheURL string) {
c.Lock()
@@ -89,50 +93,30 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
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.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 {
resp, err := http.Get(src)
filename, err := c.download(src)
if err != nil {
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Stays in the queue and is retried on the next run.
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
}
// 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)
}
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
resp, err := apiHTTPClient.Do(req)
if err != nil {
return
}
+32 -15
View File
@@ -142,8 +142,16 @@ func getProviderData(fileType, fileID string) (err error) {
for dataID, d := range dataMap {
var data = d.(map[string]any)
var fileSource = data["file.source"].(string)
data, ok := d.(map[string]any)
if !ok {
continue
}
fileSource, ok := data["file.source"].(string)
if !ok {
continue
}
newProvider = false
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 {
data = value
data["counter.error"] = data["counter.error"].(float64) + 1
data["counter.download"] = data["counter.download"].(float64) + 1
data["counter.error"] = toFloat64(data["counter.error"]) + 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)
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
} 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())
client := &http.Client{}
resp, err := client.Do(req)
resp, err := providerHTTPClient.Do(req)
if err != nil {
return
}
@@ -307,15 +317,22 @@ func downloadFileFromServer(providerURL string) (filename string, body []byte, e
if index > -1 {
var headerFilename = resp.Header.Get("Content-Disposition")[index:len(resp.Header.Get("Content-Disposition"))]
var value = strings.Split(headerFilename, `=`)
var f = strings.Replace(value[1], `"`, "", -1)
var headerFilename = resp.Header.Get("Content-Disposition")[index:]
var value = strings.SplitN(headerFilename, `=`, 2)
f = strings.Replace(f, `;`, "", -1)
filename = f
showInfo("Header filename:" + filename)
if len(value) == 2 {
} else {
var f = strings.Replace(value[1], `"`, "", -1)
f = strings.Replace(f, `;`, "", -1)
filename = f
showInfo("Header filename:" + filename)
}
}
if len(filename) == 0 {
var cleanFilename = strings.SplitN(getFilenameFromPath(providerURL), "?", 2)
filename = cleanFilename[0]
+144 -39
View File
@@ -10,6 +10,82 @@ import (
"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) {
if System.Flag.Info {
@@ -33,9 +109,7 @@ func showInfo(str string) {
printLogOnScreen(logMsg, "info")
logMsg = strings.Replace(logMsg, " ", "&nbsp;", -1)
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
logCleanUp()
logAppend(strings.Replace(logMsg, " ", "&nbsp;", -1))
}
@@ -51,7 +125,6 @@ func showDebug(str string, level int) {
var msg = strings.SplitN(str, ":", 2)
var length = len(msg[0])
var space string
var mutex = sync.RWMutex{}
if len(msg) == 2 {
@@ -64,11 +137,7 @@ func showDebug(str string, level int) {
printLogOnScreen(logMsg, "debug")
mutex.Lock()
logMsg = strings.Replace(logMsg, " ", "&nbsp;", -1)
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
logCleanUp()
mutex.Unlock()
logAppend(strings.Replace(logMsg, " ", "&nbsp;", -1))
}
@@ -99,7 +168,8 @@ func showHighlight(str string) {
}
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)
@@ -109,31 +179,22 @@ func showWarning(errCode int) {
var errMsg = getErrMsg(errCode)
var logMsg = fmt.Sprintf("[%s] [WARNING] %s", System.Name, errMsg)
var mutex = sync.RWMutex{}
printLogOnScreen(logMsg, "warning")
mutex.Lock()
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
WebScreenLog.Warnings++
mutex.Unlock()
logAppendCounted(logMsg, false)
}
// ShowError : Zeigt die Fehlermeldungen in der Konsole
func ShowError(err error, errCode int) {
var mutex = sync.RWMutex{}
var errMsg = getErrMsg(errCode)
var logMsg = fmt.Sprintf("[%s] [ERROR] %s (%s) - EC: %d", System.Name, err, errMsg, errCode)
printLogOnScreen(logMsg, "error")
mutex.Lock()
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
WebScreenLog.Errors++
mutex.Unlock()
logAppendCounted(logMsg, true)
}
@@ -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
WebScreenLog.Warnings = 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 {
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
@@ -386,30 +457,64 @@ func getErrMsg(errCode int) (errMsg string) {
return errMsg
}
// maxNotifications : Number of notifications kept for the web interface.
const maxNotifications = 10
func addNotification(notification Notification) (err error) {
var i int
var t = time.Now().UnixNano() / (int64(time.Millisecond) / int64(time.Nanosecond))
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
if len(notification.Headline) == 0 {
notification.Headline = strings.ToUpper(notification.Type)
}
logMu.Lock()
defer logMu.Unlock()
if len(System.Notification) == 0 {
System.Notification = make(map[string]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)
}
// 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
}
i++
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.
func writePrivateFile(file string, data []byte) error {
var filename = getPlatformFile(file)
if err := os.WriteFile(filename, data, 0600); err != nil {
return err
}
// WriteFile keeps the mode of an existing file; tighten it.
return os.Chmod(filename, 0600)
// The temp file is chmod'ed before the rename, so an existing
// world-readable file is replaced by a 0600 one.
return writeFileAtomic(getPlatformFile(file), data, 0600)
}
+49 -18
View File
@@ -4,12 +4,38 @@ import (
"fmt"
"log"
"os"
"os/signal"
"sync"
"time"
"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
func SSDP() (err error) {
@@ -19,9 +45,6 @@ func SSDP() (err error) {
showInfo(fmt.Sprintf("SSDP / DLNA:%t", Settings.SSDP))
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
ad, err := ssdp.Advertise(
"upnp:rootdevice", // send as "ST"
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)
}
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 {
select {
case <-aliveTick:
err = adv.Alive()
if err != nil {
ShowError(err, 0)
case <-aliveTick.C:
if aliveErr := adv.Alive(); aliveErr != nil {
ShowError(aliveErr, 0)
ssdpState.Lock()
if ssdpState.adv == adv {
ssdpState.adv = nil
}
ssdpState.Unlock()
adv.Bye()
adv.Close()
break loop
return
}
case <-quit:
adv.Bye()
adv.Close()
os.Exit(0)
break loop
case <-done:
return
}
}
}(ad)
}(ad, done)
return
}
+6
View File
@@ -5,6 +5,10 @@ type RequestStruct struct {
// Befehle an xTeVe
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
DeleteUser bool `json:"deleteUser,omitempty"`
UserData map[string]any `json:"userData,omitempty"`
@@ -73,6 +77,8 @@ type RequestStruct struct {
// ResponseStruct : Antworten an den Client (WEB)
type ResponseStruct struct {
ID string `json:"id,omitempty"`
ClientInfo struct {
ARCH string `json:"arch"`
Branch string `json:"branch,omitempty"`
+74 -7
View File
@@ -204,12 +204,7 @@ func saveMapToJSONFile(file string, tmpMap any) error {
return err
}
err = os.WriteFile(filename, []byte(jsonString), 0644)
if err != nil {
return err
}
return nil
return writeFileAtomic(filename, []byte(jsonString), 0644)
}
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
}
// 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
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) {
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
}
+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"
"strconv"
"strings"
"time"
"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)
return
}
@@ -317,36 +328,36 @@ func DataImages(w http.ResponseWriter, r *http.Request) {
// WS : Web Sockets /ws/
func WS(w http.ResponseWriter, r *http.Request) {
var request RequestStruct
var response ResponseStruct
response.Status = true
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).
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
ShowError(err, 0)
return
}
defer conn.Close()
setGlobalDomain(r.Host)
// One connection serves any number of commands, one at a time, until the
// client goes away or a write fails.
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)
if err != nil {
return
}
response.ID = request.ID
if !System.ConfigurationWizard {
switch Settings.AuthenticationWEB {
@@ -385,10 +396,9 @@ func WS(w http.ResponseWriter, r *http.Request) {
response = setDefaultResponseData(response, false)
if err = conn.WriteJSON(response); err != nil {
ShowError(err, 1022)
} else {
return
}
return
continue
case "loadFiles":
//response.Response = Settings.Files
@@ -465,9 +475,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
}
case "resetLogs":
WebScreenLog.Log = make([]string, 0)
WebScreenLog.Errors = 0
WebScreenLog.Warnings = 0
resetWebScreenLog()
response.OpenMenu = strconv.Itoa(indexOfString("log", System.WEB.Menu))
case "xteveBackup":
@@ -478,9 +486,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
}
case "xteveRestore":
WebScreenLog.Log = make([]string, 0)
WebScreenLog.Errors = 0
WebScreenLog.Warnings = 0
resetWebScreenLog()
if len(request.Base64) > 0 {
@@ -563,8 +569,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
if err = conn.WriteJSON(response); err != nil {
ShowError(err, 1022)
} else {
break
return
}
}
@@ -855,8 +860,8 @@ func API(w http.ResponseWriter, r *http.Request) {
default:
token, err = tokenAuthentication(request.Token)
fmt.Println(err)
if err != nil {
ShowError(err, 0)
responseAPIError(err)
return
}
@@ -926,6 +931,7 @@ func API(w http.ResponseWriter, r *http.Request) {
if err != nil {
responseAPIError(err)
return
}
w.Write([]byte(mapToJSON(response)))
@@ -977,10 +983,10 @@ func setDefaultResponseData(response ResponseStruct, data bool) (defaults Respon
defaults.ClientInfo.OS = System.OS
defaults.ClientInfo.Streams = fmt.Sprintf("%d / %d", len(Data.Streams.Active), len(Data.Streams.All))
defaults.ClientInfo.UUID = Settings.UUID
defaults.ClientInfo.Errors = WebScreenLog.Errors
defaults.ClientInfo.Warnings = WebScreenLog.Warnings
defaults.Notification = System.Notification
defaults.Log = WebScreenLog
defaults.Log = webScreenLogSnapshot()
defaults.ClientInfo.Errors = defaults.Log.Errors
defaults.ClientInfo.Warnings = defaults.Log.Warnings
defaults.Notification = notificationsSnapshot()
switch System.Branch {
+31
View File
@@ -5,6 +5,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
@@ -137,3 +138,33 @@ func TestSetCookieTokenFlags(t *testing.T) {
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 {
// 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-- {
var file = Data.XMLTV.Files[i]
@@ -185,7 +190,7 @@ func createXEPGMapping() {
err = getLocalXMLTV(file, &xmltv)
if err != nil {
Data.XMLTV.Files = append(Data.XMLTV.Files, Data.XMLTV.Files[i+1:]...)
failedFiles[file] = true
var errMsg = err.Error()
err = errors.New(getProviderParameter(fileID, "xmltv", "name") + ": " + errMsg)
ShowError(err, 000)
@@ -215,6 +220,7 @@ func createXEPGMapping() {
}
Data.XMLTV.Files = removeStrings(Data.XMLTV.Files, failedFiles)
Data.XMLTV.Mapping = tmpMap
} else {
+8 -8
View File
@@ -1,7 +1,7 @@
# xTeVe improvement checklist
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
- [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] 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`
- [ ] Real mutex for screen log
- [ ] `http.Server` with timeouts; timeouts on all outbound clients
- [ ] imgcache: download outside the lock
- [ ] Atomic write helper (temp + fsync + rename)
- [x] Buffer: headers set before `WriteHeader`, bogus `Content-Length:` header gone, segments flushed to the client as they arrive. Others in 2b
- [ ] Error hygiene: no-op closures, `os.Exit`/`panic` outside main, ignored results
- [x] `logMu` guards `WebScreenLog` and notifications; accessors `logAppend`/`webScreenLogSnapshot`/`notificationsSnapshot`; ring buffer keeps the newest N (was dropping them)
- [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
- [x] imgcache downloads outside the lock; cache URL bug fixed (was a filesystem path); query-string URLs no longer produce unservable file names
- [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 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
- [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
- [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.
## 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()`
- [ ] Virtualised mapping table
- [ ] 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 SEARCH_MAPPING = new Object()
var UNDO = new Object()
var SERVER_CONNECTION = false
var WS_AVAILABLE = false
var ACTIVE_MENU_ID:string = ""
var LAST_BULK_CHECKBOX:HTMLInputElement = null
@@ -761,7 +760,6 @@ function sortSelect(elem) {
function updateLog() {
console.log("TOKEN")
var server:Server = new Server("updateLog")
server.request(new Object())
+1 -1
View File
@@ -1023,7 +1023,7 @@ function PageReady() {
return
}
if (SERVER_CONNECTION == true) {
if (WS.hasQueued("getServerConfig")) {
return
}
+228 -125
View File
@@ -1,136 +1,240 @@
class Server {
protocol:string
cmd:string
// 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.
constructor(cmd:string) {
this.cmd = cmd
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/"
}
request(data:Object):any {
// 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()
})
}
if (SERVER_CONNECTION == true) {
// 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
}
SERVER_CONNECTION = true
if (this.cmd != "updateLog") {
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 {
cmd: string
constructor(cmd: string) {
this.cmd = cmd
}
request(data: Object): any {
var isLogUpdate: boolean = this.cmd == "updateLog"
// Log polling must never pile up behind a stalled connection.
if (isLogUpdate && WS.hasQueued("updateLog")) {
return
}
if (!isLogUpdate) {
showElement("loading", true)
UNDO = new Object()
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
var requestCmd:string = data["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 requestCmd: string = this.cmd
var finishRequest = function(state:string, responseSuccess:boolean = false):void {
if (requestFinished == true) {
return
}
WS.send(data).then(function(response) {
requestFinished = true
SERVER_CONNECTION = false
window.clearTimeout(requestTimeout)
if (responseSuccess == true) {
if (state == "online") {
WS_FAILURE_COUNT = 0
}
} else {
WS_FAILURE_COUNT++
}
if (isLogUpdate == false) {
WS_FAILURE_COUNT = 0
if (!isLogUpdate) {
showElement("loading", false)
}
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);
setConnectionState("online")
if (response["status"] == false) {
setConnectionState("offline")
alert(response["err"])
if (response.hasOwnProperty("reload")) {
location.reload()
}
return
}
if (response.hasOwnProperty("logoURL")) {
var div = (document.getElementById("channel-icon") as HTMLInputElement)
div.value = response["logoURL"]
@@ -138,19 +242,17 @@ class Server {
return
}
switch (data["cmd"]) {
switch (requestCmd) {
case "updateLog":
SERVER["log"] = response["log"]
if (document.getElementById("content_log")) {
showLogs(false)
}
return
break;
default:
SERVER = new Object()
SERVER = response
break;
break
}
if (response.hasOwnProperty("openMenu")) {
@@ -171,7 +273,6 @@ class Server {
location.reload()
}
if (response.hasOwnProperty("wizard")) {
createLayout()
configurationWizard[response["wizard"]].createWizard()
@@ -180,23 +281,25 @@ class Server {
createLayout()
}
}, function(err) {
ws.onclose = function() {
if (responseReceived == true) {
return
WS_FAILURE_COUNT++
if (!isLogUpdate) {
showElement("loading", false)
}
var closeState:string = "offline"
if (isLogUpdate == true && WS_FAILURE_COUNT < 2) {
closeState = "idle"
var state: string = "offline"
if (isLogUpdate && WS_FAILURE_COUNT < 2) {
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:number = 0
var WS_FAILURE_COUNT: number = 0
+19 -6
View File
@@ -9,9 +9,11 @@ import (
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"xteve/src"
)
@@ -111,7 +113,7 @@ func main() {
err := src.Init()
if err != nil {
src.ShowError(err, 0)
os.Exit(0)
os.Exit(1)
}
src.ShowSystemInfo()
@@ -150,12 +152,13 @@ func main() {
err := src.Init()
if err != nil {
src.ShowError(err, 0)
os.Exit(0)
os.Exit(1)
}
err = src.XteveRestoreFromCLI(*restore)
if err != nil {
src.ShowError(err, 0)
os.Exit(1)
}
os.Exit(0)
@@ -164,25 +167,35 @@ func main() {
err := src.Init()
if err != nil {
src.ShowError(err, 0)
os.Exit(0)
os.Exit(1)
}
err = src.StartSystem(false)
if err != nil {
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()
if err != nil {
src.ShowError(err, 0)
os.Exit(0)
os.Exit(1)
}
err = src.StartWebserver()
if err != nil {
src.ShowError(err, 0)
os.Exit(0)
os.Exit(1)
}
}