diff --git a/.drone.yml b/.drone.yml index 1d68e89..d34cf48 100644 --- a/.drone.yml +++ b/.drone.yml @@ -18,6 +18,7 @@ steps: - cp html/js/app.js /tmp/app.js.committed - npm ci --no-audit --no-fund - npm run build + - npm test - cmp /tmp/app.js.committed html/js/app.js || (echo "html/js/app.js is stale - run 'npm run build' and commit the result" && exit 1) - name: go-build diff --git a/README-DEV.md b/README-DEV.md index bc9bb49..748eab2 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -29,12 +29,15 @@ The web interface is embedded with `go:embed` from `html/`, so a bare clone buil The UI is plain TypeScript compiled as global scripts (no modules yet) into a single bundle, `html/js/app.js`, which is committed. CI rebuilds it and fails if the committed file is stale. ```bash -npm ci # installs the pinned TypeScript compiler +npm ci # installs the pinned TypeScript compiler and jsdom npm run build # ts/*.ts -> html/js/app.js npm run check # type-check only +npm test # jsdom tests under tests/ (Node's built-in runner) ``` -File order in the bundle is fixed in `ts/tsconfig.json` and matches the old per-page script order. All pages load the same bundle. +File order in the bundle is fixed in `ts/tsconfig.json`: `network`, `menu`, `content`, `popup`, `xmltv`, `settings`, `logs`, `base`, `configuration`, `authentication`. Classes must be defined before the top-level statements in later files that instantiate them. All pages load the same bundle. + +The tests load the built bundle into a jsdom window (`tests/harness.js`) with a fixture server payload (`tests/fixture.js`), so they exercise the real rendering code. Run `npm run build` before `npm test`. English strings are inlined in the TypeScript. There is no language layer. diff --git a/html/js/app.js b/html/js/app.js index 319b5b4..896809e 100644 --- a/html/js/app.js +++ b/html/js/app.js @@ -194,7 +194,7 @@ class Server { setConnectionState("online"); if (response["status"] == false) { setConnectionState("offline"); - alert(response["err"]); + alertUser(response["err"]); if (response.hasOwnProperty("reload")) { location.reload(); } @@ -226,7 +226,7 @@ class Server { window.location = response["openLink"]; } if (response.hasOwnProperty("alert")) { - alert(response["alert"]); + alertUser(response["alert"]); } if (response.hasOwnProperty("reload")) { location.reload(); @@ -248,7 +248,7 @@ class Server { } setConnectionState(state); if (WS_AVAILABLE == false && !isLogUpdate && requestCmd != "getServerConfig") { - alert("No websocket connection to xTeVe could be established. Check your network configuration."); + alertUser("No websocket connection to xTeVe could be established. Check your network configuration."); } }); } @@ -281,7 +281,7 @@ class MainMenuItem extends MainMenu { } createItem() { var item = document.createElement("LI"); - item.setAttribute("onclick", "javascript: openThisMenu(this)"); + item.addEventListener("click", function () { openThisMenu(item); }); item.setAttribute("id", this.id); item.setAttribute("data-menu", this.menuKey); var img = this.createIMG(this.imgSrc); @@ -310,599 +310,6 @@ class MainMenuItem extends MainMenu { //console.log(this.menuKey, this.tableHeader); } } -class Content { - constructor() { - this.DocumentID = "content"; - this.TableID = "content_table"; - this.headerClass = "content_table_header"; - this.interactionID = "content-interaction"; - } - createHeadline(value) { - var element = document.createElement("H3"); - element.textContent = value; - return element; - } - createHR() { - var element = document.createElement("HR"); - return element; - } - createInteraction() { - var element = document.createElement("DIV"); - element.setAttribute("id", this.interactionID); - return element; - } - createDIV() { - var element = document.createElement("DIV"); - element.id = this.DivID; - return element; - } - createTABLE() { - var element = document.createElement("TABLE"); - element.id = this.TableID; - return element; - } - createTableRow() { - var element = document.createElement("TR"); - element.className = this.headerClass; - return element; - } - createTableContent(menuKey) { - var data = new Object(); - var rows = new Array(); - switch (menuKey) { - case "playlist": - var fileTypes = new Array("m3u", "hdhr"); - fileTypes.forEach(fileType => { - data = SERVER["settings"]["files"][fileType]; - var keys = getObjKeys(data); - keys.forEach(key => { - var tr = document.createElement("TR"); - tr.id = key; - tr.setAttribute('onclick', 'javascript: openPopUp("' + fileType + '", this)'); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["name"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (SERVER["settings"]["buffer"] != "-") { - cell.value = data[key]["tuner"]; - } - else { - cell.value = "-"; - } - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["last.update"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["provider.availability"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["type"].toUpperCase(); - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["compatibility"]["streams"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["compatibility"]["group.title"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["compatibility"]["tvg.id"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["compatibility"]["stream.id"]; - tr.appendChild(cell.createCell()); - rows.push(tr); - }); - }); - break; - case "filter": - delete SERVER["settings"]["filter"][-1]; - data = SERVER["settings"]["filter"]; - var keys = getObjKeys(data); - keys.forEach(key => { - var tr = document.createElement("TR"); - tr.id = key; - tr.setAttribute('onclick', 'javascript: openPopUp("' + data[key]["type"] + '", this)'); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["name"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - switch (data[key]["type"]) { - case "custom-filter": - cell.value = "Custom"; - break; - case "group-title": - cell.value = "Group"; - break; - default: - break; - } - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["filter"]; - tr.appendChild(cell.createCell()); - rows.push(tr); - }); - break; - case "xmltv": - var fileTypes = new Array("xmltv"); - fileTypes.forEach(fileType => { - data = SERVER["settings"]["files"][fileType]; - var keys = getObjKeys(data); - keys.forEach(key => { - var tr = document.createElement("TR"); - tr.id = key; - tr.setAttribute('onclick', 'javascript: openPopUp("' + fileType + '", this)'); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["name"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["last.update"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["provider.availability"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["compatibility"]["xmltv.channels"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["compatibility"]["xmltv.programs"]; - tr.appendChild(cell.createCell()); - rows.push(tr); - }); - }); - break; - case "users": - var fileTypes = new Array("users"); - fileTypes.forEach(fileType => { - data = SERVER[fileType]; - var keys = getObjKeys(data); - keys.forEach(key => { - var tr = document.createElement("TR"); - tr.id = key; - tr.setAttribute('onclick', 'javascript: openPopUp("' + fileType + '", this)'); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["data"]["username"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = "******"; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (data[key]["data"]["authentication.web"] == true) { - cell.value = "✓"; - } - else { - cell.value = "-"; - } - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (data[key]["data"]["authentication.pms"] == true) { - cell.value = "✓"; - } - else { - cell.value = "-"; - } - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (data[key]["data"]["authentication.m3u"] == true) { - cell.value = "✓"; - } - else { - cell.value = "-"; - } - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (data[key]["data"]["authentication.xml"] == true) { - cell.value = "✓"; - } - else { - cell.value = "-"; - } - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (data[key]["data"]["authentication.api"] == true) { - cell.value = "✓"; - } - else { - cell.value = "-"; - } - tr.appendChild(cell.createCell()); - rows.push(tr); - }); - }); - break; - case "mapping": - BULK_EDIT = false; - createSearchObj(); - checkUndo("epgMapping"); - console.log("MAPPING"); - data = SERVER["xepg"]["epgMapping"]; - var keys = getObjKeys(data); - keys.forEach(key => { - var tr = document.createElement("TR"); - tr.id = key; - //tr.setAttribute('oncontextmenu', 'javascript: rightClick(this)') - switch (data[key]["x-active"]) { - case true: - tr.className = "activeEPG"; - break; - case false: - tr.className = "notActiveEPG"; - break; - } - // Bulk - var cell = new Cell(); - cell.child = true; - cell.childType = "BULK"; - cell.value = false; - tr.appendChild(cell.createCell()); - // Kanalnummer - var cell = new Cell(); - cell.child = true; - cell.childType = "INPUTCHANNEL"; - cell.value = data[key]["x-channelID"]; - //td.setAttribute('onclick', 'javascript: changeChannelNumber("' + key + '", this)') - tr.appendChild(cell.createCell()); - // Logo - var cell = new Cell(); - cell.child = true; - cell.childType = "IMG"; - cell.imageURL = data[key]["tvg-logo"]; - tr.appendChild(cell.createCell()); - // Kanalname - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.className = data[key]["x-category"]; - cell.value = data[key]["x-name"]; - tr.appendChild(cell.createCell()); - // Playlist - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - //cell.value = data[key]["_file.m3u.name"] - cell.value = getValueFromProviderFile(data[key]["_file.m3u.id"], "m3u", "name"); - tr.appendChild(cell.createCell()); - // Gruppe (group-title) - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["x-group-title"]; - tr.appendChild(cell.createCell()); - // XMLTV Datei - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (data[key]["x-xmltv-file"] != "-") { - cell.value = getValueFromProviderFile(data[key]["x-xmltv-file"], "xmltv", "name"); - } - else { - cell.value = data[key]["x-xmltv-file"]; - } - tr.appendChild(cell.createCell()); - // XMLTV Kanal - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - //var value = str.substring(1, 4); - var value = data[key]["x-mapping"]; - if (value.length > 20) { - value = data[key]["x-mapping"].substring(0, 20) + "..."; - } - cell.value = value; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "EDIT"; - cell.value = "Edit"; - var editTd = cell.createCell(); - var editButton = editTd.firstChild; - editButton.setAttribute('onclick', 'javascript: openPopUp("mapping", this)'); - editButton.setAttribute("id", key); - editButton.setAttribute("aria-label", "Edit " + data[key]["x-name"]); - tr.appendChild(editTd); - rows.push(tr); - }); - break; - case "settings": - alert(); - break; - default: - console.log("Table content (menuKey):", menuKey); - break; - } - return rows; - } -} -class Cell { - createCell() { - var td = document.createElement("TD"); - if (this.child == true) { - var element; - switch (this.childType) { - case "P": - element = document.createElement(this.childType); - element.textContent = this.value; - element.className = this.className; - break; - case "INPUT": - element = document.createElement(this.childType); - element.value = this.value; - element.type = "text"; - break; - case "INPUTCHANNEL": - element = document.createElement("INPUT"); - element.setAttribute("onchange", "javscript: changeChannelNumber(this)"); - element.value = this.value; - element.type = "text"; - break; - case "BULK": - element = document.createElement("INPUT"); - element.checked = this.value; - element.type = "checkbox"; - element.className = "bulk hideBulk"; - element.addEventListener("click", function (event) { - scheduleChannelRangeSelection(element, event); - }); - break; - case "BULK_HEAD": - element = document.createElement("INPUT"); - element.checked = this.value; - element.type = "checkbox"; - element.className = "bulk hideBulk"; - element.setAttribute("onclick", "javascript: selectAllChannels()"); - break; - case "IMG": - element = document.createElement(this.childType); - element.setAttribute("src", this.imageURL); - if (this.imageURL != "") { - element.setAttribute("onerror", "javascript: this.onerror=null;this.src=''"); - //onerror="this.onerror=null;this.src='missing.gif';" - } - break; - case "EDIT": - element = document.createElement("INPUT"); - element.type = "button"; - element.value = this.value; - element.className = "mapping-edit-button"; - break; - } - td.appendChild(element); - } - else { - td.textContent = this.value; - } - if (this.onclick == true) { - td.setAttribute("onclick", this.onclickFunktion); - td.className = "pointer"; - } - if (this.tdClassName != undefined) { - td.className = this.tdClassName; - } - return td; - } -} -class ShowContent extends Content { - constructor(menuID) { - super(); - this.menuID = menuID; - } - createInput(type, name, value) { - var input = document.createElement("INPUT"); - input.setAttribute("type", type); - input.setAttribute("name", name); - input.setAttribute("value", value); - return input; - } - show() { - COLUMN_TO_SORT = -1; - // Alten Inhalt löschen - var doc = document.getElementById(this.DocumentID); - doc.innerHTML = ""; - showPreview(false); - // Überschrift - var headline = menuItems[this.menuID].headline; - var menuKey = menuItems[this.menuID].menuKey; - var h = this.createHeadline(headline); - doc.appendChild(h); - var hr = this.createHR(); - doc.appendChild(hr); - // Interaktion - var div = this.createInteraction(); - doc.appendChild(div); - var interaction = document.getElementById(this.interactionID); - switch (menuKey) { - case "playlist": - var input = this.createInput("button", menuKey, "New"); - input.setAttribute("id", "-"); - input.setAttribute("onclick", 'javascript: openPopUp("playlist")'); - interaction.appendChild(input); - break; - case "filter": - var input = this.createInput("button", menuKey, "New"); - input.setAttribute("id", -1); - input.setAttribute("onclick", 'javascript: openPopUp("filter", this)'); - interaction.appendChild(input); - break; - case "xmltv": - var input = this.createInput("button", menuKey, "New"); - input.setAttribute("id", "xmltv"); - input.setAttribute("onclick", 'javascript: openPopUp("xmltv")'); - interaction.appendChild(input); - break; - case "users": - var input = this.createInput("button", menuKey, "New"); - input.setAttribute("id", "users"); - input.setAttribute("onclick", 'javascript: openPopUp("users")'); - interaction.appendChild(input); - break; - case "mapping": - showElement("loading", true); - var input = this.createInput("button", menuKey, "Save"); - input.setAttribute("onclick", 'javascript: savePopupData("mapping", "", "")'); - interaction.appendChild(input); - var input = this.createInput("button", menuKey, "Bulk Edit"); - input.setAttribute("onclick", 'javascript: bulkEdit()'); - interaction.appendChild(input); - var input = this.createInput("search", "search", ""); - input.setAttribute("id", "searchMapping"); - input.setAttribute("placeholder", "Search"); - input.className = "search"; - input.setAttribute("oninput", 'javascript: searchInMapping()'); - interaction.appendChild(input); - break; - case "settings": - var input = this.createInput("button", menuKey, "Save"); - input.setAttribute("onclick", 'javascript: saveSettings();'); - interaction.appendChild(input); - var input = this.createInput("button", menuKey, "Backup"); - input.setAttribute("onclick", 'javascript: backup();'); - interaction.appendChild(input); - var input = this.createInput("button", menuKey, "Restore"); - input.setAttribute("onclick", 'javascript: restore();'); - interaction.appendChild(input); - var wrapper = document.createElement("DIV"); - wrapper.setAttribute("id", "box-wrapper"); - doc.appendChild(wrapper); - this.DivID = "content_settings"; - var settings = this.createDIV(); - wrapper.appendChild(settings); - showSettings(); - return; - break; - case "log": - var input = this.createInput("button", menuKey, "Reset Logs"); - input.setAttribute("onclick", 'javascript: resetLogs();'); - interaction.appendChild(input); - var wrapper = document.createElement("DIV"); - wrapper.setAttribute("id", "box-wrapper"); - doc.appendChild(wrapper); - this.DivID = "content_log"; - var logs = this.createDIV(); - wrapper.appendChild(logs); - showLogs(true); - return; - break; - case "logout": - location.reload(); - document.cookie = "Token= ; expires = Thu, 01 Jan 1970 00:00:00 GMT"; - break; - default: - console.log("Show content (menuKey):", menuKey); - break; - } - // Tabelle erstellen (falls benötigt) - var tableHeader = menuItems[this.menuID].tableHeader; - if (tableHeader.length > 0) { - var wrapper = document.createElement("DIV"); - doc.appendChild(wrapper); - wrapper.setAttribute("id", "box-wrapper"); - var table = this.createTABLE(); - wrapper.appendChild(table); - var header = this.createTableRow(); - table.appendChild(header); - // Kopfzeile der Tablle - tableHeader.forEach(element => { - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = element; - if (element == "BULK") { - cell.childType = "BULK_HEAD"; - cell.value = false; - } - if (menuKey == "mapping") { - if (element == "Ch. No.") { - cell.onclick = true; - cell.onclickFunktion = "javascript: sortTable(1);"; - cell.tdClassName = "sortThis"; - } - if (element == "Channel Name") { - cell.onclick = true; - cell.onclickFunktion = "javascript: sortTable(3);"; - } - if (element == "Playlist") { - cell.onclick = true; - cell.onclickFunktion = "javascript: sortTable(4);"; - } - if (element == "Group Title") { - cell.onclick = true; - cell.onclickFunktion = "javascript: sortTable(5);"; - } - } - header.appendChild(cell.createCell()); - }); - table.appendChild(header); - // Inhalt der Tabelle - var rows = this.createTableContent(menuKey); - rows.forEach(tr => { - table.appendChild(tr); - }); - } - switch (menuKey) { - case "mapping": - sortTable(1); - break; - case "filter": - showPreview(true); - sortTable(0); - break; - default: - COLUMN_TO_SORT = -1; - sortTable(0); - break; - } - showElement("loading", false); - } -} var SHELL_LAYOUT_READY = false; function setLayoutMenuState(open) { if (document.body == null) { @@ -1110,6 +517,28 @@ function setClientInfoValue(key, value) { } element.textContent = textValue; } +// menuItemVisible reports whether a menu entry applies to the current server state. +function menuItemVisible(item) { + switch (item.menuKey) { + case "users": + case "logout": + return SERVER["settings"]["authentication.web"] == true; + case "mapping": + case "xmltv": + return SERVER["clientInfo"]["epgSource"] == "XEPG"; + default: + return true; + } +} +// renderedMenuKeys lists the menu entries currently in the document, in order. +function renderedMenuKeys(menu) { + var keys = []; + var items = menu.getElementsByTagName("LI"); + for (var i = 0; i < items.length; i++) { + keys.push(items[i].getAttribute("data-menu")); + } + return keys; +} function createLayout() { // Client Info var obj = SERVER["clientInfo"]; @@ -1118,37 +547,32 @@ function createLayout() { setClientInfoValue(keys[i], obj[keys[i]]); } renderStatusCards(); - if (!document.getElementById("main-menu")) { + var menu = document.getElementById("main-menu"); + if (menu == null) { return; } - // Menü erstellen - document.getElementById("main-menu").innerHTML = ""; + // Menu: rebuilt only when the set of visible entries changes, so that + // keyboard focus inside the list survives routine server responses. + var visibleKeys = []; for (let i = 0; i < menuItems.length; i++) { menuItems[i].id = i; - switch (menuItems[i]["menuKey"]) { - case "users": - case "logout": - if (SERVER["settings"]["authentication.web"] == true) { - menuItems[i].createItem(); - } - break; - case "mapping": - case "xmltv": - if (SERVER["clientInfo"]["epgSource"] == "XEPG") { - menuItems[i].createItem(); - } - break; - default: + if (menuItemVisible(menuItems[i])) { + visibleKeys.push(menuItems[i].menuKey); + } + } + if (visibleKeys.join(",") != renderedMenuKeys(menu).join(",")) { + menu.innerHTML = ""; + for (let i = 0; i < menuItems.length; i++) { + if (menuItemVisible(menuItems[i])) { menuItems[i].createItem(); - break; + } } } if (ACTIVE_MENU_ID.length > 0 && document.getElementById(ACTIVE_MENU_ID)) { setActiveMenu(ACTIVE_MENU_ID); } var content = document.getElementById("content"); - var menu = document.getElementById("main-menu"); - if (ACTIVE_MENU_ID.length == 0 && content != null && menu != null) { + if (ACTIVE_MENU_ID.length == 0 && content != null) { if (content.innerHTML.replace(/\\s/g, "").length == 0) { var firstItem = menu.getElementsByTagName("LI")[0]; if (firstItem != undefined) { @@ -1158,6 +582,600 @@ function createLayout() { } return; } +class Content { + constructor() { + this.DocumentID = "content"; + this.TableID = "content_table"; + this.headerClass = "content_table_header"; + this.interactionID = "content-interaction"; + } + createHeadline(value) { + var element = document.createElement("H3"); + element.textContent = value; + return element; + } + createHR() { + var element = document.createElement("HR"); + return element; + } + createInteraction() { + var element = document.createElement("DIV"); + element.setAttribute("id", this.interactionID); + return element; + } + createDIV() { + var element = document.createElement("DIV"); + element.id = this.DivID; + return element; + } + createTABLE() { + var element = document.createElement("TABLE"); + element.id = this.TableID; + return element; + } + createTableRow() { + var element = document.createElement("TR"); + element.className = this.headerClass; + return element; + } + createTableContent(menuKey) { + var data = new Object(); + var rows = new Array(); + switch (menuKey) { + case "playlist": + var fileTypes = new Array("m3u", "hdhr"); + fileTypes.forEach(fileType => { + data = SERVER["settings"]["files"][fileType]; + var keys = getObjKeys(data); + keys.forEach(key => { + var tr = document.createElement("TR"); + tr.id = key; + tr.addEventListener("click", function () { openPopUp(fileType, tr); }); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["name"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (SERVER["settings"]["buffer"] != "-") { + cell.value = data[key]["tuner"]; + } + else { + cell.value = "-"; + } + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["last.update"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["provider.availability"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["type"].toUpperCase(); + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["compatibility"]["streams"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["compatibility"]["group.title"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["compatibility"]["tvg.id"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["compatibility"]["stream.id"]; + tr.appendChild(cell.createCell()); + rows.push(tr); + }); + }); + break; + case "filter": + delete SERVER["settings"]["filter"][-1]; + data = SERVER["settings"]["filter"]; + var keys = getObjKeys(data); + keys.forEach(key => { + var tr = document.createElement("TR"); + tr.id = key; + tr.addEventListener("click", function () { openPopUp(data[key]["type"], tr); }); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["name"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + switch (data[key]["type"]) { + case "custom-filter": + cell.value = "Custom"; + break; + case "group-title": + cell.value = "Group"; + break; + case "regex-filter": + cell.value = "Regex"; + break; + default: + break; + } + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["filter"]; + tr.appendChild(cell.createCell()); + rows.push(tr); + }); + break; + case "xmltv": + var fileTypes = new Array("xmltv"); + fileTypes.forEach(fileType => { + data = SERVER["settings"]["files"][fileType]; + var keys = getObjKeys(data); + keys.forEach(key => { + var tr = document.createElement("TR"); + tr.id = key; + tr.addEventListener("click", function () { openPopUp(fileType, tr); }); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["name"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["last.update"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["provider.availability"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["compatibility"]["xmltv.channels"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["compatibility"]["xmltv.programs"]; + tr.appendChild(cell.createCell()); + rows.push(tr); + }); + }); + break; + case "users": + var fileTypes = new Array("users"); + fileTypes.forEach(fileType => { + data = SERVER[fileType]; + var keys = getObjKeys(data); + keys.forEach(key => { + var tr = document.createElement("TR"); + tr.id = key; + tr.addEventListener("click", function () { openPopUp(fileType, tr); }); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["data"]["username"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = "******"; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (data[key]["data"]["authentication.web"] == true) { + cell.value = "✓"; + } + else { + cell.value = "-"; + } + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (data[key]["data"]["authentication.pms"] == true) { + cell.value = "✓"; + } + else { + cell.value = "-"; + } + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (data[key]["data"]["authentication.m3u"] == true) { + cell.value = "✓"; + } + else { + cell.value = "-"; + } + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (data[key]["data"]["authentication.xml"] == true) { + cell.value = "✓"; + } + else { + cell.value = "-"; + } + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (data[key]["data"]["authentication.api"] == true) { + cell.value = "✓"; + } + else { + cell.value = "-"; + } + tr.appendChild(cell.createCell()); + rows.push(tr); + }); + }); + break; + case "mapping": + BULK_EDIT = false; + createSearchObj(); + checkUndo("epgMapping"); + console.log("MAPPING"); + data = SERVER["xepg"]["epgMapping"]; + var keys = getObjKeys(data); + keys.forEach(key => { + var tr = document.createElement("TR"); + tr.id = key; + switch (data[key]["x-active"]) { + case true: + tr.className = "activeEPG"; + break; + case false: + tr.className = "notActiveEPG"; + break; + } + // Bulk + var cell = new Cell(); + cell.child = true; + cell.childType = "BULK"; + cell.value = false; + tr.appendChild(cell.createCell()); + // Kanalnummer + var cell = new Cell(); + cell.child = true; + cell.childType = "INPUTCHANNEL"; + cell.value = data[key]["x-channelID"]; + tr.appendChild(cell.createCell()); + // Logo + var cell = new Cell(); + cell.child = true; + cell.childType = "IMG"; + cell.imageURL = data[key]["tvg-logo"]; + tr.appendChild(cell.createCell()); + // Kanalname + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.className = data[key]["x-category"]; + cell.value = data[key]["x-name"]; + tr.appendChild(cell.createCell()); + // Playlist + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + //cell.value = data[key]["_file.m3u.name"] + cell.value = getValueFromProviderFile(data[key]["_file.m3u.id"], "m3u", "name"); + tr.appendChild(cell.createCell()); + // Gruppe (group-title) + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["x-group-title"]; + tr.appendChild(cell.createCell()); + // XMLTV Datei + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (data[key]["x-xmltv-file"] != "-") { + cell.value = getValueFromProviderFile(data[key]["x-xmltv-file"], "xmltv", "name"); + } + else { + cell.value = data[key]["x-xmltv-file"]; + } + tr.appendChild(cell.createCell()); + // XMLTV Kanal + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + //var value = str.substring(1, 4); + var value = data[key]["x-mapping"]; + if (value.length > 20) { + value = data[key]["x-mapping"].substring(0, 20) + "..."; + } + cell.value = value; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "EDIT"; + cell.value = "Edit"; + var editTd = cell.createCell(); + var editButton = editTd.firstChild; + editButton.addEventListener("click", function () { openPopUp("mapping", editButton); }); + editButton.setAttribute("id", key); + editButton.setAttribute("aria-label", "Edit " + data[key]["x-name"]); + tr.appendChild(editTd); + rows.push(tr); + }); + break; + case "settings": + alert(); + break; + default: + console.log("Table content (menuKey):", menuKey); + break; + } + return rows; + } +} +class Cell { + createCell() { + var td = document.createElement("TD"); + if (this.child == true) { + var element; + switch (this.childType) { + case "P": + element = document.createElement(this.childType); + element.textContent = this.value; + element.className = this.className; + break; + case "INPUT": + element = document.createElement(this.childType); + element.value = this.value; + element.type = "text"; + break; + case "INPUTCHANNEL": + element = document.createElement("INPUT"); + element.addEventListener("change", function () { changeChannelNumber(element); }); + element.value = this.value; + element.type = "text"; + break; + case "BULK": + element = document.createElement("INPUT"); + element.checked = this.value; + element.type = "checkbox"; + element.className = "bulk hideBulk"; + element.addEventListener("click", function (event) { + scheduleChannelRangeSelection(element, event); + }); + break; + case "BULK_HEAD": + element = document.createElement("INPUT"); + element.checked = this.value; + element.type = "checkbox"; + element.className = "bulk hideBulk"; + element.addEventListener("click", function () { selectAllChannels(); }); + break; + case "IMG": + element = document.createElement(this.childType); + element.setAttribute("src", this.imageURL); + if (this.imageURL != "") { + // A broken logo falls back to no image; once, so the empty src cannot loop. + element.addEventListener("error", function () { element.setAttribute("src", ""); }, { once: true }); + } + break; + case "EDIT": + element = document.createElement("INPUT"); + element.type = "button"; + element.value = this.value; + element.className = "mapping-edit-button"; + break; + } + td.appendChild(element); + } + else { + td.textContent = this.value; + } + if (this.onclick == true) { + td.addEventListener("click", this.onclickHandler); + td.className = "pointer"; + } + if (this.tdClassName != undefined) { + td.className = this.tdClassName; + } + return td; + } +} +class ShowContent extends Content { + constructor(menuID) { + super(); + this.menuID = menuID; + } + createInput(type, name, value) { + var input = document.createElement("INPUT"); + input.setAttribute("type", type); + input.setAttribute("name", name); + input.setAttribute("value", value); + return input; + } + show() { + COLUMN_TO_SORT = -1; + // Alten Inhalt löschen + var doc = document.getElementById(this.DocumentID); + doc.innerHTML = ""; + showPreview(false); + // Überschrift + var headline = menuItems[this.menuID].headline; + var menuKey = menuItems[this.menuID].menuKey; + var h = this.createHeadline(headline); + doc.appendChild(h); + var hr = this.createHR(); + doc.appendChild(hr); + // Interaktion + var div = this.createInteraction(); + doc.appendChild(div); + var interaction = document.getElementById(this.interactionID); + switch (menuKey) { + case "playlist": + var input = this.createInput("button", menuKey, "New"); + input.setAttribute("id", "-"); + input.addEventListener("click", function () { openPopUp("playlist"); }); + interaction.appendChild(input); + break; + case "filter": + var input = this.createInput("button", menuKey, "New"); + input.setAttribute("id", -1); + input.addEventListener("click", function (event) { openPopUp("filter", event.currentTarget); }); + interaction.appendChild(input); + break; + case "xmltv": + var input = this.createInput("button", menuKey, "New"); + input.setAttribute("id", "xmltv"); + input.addEventListener("click", function () { openPopUp("xmltv"); }); + interaction.appendChild(input); + break; + case "users": + var input = this.createInput("button", menuKey, "New"); + input.setAttribute("id", "users"); + input.addEventListener("click", function () { openPopUp("users"); }); + interaction.appendChild(input); + break; + case "mapping": + showElement("loading", true); + var input = this.createInput("button", menuKey, "Save"); + input.addEventListener("click", function () { savePopupData("mapping", "", false, 0); }); + interaction.appendChild(input); + var input = this.createInput("button", menuKey, "Bulk Edit"); + input.addEventListener("click", function () { bulkEdit(); }); + interaction.appendChild(input); + var input = this.createInput("search", "search", ""); + input.setAttribute("id", "searchMapping"); + input.setAttribute("placeholder", "Search"); + input.className = "search"; + input.addEventListener("input", function () { searchInMapping(); }); + interaction.appendChild(input); + break; + case "settings": + var input = this.createInput("button", menuKey, "Save"); + input.addEventListener("click", function () { saveSettings(); }); + interaction.appendChild(input); + var input = this.createInput("button", menuKey, "Backup"); + input.addEventListener("click", function () { backup(); }); + interaction.appendChild(input); + var input = this.createInput("button", menuKey, "Restore"); + input.addEventListener("click", function () { restore(); }); + interaction.appendChild(input); + var wrapper = document.createElement("DIV"); + wrapper.setAttribute("id", "box-wrapper"); + doc.appendChild(wrapper); + this.DivID = "content_settings"; + var settings = this.createDIV(); + wrapper.appendChild(settings); + showSettings(); + return; + break; + case "log": + var input = this.createInput("button", menuKey, "Reset Logs"); + input.addEventListener("click", function () { resetLogs(); }); + interaction.appendChild(input); + var wrapper = document.createElement("DIV"); + wrapper.setAttribute("id", "box-wrapper"); + doc.appendChild(wrapper); + this.DivID = "content_log"; + var logs = this.createDIV(); + wrapper.appendChild(logs); + showLogs(true); + return; + break; + case "logout": + location.reload(); + document.cookie = "Token= ; expires = Thu, 01 Jan 1970 00:00:00 GMT"; + break; + default: + console.log("Show content (menuKey):", menuKey); + break; + } + // Tabelle erstellen (falls benötigt) + var tableHeader = menuItems[this.menuID].tableHeader; + if (tableHeader.length > 0) { + var wrapper = document.createElement("DIV"); + doc.appendChild(wrapper); + wrapper.setAttribute("id", "box-wrapper"); + var table = this.createTABLE(); + wrapper.appendChild(table); + var header = this.createTableRow(); + table.appendChild(header); + // Kopfzeile der Tablle + tableHeader.forEach(element => { + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = element; + if (element == "BULK") { + cell.childType = "BULK_HEAD"; + cell.value = false; + } + if (menuKey == "mapping") { + if (element == "Ch. No.") { + cell.onclick = true; + cell.onclickHandler = function () { sortTable(1); }; + cell.tdClassName = "sortThis"; + } + if (element == "Channel Name") { + cell.onclick = true; + cell.onclickHandler = function () { sortTable(3); }; + } + if (element == "Playlist") { + cell.onclick = true; + cell.onclickHandler = function () { sortTable(4); }; + } + if (element == "Group Title") { + cell.onclick = true; + cell.onclickHandler = function () { sortTable(5); }; + } + } + header.appendChild(cell.createCell()); + }); + table.appendChild(header); + // Inhalt der Tabelle + var rows = this.createTableContent(menuKey); + rows.forEach(tr => { + table.appendChild(tr); + }); + } + switch (menuKey) { + case "mapping": + sortTable(1); + break; + case "filter": + showPreview(true); + sortTable(0); + break; + default: + COLUMN_TO_SORT = -1; + sortTable(0); + break; + } + showElement("loading", false); + } +} function openThisMenu(element) { var id = element.id; var content = new ShowContent(id); @@ -1275,6 +1293,33 @@ class PopupContent extends PopupWindow { interaction.appendChild(element); } } +// Buffer choices a playlist can make; "default" means the global setting. +const PLAYLIST_BUFFER_OPTIONS = [ + { text: "Default (global setting)", value: "default" }, + { text: "No buffer (pass the URL through)", value: "-" }, + { text: "xTeVe", value: "xteve" }, + { text: "FFmpeg", value: "ffmpeg" }, + { text: "VLC", value: "vlc" } +]; +function createBufferSelect(content, data) { + var text = PLAYLIST_BUFFER_OPTIONS.map(function (o) { return o.text; }); + var values = PLAYLIST_BUFFER_OPTIONS.map(function (o) { return o.value; }); + var current = data["buffer"]; + if (values.indexOf(current) == -1) { + current = "default"; + } + return content.createSelect(text, values, current, "buffer"); +} +// playlistBufferActive: is a buffer in use for this playlist (its own choice, +// or the global setting when it has none)? Decides whether the tuner count +// can be edited. +function playlistBufferActive(data) { + var own = data["buffer"]; + if (own != undefined && own != "default" && own != "") { + return own != "-"; + } + return SERVER["settings"]["buffer"] != "-"; +} function openPopUp(dataType, element) { var data = new Object(); var id; @@ -1295,6 +1340,13 @@ function openPopUp(dataType, element) { data = getLocalData("filter", id); data["type"] = "custom-filter"; break; + case "regex-filter": + if (id == undefined) { + id = -1; + } + data = getLocalData("filter", id); + data["type"] = "regex-filter"; + break; default: data["id.provider"] = "-"; data["type"] = dataType; @@ -1313,20 +1365,20 @@ function openPopUp(dataType, element) { content.createHeadline("Playlist type"); // Type var text = ["M3U", "HDHomeRun"]; - var values = ["javascript: openPopUp('m3u')", "javascript: openPopUp('hdhr')"]; + var values = ["m3u", "hdhr"]; var select = content.createSelect(text, values, "", "type"); select.setAttribute("id", "type"); - select.setAttribute("onchange", 'javascript: changeButtonAction(this, "next", "onclick")'); // changeButtonAction content.appendRow("Type", select); + const typeSelect = select; // Interaktion content.createInteraction(); // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Weiter var input = content.createInput("button", "next", "Next"); - input.setAttribute("onclick", 'javascript: openPopUp("m3u")'); + input.addEventListener("click", function () { openPopUp(typeSelect.value); }); input.setAttribute("id", 'next'); content.addInteraction(input); break; @@ -1347,8 +1399,11 @@ function openPopUp(dataType, element) { var input = content.createInput("text", dbKey, data[dbKey]); input.setAttribute("placeholder", "File path or URL of the M3U"); content.appendRow("M3U File", input); + // Buffer (per playlist override of the global setting) + var bufferSelect = createBufferSelect(content, data); + content.appendRow("Buffer", bufferSelect); // Tuner - if (SERVER["settings"]["buffer"] != "-") { + if (playlistBufferActive(data)) { var text = new Array(); var values = new Array(); for (var i = 1; i <= 100; i++) { @@ -1357,7 +1412,6 @@ function openPopUp(dataType, element) { } var dbKey = "tuner"; var select = content.createSelect(text, values, data[dbKey], dbKey); - select.setAttribute("onfocus", "javascript: return;"); content.appendRow("Tuner / Streams", select); } else { @@ -1377,27 +1431,27 @@ function openPopUp(dataType, element) { if (data["id.provider"] != "-") { var input = content.createInput("button", "delete", "Delete"); input.className = "delete"; - input.setAttribute('onclick', 'javascript: savePopupData("m3u", "' + id + '", true, 0)'); + input.addEventListener("click", function () { savePopupData("m3u", String(id), true, 0); }); content.addInteraction(input); } else { var input = content.createInput("button", "back", "Back"); - input.setAttribute("onclick", 'javascript: openPopUp("playlist")'); + input.addEventListener("click", function () { openPopUp("playlist"); }); content.addInteraction(input); } // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Aktualisieren if (data["id.provider"] != "-") { var input = content.createInput("button", "update", "Update"); - input.setAttribute('onclick', 'javascript: savePopupData("m3u", "' + id + '", false, 1)'); + input.addEventListener("click", function () { savePopupData("m3u", String(id), false, 1); }); content.addInteraction(input); } // Speichern var input = content.createInput("button", "save", "Save"); - input.setAttribute('onclick', 'javascript: savePopupData("m3u", "' + id + '", false, 0)'); + input.addEventListener("click", function () { savePopupData("m3u", String(id), false, 0); }); content.addInteraction(input); break; case "hdhr": @@ -1417,8 +1471,11 @@ function openPopUp(dataType, element) { var input = content.createInput("text", dbKey, data[dbKey]); input.setAttribute("placeholder", "IP address and port (192.168.1.10:5004)"); content.appendRow("HDHomeRun IP", input); + // Buffer (per playlist override of the global setting) + var bufferSelect = createBufferSelect(content, data); + content.appendRow("Buffer", bufferSelect); // Tuner - if (SERVER["settings"]["buffer"] != "-") { + if (playlistBufferActive(data)) { var text = new Array(); var values = new Array(); for (var i = 1; i <= 100; i++) { @@ -1427,7 +1484,6 @@ function openPopUp(dataType, element) { } var dbKey = "tuner"; var select = content.createSelect(text, values, data[dbKey], dbKey); - select.setAttribute("onfocus", "javascript: return;"); content.appendRow("Tuner / Streams", select); } else { @@ -1446,54 +1502,55 @@ function openPopUp(dataType, element) { // Löschen if (data["id.provider"] != "-") { var input = content.createInput("button", "delete", "Delete"); - input.setAttribute('onclick', 'javascript: savePopupData("hdhr", "' + id + '", true, 0)'); + input.addEventListener("click", function () { savePopupData("hdhr", String(id), true, 0); }); input.className = "delete"; content.addInteraction(input); } else { var input = content.createInput("button", "back", "Back"); - input.setAttribute("onclick", 'javascript: openPopUp("playlist")'); + input.addEventListener("click", function () { openPopUp("playlist"); }); content.addInteraction(input); } // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Aktualisieren if (data["id.provider"] != "-") { var input = content.createInput("button", "update", "Update"); - input.setAttribute('onclick', 'javascript: savePopupData("hdhr", "' + id + '", false, 1)'); + input.addEventListener("click", function () { savePopupData("hdhr", String(id), false, 1); }); content.addInteraction(input); } // Speichern var input = content.createInput("button", "save", "Save"); - input.setAttribute('onclick', 'javascript: savePopupData("hdhr", "' + id + '", false, 0)'); + input.addEventListener("click", function () { savePopupData("hdhr", String(id), false, 0); }); content.addInteraction(input); break; case "filter": content.createHeadline(dataType); // Type var dbKey = "type"; - var text = ["M3U: " + "Group Title", "xTeVe: " + "Custom Filter"]; - var values = ["javascript: openPopUp('group-title')", "javascript: openPopUp('custom-filter')"]; - var select = content.createSelect(text, values, "javascript: openPopUp('group-title')", dbKey); + var text = ["M3U: " + "Group Title", "xTeVe: " + "Custom Filter", "xTeVe: " + "Regular Expression"]; + var values = ["group-title", "custom-filter", "regex-filter"]; + var select = content.createSelect(text, values, "group-title", dbKey); select.setAttribute("id", id); - select.setAttribute("onchange", 'javascript: changeButtonAction(this, "next", "onclick");'); // changeButtonAction content.appendRow("Type", select); + const filterTypeSelect = select; // Interaktion content.createInteraction(); // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Weiter var input = content.createInput("button", "next", "Next"); - input.setAttribute("onclick", 'javascript: openPopUp("group-title")'); + input.addEventListener("click", function () { openPopUp(filterTypeSelect.value); }); input.setAttribute("id", 'next'); content.addInteraction(input); break; case "custom-filter": case "group-title": + case "regex-filter": switch (dataType) { case "custom-filter": content.createHeadline("Custom"); @@ -1501,6 +1558,9 @@ function openPopUp(dataType, element) { case "group-title": content.createHeadline("Group"); break; + case "regex-filter": + content.createHeadline("Regular Expression"); + break; } // Name var dbKey = "name"; @@ -1530,6 +1590,19 @@ function openPopUp(dataType, element) { input.setAttribute("placeholder", "Sport {HD} !{ES,IT}"); content.appendRow("Filter Rule", input); break; + case "regex-filter": + // Groß- Kleinschreibung beachten + var dbKey = "caseSensitive"; + var input = content.createCheckbox(dbKey); + input.checked = data[dbKey]; + content.appendRow("Case Sensitive", input); + // Regulärer Ausdruck + var dbKey = "filter"; + var input = content.createInput("text", dbKey, data[dbKey]); + input.setAttribute("placeholder", "^(BBC|ITV) \\d+$"); + content.appendRow("Regular Expression", input); + content.description("Go regular expression, tried against the channel name, then the group title, then the raw M3U attributes. Case-insensitive unless Case Sensitive is checked. Invalid patterns are rejected on save."); + break; case "group-title": //alert(dbKey + " " + filterType) // Filter basierend auf den Gruppen in der M3U @@ -1538,7 +1611,7 @@ function openPopUp(dataType, element) { var text = groupsM3U["text"]; var values = groupsM3U["value"]; var select = content.createSelect(text, values, data[dbKey], dbKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(select); content.appendRow("Group Title", select); content.description("Select a M3U group. (Counter)
Changing the group title in the M3U invalidates the filter.", true); // Groß- Kleinschreibung beachten @@ -1564,16 +1637,16 @@ function openPopUp(dataType, element) { content.createInteraction(); // Löschen var input = content.createInput("button", "delete", "Delete"); - input.setAttribute('onclick', 'javascript: savePopupData("filter", "' + id + '", true, 0)'); + input.addEventListener("click", function () { savePopupData("filter", String(id), true, 0); }); input.className = "delete"; content.addInteraction(input); // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Speichern var input = content.createInput("button", "save", "Save"); - input.setAttribute('onclick', 'javascript: savePopupData("filter", "' + id + '", false, 0)'); + input.addEventListener("click", function () { savePopupData("filter", String(id), false, 0); }); content.addInteraction(input); break; case "xmltv": @@ -1598,23 +1671,23 @@ function openPopUp(dataType, element) { // Löschen if (data["id.provider"] != "-") { var input = content.createInput("button", "delete", "Delete"); - input.setAttribute('onclick', 'javascript: savePopupData("xmltv", "' + id + '", true, 0)'); + input.addEventListener("click", function () { savePopupData("xmltv", String(id), true, 0); }); input.className = "delete"; content.addInteraction(input); } // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Aktualisieren if (data["id.provider"] != "-") { var input = content.createInput("button", "update", "Update"); - input.setAttribute('onclick', 'javascript: savePopupData("xmltv", "' + id + '", false, 1)'); + input.addEventListener("click", function () { savePopupData("xmltv", String(id), false, 1); }); content.addInteraction(input); } // Speichern var input = content.createInput("button", "save", "Save"); - input.setAttribute('onclick', 'javascript: savePopupData("xmltv", "' + id + '", false, 0)'); + input.addEventListener("click", function () { savePopupData("xmltv", String(id), false, 0); }); content.addInteraction(input); break; case "users": @@ -1639,7 +1712,7 @@ function openPopUp(dataType, element) { var input = content.createCheckbox(dbKey); input.checked = data[dbKey]; if (data["defaultUser"] == true) { - input.setAttribute("onclick", "javascript: return false"); + input.addEventListener("click", function (event) { event.preventDefault(); }); } content.appendRow("Web Access", input); // Berechtigung PMS @@ -1668,16 +1741,16 @@ function openPopUp(dataType, element) { if (data["defaultUser"] != true && id != "-") { var input = content.createInput("button", "delete", "Delete"); input.className = "delete"; - input.setAttribute('onclick', 'javascript: savePopupData("' + dataType + '", "' + id + '", true, 0)'); + input.addEventListener("click", function () { savePopupData(dataType, String(id), true, 0); }); content.addInteraction(input); } // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Speichern var input = content.createInput("button", "save", "Save"); - input.setAttribute("onclick", 'javascript: savePopupData("' + dataType + '", "' + id + '", "false");'); + input.addEventListener("click", function () { savePopupData(dataType, String(id), false, 0); }); content.addInteraction(input); break; case "mapping": @@ -1687,13 +1760,13 @@ function openPopUp(dataType, element) { var input = content.createCheckbox(dbKey); input.checked = data[dbKey]; input.id = "active"; - //input.setAttribute("onchange", "javascript: this.className = 'changed'") - input.setAttribute("onchange", "javascript: toggleChannelStatus('" + id + "', this)"); + //markChangedOnChange(input) + input.addEventListener("change", function () { toggleChannelStatus(id); }); content.appendRow("Active", input); // Kanalname var dbKey = "x-name"; var input = content.createInput("text", dbKey, data[dbKey]); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(input); if (BULK_EDIT == true) { input.style.border = "solid 1px red"; input.setAttribute("readonly", "true"); @@ -1704,14 +1777,14 @@ function openPopUp(dataType, element) { var dbKey = "x-description"; var input = content.createInput("text", dbKey, data[dbKey]); input.setAttribute("placeholder", "Used by the Dummy as an XML description"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(input); content.appendRow("Channel Description", input); // Aktualisierung des Kanalnamens if (data.hasOwnProperty("_uuid.key")) { if (data["_uuid.key"] != "") { var dbKey = "x-update-channel-name"; var input = content.createCheckbox(dbKey); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(input); input.checked = data[dbKey]; content.appendRow("Update Channel Name", input); } @@ -1719,7 +1792,7 @@ function openPopUp(dataType, element) { // Logo URL (Kanal) var dbKey = "tvg-logo"; var input = content.createInput("text", dbKey, data[dbKey]); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(input); input.setAttribute("id", "channel-icon"); content.appendRow("Logo URL", input); // Aktualisierung des Kanallogos @@ -1727,19 +1800,19 @@ function openPopUp(dataType, element) { var input = content.createCheckbox(dbKey); input.checked = data[dbKey]; input.setAttribute("id", "update-icon"); - input.setAttribute("onchange", "javascript: this.className = 'changed'; changeChannelLogo('" + id + "');"); + markChangedOnChange(input, function () { changeChannelLogo(id); }); content.appendRow("Update Channel Logo", input); // Erweitern der EPG Kategorie var dbKey = "x-category"; var text = ["-", "Kids (Emby only)", "News", "Movie", "Series", "Sports"]; var values = ["", "Kids", "News", "Movie", "Series", "Sports"]; var select = content.createSelect(text, values, data[dbKey], dbKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(select); content.appendRow("EPG Category", select); // M3U Gruppentitel var dbKey = "x-group-title"; var input = content.createInput("text", dbKey, data[dbKey]); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(input); content.appendRow("Group Title (xteve.m3u)", input); if (data["group-title"] != undefined) { content.description(data["group-title"]); @@ -1751,7 +1824,7 @@ function openPopUp(dataType, element) { var select = xmltv.getFiles(data[dbKey]); select.setAttribute("name", dbKey); select.setAttribute("id", "popup-xmltv"); - select.setAttribute("onchange", "javascript: this.className = 'changed'; setXmltvChannel('" + id + "',this);"); + markChangedOnChange(select, function (element) { setXmltvChannel(id, element); }); content.appendRow("XMLTV File", select); var file = data[dbKey]; // XMLTV Mapping @@ -1760,18 +1833,18 @@ function openPopUp(dataType, element) { var select = xmltv.getPrograms(file, data[dbKey]); select.setAttribute("name", dbKey); select.setAttribute("id", "popup-mapping"); - select.setAttribute("onchange", "javascript: this.className = 'changed'; checkXmltvChannel('" + id + "',this,'" + xmlFile + "');"); + markChangedOnChange(select, function (element) { checkXmltvChannel(id, element, xmlFile); }); sortSelect(select); content.appendRow("XMLTV Channel", select); // Interaktion content.createInteraction(); // Logo hochladen var input = content.createInput("button", "cancel", "Upload Logo"); - input.setAttribute("onclick", 'javascript: uploadLogo();'); + input.addEventListener("click", function () { uploadLogo(); }); content.addInteraction(input); // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Fertig var ids = new Array(); @@ -1780,7 +1853,7 @@ function openPopUp(dataType, element) { ids.push(id); } var input = content.createInput("button", "save", "Done"); - input.setAttribute("onclick", 'javascript: donePopupData("' + dataType + '", "' + ids + '", "false");'); + input.addEventListener("click", function () { donePopupData(dataType, ids.join(",")); }); content.addInteraction(input); break; default: @@ -1788,162 +1861,6 @@ function openPopUp(dataType, element) { } showPopUpElement('popup-custom'); } -class XMLTVFile { - getFiles(set) { - var fileIDs = getObjKeys(SERVER["xepg"]["xmltvMap"]); - var values = new Array("-"); - var text = new Array("-"); - for (let i = 0; i < fileIDs.length; i++) { - if (fileIDs[i] != "xTeVe Dummy") { - values.push(getValueFromProviderFile(fileIDs[i], "xmltv", "file.xteve")); - text.push(getValueFromProviderFile(fileIDs[i], "xmltv", "name")); - } - else { - values.push(fileIDs[i]); - text.push(fileIDs[i]); - } - } - var select = document.createElement("SELECT"); - for (let i = 0; i < text.length; i++) { - var option = document.createElement("OPTION"); - option.setAttribute("value", values[i]); - option.innerText = text[i]; - select.appendChild(option); - } - if (set != "") { - select.value = set; - } - return select; - } - getPrograms(file, set) { - //var fileIDs:string[] = getObjKeys(SERVER["xepg"]["xmltvMap"]) - var values = getObjKeys(SERVER["xepg"]["xmltvMap"][file]); - var text = new Array(); - var displayName; - for (let i = 0; i < values.length; i++) { - if (SERVER["xepg"]["xmltvMap"][file][values[i]].hasOwnProperty('display-name') == true) { - displayName = SERVER["xepg"]["xmltvMap"][file][values[i]]["display-name"]; - } - else { - displayName = "-"; - } - text[i] = displayName + " (" + values[i] + ")"; - } - text.unshift("-"); - values.unshift("-"); - var select = document.createElement("SELECT"); - for (let i = 0; i < text.length; i++) { - var option = document.createElement("OPTION"); - option.setAttribute("value", values[i]); - option.innerText = text[i]; - select.appendChild(option); - } - if (set != "") { - select.value = set; - } - if (select.value != set) { - select.value = "-"; - } - return select; - } -} -function getValueFromProviderFile(file, fileType, key) { - if (file == "xTeVe Dummy") { - return file; - } - var fileID; - var indicator = file.charAt(0); - switch (indicator) { - case "M": - fileType = "m3u"; - fileID = file; - break; - case "H": - fileType = "hdhr"; - fileID = file; - break; - case "X": - fileType = "xmltv"; - fileID = file.substring(0, file.lastIndexOf('.')); - break; - } - if (SERVER["settings"]["files"][fileType].hasOwnProperty(fileID) == true) { - var data = SERVER["settings"]["files"][fileType][fileID]; - return data[key]; - } - return; -} -function setXmltvChannel(id, element) { - var xmltv = new XMLTVFile(); - var xmlFile = element.value; - var tvgId = SERVER["xepg"]["epgMapping"][id]["tvg-id"]; - var td = document.getElementById("popup-mapping").parentElement; - td.innerHTML = ""; - var select = xmltv.getPrograms(element.value, tvgId); - select.setAttribute("name", "x-mapping"); - select.setAttribute("id", "popup-mapping"); - select.setAttribute("onchange", "javascript: this.className = 'changed'; checkXmltvChannel('" + id + "',this,'" + xmlFile + "');"); - select.className = "changed"; - sortSelect(select); - td.appendChild(select); - checkXmltvChannel(id, select, xmlFile); -} -function checkXmltvChannel(id, element, xmlFile) { - var value = element.value; - var bool; - var checkbox = document.getElementById('active'); - var channel = SERVER["xepg"]["epgMapping"][id]; - var updateLogo; - if (value == "-") { - bool = false; - } - else { - bool = true; - } - checkbox.checked = bool; - checkbox.className = "changed"; - console.log(xmlFile); - // Kanallogo aktualisieren - /* - updateLogo = (document.getElementById("update-icon") as HTMLInputElement).checked - console.log(updateLogo); - */ - if (xmlFile != "xTeVe Dummy" && bool == true) { - //(document.getElementById("update-icon") as HTMLInputElement).checked = true; - //(document.getElementById("update-icon") as HTMLInputElement).className = "changed"; - console.log("ID", id); - changeChannelLogo(id); - return; - } - if (xmlFile == "xTeVe Dummy") { - document.getElementById("update-icon").checked = false; - document.getElementById("update-icon").className = "changed"; - } - return; -} -function changeChannelLogo(id) { - var updateLogo; - var channel = SERVER["xepg"]["epgMapping"][id]; - var f = document.getElementById("popup-xmltv"); - var xmltvFile = f.options[f.selectedIndex].value; - var m = document.getElementById("popup-mapping"); - var xMapping = m.options[m.selectedIndex].value; - var xmltvLogo = SERVER["xepg"]["xmltvMap"][xmltvFile][xMapping]["icon"]; - updateLogo = document.getElementById("update-icon").checked; - if (updateLogo == true && xmltvFile != "xTeVe Dummy") { - if (SERVER["xepg"]["xmltvMap"][xmltvFile].hasOwnProperty(xMapping)) { - var logo = xmltvLogo; - } - else { - logo = channel["tvg-logo"]; - } - var logoInput = document.getElementById("channel-icon"); - logoInput.value = logo; - if (BULK_EDIT == false) { - logoInput.className = "changed"; - } - } -} function savePopupData(dataType, id, remove, option) { if (dataType == "mapping") { var data = new Object(); @@ -2199,6 +2116,253 @@ function showPreview(element) { div.className = "visible"; return; } +class XMLTVFile { + getFiles(set) { + var fileIDs = getObjKeys(SERVER["xepg"]["xmltvMap"]); + var values = new Array("-"); + var text = new Array("-"); + for (let i = 0; i < fileIDs.length; i++) { + if (fileIDs[i] != "xTeVe Dummy") { + values.push(getValueFromProviderFile(fileIDs[i], "xmltv", "file.xteve")); + text.push(getValueFromProviderFile(fileIDs[i], "xmltv", "name")); + } + else { + values.push(fileIDs[i]); + text.push(fileIDs[i]); + } + } + var select = document.createElement("SELECT"); + for (let i = 0; i < text.length; i++) { + var option = document.createElement("OPTION"); + option.setAttribute("value", values[i]); + option.innerText = text[i]; + select.appendChild(option); + } + if (set != "") { + select.value = set; + } + return select; + } + getPrograms(file, set) { + //var fileIDs:string[] = getObjKeys(SERVER["xepg"]["xmltvMap"]) + var values = getObjKeys(SERVER["xepg"]["xmltvMap"][file]); + var text = new Array(); + var displayName; + for (let i = 0; i < values.length; i++) { + if (SERVER["xepg"]["xmltvMap"][file][values[i]].hasOwnProperty('display-name') == true) { + displayName = SERVER["xepg"]["xmltvMap"][file][values[i]]["display-name"]; + } + else { + displayName = "-"; + } + text[i] = displayName + " (" + values[i] + ")"; + } + text.unshift("-"); + values.unshift("-"); + var select = document.createElement("SELECT"); + for (let i = 0; i < text.length; i++) { + var option = document.createElement("OPTION"); + option.setAttribute("value", values[i]); + option.innerText = text[i]; + select.appendChild(option); + } + if (set != "") { + select.value = set; + } + if (select.value != set) { + select.value = "-"; + } + return select; + } +} +function getValueFromProviderFile(file, fileType, key) { + if (file == "xTeVe Dummy") { + return file; + } + var fileID; + var indicator = file.charAt(0); + switch (indicator) { + case "M": + fileType = "m3u"; + fileID = file; + break; + case "H": + fileType = "hdhr"; + fileID = file; + break; + case "X": + fileType = "xmltv"; + fileID = file.substring(0, file.lastIndexOf('.')); + break; + } + if (SERVER["settings"]["files"][fileType].hasOwnProperty(fileID) == true) { + var data = SERVER["settings"]["files"][fileType][fileID]; + return data[key]; + } + return; +} +function setXmltvChannel(id, element) { + var xmltv = new XMLTVFile(); + var xmlFile = element.value; + var tvgId = SERVER["xepg"]["epgMapping"][id]["tvg-id"]; + var td = document.getElementById("popup-mapping").parentElement; + td.innerHTML = ""; + var select = xmltv.getPrograms(element.value, tvgId); + select.setAttribute("name", "x-mapping"); + select.setAttribute("id", "popup-mapping"); + markChangedOnChange(select, function (element) { checkXmltvChannel(id, element, xmlFile); }); + select.className = "changed"; + sortSelect(select); + td.appendChild(select); + checkXmltvChannel(id, select, xmlFile); +} +function checkXmltvChannel(id, element, xmlFile) { + var value = element.value; + var bool; + var checkbox = document.getElementById('active'); + var channel = SERVER["xepg"]["epgMapping"][id]; + var updateLogo; + if (value == "-") { + bool = false; + } + else { + bool = true; + } + checkbox.checked = bool; + checkbox.className = "changed"; + console.log(xmlFile); + // Kanallogo aktualisieren + /* + updateLogo = (document.getElementById("update-icon") as HTMLInputElement).checked + console.log(updateLogo); + */ + if (xmlFile != "xTeVe Dummy" && bool == true) { + //(document.getElementById("update-icon") as HTMLInputElement).checked = true; + //(document.getElementById("update-icon") as HTMLInputElement).className = "changed"; + console.log("ID", id); + changeChannelLogo(id); + return; + } + if (xmlFile == "xTeVe Dummy") { + document.getElementById("update-icon").checked = false; + document.getElementById("update-icon").className = "changed"; + } + return; +} +function changeChannelLogo(id) { + var updateLogo; + var channel = SERVER["xepg"]["epgMapping"][id]; + var f = document.getElementById("popup-xmltv"); + var xmltvFile = f.options[f.selectedIndex].value; + var m = document.getElementById("popup-mapping"); + var xMapping = m.options[m.selectedIndex].value; + var xmltvLogo = SERVER["xepg"]["xmltvMap"][xmltvFile][xMapping]["icon"]; + updateLogo = document.getElementById("update-icon").checked; + if (updateLogo == true && xmltvFile != "xTeVe Dummy") { + if (SERVER["xepg"]["xmltvMap"][xmltvFile].hasOwnProperty(xMapping)) { + var logo = xmltvLogo; + } + else { + logo = channel["tvg-logo"]; + } + var logoInput = document.getElementById("channel-icon"); + logoInput.value = logo; + if (BULK_EDIT == false) { + logoInput.className = "changed"; + } + } +} +function settingsOptions(text, values) { + var options = []; + for (var i = 0; i < text.length; i++) { + options.push({ text: text[i], value: values[i] }); + } + return options; +} +function settingsNumberOptions(from, to) { + var options = []; + for (var i = from; i <= to; i++) { + options.push({ text: i.toString(), value: i.toString() }); + } + return options; +} +const SETTINGS_FIELDS = [ + // Text inputs + { key: "update", title: "Schedule for updating (Playlist, XMLTV, Backup)", type: "text", placeholder: "0000,1000,2000", + description: "Time in 24 hour format (0800 = 8:00 am). More times can be entered comma separated. Leave this field empty if no updates are to be carried out." }, + { key: "backup.path", title: "Location for automatic backups", type: "text", placeholder: "/mnt/data/backup/xteve/", + description: "Before any update of the provider data by the schedule, xTeVe creates a backup. The path for the automatic backups can be changed. xTeVe requires write permission for this folder." }, + { key: "temp.path", title: "Location for the temporary files", type: "text", placeholder: "/tmp/xteve/", + description: "Location for the buffer files." }, + { key: "user.agent", title: "User Agent", type: "text", placeholder: "xTeVe", + description: "User Agent for HTTP requests. For every HTTP connection, this value is used for the user agent. Should only be changed if xTeVe is blocked." }, + { key: "plex.url", title: "Plex Server URL", type: "text", placeholder: "http://plex-host:32400", + description: "Base URL of your Plex server. Example: http://192.168.1.10:32400" }, + // The server sends the mask "********" and treats it coming back as "unchanged". + { key: "plex.token", title: "Plex API Token", type: "password", placeholder: "Plex X-Plex-Token", autocomplete: "off", + description: "Plex token used for authenticated API calls to refresh your DVR guide." }, + { key: "buffer.timeout", title: "Timeout for new client connections", type: "text", placeholder: "100", + description: "The xTeVe buffer waits until new client connections are established. Helpful for fast channel switching. Value in milliseconds." }, + { key: "ffmpeg.path", title: "FFmpeg Binary Path", type: "text", placeholder: "/path/to/ffmpeg", + description: "Path to FFmpeg binary." }, + { key: "ffmpeg.options", title: "FFmpeg Options", type: "text", placeholder: "Leave blank to set default settings", + description: "FFmpeg options.
Only change if you know what you are doing.
Leave blank to set default settings." }, + { key: "vlc.path", title: "VLC / CVLC Binary Path", type: "text", placeholder: "/path/to/cvlc", + description: "Path to VLC / CVLC binary." }, + { key: "vlc.options", title: "VLC / CVLC Options", type: "text", placeholder: "Leave blank to set default settings", + description: "VLC / CVLC options.
Only change if you know what you are doing.
Leave blank to set default settings." }, + { key: "udpxy", title: "UDPxy address", type: "text", placeholder: "host:port", + description: "The address of your UDPxy server. If set, and the channel URLs in the m3u is multicast, xTeVe will rewrite it so that it is accessed via the UDPxy service." }, + // Checkboxes + { key: "authentication.web", title: "WEB Authentication", type: "checkbox", + description: "Access to the web interface only possible with credentials." }, + { key: "authentication.pms", title: "PMS Authentication", type: "checkbox", + description: "Plex requests are only possible with authentication.
Warning!!! After activating this function xTeVe must be delete in the PMS DVR settings and set up again." }, + { key: "authentication.m3u", title: "M3U Authentication", type: "checkbox", + description: "Downloading the xteve.m3u file via an HTTP request is only possible with authentication." }, + { key: "authentication.xml", title: "XML Authentication", type: "checkbox", + description: "Downloading the xteve.xml file via an HTTP request is only possible with authentication" }, + // Only rendered while web authentication is on (see SettingsCategoryItem.createCategory). + { key: "authentication.api", title: "API Authentication", type: "checkbox", + description: "Access to the API interface is only possible with authentication." }, + { key: "files.update", title: "Updates all files at startup", type: "checkbox", + description: "Updates all playlists, tuner and XMLTV files at startup." }, + { key: "cache.images", title: "Image Caching", type: "checkbox", + description: "All images from the XMLTV file are cached, allowing faster rendering of the grid in the client.
Downloading the images may take a while and will be done in the background." }, + { key: "xepg.replace.missing.images", title: "Replace missing program images", type: "checkbox", + description: "If the poster in the XMLTV program is missing, the channel logo will be used." }, + { key: "api", title: "API Interface", type: "checkbox", + description: "Via API interface it is possible to send commands to xTeVe. API documentation is here" }, + { key: "use_plexAPI", title: "Use Plex API Refresh", type: "checkbox", + description: "When enabled, xTeVe calls Plex directly to refresh DVR guide data after lineup or XEPG updates." }, + // Selects + { key: "xepg.missing.epg.mode", title: "Missing EPG Handling", type: "select", + options: settingsOptions(["Strict (deactivate channels)", "Relaxed (keep active / dummy guide)"], ["strict", "relaxed"]), + description: "Strict: channels are deactivated when XMLTV mappings disappear.
Relaxed: channels stay active with last-known mappings and fall back to a dummy guide whenever guide data cannot be resolved." }, + { key: "tuner", title: "Number of Tuners", type: "select", + options: settingsNumberOptions(1, 100), + description: "Number of parallel connections that can be established to the provider.
Available for: Plex, Emby (HDHR), M3U (with active buffer).
After a change, xTeVe must be delete in the Plex / Emby DVR settings and set up again." }, + { key: "epgSource", title: "EPG Source", type: "select", + options: settingsOptions(["PMS", "XEPG"], ["PMS", "XEPG"]), + description: "PMS:
- Use EPG data from Plex or Emby

XEPG:
- Use of one or more XMLTV files
- Channel management
- M3U / XMLTV export (HTTP link for IPTV apps)" }, + { key: "backup.keep", title: "Number of backups to keep", type: "select", + options: settingsOptions(["5", "10", "20", "30", "40", "50"], ["5", "10", "20", "30", "40", "50"]), + description: "Number of backups to keep. Older backups are automatically deleted." }, + { key: "buffer.size.kb", title: "Buffer Size", type: "select", + options: settingsOptions(["0.5 MB", "1 MB", "2 MB", "3 MB", "4 MB", "5 MB", "6 MB", "7 MB", "8 MB"], ["512", "1024", "2048", "3072", "4096", "5120", "6144", "7168", "8192"]), + description: "Buffer size in MB.
M3U8: If the TS segment smaller then the buffer size, the file size of the segment is used." }, + { key: "buffer", title: "Stream Buffer", type: "select", + options: settingsOptions(["No Buffer (Client connects to the streaming server)", "xTeVe: (xTeVe connects to the streaming server)", "FFmpeg: (FFmpeg connects to the streaming server)", "VLC: (VLC connects to the streaming server)"], ["-", "xteve", "ffmpeg", "vlc"]), + description: "Functions of the buffer:
- The stream is passed from xTeVe, FFmpeg or VLC to Plex, Emby or M3U Player
- Small jerking of the streams can be compensated
- HLS / M3U8 support
- RTP / RTPS support (only FFmpeg or VLC)
- Re-streaming
- Separate tuner limit for each playlist" }, +]; +function getSettingsField(settingsKey) { + for (var i = 0; i < SETTINGS_FIELDS.length; i++) { + if (SETTINGS_FIELDS[i].key == settingsKey) { + return SETTINGS_FIELDS[i]; + } + } + return undefined; +} class SettingsCategory { constructor() { this.DocumentID = "content_settings"; @@ -2212,429 +2376,59 @@ class SettingsCategory { var element = document.createElement("HR"); return element; } - createSettings(settingsKey) { - var setting = document.createElement("TR"); + // createControl builds the input for one field, filled with the current value. + createControl(field, data) { var content = new PopupContent(); - var data = SERVER["settings"][settingsKey]; - switch (settingsKey) { - // Texteingaben - case "update": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Schedule for updating (Playlist, XMLTV, Backup)" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "update", data.toString()); - input.setAttribute("placeholder", "0000,1000,2000"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); + var control; + switch (field.type) { + case "checkbox": + control = content.createCheckbox(field.key); + control.checked = data; break; - case "backup.path": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Location for automatic backups" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "backup.path", data); - input.setAttribute("placeholder", "/mnt/data/backup/xteve/"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); + case "select": + var text = field.options.map(function (option) { return option.text; }); + var values = field.options.map(function (option) { return option.value; }); + control = content.createSelect(text, values, data, field.key); break; - case "temp.path": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Location for the temporary files" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "temp.path", data); - input.setAttribute("placeholder", "/tmp/xteve/"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "user.agent": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "User Agent" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "user.agent", data); - input.setAttribute("placeholder", "xTeVe"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "plex.url": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Plex Server URL" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "plex.url", data); - input.setAttribute("placeholder", "http://plex-host:32400"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "plex.token": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Plex API Token" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("password", "plex.token", data); - input.setAttribute("placeholder", "Plex X-Plex-Token"); - input.setAttribute("autocomplete", "off"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "buffer.timeout": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Timeout for new client connections" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "buffer.timeout", data); - input.setAttribute("placeholder", "100"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "ffmpeg.path": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "FFmpeg Binary Path" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "ffmpeg.path", data); - input.setAttribute("placeholder", "/path/to/ffmpeg"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "ffmpeg.options": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "FFmpeg Options" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "ffmpeg.options", data); - input.setAttribute("placeholder", "Leave blank to set default settings"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "vlc.path": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "VLC / CVLC Binary Path" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "vlc.path", data); - input.setAttribute("placeholder", "/path/to/cvlc"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "vlc.options": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "VLC / CVLC Options" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "vlc.options", data); - input.setAttribute("placeholder", "Leave blank to set default settings"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - // Checkboxen - case "authentication.web": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "WEB Authentication" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "authentication.pms": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "PMS Authentication" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "authentication.m3u": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "M3U Authentication" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "authentication.xml": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "XML Authentication" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "authentication.api": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "API Authentication" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "files.update": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Updates all files at startup" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "cache.images": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Image Caching" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "xepg.replace.missing.images": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Replace missing program images" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "api": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "API Interface" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "use_plexAPI": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Use Plex API Refresh" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - // Select - case "xepg.missing.epg.mode": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Missing EPG Handling" + ":"; - var tdRight = document.createElement("TD"); - var text = ["Strict (deactivate channels)", "Relaxed (keep active / dummy guide)"]; - var values = ["strict", "relaxed"]; - var select = content.createSelect(text, values, data, settingsKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(select); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "tuner": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Number of Tuners" + ":"; - var tdRight = document.createElement("TD"); - var text = new Array(); - var values = new Array(); - for (var i = 1; i <= 100; i++) { - text.push(i); - values.push(i); + default: + // "text", "password", "number": lists (the update schedule) render comma separated. + var value = data; + if (Array.isArray(data)) { + value = data.join(","); + } + control = content.createInput(field.type, field.key, value); + if (field.placeholder != undefined) { + control.setAttribute("placeholder", field.placeholder); + } + if (field.autocomplete != undefined) { + control.setAttribute("autocomplete", field.autocomplete); } - var select = content.createSelect(text, values, data, settingsKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(select); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "epgSource": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "EPG Source" + ":"; - var tdRight = document.createElement("TD"); - var text = ["PMS", "XEPG"]; - var values = ["PMS", "XEPG"]; - var select = content.createSelect(text, values, data, settingsKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(select); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "backup.keep": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Number of backups to keep" + ":"; - var tdRight = document.createElement("TD"); - var text = ["5", "10", "20", "30", "40", "50"]; - var values = ["5", "10", "20", "30", "40", "50"]; - var select = content.createSelect(text, values, data, settingsKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(select); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "buffer.size.kb": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Buffer Size" + ":"; - var tdRight = document.createElement("TD"); - var text = ["0.5 MB", "1 MB", "2 MB", "3 MB", "4 MB", "5 MB", "6 MB", "7 MB", "8 MB"]; - var values = ["512", "1024", "2048", "3072", "4096", "5120", "6144", "7168", "8192"]; - var select = content.createSelect(text, values, data, settingsKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(select); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "buffer": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Stream Buffer" + ":"; - var tdRight = document.createElement("TD"); - var text = ["No Buffer (Client connects to the streaming server)", "xTeVe: (xTeVe connects to the streaming server)", "FFmpeg: (FFmpeg connects to the streaming server)", "VLC: (VLC connects to the streaming server)"]; - var values = ["-", "xteve", "ffmpeg", "vlc"]; - var select = content.createSelect(text, values, data, settingsKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(select); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "udpxy": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "UDPxy address" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "udpxy", data); - input.setAttribute("placeholder", "host:port"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); break; } + markChangedOnChange(control); + return control; + } + createSettings(settingsKey) { + var setting = document.createElement("TR"); + var field = getSettingsField(settingsKey); + if (field == undefined) { + return setting; + } + var data = SERVER["settings"][settingsKey]; + var tdLeft = document.createElement("TD"); + tdLeft.textContent = field.title + ":"; + var tdRight = document.createElement("TD"); + tdRight.appendChild(this.createControl(field, data)); + setting.appendChild(tdLeft); + setting.appendChild(tdRight); return setting; } createDescription(settingsKey) { var description = document.createElement("TR"); - var text; - switch (settingsKey) { - case "authentication.web": - text = "Access to the web interface only possible with credentials."; - break; - case "authentication.m3u": - text = "Downloading the xteve.m3u file via an HTTP request is only possible with authentication."; - break; - case "authentication.pms": - text = "Plex requests are only possible with authentication.
Warning!!! After activating this function xTeVe must be delete in the PMS DVR settings and set up again."; - break; - case "authentication.xml": - text = "Downloading the xteve.xml file via an HTTP request is only possible with authentication"; - break; - case "authentication.api": - if (SERVER["settings"]["authentication.web"] == true) { - text = "Access to the API interface is only possible with authentication."; - } - break; - case "backup.keep": - text = "Number of backups to keep. Older backups are automatically deleted."; - break; - case "backup.path": - text = "Before any update of the provider data by the schedule, xTeVe creates a backup. The path for the automatic backups can be changed. xTeVe requires write permission for this folder."; - break; - case "temp.path": - text = "Location for the buffer files."; - break; - case "buffer": - text = "Functions of the buffer:
- The stream is passed from xTeVe, FFmpeg or VLC to Plex, Emby or M3U Player
- Small jerking of the streams can be compensated
- HLS / M3U8 support
- RTP / RTPS support (only FFmpeg or VLC)
- Re-streaming
- Separate tuner limit for each playlist"; - break; - case "buffer.size.kb": - text = "Buffer size in MB.
M3U8: If the TS segment smaller then the buffer size, the file size of the segment is used."; - break; - case "buffer.timeout": - text = "The xTeVe buffer waits until new client connections are established. Helpful for fast channel switching. Value in milliseconds."; - break; - case "user.agent": - text = "User Agent for HTTP requests. For every HTTP connection, this value is used for the user agent. Should only be changed if xTeVe is blocked."; - break; - case "plex.url": - text = "Base URL of your Plex server. Example: http://192.168.1.10:32400"; - break; - case "plex.token": - text = "Plex token used for authenticated API calls to refresh your DVR guide."; - break; - case "ffmpeg.path": - text = "Path to FFmpeg binary."; - break; - case "ffmpeg.options": - text = "FFmpeg options.
Only change if you know what you are doing.
Leave blank to set default settings."; - break; - case "vlc.path": - text = "Path to VLC / CVLC binary."; - break; - case "vlc.options": - text = "VLC / CVLC options.
Only change if you know what you are doing.
Leave blank to set default settings."; - break; - case "epgSource": - text = "PMS:
- Use EPG data from Plex or Emby

XEPG:
- Use of one or more XMLTV files
- Channel management
- M3U / XMLTV export (HTTP link for IPTV apps)"; - break; - case "tuner": - text = "Number of parallel connections that can be established to the provider.
Available for: Plex, Emby (HDHR), M3U (with active buffer).
After a change, xTeVe must be delete in the Plex / Emby DVR settings and set up again."; - break; - case "update": - text = "Time in 24 hour format (0800 = 8:00 am). More times can be entered comma separated. Leave this field empty if no updates are to be carried out."; - break; - case "api": - text = "Via API interface it is possible to send commands to xTeVe. API documentation is here"; - break; - case "use_plexAPI": - text = "When enabled, xTeVe calls Plex directly to refresh DVR guide data after lineup or XEPG updates."; - break; - case "files.update": - text = "Updates all playlists, tuner and XMLTV files at startup."; - break; - case "cache.images": - text = "All images from the XMLTV file are cached, allowing faster rendering of the grid in the client.
Downloading the images may take a while and will be done in the background."; - break; - case "xepg.replace.missing.images": - text = "If the poster in the XMLTV program is missing, the channel logo will be used."; - break; - case "xepg.missing.epg.mode": - text = "Strict: channels are deactivated when XMLTV mappings disappear.
Relaxed: channels stay active with last-known mappings and fall back to a dummy guide whenever guide data cannot be resolved."; - break; - case "udpxy": - text = "The address of your UDPxy server. If set, and the channel URLs in the m3u is multicast, xTeVe will rewrite it so that it is accessed via the UDPxy service."; - break; - default: - text = ""; - break; + var field = getSettingsField(settingsKey); + var text = ""; + if (field != undefined) { + text = field.description; } var tdLeft = document.createElement("TD"); tdLeft.innerHTML = ""; @@ -2706,6 +2500,7 @@ function saveSettings() { newSettings[name] = value; break; case "text": + case "password": name = settings[i].name; value = settings[i].value; switch (name) { @@ -2837,6 +2632,31 @@ function showElement(elmID, type) { } element.className = cssClass; } +// announce writes a message into the ARIA live region so assistive +// technology hears it; the region is created when the page has none. +function announce(message) { + if (document.body == null) { + return; + } + var region = document.getElementById("announcer"); + if (region == null) { + region = document.getElementById("sr-announcer"); + } + if (region == null) { + region = document.createElement("DIV"); + region.id = "announcer"; + region.className = "sr-only"; + region.setAttribute("aria-live", "polite"); + region.setAttribute("aria-atomic", "true"); + document.body.appendChild(region); + } + region.textContent = message; +} +// alertUser shows the browser alert and mirrors it into the live region. +function alertUser(message) { + announce(message); + alert(message); +} function setConnectionState(state, text = "") { var label = text; if (label == undefined || label.length == 0) { @@ -2862,9 +2682,17 @@ function setConnectionState(state, text = "") { indicator.className = "status-" + state; indicator.innerText = label; } -function changeButtonAction(element, buttonID, attribute) { - var value = element.options[element.selectedIndex].value; - document.getElementById(buttonID).setAttribute(attribute, value); +// markChangedOnChange flags a control as edited on "change" so that +// saveSettings / donePopupData pick it up, then runs an optional follow-up. +// Callers build many controls through one reused `var`, so the listener must +// close over this parameter and not over the caller's variable. +function markChangedOnChange(element, then) { + element.addEventListener("change", function () { + element.className = "changed"; + if (then != undefined) { + then(element); + } + }); } function getLocalData(dataType, id) { var data = new Object(); @@ -3151,7 +2979,7 @@ function changeChannelNumber(element) { var data = SERVER["xepg"]["epgMapping"]; var channels = getObjKeys(data); if (isNaN(newNumber)) { - alert("Invalid channel number"); + alertUser("Invalid channel number"); return; } channels.forEach(id => { @@ -3208,7 +3036,7 @@ function toggleChannelStatus(id) { case true: if (channel["x-xmltv-file"] == "-" || channel["x-mapping"] == "-") { if (BULK_EDIT == false) { - alert(channel["x-name"] + ": Missing XMLTV file / channel"); + alertUser(channel["x-name"] + ": Missing XMLTV file / channel"); checkbox.checked = false; } channel["x-active"] = false; @@ -3255,7 +3083,7 @@ function restore() { }; } else { - alert("File could not be loaded"); + alertUser("File could not be loaded"); } restore.remove(); return; @@ -3297,7 +3125,7 @@ function uploadLogo() { }; } else { - alert("File could not be loaded"); + alertUser("File could not be loaded"); } upload.remove(); return; @@ -3445,7 +3273,7 @@ function saveWizard() { value = config[i].value; if (value.length == 0) { var msg = name.toUpperCase() + ": " + "Missing input"; - alert(msg); + alertUser(msg); return; } wizard[name] = value; diff --git a/package-lock.json b/package-lock.json index 5c5bd4f..ee8ce86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,9 +6,466 @@ "": { "name": "xteve-webui", "devDependencies": { + "jsdom": "30.1.1", "typescript": "5.9.3" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz", + "integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.0", + "@csstools/css-color-parser": "^4.2.3", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz", + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz", + "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz", + "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz", + "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tldts": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz", + "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.15" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz", + "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -22,6 +479,81 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 7d97cfd..20af33b 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,11 @@ "description": "Build tooling for the xTeVe web interface (TypeScript -> html/js/app.js)", "scripts": { "build": "tsc -p ts/tsconfig.json", - "check": "tsc -p ts/tsconfig.json --noEmit" + "check": "tsc -p ts/tsconfig.json --noEmit", + "test": "node --test \"tests/**/*.test.js\"" }, "devDependencies": { + "jsdom": "30.1.1", "typescript": "5.9.3" } } diff --git a/tasks/todo.md b/tasks/todo.md index c4cfd17..173f2a3 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,7 +1,7 @@ # xTeVe improvement checklist Detailed rationale, file references and effort estimates: `tasks/improvement-plan.md`. -Status: Phases 0, 1, 2, 3 done on branch `improvements` (2026-09-26); Phase 5 in progress. +Status: all phases done on branch `improvements` (2026-09-26). Open: the `agent.md` design-system reference (user decision) and two items kept by design (Host-derived URLs, no user roles). ## Phase 0: Hygiene - [x] `.gitignore` (`.gocache/`), deleted `.gocache/`, extended `.dockerignore` @@ -59,18 +59,18 @@ Not planned (lineup is ~170 channels, decided 2026-09-25). See plan for the refe ## Phase 5: Frontend architecture - [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 -- [ ] Accessibility follow-ups; light theme via `prefers-color-scheme` -- [ ] Consolidate CSS layers +- [x] `createLayout()` rebuilds the menu list only when the set of visible items changes; client info and status cards refresh in place (jsdom test keeps the same nodes and focus) +- [~] Virtualised mapping table skipped: ~170 channels render instantly, and sorting/search operate on DOM rows, so virtualisation would add risk for no gain (same reasoning as dropping Phase 4) +- [x] `menu_ts.ts` split into `menu.ts`, `content.ts`, `popup.ts`, `xmltv.ts` (all `_ts` suffixes dropped); all 94 string `on*` handlers replaced by `addEventListener`; settings rows generated from `SETTINGS_FIELDS`; password fields now saved (Plex token); 27 jsdom tests under `tests/` run by `npm test` in CI +- [x] Live-region announcements alongside alerts, `lang`, single h1, `
` landmark; light theme via `prefers-color-scheme` with `data-theme` override (contrast measured). `` kept because every button style keys on it +- [x] CSS split into tokens / base / components / layout, every colour a token, `screen.css` removed ## Phase 6: Optional features -- [ ] Regex filters -- [ ] Per-playlist tuner limit and buffer choice -- [ ] Bulk channel edit -- [ ] Versioned backup/restore -- [ ] Dummy EPG durations +- [x] Regex filters: `regex-filter` type (validated on save, cached compile, matches name, then group, then raw line) with a popup form and table label; tested in Go and jsdom +- [x] Per-playlist tuner limit already existed. Per-playlist buffer: `buffer` select in the playlist popup (default / none / xTeVe / FFmpeg / VLC) backed by `bufferModeFor`; tuner field stays editable when the playlist's own buffer is active +- [x] Already present in the fork (bulk edit with shift-range selection); nothing to add +- [x] Already present: restore checks the backup's version against `System.Compatibility`; writes are now atomic (Phase 2) +- [x] Already present: `xTeVe Dummy` offers several durations in the mapping dropdown ## Review diff --git a/tests/app.test.js b/tests/app.test.js new file mode 100644 index 0000000..a425ee1 --- /dev/null +++ b/tests/app.test.js @@ -0,0 +1,218 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const vm = require("vm"); + +const {loadApp, readBundle} = require("./harness"); +const {makeServer, M3U_ID} = require("./fixture"); + +function menuKeys(document) { + return Array.from(document.querySelectorAll("#main-menu li")).map(li => li.getAttribute("data-menu")); +} + +function clickMenu(document, key) { + const item = document.querySelector('#main-menu li[data-menu="' + key + '"]'); + assert.ok(item, "menu item " + key + " is rendered"); + item.click(); + return item; +} + +test("bundle evaluates without throwing", () => { + const app = loadApp(); + assert.equal(typeof app.window.createLayout, "function"); + assert.equal(typeof app.window.PageReady, "function"); + assert.equal(app.evalIn("typeof Server"), "function"); + assert.equal(app.evalIn("typeof WSClient"), "function"); + assert.equal(app.evalIn("menuItems.length"), 8); +}); + +test("bundle evaluates against a bare window without a document", () => { + const context = vm.createContext({ + window: {}, + console, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + WebSocket: function() {}, + }); + context.window = context; + assert.doesNotThrow(() => vm.runInContext(readBundle(), context, {filename: "app.js"})); +}); + +test("createLayout hides users and logout while web authentication is off", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + + const keys = menuKeys(app.document); + assert.deepEqual(keys, ["playlist", "filter", "xmltv", "mapping", "settings", "log"]); + assert.equal(app.document.getElementById("version").textContent, "2.5.0"); + assert.equal(app.document.querySelectorAll("#status-cards .status-card").length, 6); +}); + +test("createLayout shows users and logout once web authentication is on", () => { + const app = loadApp({server: makeServer({settings: {"authentication.web": true}})}); + app.window.createLayout(); + + const keys = menuKeys(app.document); + assert.ok(keys.includes("users"), "users item present"); + assert.ok(keys.includes("logout"), "logout item present"); + assert.equal(keys.length, 8); +}); + +test("createLayout hides mapping and xmltv unless the EPG source is XEPG", () => { + const app = loadApp({server: makeServer({clientInfo: {epgSource: "PMS"}, settings: {epgSource: "PMS"}})}); + app.window.createLayout(); + + const keys = menuKeys(app.document); + assert.ok(!keys.includes("mapping"), "mapping hidden"); + assert.ok(!keys.includes("xmltv"), "xmltv hidden"); + assert.ok(keys.includes("playlist"), "playlist still present"); +}); + +test("createLayout opens the first menu item when nothing is active", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + + const active = app.document.querySelector("#main-menu li.menu-active"); + assert.ok(active, "an item is marked active"); + assert.equal(active.getAttribute("data-menu"), "playlist"); + assert.equal(app.document.body.getAttribute("data-active-menu"), "playlist"); + assert.equal(app.document.querySelectorAll("#content_table tr").length, 2, "header plus one playlist row"); +}); + +test("mapping menu renders one row per channel with channel names as text", () => { + const server = makeServer(); + const app = loadApp({server}); + app.window.createLayout(); + clickMenu(app.document, "mapping"); + + const table = app.document.getElementById("content_table"); + assert.ok(table, "mapping table rendered"); + + const channelCount = Object.keys(server.xepg.epgMapping).length; + const rows = Array.from(table.querySelectorAll("tr")).slice(1); + assert.equal(rows.length, channelCount); + + const ids = rows.map(tr => tr.id).sort(); + assert.deepEqual(ids, Object.keys(server.xepg.epgMapping).sort()); + + const hostile = app.document.getElementById(M3U_ID + "-2"); + assert.ok(hostile, "row for the hostile channel name exists"); + assert.equal(hostile.querySelector("b"), null, "channel name is not parsed as markup"); + const names = rows.map(tr => tr.children[3].textContent); + assert.ok(names.includes("x"), "channel name appears literally"); + assert.ok(names.includes("News 24")); + + const playlists = rows.map(tr => tr.children[4].textContent); + assert.ok(playlists.every(p => p === "Provider A"), "playlist name resolved from the file id"); + + const guides = rows.map(tr => tr.children[6].textContent).sort(); + assert.deepEqual(guides, ["-", "Guide A", "Guide A", "Guide A", "xTeVe Dummy"]); + + const inactive = app.document.getElementById(M3U_ID + "-5"); + assert.equal(inactive.className, "notActiveEPG"); + assert.equal(app.document.getElementById(M3U_ID + "-1").className, "activeEPG"); + + const edit = hostile.querySelector(".mapping-edit-button"); + assert.ok(edit, "edit button rendered"); + assert.equal(edit.getAttribute("aria-label"), "Edit x"); +}); + +test("mapping edit button opens the popup for its channel", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "mapping"); + + const edit = app.document.getElementById(M3U_ID + "-3").querySelector(".mapping-edit-button"); + edit.click(); + + const popup = app.document.getElementById("popup-custom"); + assert.equal(popup.querySelector("h3").textContent, "MAPPING"); + assert.equal(popup.querySelector('input[name="x-name"]').value, "Sports HD"); + assert.equal(popup.querySelector('select[name="x-xmltv-file"]').value, "X-1-def.xml"); + assert.equal(popup.querySelector('select[name="x-mapping"]').value, "ch3.example"); +}); + +function expectedSettingsKeys(app, webAuth) { + const all = app.evalIn("settingsCategory.map(function(c) { return c.settingsKeys }).join(',')").split(","); + if (webAuth) { + return all; + } + return all.filter(key => !["authentication.pms", "authentication.m3u", "authentication.xml", "authentication.api"].includes(key)); +} + +function assertSettingsControls(app, keys) { + const panel = app.document.getElementById("content_settings"); + assert.ok(panel, "settings panel rendered"); + + const controls = Array.from(panel.querySelectorAll("input[name], select[name]")); + assert.equal(controls.length, keys.length, "one control per settings key"); + + for (const key of keys) { + const matches = controls.filter(c => c.getAttribute("name") === key); + assert.equal(matches.length, 1, "exactly one control for " + key); + } + return controls; +} + +test("settings panel renders one input per settings key", () => { + const server = makeServer(); + const app = loadApp({server}); + app.window.createLayout(); + clickMenu(app.document, "settings"); + + const keys = expectedSettingsKeys(app, false); + const controls = assertSettingsControls(app, keys); + const byName = Object.fromEntries(controls.map(c => [c.getAttribute("name"), c])); + + assert.equal(byName["tuner"].tagName, "SELECT"); + assert.equal(byName["tuner"].value, "1"); + assert.equal(byName["buffer"].value, "ffmpeg"); + assert.equal(byName["epgSource"].value, "XEPG"); + assert.equal(byName["plex.token"].type, "password"); + assert.equal(byName["plex.token"].value, "********"); + assert.equal(byName["update"].value, "0000,1200"); + assert.equal(byName["files.update"].type, "checkbox"); + assert.equal(byName["files.update"].checked, true); + assert.equal(byName["cache.images"].checked, false); + assert.equal(byName["authentication.web"].checked, false); +}); + +test("settings panel includes the per-service authentication keys when web auth is on", () => { + const app = loadApp({server: makeServer({settings: {"authentication.web": true, "authentication.api": true}})}); + app.window.createLayout(); + clickMenu(app.document, "settings"); + + const keys = expectedSettingsKeys(app, true); + const controls = assertSettingsControls(app, keys); + const api = controls.find(c => c.getAttribute("name") === "authentication.api"); + assert.equal(api.checked, true); +}); + +test("saveSettings sends only the changed controls", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "settings"); + + const tuner = app.document.querySelector('#content_settings select[name="tuner"]'); + tuner.value = "4"; + tuner.dispatchEvent(new app.window.Event("change", {bubbles: true})); + + const agent = app.document.querySelector('#content_settings input[name="user.agent"]'); + agent.value = "Custom"; + agent.dispatchEvent(new app.window.Event("change", {bubbles: true})); + + app.window.saveSettings(); + + const socket = app.sockets[app.sockets.length - 1]; + assert.ok(socket, "a websocket was opened"); + socket.readyState = 1; + socket.onopen(); + + assert.equal(socket.sent.length, 1); + const payload = JSON.parse(socket.sent[0]); + assert.equal(payload.cmd, "saveSettings"); + assert.deepEqual(payload.settings, {"tuner": 4, "user.agent": "Custom"}); +}); diff --git a/tests/fixture.js b/tests/fixture.js new file mode 100644 index 0000000..c561b55 --- /dev/null +++ b/tests/fixture.js @@ -0,0 +1,162 @@ +"use strict"; + +// A SERVER payload shaped like the answer to getServerConfig. Every call +// returns a fresh deep copy so tests can mutate it freely. + +const M3U_ID = "M-1-abc"; +const XMLTV_ID = "X-1-def"; +const XMLTV_FILE = XMLTV_ID + ".xml"; + +function channel(number, name, extra) { + return Object.assign({ + "_file.m3u.id": M3U_ID, + "_file.m3u.name": "Provider A", + "_file.m3u.path": "/config/data/" + M3U_ID + ".m3u", + "_uuid.key": "CUID", + "_uuid.value": "cuid-" + number, + "group-title": "News", + "name": name, + "tvg-id": "", + "tvg-logo": "http://logos.example/" + number + ".png", + "tvg-name": name, + "url": "http://stream.example/" + number, + "x-active": true, + "x-category": "", + "x-channelID": String(number), + "x-description": "", + "x-group-title": "News", + "x-mapping": "ch" + number + ".example", + "x-name": name, + "x-update-channel-icon": true, + "x-update-channel-name": true, + "x-xmltv-file": XMLTV_FILE, + }, extra || {}); +} + +function makeServer(overrides) { + const server = { + clientInfo: { + arch: "darwin/arm64", + os: "darwin", + version: "2.5.0", + uuid: "xteve-test-uuid", + DVR: "192.168.1.10:34400", + "m3u-url": "http://192.168.1.10:34400/m3u/xteve.m3u", + "xepg-url": "http://192.168.1.10:34400/xmltv/xteve.xml", + streams: "5", + epgSource: "XEPG", + xepg: "4", + errors: "0", + warnings: "1", + }, + settings: { + "api": false, + "authentication.api": false, + "authentication.m3u": false, + "authentication.pms": false, + "authentication.web": false, + "authentication.xml": false, + "backup.keep": 10, + "backup.path": "/config/backup/", + "buffer": "ffmpeg", + "buffer.size.kb": 1024, + "buffer.timeout": 500, + "cache.images": false, + "epgSource": "XEPG", + "ffmpeg.options": "-hide_banner -loglevel error -i [URL] -c copy -f mpegts pipe:1", + "ffmpeg.path": "/usr/bin/ffmpeg", + "files.update": true, + "plex.token": "********", + "plex.url": "http://plex.example:32400", + "temp.path": "/tmp/xteve/", + "tuner": 1, + "udpxy": "", + "update": ["0000", "1200"], + "use_plexAPI": false, + "user.agent": "xTeVe", + "vlc.options": "--intf dummy --no-audio --no-video --sout '#standard{access=file,mux=ts,dst=-}' [URL]", + "vlc.path": "/usr/bin/cvlc", + "xepg.missing.epg.mode": "strict", + "xepg.replace.missing.images": true, + files: { + m3u: { + [M3U_ID]: { + "compatibility": {"group.title": 100, "stream.id": 100, "streams": 5, "tvg.id": 80}, + "counter.download": 3, + "counter.error": 0, + "description": "Main playlist", + "file.source": "http://provider.example/list.m3u", + "file.xteve": M3U_ID + ".m3u", + "last.update": "2026-09-01 10:00:00", + "name": "Provider A", + "provider.availability": 100, + "tuner": 2, + "type": "m3u", + }, + }, + hdhr: {}, + xmltv: { + [XMLTV_ID]: { + "compatibility": {"xmltv.channels": 40, "xmltv.programs": 12000}, + "counter.download": 3, + "counter.error": 0, + "description": "Main guide", + "file.source": "http://provider.example/guide.xml", + "file.xteve": XMLTV_FILE, + "last.update": "2026-09-01 10:05:00", + "name": "Guide A", + "provider.availability": 100, + "type": "xmltv", + }, + }, + }, + filter: {}, + }, + xepg: { + epgMapping: { + [M3U_ID + "-1"]: channel(1, "News 24"), + [M3U_ID + "-2"]: channel(2, "x"), + [M3U_ID + "-3"]: channel(3, "Sports HD", {"x-group-title": "Sport", "group-title": "Sport", "x-category": "Sports"}), + [M3U_ID + "-4"]: channel(4, "Kids TV", {"x-xmltv-file": "xTeVe Dummy", "x-mapping": "30_Minutes"}), + [M3U_ID + "-5"]: channel(5, "Unmapped", {"x-active": false, "x-xmltv-file": "-", "x-mapping": "-"}), + }, + xmltvMap: { + [XMLTV_FILE]: { + "ch1.example": {id: "ch1.example", "display-name": "News 24", icon: "http://logos.example/1.png"}, + "ch2.example": {id: "ch2.example", "display-name": "Channel Two", icon: ""}, + "ch3.example": {id: "ch3.example", "display-name": "Sports HD", icon: "http://logos.example/3.png"}, + }, + "xTeVe Dummy": { + "30_Minutes": {id: "30_Minutes", "display-name": "30 Minutes", icon: ""}, + "60_Minutes": {id: "60_Minutes", "display-name": "60 Minutes", icon: ""}, + }, + }, + }, + data: { + StreamPreviewUI: {activeStreams: ["News 24", "Sports HD"], inactiveStreams: ["Unmapped"]}, + playlist: {m3u: {groups: {text: ["News (2)", "Sport (1)"], value: ["News", "Sport"]}}}, + }, + users: {}, + log: {log: {}}, + status: true, + }; + + const copy = JSON.parse(JSON.stringify(server)); + if (overrides) { + deepMerge(copy, overrides); + } + return copy; +} + +function deepMerge(target, source) { + for (const key of Object.keys(source)) { + const value = source[key]; + if (value && typeof value === "object" && !Array.isArray(value) && target[key] && typeof target[key] === "object") { + deepMerge(target[key], value); + } else { + target[key] = value; + } + } +} + +module.exports = {makeServer, M3U_ID, XMLTV_ID, XMLTV_FILE}; diff --git a/tests/handlers.test.js b/tests/handlers.test.js new file mode 100644 index 0000000..68bf0a4 --- /dev/null +++ b/tests/handlers.test.js @@ -0,0 +1,226 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const {loadApp} = require("./harness"); +const {makeServer, M3U_ID, XMLTV_ID} = require("./fixture"); + +function clickMenu(document, key) { + const item = document.querySelector('#main-menu li[data-menu="' + key + '"]'); + assert.ok(item, "menu item " + key + " is rendered"); + item.click(); + return item; +} + +function inlineHandlerAttributes(root) { + const found = []; + for (const element of root.querySelectorAll("*")) { + for (const attr of element.attributes) { + if (/^on/i.test(attr.name)) { + found.push(element.tagName + "@" + attr.name); + } + } + } + return found; +} + +function change(window, element) { + element.dispatchEvent(new window.Event("change", {bubbles: true})); +} + +test("rendered pages carry no inline event handler attributes", () => { + const app = loadApp({server: makeServer({settings: {"authentication.web": true}})}); + app.window.createLayout(); + + for (const key of ["playlist", "filter", "xmltv", "mapping", "users", "settings", "log"]) { + clickMenu(app.document, key); + assert.deepEqual(inlineHandlerAttributes(app.document.body), [], "no inline handlers after opening " + key); + } + + clickMenu(app.document, "playlist"); + app.document.getElementById(M3U_ID).click(); + assert.deepEqual(inlineHandlerAttributes(app.document.getElementById("popup-custom")), [], "no inline handlers in the m3u popup"); +}); + +test("menu items open their content through listeners", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + + clickMenu(app.document, "xmltv"); + const rows = app.document.querySelectorAll("#content_table tr"); + assert.equal(rows.length, 2); + assert.equal(rows[1].id, XMLTV_ID); + assert.equal(app.document.body.getAttribute("data-active-menu"), "xmltv"); +}); + +test("playlist rows open the edit popup and the New button walks the type chooser", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "playlist"); + + app.document.getElementById(M3U_ID).click(); + const popup = app.document.getElementById("popup-custom"); + assert.equal(popup.querySelector("h3").textContent, "M3U"); + assert.equal(popup.querySelector('input[name="name"]').value, "Provider A"); + assert.equal(popup.querySelector('select[name="tuner"]').value, "2"); + assert.ok(popup.querySelector('input[name="delete"]'), "existing playlist offers delete"); + + app.document.querySelector('#content-interaction input[name="playlist"]').click(); + assert.equal(popup.querySelector("h3").textContent, "PLAYLIST TYPE"); + const type = popup.querySelector("select#type"); + assert.deepEqual(Array.from(type.options).map(o => o.value), ["m3u", "hdhr"]); + + type.value = "hdhr"; + change(app.window, type); + app.document.getElementById("next").click(); + assert.equal(popup.querySelector("h3").textContent, "HDHR"); + assert.equal(popup.querySelector('input[name="back"]') != null, true, "new entry offers back instead of delete"); + assert.equal(popup.querySelector('input[name="delete"]'), null); + + popup.querySelector('input[name="back"]').click(); + assert.equal(popup.querySelector("h3").textContent, "PLAYLIST TYPE"); + app.document.getElementById("next").click(); + assert.equal(popup.querySelector("h3").textContent, "M3U"); + + popup.querySelector('input[name="cancel"]').click(); + assert.equal(app.document.getElementById("popup").className, "none"); +}); + +test("filter New button offers group-title and custom filters", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "filter"); + + app.document.querySelector('#content-interaction input[name="filter"]').click(); + const popup = app.document.getElementById("popup-custom"); + assert.equal(popup.querySelector("h3").textContent, "FILTER"); + const type = popup.querySelector('select[name="type"]'); + assert.equal(type.value, "group-title"); + + type.value = "custom-filter"; + change(app.window, type); + app.document.getElementById("next").click(); + assert.equal(popup.querySelector("h3").textContent, "CUSTOM"); + assert.equal(popup.querySelector('input[name="type"]').value, "custom-filter"); + assert.ok(popup.querySelector('input[name="filter"]'), "custom filter rule input"); +}); + +test("mapping popup marks edited controls and applies them on Done", () => { + const server = makeServer(); + const app = loadApp({server}); + app.window.createLayout(); + clickMenu(app.document, "mapping"); + + const id = M3U_ID + "-1"; + app.document.getElementById(id).querySelector(".mapping-edit-button").click(); + const popup = app.document.getElementById("popup-custom"); + + const name = popup.querySelector('input[name="x-name"]'); + assert.equal(name.className, ""); + name.value = "Renamed"; + change(app.window, name); + assert.equal(name.className, "changed"); + + const group = popup.querySelector('input[name="x-group-title"]'); + group.value = "Docs"; + change(app.window, group); + + popup.querySelector('input[name="save"]').click(); + + const row = app.document.getElementById(id); + assert.equal(row.children[3].textContent, "Renamed"); + assert.equal(row.children[5].textContent, "Docs"); + assert.equal(server.xepg.epgMapping[id]["x-name"], "Renamed"); + assert.equal(server.xepg.epgMapping[id]["x-group-title"], "Docs"); + assert.equal(app.document.getElementById("popup").className, "none"); +}); + +test("changing the XMLTV file in the mapping popup rebuilds the channel list", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "mapping"); + + app.document.getElementById(M3U_ID + "-1").querySelector(".mapping-edit-button").click(); + const popup = app.document.getElementById("popup-custom"); + + const file = popup.querySelector("#popup-xmltv"); + file.value = "xTeVe Dummy"; + change(app.window, file); + + const mapping = popup.querySelector("#popup-mapping"); + const values = Array.from(mapping.options).map(o => o.value); + assert.ok(values.includes("30_Minutes"), "dummy programs listed"); + assert.equal(mapping.className, "changed"); + assert.equal(popup.querySelector("#update-icon").className, "changed"); + + mapping.value = "60_Minutes"; + change(app.window, mapping); + assert.equal(popup.querySelector("#active").checked, true); +}); + +test("mapping table header sorts by channel name on click", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "mapping"); + + const header = app.document.querySelector("#content_table tr"); + const nameHeader = header.children[3]; + assert.equal(nameHeader.className, "pointer"); + nameHeader.click(); + + const names = Array.from(app.document.querySelectorAll("#content_table tr")).slice(1).map(tr => tr.children[3].textContent); + assert.deepEqual(names, names.slice().sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)); +}); + +test("bulk header checkbox selects every visible channel", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "mapping"); + app.document.querySelector('#content-interaction input[value="Bulk Edit"]').click(); + + const head = app.document.querySelector("#content_table tr input.bulk"); + head.click(); + + const boxes = Array.from(app.document.querySelectorAll("#content_table tr")).slice(1).map(tr => tr.querySelector("input.bulk")); + assert.equal(boxes.length, 5); + assert.ok(boxes.every(b => b.checked), "every row checkbox is ticked"); + assert.ok(boxes.every(b => b.className == "bulk showBulk")); +}); + +test("filter New button offers a regular-expression filter", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "filter"); + + app.document.querySelector('#content-interaction input[name="filter"]').click(); + const popup = app.document.getElementById("popup-custom"); + const type = popup.querySelector('select[name="type"]'); + const values = Array.from(type.options).map((o) => o.value); + assert.deepEqual(values, ["group-title", "custom-filter", "regex-filter"]); + + type.value = "regex-filter"; + change(app.window, type); + app.document.getElementById("next").click(); + assert.equal(popup.querySelector("h3").textContent, "REGULAR EXPRESSION"); + assert.equal(popup.querySelector('input[name="type"]').value, "regex-filter"); + assert.ok(popup.querySelector('input[name="filter"]'), "regex rule input"); + assert.ok(popup.querySelector('input[name="caseSensitive"]'), "case sensitive checkbox"); +}); + +test("playlist popup offers a per-playlist buffer choice and keeps the tuner editable", () => { + const server = makeServer(); + const app = loadApp({server}); + app.window.createLayout(); + clickMenu(app.document, "playlist"); + + const row = app.document.querySelector("#content_table tbody tr, #content_table tr[id]"); + assert.ok(row, "a playlist row"); + row.click(); + const popup = app.document.getElementById("popup-custom"); + const buffer = popup.querySelector('select[name="buffer"]'); + assert.ok(buffer, "buffer select"); + assert.deepEqual(Array.from(buffer.options).map((o) => o.value), ["default", "-", "xteve", "ffmpeg", "vlc"]); + assert.equal(buffer.value, "default"); + assert.ok(popup.querySelector('select[name="tuner"]'), "tuner select shown while a buffer is active"); +}); diff --git a/tests/harness.js b/tests/harness.js new file mode 100644 index 0000000..7dd2643 --- /dev/null +++ b/tests/harness.js @@ -0,0 +1,127 @@ +"use strict"; + +// Loads the compiled bundle (html/js/app.js) into a jsdom window so the +// global-script UI code can be driven from Node's test runner. + +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const {JSDOM, VirtualConsole} = require("jsdom"); + +const BUNDLE_PATH = path.join(__dirname, "..", "html", "js", "app.js"); + +// A trimmed copy of index.html: only the ids the bundle looks up. +const SHELL_HTML = ` + + xTeVe + +
+ +
+
+ +
+
+ +

Connecting...

+
+ + + + + +
+
+
+
+
+
+
+

+
+
+ +`; + +function readBundle() { + return fs.readFileSync(BUNDLE_PATH, "utf8"); +} + +class FakeWebSocket { + constructor(url) { + this.url = url; + this.readyState = FakeWebSocket.CONNECTING; + this.sent = []; + this.onopen = null; + this.onmessage = null; + this.onerror = null; + this.onclose = null; + FakeWebSocket.instances.push(this); + } + send(data) { + this.sent.push(data); + } + close() { + this.readyState = FakeWebSocket.CLOSED; + } +} +FakeWebSocket.CONNECTING = 0; +FakeWebSocket.OPEN = 1; +FakeWebSocket.CLOSING = 2; +FakeWebSocket.CLOSED = 3; +FakeWebSocket.instances = []; + +// loadApp evaluates the bundle in a fresh window. Options: +// server - object assigned to the global SERVER after the bundle runs +// html - alternative document markup +function loadApp(options) { + options = options || {}; + + // The UI logs freely; keep that out of the test output but keep jsdom's + // own errors (unimplemented APIs, uncaught handler exceptions) visible. + const jsdomErrors = []; + const virtualConsole = new VirtualConsole(); + virtualConsole.on("jsdomError", err => { jsdomErrors.push(err); }); + + const dom = new JSDOM(options.html || SHELL_HTML, { + url: "http://localhost:34400/", + runScripts: "dangerously", + pretendToBeVisual: true, + virtualConsole, + }); + const window = dom.window; + + // jsdom does not implement innerText; the UI uses it as a textContent alias. + Object.defineProperty(window.HTMLElement.prototype, "innerText", { + configurable: true, + get() { return this.textContent; }, + set(value) { this.textContent = value; }, + }); + + const alerts = []; + const confirms = []; + window.alert = function(message) { alerts.push(message); }; + window.confirm = function(message) { confirms.push(message); return true; }; + + FakeWebSocket.instances = []; + window.WebSocket = FakeWebSocket; + + const context = dom.getInternalVMContext(); + vm.runInContext(readBundle(), context, {filename: "app.js"}); + + // evalIn runs code inside the window, where top-level class declarations + // from the bundle are visible (they are lexical bindings, not window props). + function evalIn(code) { + return vm.runInContext(code, context); + } + + if (options.server) { + window.SERVER = options.server; + } + + return {dom, window, document: window.document, evalIn, alerts, confirms, jsdomErrors, sockets: FakeWebSocket.instances}; +} + +module.exports = {loadApp, readBundle, SHELL_HTML, BUNDLE_PATH}; diff --git a/tests/layout.test.js b/tests/layout.test.js new file mode 100644 index 0000000..d7f73ec --- /dev/null +++ b/tests/layout.test.js @@ -0,0 +1,116 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const {loadApp, SHELL_HTML} = require("./harness"); +const {makeServer} = require("./fixture"); + +function menuKeys(document) { + return Array.from(document.querySelectorAll("#main-menu li")).map(li => li.getAttribute("data-menu")); +} + +test("createLayout keeps the menu nodes when the visible items are unchanged", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + + const before = Array.from(app.document.querySelectorAll("#main-menu li")); + const focused = before[2]; + focused.setAttribute("tabindex", "0"); + focused.focus(); + assert.equal(app.document.activeElement, focused); + + app.window.SERVER = makeServer({clientInfo: {streams: "9", errors: "3"}}); + app.window.createLayout(); + + const after = Array.from(app.document.querySelectorAll("#main-menu li")); + assert.equal(after.length, before.length); + for (let i = 0; i < before.length; i++) { + assert.equal(after[i], before[i], "menu node " + i + " is the same object"); + } + assert.equal(app.document.activeElement, focused, "focus survives the refresh"); + + assert.equal(app.document.getElementById("streams").textContent, "9", "client info refreshed"); + const errorCard = Array.from(app.document.querySelectorAll("#status-cards .status-card")).find(c => c.textContent.startsWith("Errors")); + assert.ok(errorCard.className.includes("status-card-error"), "status cards refreshed"); +}); + +test("createLayout rebuilds the menu when the visible items change", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + assert.deepEqual(menuKeys(app.document), ["playlist", "filter", "xmltv", "mapping", "settings", "log"]); + + const playlist = app.document.querySelector('#main-menu li[data-menu="playlist"]'); + + app.window.SERVER = makeServer({settings: {"authentication.web": true}}); + app.window.createLayout(); + assert.deepEqual(menuKeys(app.document), ["playlist", "filter", "xmltv", "mapping", "users", "settings", "log", "logout"]); + assert.notEqual(app.document.querySelector('#main-menu li[data-menu="playlist"]'), playlist, "list was rebuilt"); + + const active = app.document.querySelector("#main-menu li.menu-active"); + assert.ok(active, "active item restored after rebuild"); + assert.equal(active.getAttribute("data-menu"), "playlist"); + + app.window.SERVER = makeServer({clientInfo: {epgSource: "PMS"}}); + app.window.createLayout(); + assert.deepEqual(menuKeys(app.document), ["playlist", "filter", "settings", "log"]); +}); + +test("createLayout keeps the active menu when another item is selected", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + app.document.querySelector('#main-menu li[data-menu="mapping"]').click(); + assert.equal(app.document.body.getAttribute("data-active-menu"), "mapping"); + + app.window.SERVER = makeServer(); + app.window.createLayout(); + assert.equal(app.document.body.getAttribute("data-active-menu"), "mapping"); + assert.equal(app.document.querySelector("#main-menu li.menu-active").getAttribute("data-menu"), "mapping"); + assert.equal(app.document.querySelectorAll("#content_table tr").length, 6, "mapping content left in place"); +}); + +test("alertUser mirrors the message into the existing live region", () => { + const app = loadApp({server: makeServer()}); + app.window.alertUser("Something went wrong"); + + assert.deepEqual(app.alerts, ["Something went wrong"]); + assert.equal(app.document.getElementById("sr-announcer").textContent, "Something went wrong"); + assert.equal(app.document.getElementById("announcer"), null, "no second region created"); +}); + +test("announce creates a polite live region when the page has none", () => { + const html = SHELL_HTML.replace(/

]*><\/p>/, ""); + const app = loadApp({server: makeServer(), html}); + assert.equal(app.document.getElementById("sr-announcer"), null); + + app.window.announce("Saved"); + + const region = app.document.getElementById("announcer"); + assert.ok(region, "region created"); + assert.equal(region.getAttribute("aria-live"), "polite"); + assert.equal(region.className, "sr-only"); + assert.equal(region.textContent, "Saved"); + + app.window.announce("Again"); + assert.equal(app.document.querySelectorAll("#announcer").length, 1); + assert.equal(region.textContent, "Again"); +}); + +test("server error responses alert and announce", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + + app.evalIn('new Server("saveSettings").request({settings: {}})'); + const socket = app.sockets[app.sockets.length - 1]; + socket.readyState = 1; + socket.onopen(); + const sent = JSON.parse(socket.sent[0]); + + socket.onmessage({data: JSON.stringify({id: sent.id, status: false, err: "Setting rejected"})}); + + return new Promise(resolve => setImmediate(resolve)).then(() => { + assert.deepEqual(app.alerts, ["Setting rejected"]); + assert.equal(app.document.getElementById("sr-announcer").textContent, "Setting rejected"); + assert.equal(app.document.getElementById("connection-indicator").className, "status-offline"); + }); +}); diff --git a/ts/base.ts b/ts/base.ts index 293b9b1..58c1bec 100644 --- a/ts/base.ts +++ b/ts/base.ts @@ -60,6 +60,35 @@ function showElement(elmID, type) { element.className = cssClass; } +// announce writes a message into the ARIA live region so assistive +// technology hears it; the region is created when the page has none. +function announce(message:string) { + if (document.body == null) { + return + } + + var region = document.getElementById("announcer") + if (region == null) { + region = document.getElementById("sr-announcer") + } + if (region == null) { + region = document.createElement("DIV") + region.id = "announcer" + region.className = "sr-only" + region.setAttribute("aria-live", "polite") + region.setAttribute("aria-atomic", "true") + document.body.appendChild(region) + } + + region.textContent = message +} + +// alertUser shows the browser alert and mirrors it into the live region. +function alertUser(message:string) { + announce(message) + alert(message) +} + function setConnectionState(state:string, text:string = "") { var label:string = text @@ -92,9 +121,17 @@ function setConnectionState(state:string, text:string = "") { indicator.innerText = label } -function changeButtonAction(element, buttonID, attribute) { - var value = element.options[element.selectedIndex].value; - document.getElementById(buttonID).setAttribute(attribute, value) +// markChangedOnChange flags a control as edited on "change" so that +// saveSettings / donePopupData pick it up, then runs an optional follow-up. +// Callers build many controls through one reused `var`, so the listener must +// close over this parameter and not over the caller's variable. +function markChangedOnChange(element:HTMLElement, then?:(element:HTMLElement) => void) { + element.addEventListener("change", function() { + element.className = "changed" + if (then != undefined) { + then(element) + } + }) } function getLocalData(dataType, id):object { @@ -501,7 +538,7 @@ function changeChannelNumber(element) { var channels = getObjKeys(data) if (isNaN(newNumber)) { - alert("Invalid channel number") + alertUser("Invalid channel number") return } @@ -584,7 +621,7 @@ function toggleChannelStatus(id:string) { if (channel["x-xmltv-file"] == "-" || channel["x-mapping"] == "-") { if (BULK_EDIT == false) { - alert(channel["x-name"] + ": Missing XMLTV file / channel") + alertUser(channel["x-name"] + ": Missing XMLTV file / channel") checkbox.checked = false } @@ -649,7 +686,7 @@ function restore() { }; } else { - alert("File could not be loaded") + alertUser("File could not be loaded") } restore.remove() @@ -707,7 +744,7 @@ function uploadLogo() { }; } else { - alert("File could not be loaded") + alertUser("File could not be loaded") } upload.remove() diff --git a/ts/configuration.ts b/ts/configuration.ts index 687c295..2229def 100644 --- a/ts/configuration.ts +++ b/ts/configuration.ts @@ -144,7 +144,7 @@ function saveWizard() { if (value.length == 0) { var msg = name.toUpperCase() + ": " + "Missing input" - alert(msg) + alertUser(msg) return } diff --git a/ts/content.ts b/ts/content.ts new file mode 100644 index 0000000..b6347b0 --- /dev/null +++ b/ts/content.ts @@ -0,0 +1,764 @@ +class Content { + + DocumentID:string = "content" + TableID:string = "content_table" + DivID:string + headerClass:string = "content_table_header" + interactionID:string = "content-interaction" + + createHeadline(value):any { + var element = document.createElement("H3") + element.textContent = value + return element + } + + createHR():any { + var element = document.createElement("HR") + return element + } + + createInteraction():any { + var element = document.createElement("DIV") + element.setAttribute("id", this.interactionID) + return element + } + + createDIV():any { + var element = document.createElement("DIV") + element.id = this.DivID + return element + } + + createTABLE():any { + var element = document.createElement("TABLE") + element.id = this.TableID + return element + } + + createTableRow():any { + var element = document.createElement("TR") + element.className = this.headerClass + return element + } + + createTableContent(menuKey:string):string[] { + + var data = new Object() + var rows = new Array() + + switch(menuKey) { + case "playlist": + var fileTypes = new Array("m3u", "hdhr") + + fileTypes.forEach(fileType => { + + data = SERVER["settings"]["files"][fileType] + + var keys = getObjKeys(data) + + keys.forEach(key => { + var tr = document.createElement("TR") + tr.id = key + + tr.addEventListener("click", function() { openPopUp(fileType, tr) }) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["name"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + if (SERVER["settings"]["buffer"] != "-") { + cell.value = data[key]["tuner"] + } else { + cell.value = "-" + } + + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["last.update"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["provider.availability"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["type"].toUpperCase(); + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["compatibility"]["streams"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["compatibility"]["group.title"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["compatibility"]["tvg.id"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["compatibility"]["stream.id"] + tr.appendChild(cell.createCell()) + + rows.push(tr) + }); + + }); + break + + case "filter": + delete SERVER["settings"]["filter"][-1] + data = SERVER["settings"]["filter"] + var keys = getObjKeys(data) + keys.forEach(key => { + var tr = document.createElement("TR") + tr.id = key + + tr.addEventListener("click", function() { openPopUp(data[key]["type"], tr) }) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["name"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + switch (data[key]["type"]) { + case "custom-filter": + cell.value = "Custom" + break; + + case "group-title": + cell.value = "Group" + break; + + case "regex-filter": + cell.value = "Regex" + break; + + default: + break; + } + + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["filter"] + tr.appendChild(cell.createCell()) + + rows.push(tr) + + }); + break + + case "xmltv": + var fileTypes = new Array("xmltv") + + fileTypes.forEach(fileType => { + + data = SERVER["settings"]["files"][fileType] + + var keys = getObjKeys(data) + + keys.forEach(key => { + var tr = document.createElement("TR") + + tr.id = key + tr.addEventListener("click", function() { openPopUp(fileType, tr) }) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["name"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["last.update"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["provider.availability"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["compatibility"]["xmltv.channels"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["compatibility"]["xmltv.programs"] + tr.appendChild(cell.createCell()) + + rows.push(tr) + }); + + }); + break + + case "users": + var fileTypes = new Array("users") + + fileTypes.forEach(fileType => { + data = SERVER[fileType] + + var keys = getObjKeys(data) + + keys.forEach(key => { + var tr = document.createElement("TR") + tr.id = key + tr.addEventListener("click", function() { openPopUp(fileType, tr) }) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["data"]["username"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = "******" + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + if (data[key]["data"]["authentication.web"] == true) { + cell.value = "✓" + } else { + cell.value = "-" + } + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + if (data[key]["data"]["authentication.pms"] == true) { + cell.value = "✓" + } else { + cell.value = "-" + } + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + if (data[key]["data"]["authentication.m3u"] == true) { + cell.value = "✓" + } else { + cell.value = "-" + } + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + if (data[key]["data"]["authentication.xml"] == true) { + cell.value = "✓" + } else { + cell.value = "-" + } + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + if (data[key]["data"]["authentication.api"] == true) { + cell.value = "✓" + } else { + cell.value = "-" + } + tr.appendChild(cell.createCell()) + + rows.push(tr) + }); + + }); + break + + case "mapping": + BULK_EDIT = false + createSearchObj() + checkUndo("epgMapping") + console.log("MAPPING") + data = SERVER["xepg"]["epgMapping"] + + var keys = getObjKeys(data) + keys.forEach(key => { + var tr = document.createElement("TR") + tr.id = key + + + switch (data[key]["x-active"]) { + case true: + tr.className = "activeEPG" + break; + + case false: + tr.className = "notActiveEPG" + break; + } + + // Bulk + var cell:Cell = new Cell() + cell.child = true + cell.childType = "BULK" + cell.value = false + tr.appendChild(cell.createCell()) + + // Kanalnummer + var cell:Cell = new Cell() + cell.child = true + cell.childType = "INPUTCHANNEL" + cell.value = data[key]["x-channelID"] + tr.appendChild(cell.createCell()) + + // Logo + var cell:Cell = new Cell() + cell.child = true + cell.childType = "IMG" + cell.imageURL = data[key]["tvg-logo"] + tr.appendChild(cell.createCell()) + + // Kanalname + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.className = data[key]["x-category"] + cell.value = data[key]["x-name"] + tr.appendChild(cell.createCell()) + + + // Playlist + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + //cell.value = data[key]["_file.m3u.name"] + cell.value = getValueFromProviderFile(data[key]["_file.m3u.id"], "m3u", "name") + tr.appendChild(cell.createCell()) + + + // Gruppe (group-title) + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["x-group-title"] + tr.appendChild(cell.createCell()) + + // XMLTV Datei + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + + if (data[key]["x-xmltv-file"] != "-") { + cell.value = getValueFromProviderFile(data[key]["x-xmltv-file"], "xmltv", "name") + } else { + cell.value = data[key]["x-xmltv-file"] + } + + tr.appendChild(cell.createCell()) + + // XMLTV Kanal + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + //var value = str.substring(1, 4); + var value = data[key]["x-mapping"] + if (value.length > 20) { + value = data[key]["x-mapping"].substring(0, 20) + "..." + } + cell.value = value + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "EDIT" + cell.value = "Edit" + var editTd = cell.createCell() + var editButton = (editTd.firstChild as HTMLInputElement) + editButton.addEventListener("click", function() { openPopUp("mapping", editButton) }) + editButton.setAttribute("id", key) + editButton.setAttribute("aria-label", "Edit " + data[key]["x-name"]) + tr.appendChild(editTd) + + rows.push(tr) + }); + + break + + case "settings": + alert() + break + + default: + console.log("Table content (menuKey):", menuKey); + + break + + } + + return rows + + } + + return +} + +class Cell { + child:Boolean + childType:string + value:any + className:string + tdClassName:string + imageURL:string + onclick:boolean + onclickHandler:() => void + + createCell():any { + var td = document.createElement("TD") + + + if (this.child == true) { + var element:any + + switch(this.childType){ + case "P": + element = document.createElement(this.childType); + element.textContent = this.value + element.className = this.className + break + + case "INPUT": + element = document.createElement(this.childType); + (element as HTMLInputElement).value = this.value; + (element as HTMLInputElement).type = "text"; + break + + case "INPUTCHANNEL": + element = document.createElement("INPUT"); + (element as HTMLInputElement).addEventListener("change", function() { changeChannelNumber(element) }); + (element as HTMLInputElement).value = this.value; + (element as HTMLInputElement).type = "text"; + break + + case "BULK": + element = document.createElement("INPUT"); + (element as HTMLInputElement).checked = this.value; + (element as HTMLInputElement).type = "checkbox"; + (element as HTMLInputElement).className = "bulk hideBulk"; + (element as HTMLInputElement).addEventListener("click", function(event) { + scheduleChannelRangeSelection((element as HTMLInputElement), (event as MouseEvent)) + }) + break + + case "BULK_HEAD": + element = document.createElement("INPUT"); + (element as HTMLInputElement).checked = this.value; + (element as HTMLInputElement).type = "checkbox"; + (element as HTMLInputElement).className = "bulk hideBulk"; + (element as HTMLInputElement).addEventListener("click", function() { selectAllChannels() }) + break + + case "IMG": + element = document.createElement(this.childType); + element.setAttribute("src", this.imageURL) + if (this.imageURL != "") { + // A broken logo falls back to no image; once, so the empty src cannot loop. + element.addEventListener("error", function() { element.setAttribute("src", "") }, {once: true}) + } + break + + case "EDIT": + element = document.createElement("INPUT"); + (element as HTMLInputElement).type = "button"; + (element as HTMLInputElement).value = this.value; + (element as HTMLInputElement).className = "mapping-edit-button"; + break + } + + td.appendChild(element) + + } else { + td.textContent = this.value + } + + if (this.onclick == true) { + td.addEventListener("click", this.onclickHandler) + td.className = "pointer" + } + + if (this.tdClassName != undefined) { + td.className = this.tdClassName + } + + return td + } + + return +} + +class ShowContent extends Content { + menuID:number + + constructor(menuID:number) { + super() + this.menuID = menuID + } + + createInput(type:string, name:string, value:string,):any { + + var input = document.createElement("INPUT") + input.setAttribute("type", type) + input.setAttribute("name", name) + input.setAttribute("value", value) + return input + } + + show():void { + COLUMN_TO_SORT = -1 + // Alten Inhalt löschen + var doc = document.getElementById(this.DocumentID) + doc.innerHTML = "" + showPreview(false) + + // Überschrift + var headline:string[] = menuItems[this.menuID].headline + + var menuKey = menuItems[this.menuID].menuKey + var h = this.createHeadline(headline) + doc.appendChild(h) + + var hr = this.createHR() + doc.appendChild(hr) + + // Interaktion + var div =this.createInteraction() + doc.appendChild(div) + var interaction = document.getElementById(this.interactionID) + switch (menuKey) { + case "playlist": + var input = this.createInput("button", menuKey, "New") + input.setAttribute("id", "-") + input.addEventListener("click", function() { openPopUp("playlist") }) + interaction.appendChild(input) + break; + + case "filter": + var input = this.createInput("button", menuKey, "New") + input.setAttribute("id", -1) + input.addEventListener("click", function(event) { openPopUp("filter", event.currentTarget) }) + interaction.appendChild(input) + break; + + + case "xmltv": + var input = this.createInput("button", menuKey, "New") + input.setAttribute("id", "xmltv") + input.addEventListener("click", function() { openPopUp("xmltv") }) + interaction.appendChild(input) + break; + + case "users": + var input = this.createInput("button", menuKey, "New") + input.setAttribute("id", "users") + input.addEventListener("click", function() { openPopUp("users") }) + interaction.appendChild(input) + break; + + case "mapping": + showElement("loading", true) + var input = this.createInput("button", menuKey, "Save") + input.addEventListener("click", function() { savePopupData("mapping", "", false, 0) }) + interaction.appendChild(input) + + var input = this.createInput("button", menuKey, "Bulk Edit") + input.addEventListener("click", function() { bulkEdit() }) + interaction.appendChild(input) + + var input = this.createInput("search", "search", "") + input.setAttribute("id", "searchMapping") + input.setAttribute("placeholder", "Search") + input.className = "search" + input.addEventListener("input", function() { searchInMapping() }) + interaction.appendChild(input) + break; + + case "settings": + var input = this.createInput("button", menuKey, "Save") + input.addEventListener("click", function() { saveSettings() }) + interaction.appendChild(input) + + var input = this.createInput("button", menuKey, "Backup") + input.addEventListener("click", function() { backup() }) + interaction.appendChild(input) + + var input = this.createInput("button", menuKey, "Restore") + input.addEventListener("click", function() { restore() }) + interaction.appendChild(input) + + var wrapper = document.createElement("DIV") + wrapper.setAttribute("id", "box-wrapper") + doc.appendChild(wrapper) + + this.DivID = "content_settings" + var settings = this.createDIV() + wrapper.appendChild(settings) + + showSettings() + + return + break + + case "log": + var input = this.createInput("button", menuKey, "Reset Logs") + input.addEventListener("click", function() { resetLogs() }) + interaction.appendChild(input) + + var wrapper = document.createElement("DIV") + wrapper.setAttribute("id", "box-wrapper") + doc.appendChild(wrapper) + + this.DivID = "content_log" + var logs = this.createDIV() + wrapper.appendChild(logs) + + showLogs(true) + + return + break + + case "logout": + location.reload() + document.cookie = "Token= ; expires = Thu, 01 Jan 1970 00:00:00 GMT" + break + + default: + console.log("Show content (menuKey):", menuKey); + break; + } + + // Tabelle erstellen (falls benötigt) + var tableHeader:string[] = menuItems[this.menuID].tableHeader + if (tableHeader.length > 0) { + var wrapper = document.createElement("DIV") + doc.appendChild(wrapper) + wrapper.setAttribute("id", "box-wrapper") + + var table = this.createTABLE() + wrapper.appendChild(table) + + var header = this.createTableRow() + table.appendChild(header) + + // Kopfzeile der Tablle + tableHeader.forEach(element => { + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = element + if (element == "BULK") { + cell.childType = "BULK_HEAD"; + cell.value = false + } + + if (menuKey == "mapping") { + + if (element == "Ch. No.") { + cell.onclick = true + cell.onclickHandler = function() { sortTable(1) } + cell.tdClassName = "sortThis" + } + + if (element == "Channel Name") { + cell.onclick = true + cell.onclickHandler = function() { sortTable(3) } + } + + if (element == "Playlist") { + cell.onclick = true + cell.onclickHandler = function() { sortTable(4) } + } + + if (element == "Group Title") { + cell.onclick = true + cell.onclickHandler = function() { sortTable(5) } + } + + } + + header.appendChild(cell.createCell()) + }); + + table.appendChild(header) + + // Inhalt der Tabelle + var rows:any = this.createTableContent(menuKey) + rows.forEach(tr => { + table.appendChild(tr) + }); + + } + + switch (menuKey) { + case "mapping": + sortTable(1) + break; + + case "filter": + showPreview(true) + sortTable(0) + break + + default: + COLUMN_TO_SORT = -1 + sortTable(0) + break; + } + + showElement("loading", false) + } + +} + +function openThisMenu(element) { + var id = element.id + var content:ShowContent = new ShowContent(id) + setActiveMenu(id) + content.show() + closeLayoutMenuIfMobile() + calculateWrapperHeight() + + return +} diff --git a/ts/menu.ts b/ts/menu.ts new file mode 100644 index 0000000..26d2435 --- /dev/null +++ b/ts/menu.ts @@ -0,0 +1,414 @@ +class MainMenu { + DocumentID:string = "main-menu" + HTMLTag:string = "LI" + ImagePath:string = "img/" + + createIMG(src):any { + var element = document.createElement("IMG") + element.setAttribute("src", this.ImagePath + src) + return element + } + + createValue(value):any { + var element = document.createElement("P") + element.textContent = value + return element + } +} + +class MainMenuItem extends MainMenu { + menuKey:string + value:string + imgSrc:string + headline:string + id:string + tableHeader:string[] + + constructor(menuKey:string, value:string, image:string, headline:string) { + super() + this.menuKey = menuKey + this.value = value + this.imgSrc = image + this.headline = headline + } + + createItem():void { + var item = document.createElement("LI") + item.addEventListener("click", function() { openThisMenu(item) }) + item.setAttribute("id", this.id) + item.setAttribute("data-menu", this.menuKey) + var img = this.createIMG(this.imgSrc) + var value = this.createValue(this.value) + + item.appendChild(img) + item.appendChild(value) + + var doc = document.getElementById(this.DocumentID) + doc.appendChild(item) + + switch(this.menuKey) { + case "playlist": + this.tableHeader = ["Playlist", "Tuner", "Last Update", "Availability %", "Type", "Streams", "group-title %", "tvg-id %", "Unique ID %"] + break + + case "xmltv": + this.tableHeader = ["Guide", "Last Update", "Availability %", "Channels", "Programs"] + break + + case "filter": + this.tableHeader = ["Filter Name", "Filter Type", "Filter"] + break + + case "users": + this.tableHeader = ["Username", "Password", "WEB", "PMS", "M3U", "XML", "API"] + break + + case "mapping": + this.tableHeader = ["BULK", "Ch. No.", "Logo", "Channel Name", "Playlist", "Group Title", "XMLTV File", "XMLTV ID", "Edit"] + break + + } + + //console.log(this.menuKey, this.tableHeader); + + } +} + +var SHELL_LAYOUT_READY:boolean = false + +function setLayoutMenuState(open:boolean) { + if (document.body == null) { + return + } + + if (open == true) { + document.body.classList.add("menu-open") + } else { + document.body.classList.remove("menu-open") + } +} + +function toggleLayoutMenu() { + if (document.body == null) { + return + } + + var isOpen:boolean = document.body.classList.contains("menu-open") + setLayoutMenuState(!isOpen) +} + +function closeLayoutMenuIfMobile() { + if (window.innerWidth <= 900) { + setLayoutMenuState(false) + } +} + +function setActiveMenu(menuID:string) { + + ACTIVE_MENU_ID = menuID.toString() + var menu = document.getElementById("main-menu") + if (menu == null) { + return + } + + var items = menu.getElementsByTagName("LI") + for (var i = 0; i < items.length; i++) { + items[i].classList.remove("menu-active") + } + + var activeItem = document.getElementById(ACTIVE_MENU_ID) + var activeMenuKey:string = "" + if (activeItem != null) { + activeItem.classList.add("menu-active") + var menuKeyValue = activeItem.getAttribute("data-menu") + if (menuKeyValue != null) { + activeMenuKey = menuKeyValue + } + } + + if (document.body != null) { + if (activeMenuKey.length > 0) { + document.body.setAttribute("data-active-menu", activeMenuKey) + } else { + document.body.removeAttribute("data-active-menu") + } + + if (activeMenuKey == "log") { + document.body.classList.add("menu-log-focus") + } else { + document.body.classList.remove("menu-log-focus") + } + } +} + +function renderStatusCards() { + var wrapper = document.getElementById("status-cards") + if (wrapper == null || SERVER.hasOwnProperty("clientInfo") == false) { + return + } + + var info = SERVER["clientInfo"] + var errors:number = parseInt(info["errors"], 10) + var warnings:number = parseInt(info["warnings"], 10) + var cards:any[] = [ + {label: "Streams", value: info["streams"], tone: "ok"}, + {label: "EPG Source", value: info["epgSource"], tone: "neutral"}, + {label: "XEPG Channels", value: info["xepg"], tone: "ok"}, + {label: "Errors", value: info["errors"], tone: errors > 0 ? "error" : "ok"}, + {label: "Warnings", value: info["warnings"], tone: warnings > 0 ? "warn" : "ok"}, + {label: "DVR", value: info["DVR"], tone: "neutral"}, + ] + + wrapper.innerHTML = "" + cards.forEach(card => { + + var box = document.createElement("DIV") + box.className = "status-card status-card-" + card.tone + + var label = document.createElement("P") + label.className = "status-card-label" + label.innerText = card.label + + var value = document.createElement("P") + value.className = "status-card-value" + if (card.value == undefined || card.value == "") { + value.innerText = "-" + } else { + value.innerText = card.value + } + + box.appendChild(label) + box.appendChild(value) + wrapper.appendChild(box) + }); + +} + +function initShellLayout() { + + if (SHELL_LAYOUT_READY == true) { + return + } + + var toggle = document.getElementById("menu-toggle") + if (toggle != null) { + toggle.onclick = function() { + toggleLayoutMenu() + } + } + + var overlay = document.getElementById("layout-overlay") + if (overlay != null) { + overlay.onclick = function() { + setLayoutMenuState(false) + } + } + + document.addEventListener("keydown", function(event) { + + if (event.key == "Escape") { + setLayoutMenuState(false) + showElement("popup", false) + return + } + + if (event.key == "/") { + var target = event.target as HTMLElement + var onInput = target.tagName == "INPUT" || target.tagName == "TEXTAREA" || target.tagName == "SELECT" + + if (onInput == true) { + return + } + + var search = document.getElementById("searchMapping") + if (search != null) { + event.preventDefault(); + (search as HTMLInputElement).focus() + } + } + + }); + + setConnectionState("idle") + SHELL_LAYOUT_READY = true +} + +function shouldPollLogs():boolean { + if (document.hidden == true) { + return false + } + + if (document.getElementById("content_log") == null) { + return false + } + + if (ACTIVE_MENU_ID.length < 1) { + return false + } + + var activeItem = document.getElementById(ACTIVE_MENU_ID) + if (activeItem == null) { + return false + } + + return activeItem.getAttribute("data-menu") == "log" +} + +function PageReady() { + + initShellLayout() + + var server:Server = new Server("getServerConfig") + server.request(new Object()) + + var bootstrapAttempts:number = 0 + var maxBootstrapAttempts:number = 5 + var bootstrapTimer:number = window.setInterval(function() { + + if (SERVER.hasOwnProperty("clientInfo") == true) { + window.clearInterval(bootstrapTimer) + return + } + + if (WS.hasQueued("getServerConfig")) { + return + } + + bootstrapAttempts++ + var retryServer:Server = new Server("getServerConfig") + retryServer.request(new Object()) + + if (bootstrapAttempts >= maxBootstrapAttempts) { + window.clearInterval(bootstrapTimer) + } + + }, 3000) + + window.addEventListener("resize", function(){ + if (window.innerWidth > 900) { + setLayoutMenuState(false) + } + calculateWrapperHeight(); + }, true); + + setInterval(function(){ + if (shouldPollLogs() == false) { + return + } + updateLog() + }, 10000); + + + return +} + +function isClientInfoHttpURL(value:string):boolean { + return /^https?:\/\//i.test(value) +} + +function setClientInfoValue(key:string, value:any) { + var element = document.getElementById(key) + if (element == null) { + return + } + + var textValue = "" + if (value != undefined && value != null) { + textValue = String(value) + } + + if ((key == "m3u-url" || key == "xepg-url") && isClientInfoHttpURL(textValue)) { + element.innerHTML = "" + var anchor = document.createElement("a") + anchor.href = textValue + anchor.target = "_blank" + anchor.rel = "noopener noreferrer" + anchor.textContent = textValue + element.appendChild(anchor) + return + } + + element.textContent = textValue +} + +// menuItemVisible reports whether a menu entry applies to the current server state. +function menuItemVisible(item:MainMenuItem):boolean { + switch (item.menuKey) { + + case "users": + case "logout": + return SERVER["settings"]["authentication.web"] == true + + case "mapping": + case "xmltv": + return SERVER["clientInfo"]["epgSource"] == "XEPG" + + default: + return true + } +} + +// renderedMenuKeys lists the menu entries currently in the document, in order. +function renderedMenuKeys(menu:HTMLElement):string[] { + var keys:string[] = [] + var items = menu.getElementsByTagName("LI") + for (var i = 0; i < items.length; i++) { + keys.push(items[i].getAttribute("data-menu")) + } + return keys +} + +function createLayout() { + + // Client Info + var obj = SERVER["clientInfo"] + var keys = getObjKeys(obj); + for (var i = 0; i < keys.length; i++) { + + setClientInfoValue(keys[i], obj[keys[i]]) + + } + renderStatusCards() + + var menu = document.getElementById("main-menu") + if (menu == null) { + return + } + + // Menu: rebuilt only when the set of visible entries changes, so that + // keyboard focus inside the list survives routine server responses. + var visibleKeys:string[] = [] + for (let i = 0; i < menuItems.length; i++) { + menuItems[i].id = i + if (menuItemVisible(menuItems[i])) { + visibleKeys.push(menuItems[i].menuKey) + } + } + + if (visibleKeys.join(",") != renderedMenuKeys(menu).join(",")) { + menu.innerHTML = "" + for (let i = 0; i < menuItems.length; i++) { + if (menuItemVisible(menuItems[i])) { + menuItems[i].createItem() + } + } + } + + if (ACTIVE_MENU_ID.length > 0 && document.getElementById(ACTIVE_MENU_ID)) { + setActiveMenu(ACTIVE_MENU_ID) + } + + var content = document.getElementById("content") + if (ACTIVE_MENU_ID.length == 0 && content != null) { + + if (content.innerHTML.replace(/\\s/g, "").length == 0) { + var firstItem = menu.getElementsByTagName("LI")[0] as HTMLElement + if (firstItem != undefined) { + firstItem.click() + } + } + + } + + return +} diff --git a/ts/menu_ts.ts b/ts/menu_ts.ts deleted file mode 100644 index 012a55d..0000000 --- a/ts/menu_ts.ts +++ /dev/null @@ -1,2477 +0,0 @@ - -class MainMenu { - DocumentID:string = "main-menu" - HTMLTag:string = "LI" - ImagePath:string = "img/" - - createIMG(src):any { - var element = document.createElement("IMG") - element.setAttribute("src", this.ImagePath + src) - return element - } - - createValue(value):any { - var element = document.createElement("P") - element.textContent = value - return element - } -} - -class MainMenuItem extends MainMenu { - menuKey:string - value:string - imgSrc:string - headline:string - id:string - tableHeader:string[] - - constructor(menuKey:string, value:string, image:string, headline:string) { - super() - this.menuKey = menuKey - this.value = value - this.imgSrc = image - this.headline = headline - } - - createItem():void { - var item = document.createElement("LI") - item.setAttribute("onclick", "javascript: openThisMenu(this)") - item.setAttribute("id", this.id) - item.setAttribute("data-menu", this.menuKey) - var img = this.createIMG(this.imgSrc) - var value = this.createValue(this.value) - - item.appendChild(img) - item.appendChild(value) - - var doc = document.getElementById(this.DocumentID) - doc.appendChild(item) - - switch(this.menuKey) { - case "playlist": - this.tableHeader = ["Playlist", "Tuner", "Last Update", "Availability %", "Type", "Streams", "group-title %", "tvg-id %", "Unique ID %"] - break - - case "xmltv": - this.tableHeader = ["Guide", "Last Update", "Availability %", "Channels", "Programs"] - break - - case "filter": - this.tableHeader = ["Filter Name", "Filter Type", "Filter"] - break - - case "users": - this.tableHeader = ["Username", "Password", "WEB", "PMS", "M3U", "XML", "API"] - break - - case "mapping": - this.tableHeader = ["BULK", "Ch. No.", "Logo", "Channel Name", "Playlist", "Group Title", "XMLTV File", "XMLTV ID", "Edit"] - break - - } - - //console.log(this.menuKey, this.tableHeader); - - } -} - -class Content { - - DocumentID:string = "content" - TableID:string = "content_table" - DivID:string - headerClass:string = "content_table_header" - interactionID:string = "content-interaction" - - createHeadline(value):any { - var element = document.createElement("H3") - element.textContent = value - return element - } - - createHR():any { - var element = document.createElement("HR") - return element - } - - createInteraction():any { - var element = document.createElement("DIV") - element.setAttribute("id", this.interactionID) - return element - } - - createDIV():any { - var element = document.createElement("DIV") - element.id = this.DivID - return element - } - - createTABLE():any { - var element = document.createElement("TABLE") - element.id = this.TableID - return element - } - - createTableRow():any { - var element = document.createElement("TR") - element.className = this.headerClass - return element - } - - createTableContent(menuKey:string):string[] { - - var data = new Object() - var rows = new Array() - - switch(menuKey) { - case "playlist": - var fileTypes = new Array("m3u", "hdhr") - - fileTypes.forEach(fileType => { - - data = SERVER["settings"]["files"][fileType] - - var keys = getObjKeys(data) - - keys.forEach(key => { - var tr = document.createElement("TR") - tr.id = key - - tr.setAttribute('onclick', 'javascript: openPopUp("' + fileType + '", this)') - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["name"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - if (SERVER["settings"]["buffer"] != "-") { - cell.value = data[key]["tuner"] - } else { - cell.value = "-" - } - - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["last.update"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["provider.availability"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["type"].toUpperCase(); - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["compatibility"]["streams"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["compatibility"]["group.title"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["compatibility"]["tvg.id"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["compatibility"]["stream.id"] - tr.appendChild(cell.createCell()) - - rows.push(tr) - }); - - }); - break - - case "filter": - delete SERVER["settings"]["filter"][-1] - data = SERVER["settings"]["filter"] - var keys = getObjKeys(data) - keys.forEach(key => { - var tr = document.createElement("TR") - tr.id = key - - tr.setAttribute('onclick', 'javascript: openPopUp("' + data[key]["type"] + '", this)') - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["name"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - switch (data[key]["type"]) { - case "custom-filter": - cell.value = "Custom" - break; - - case "group-title": - cell.value = "Group" - break; - - default: - break; - } - - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["filter"] - tr.appendChild(cell.createCell()) - - rows.push(tr) - - }); - break - - case "xmltv": - var fileTypes = new Array("xmltv") - - fileTypes.forEach(fileType => { - - data = SERVER["settings"]["files"][fileType] - - var keys = getObjKeys(data) - - keys.forEach(key => { - var tr = document.createElement("TR") - - tr.id = key - tr.setAttribute('onclick', 'javascript: openPopUp("' + fileType + '", this)') - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["name"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["last.update"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["provider.availability"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["compatibility"]["xmltv.channels"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["compatibility"]["xmltv.programs"] - tr.appendChild(cell.createCell()) - - rows.push(tr) - }); - - }); - break - - case "users": - var fileTypes = new Array("users") - - fileTypes.forEach(fileType => { - data = SERVER[fileType] - - var keys = getObjKeys(data) - - keys.forEach(key => { - var tr = document.createElement("TR") - tr.id = key - tr.setAttribute('onclick', 'javascript: openPopUp("' + fileType + '", this)') - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["data"]["username"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = "******" - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - if (data[key]["data"]["authentication.web"] == true) { - cell.value = "✓" - } else { - cell.value = "-" - } - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - if (data[key]["data"]["authentication.pms"] == true) { - cell.value = "✓" - } else { - cell.value = "-" - } - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - if (data[key]["data"]["authentication.m3u"] == true) { - cell.value = "✓" - } else { - cell.value = "-" - } - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - if (data[key]["data"]["authentication.xml"] == true) { - cell.value = "✓" - } else { - cell.value = "-" - } - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - if (data[key]["data"]["authentication.api"] == true) { - cell.value = "✓" - } else { - cell.value = "-" - } - tr.appendChild(cell.createCell()) - - rows.push(tr) - }); - - }); - break - - case "mapping": - BULK_EDIT = false - createSearchObj() - checkUndo("epgMapping") - console.log("MAPPING") - data = SERVER["xepg"]["epgMapping"] - - var keys = getObjKeys(data) - keys.forEach(key => { - var tr = document.createElement("TR") - tr.id = key - - //tr.setAttribute('oncontextmenu', 'javascript: rightClick(this)') - - switch (data[key]["x-active"]) { - case true: - tr.className = "activeEPG" - break; - - case false: - tr.className = "notActiveEPG" - break; - } - - // Bulk - var cell:Cell = new Cell() - cell.child = true - cell.childType = "BULK" - cell.value = false - tr.appendChild(cell.createCell()) - - // Kanalnummer - var cell:Cell = new Cell() - cell.child = true - cell.childType = "INPUTCHANNEL" - cell.value = data[key]["x-channelID"] - //td.setAttribute('onclick', 'javascript: changeChannelNumber("' + key + '", this)') - tr.appendChild(cell.createCell()) - - // Logo - var cell:Cell = new Cell() - cell.child = true - cell.childType = "IMG" - cell.imageURL = data[key]["tvg-logo"] - tr.appendChild(cell.createCell()) - - // Kanalname - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.className = data[key]["x-category"] - cell.value = data[key]["x-name"] - tr.appendChild(cell.createCell()) - - - // Playlist - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - //cell.value = data[key]["_file.m3u.name"] - cell.value = getValueFromProviderFile(data[key]["_file.m3u.id"], "m3u", "name") - tr.appendChild(cell.createCell()) - - - // Gruppe (group-title) - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["x-group-title"] - tr.appendChild(cell.createCell()) - - // XMLTV Datei - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - - if (data[key]["x-xmltv-file"] != "-") { - cell.value = getValueFromProviderFile(data[key]["x-xmltv-file"], "xmltv", "name") - } else { - cell.value = data[key]["x-xmltv-file"] - } - - tr.appendChild(cell.createCell()) - - // XMLTV Kanal - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - //var value = str.substring(1, 4); - var value = data[key]["x-mapping"] - if (value.length > 20) { - value = data[key]["x-mapping"].substring(0, 20) + "..." - } - cell.value = value - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "EDIT" - cell.value = "Edit" - var editTd = cell.createCell() - var editButton = (editTd.firstChild as HTMLInputElement) - editButton.setAttribute('onclick', 'javascript: openPopUp("mapping", this)') - editButton.setAttribute("id", key) - editButton.setAttribute("aria-label", "Edit " + data[key]["x-name"]) - tr.appendChild(editTd) - - rows.push(tr) - }); - - break - - case "settings": - alert() - break - - default: - console.log("Table content (menuKey):", menuKey); - - break - - } - - return rows - - } - - return -} - -class Cell { - child:Boolean - childType:string - value:any - className:string - tdClassName:string - imageURL:string - onclick:boolean - onclickFunktion:string - - createCell():any { - var td = document.createElement("TD") - - - if (this.child == true) { - var element:any - - switch(this.childType){ - case "P": - element = document.createElement(this.childType); - element.textContent = this.value - element.className = this.className - break - - case "INPUT": - element = document.createElement(this.childType); - (element as HTMLInputElement).value = this.value; - (element as HTMLInputElement).type = "text"; - break - - case "INPUTCHANNEL": - element = document.createElement("INPUT"); - (element as HTMLInputElement).setAttribute("onchange", "javscript: changeChannelNumber(this)"); - (element as HTMLInputElement).value = this.value; - (element as HTMLInputElement).type = "text"; - break - - case "BULK": - element = document.createElement("INPUT"); - (element as HTMLInputElement).checked = this.value; - (element as HTMLInputElement).type = "checkbox"; - (element as HTMLInputElement).className = "bulk hideBulk"; - (element as HTMLInputElement).addEventListener("click", function(event) { - scheduleChannelRangeSelection((element as HTMLInputElement), (event as MouseEvent)) - }) - break - - case "BULK_HEAD": - element = document.createElement("INPUT"); - (element as HTMLInputElement).checked = this.value; - (element as HTMLInputElement).type = "checkbox"; - (element as HTMLInputElement).className = "bulk hideBulk"; - (element as HTMLInputElement).setAttribute("onclick", "javascript: selectAllChannels()") - break - - case "IMG": - element = document.createElement(this.childType); - element.setAttribute("src", this.imageURL) - if (this.imageURL != "") { - element.setAttribute("onerror", "javascript: this.onerror=null;this.src=''" ) - //onerror="this.onerror=null;this.src='missing.gif';" - } - break - - case "EDIT": - element = document.createElement("INPUT"); - (element as HTMLInputElement).type = "button"; - (element as HTMLInputElement).value = this.value; - (element as HTMLInputElement).className = "mapping-edit-button"; - break - } - - td.appendChild(element) - - } else { - td.textContent = this.value - } - - if (this.onclick == true) { - td.setAttribute("onclick", this.onclickFunktion) - td.className = "pointer" - } - - if (this.tdClassName != undefined) { - td.className = this.tdClassName - } - - return td - } - - return -} - -class ShowContent extends Content { - menuID:number - - constructor(menuID:number) { - super() - this.menuID = menuID - } - - createInput(type:string, name:string, value:string,):any { - - var input = document.createElement("INPUT") - input.setAttribute("type", type) - input.setAttribute("name", name) - input.setAttribute("value", value) - return input - } - - show():void { - COLUMN_TO_SORT = -1 - // Alten Inhalt löschen - var doc = document.getElementById(this.DocumentID) - doc.innerHTML = "" - showPreview(false) - - // Überschrift - var headline:string[] = menuItems[this.menuID].headline - - var menuKey = menuItems[this.menuID].menuKey - var h = this.createHeadline(headline) - doc.appendChild(h) - - var hr = this.createHR() - doc.appendChild(hr) - - // Interaktion - var div =this.createInteraction() - doc.appendChild(div) - var interaction = document.getElementById(this.interactionID) - switch (menuKey) { - case "playlist": - var input = this.createInput("button", menuKey, "New") - input.setAttribute("id", "-") - input.setAttribute("onclick", 'javascript: openPopUp("playlist")') - interaction.appendChild(input) - break; - - case "filter": - var input = this.createInput("button", menuKey, "New") - input.setAttribute("id", -1) - input.setAttribute("onclick", 'javascript: openPopUp("filter", this)') - interaction.appendChild(input) - break; - - - case "xmltv": - var input = this.createInput("button", menuKey, "New") - input.setAttribute("id", "xmltv") - input.setAttribute("onclick", 'javascript: openPopUp("xmltv")') - interaction.appendChild(input) - break; - - case "users": - var input = this.createInput("button", menuKey, "New") - input.setAttribute("id", "users") - input.setAttribute("onclick", 'javascript: openPopUp("users")') - interaction.appendChild(input) - break; - - case "mapping": - showElement("loading", true) - var input = this.createInput("button", menuKey, "Save") - input.setAttribute("onclick", 'javascript: savePopupData("mapping", "", "")') - interaction.appendChild(input) - - var input = this.createInput("button", menuKey, "Bulk Edit") - input.setAttribute("onclick", 'javascript: bulkEdit()') - interaction.appendChild(input) - - var input = this.createInput("search", "search", "") - input.setAttribute("id", "searchMapping") - input.setAttribute("placeholder", "Search") - input.className = "search" - input.setAttribute("oninput", 'javascript: searchInMapping()') - interaction.appendChild(input) - break; - - case "settings": - var input = this.createInput("button", menuKey, "Save") - input.setAttribute("onclick", 'javascript: saveSettings();') - interaction.appendChild(input) - - var input = this.createInput("button", menuKey, "Backup") - input.setAttribute("onclick", 'javascript: backup();') - interaction.appendChild(input) - - var input = this.createInput("button", menuKey, "Restore") - input.setAttribute("onclick", 'javascript: restore();') - interaction.appendChild(input) - - var wrapper = document.createElement("DIV") - wrapper.setAttribute("id", "box-wrapper") - doc.appendChild(wrapper) - - this.DivID = "content_settings" - var settings = this.createDIV() - wrapper.appendChild(settings) - - showSettings() - - return - break - - case "log": - var input = this.createInput("button", menuKey, "Reset Logs") - input.setAttribute("onclick", 'javascript: resetLogs();') - interaction.appendChild(input) - - var wrapper = document.createElement("DIV") - wrapper.setAttribute("id", "box-wrapper") - doc.appendChild(wrapper) - - this.DivID = "content_log" - var logs = this.createDIV() - wrapper.appendChild(logs) - - showLogs(true) - - return - break - - case "logout": - location.reload() - document.cookie = "Token= ; expires = Thu, 01 Jan 1970 00:00:00 GMT" - break - - default: - console.log("Show content (menuKey):", menuKey); - break; - } - - // Tabelle erstellen (falls benötigt) - var tableHeader:string[] = menuItems[this.menuID].tableHeader - if (tableHeader.length > 0) { - var wrapper = document.createElement("DIV") - doc.appendChild(wrapper) - wrapper.setAttribute("id", "box-wrapper") - - var table = this.createTABLE() - wrapper.appendChild(table) - - var header = this.createTableRow() - table.appendChild(header) - - // Kopfzeile der Tablle - tableHeader.forEach(element => { - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = element - if (element == "BULK") { - cell.childType = "BULK_HEAD"; - cell.value = false - } - - if (menuKey == "mapping") { - - if (element == "Ch. No.") { - cell.onclick = true - cell.onclickFunktion = "javascript: sortTable(1);" - cell.tdClassName = "sortThis" - } - - if (element == "Channel Name") { - cell.onclick = true - cell.onclickFunktion = "javascript: sortTable(3);" - } - - if (element == "Playlist") { - cell.onclick = true - cell.onclickFunktion = "javascript: sortTable(4);" - } - - if (element == "Group Title") { - cell.onclick = true - cell.onclickFunktion = "javascript: sortTable(5);" - } - - } - - header.appendChild(cell.createCell()) - }); - - table.appendChild(header) - - // Inhalt der Tabelle - var rows:any = this.createTableContent(menuKey) - rows.forEach(tr => { - table.appendChild(tr) - }); - - } - - switch (menuKey) { - case "mapping": - sortTable(1) - break; - - case "filter": - showPreview(true) - sortTable(0) - break - - default: - COLUMN_TO_SORT = -1 - sortTable(0) - break; - } - - showElement("loading", false) - } - -} - -var SHELL_LAYOUT_READY:boolean = false - -function setLayoutMenuState(open:boolean) { - if (document.body == null) { - return - } - - if (open == true) { - document.body.classList.add("menu-open") - } else { - document.body.classList.remove("menu-open") - } -} - -function toggleLayoutMenu() { - if (document.body == null) { - return - } - - var isOpen:boolean = document.body.classList.contains("menu-open") - setLayoutMenuState(!isOpen) -} - -function closeLayoutMenuIfMobile() { - if (window.innerWidth <= 900) { - setLayoutMenuState(false) - } -} - -function setActiveMenu(menuID:string) { - - ACTIVE_MENU_ID = menuID.toString() - var menu = document.getElementById("main-menu") - if (menu == null) { - return - } - - var items = menu.getElementsByTagName("LI") - for (var i = 0; i < items.length; i++) { - items[i].classList.remove("menu-active") - } - - var activeItem = document.getElementById(ACTIVE_MENU_ID) - var activeMenuKey:string = "" - if (activeItem != null) { - activeItem.classList.add("menu-active") - var menuKeyValue = activeItem.getAttribute("data-menu") - if (menuKeyValue != null) { - activeMenuKey = menuKeyValue - } - } - - if (document.body != null) { - if (activeMenuKey.length > 0) { - document.body.setAttribute("data-active-menu", activeMenuKey) - } else { - document.body.removeAttribute("data-active-menu") - } - - if (activeMenuKey == "log") { - document.body.classList.add("menu-log-focus") - } else { - document.body.classList.remove("menu-log-focus") - } - } -} - -function renderStatusCards() { - var wrapper = document.getElementById("status-cards") - if (wrapper == null || SERVER.hasOwnProperty("clientInfo") == false) { - return - } - - var info = SERVER["clientInfo"] - var errors:number = parseInt(info["errors"], 10) - var warnings:number = parseInt(info["warnings"], 10) - var cards:any[] = [ - {label: "Streams", value: info["streams"], tone: "ok"}, - {label: "EPG Source", value: info["epgSource"], tone: "neutral"}, - {label: "XEPG Channels", value: info["xepg"], tone: "ok"}, - {label: "Errors", value: info["errors"], tone: errors > 0 ? "error" : "ok"}, - {label: "Warnings", value: info["warnings"], tone: warnings > 0 ? "warn" : "ok"}, - {label: "DVR", value: info["DVR"], tone: "neutral"}, - ] - - wrapper.innerHTML = "" - cards.forEach(card => { - - var box = document.createElement("DIV") - box.className = "status-card status-card-" + card.tone - - var label = document.createElement("P") - label.className = "status-card-label" - label.innerText = card.label - - var value = document.createElement("P") - value.className = "status-card-value" - if (card.value == undefined || card.value == "") { - value.innerText = "-" - } else { - value.innerText = card.value - } - - box.appendChild(label) - box.appendChild(value) - wrapper.appendChild(box) - }); - -} - -function initShellLayout() { - - if (SHELL_LAYOUT_READY == true) { - return - } - - var toggle = document.getElementById("menu-toggle") - if (toggle != null) { - toggle.onclick = function() { - toggleLayoutMenu() - } - } - - var overlay = document.getElementById("layout-overlay") - if (overlay != null) { - overlay.onclick = function() { - setLayoutMenuState(false) - } - } - - document.addEventListener("keydown", function(event) { - - if (event.key == "Escape") { - setLayoutMenuState(false) - showElement("popup", false) - return - } - - if (event.key == "/") { - var target = event.target as HTMLElement - var onInput = target.tagName == "INPUT" || target.tagName == "TEXTAREA" || target.tagName == "SELECT" - - if (onInput == true) { - return - } - - var search = document.getElementById("searchMapping") - if (search != null) { - event.preventDefault(); - (search as HTMLInputElement).focus() - } - } - - }); - - setConnectionState("idle") - SHELL_LAYOUT_READY = true -} - -function shouldPollLogs():boolean { - if (document.hidden == true) { - return false - } - - if (document.getElementById("content_log") == null) { - return false - } - - if (ACTIVE_MENU_ID.length < 1) { - return false - } - - var activeItem = document.getElementById(ACTIVE_MENU_ID) - if (activeItem == null) { - return false - } - - return activeItem.getAttribute("data-menu") == "log" -} - -function PageReady() { - - initShellLayout() - - var server:Server = new Server("getServerConfig") - server.request(new Object()) - - var bootstrapAttempts:number = 0 - var maxBootstrapAttempts:number = 5 - var bootstrapTimer:number = window.setInterval(function() { - - if (SERVER.hasOwnProperty("clientInfo") == true) { - window.clearInterval(bootstrapTimer) - return - } - - if (WS.hasQueued("getServerConfig")) { - return - } - - bootstrapAttempts++ - var retryServer:Server = new Server("getServerConfig") - retryServer.request(new Object()) - - if (bootstrapAttempts >= maxBootstrapAttempts) { - window.clearInterval(bootstrapTimer) - } - - }, 3000) - - window.addEventListener("resize", function(){ - if (window.innerWidth > 900) { - setLayoutMenuState(false) - } - calculateWrapperHeight(); - }, true); - - setInterval(function(){ - if (shouldPollLogs() == false) { - return - } - updateLog() - }, 10000); - - - return -} - -function isClientInfoHttpURL(value:string):boolean { - return /^https?:\/\//i.test(value) -} - -function setClientInfoValue(key:string, value:any) { - var element = document.getElementById(key) - if (element == null) { - return - } - - var textValue = "" - if (value != undefined && value != null) { - textValue = String(value) - } - - if ((key == "m3u-url" || key == "xepg-url") && isClientInfoHttpURL(textValue)) { - element.innerHTML = "" - var anchor = document.createElement("a") - anchor.href = textValue - anchor.target = "_blank" - anchor.rel = "noopener noreferrer" - anchor.textContent = textValue - element.appendChild(anchor) - return - } - - element.textContent = textValue -} - -function createLayout() { - - // Client Info - var obj = SERVER["clientInfo"] - var keys = getObjKeys(obj); - for (var i = 0; i < keys.length; i++) { - - setClientInfoValue(keys[i], obj[keys[i]]) - - } - renderStatusCards() - - if (!document.getElementById("main-menu")) { - return - } - - - - // Menü erstellen - document.getElementById("main-menu").innerHTML = "" - for (let i = 0; i < menuItems.length; i++) { - - menuItems[i].id = i - - switch (menuItems[i]["menuKey"]) { - - case "users": - case "logout": - if (SERVER["settings"]["authentication.web"] == true) { - menuItems[i].createItem() - } - break - - case "mapping": - case "xmltv": - if (SERVER["clientInfo"]["epgSource"] == "XEPG") { - menuItems[i].createItem() - } - break - - default: - menuItems[i].createItem() - break - } - - } - - if (ACTIVE_MENU_ID.length > 0 && document.getElementById(ACTIVE_MENU_ID)) { - setActiveMenu(ACTIVE_MENU_ID) - } - - var content = document.getElementById("content") - var menu = document.getElementById("main-menu") - if (ACTIVE_MENU_ID.length == 0 && content != null && menu != null) { - - if (content.innerHTML.replace(/\\s/g, "").length == 0) { - var firstItem = menu.getElementsByTagName("LI")[0] as HTMLElement - if (firstItem != undefined) { - firstItem.click() - } - } - - } - - return -} - -function openThisMenu(element) { - var id = element.id - var content:ShowContent = new ShowContent(id) - setActiveMenu(id) - content.show() - closeLayoutMenuIfMobile() - calculateWrapperHeight() - - return -} - -class PopupWindow { - DocumentID:string = "popup-custom" - InteractionID:string = "interaction" - doc = document.getElementById(this.DocumentID) - - createTitle(title:string):any { - var td = document.createElement("TD") - td.className = "left" - td.innerHTML = title + ":" - return td - } - - createContent(element):any { - var td = document.createElement("TD") - td.appendChild(element) - return td - } - - createInteraction():any { - var div = document.createElement("div") - div.setAttribute("id", "popup-interaction") - div.className = "interaction" - this.doc.appendChild(div) - } -} - -class PopupContent extends PopupWindow{ - - table = document.createElement("TABLE") - - createHeadline(headline):void { - this.doc.innerHTML = "" - var element = document.createElement("H3") - element.innerHTML = headline.toUpperCase() - this.doc.appendChild(element) - - // Tabelle erstellen - this.table = document.createElement("TABLE") - this.doc.appendChild(this.table) - } - - appendRow(title:string, element:any):void { - var tr = document.createElement("TR") - - // Bezeichnung - if (title.length != 0) { - tr.appendChild(this.createTitle(title)) - } - - - // Content - tr.appendChild(this.createContent(element)) - this.table.appendChild(tr) - } - - - createInput(type:string, name:string, value:string):any { - - var input = document.createElement("INPUT") - if (value == undefined) { - value = "" - } - - input.setAttribute("type", type) - input.setAttribute("name", name) - input.setAttribute("value", value) - return input - } - - createCheckbox(name:string):any { - var input = document.createElement("INPUT") - - input.setAttribute("type", "checkbox") - input.setAttribute("name", name) - return input - } - - createSelect(text:string[], values:string[], set:string, dbKey:string):any { - var select = document.createElement("SELECT") - select.setAttribute("name", dbKey) - for (let i = 0; i < text.length; i++) { - var option = document.createElement("OPTION") - option.setAttribute("value", values[i]) - option.innerText = text[i] - select.appendChild(option) - } - if(set != "") { - (select as HTMLSelectElement).value = set - } - - if (set == undefined) { - (select as HTMLSelectElement).value = values[0] - } - - return select - } - - selectOption(select:any, value:string):any { - //select.selectedOptions = value - var s:HTMLSelectElement = (select as HTMLSelectElement) - s.options[s.selectedIndex].value = value - return select - } - - // isHTML must only be true for static text written in the source; never for data. - description(value:string, isHTML:boolean = false):any { - var tr = document.createElement("TR") - var td = document.createElement("TD") - var span = document.createElement("PRE") - - if (isHTML) { - span.innerHTML = value - } else { - span.textContent = value - } - - tr.appendChild(td) - - tr.appendChild(this.createContent(span)) - - this.table.appendChild(tr) - } - - // Interaktion - addInteraction(element:any) { - var interaction = document.getElementById("popup-interaction") - interaction.appendChild(element) - } -} - -function openPopUp(dataType, element) { - - var data:object = new Object(); - var id:any - switch (element) { - case undefined: - - switch (dataType) { - case "group-title": - if (id == undefined) { - id = -1 - } - data = getLocalData("filter", id) - data["type"] = "group-title" - break; - - case "custom-filter": - if (id == undefined) { - id = -1 - } - data = getLocalData("filter", id) - data["type"] = "custom-filter" - break; - - default: - data["id.provider"] = "-" - data["type"] = dataType - id = "-" - break; - } - - break - - default: - id = element.id - data = getLocalData(dataType, id) - break; - } - - var content:PopupContent = new PopupContent() - - switch (dataType) { - case "playlist": - content.createHeadline("Playlist type") - // Type - var text:string[] = ["M3U", "HDHomeRun"] - var values:string[] = ["javascript: openPopUp('m3u')", "javascript: openPopUp('hdhr')"] - var select = content.createSelect(text, values, "", "type") - select.setAttribute("id", "type") - select.setAttribute("onchange", 'javascript: changeButtonAction(this, "next", "onclick")') // changeButtonAction - content.appendRow("Type", select) - - // Interaktion - content.createInteraction() - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Weiter - var input = content.createInput("button", "next", "Next") - input.setAttribute("onclick", 'javascript: openPopUp("m3u")') - input.setAttribute("id", 'next') - content.addInteraction(input) - break - - case "m3u": - content.createHeadline(dataType) - // Name - var dbKey:string = "name" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Playlist name") - content.appendRow("Name", input) - - // Beschreibung - var dbKey:string = "description" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Description") - content.appendRow("Description", input) - - // URL - var dbKey:string = "file.source" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "File path or URL of the M3U") - content.appendRow("M3U File", input) - - // Tuner - if (SERVER["settings"]["buffer"] != "-") { - var text:string[] = new Array() - var values:string[] = new Array() - - for (var i = 1; i <= 100; i++) { - text.push(i.toString()) - values.push(i.toString()) - } - - var dbKey:string = "tuner" - var select = content.createSelect(text, values, data[dbKey], dbKey) - select.setAttribute("onfocus", "javascript: return;") - content.appendRow("Tuner / Streams", select) - } else { - var dbKey:string = "tuner" - if (data[dbKey] == undefined) { - data[dbKey] = 1 - } - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("readonly", "true") - input.className = "notAvailable" - content.appendRow("Tuner / Streams", input) - } - - content.description("Number of parallel connections that can be established to the provider.
Only available with activated buffer.
New settings will only be applied after quitting all streams.", true) - - // Interaktion - content.createInteraction() - // Löschen - if (data["id.provider"]!= "-") { - var input = content.createInput("button", "delete", "Delete") - input.className = "delete" - input.setAttribute('onclick', 'javascript: savePopupData("m3u", "' + id + '", true, 0)') - content.addInteraction(input) - } else { - var input = content.createInput("button", "back", "Back") - input.setAttribute("onclick", 'javascript: openPopUp("playlist")') - content.addInteraction(input) - } - - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Aktualisieren - if (data["id.provider"]!= "-") { - var input = content.createInput("button", "update", "Update") - input.setAttribute('onclick', 'javascript: savePopupData("m3u", "' + id + '", false, 1)') - content.addInteraction(input) - } - - // Speichern - var input = content.createInput("button", "save", "Save") - input.setAttribute('onclick', 'javascript: savePopupData("m3u", "' + id + '", false, 0)') - content.addInteraction(input) - break - - case "hdhr": - content.createHeadline(dataType) - // Name - var dbKey:string = "name" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Playlist name") - content.appendRow("Name", input) - - // Beschreibung - var dbKey:string = "description" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Description") - content.appendRow("Description", input) - - // URL - var dbKey:string = "file.source" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "IP address and port (192.168.1.10:5004)") - content.appendRow("HDHomeRun IP", input) - - // Tuner - if (SERVER["settings"]["buffer"] != "-") { - var text:string[] = new Array() - var values:string[] = new Array() - - for (var i = 1; i <= 100; i++) { - text.push(i.toString()) - values.push(i.toString()) - } - - var dbKey:string = "tuner" - var select = content.createSelect(text, values, data[dbKey], dbKey) - select.setAttribute("onfocus", "javascript: return;") - content.appendRow("Tuner / Streams", select) - } else { - var dbKey:string = "tuner" - if (data[dbKey] == undefined) { - data[dbKey] = 1 - } - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("readonly", "true") - input.className = "notAvailable" - content.appendRow("Tuner / Streams", input) - } - - content.description("Number of parallel connections that can be established to the provider.
Only available with activated buffer.
New settings will only be applied after quitting all streams.", true) - - // Interaktion - content.createInteraction() - // Löschen - if (data["id.provider"]!= "-") { - var input = content.createInput("button", "delete", "Delete") - input.setAttribute('onclick', 'javascript: savePopupData("hdhr", "' + id + '", true, 0)') - input.className = "delete" - content.addInteraction(input) - } else { - var input = content.createInput("button", "back", "Back") - input.setAttribute("onclick", 'javascript: openPopUp("playlist")') - content.addInteraction(input) - } - - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Aktualisieren - if (data["id.provider"]!= "-") { - var input = content.createInput("button", "update", "Update") - input.setAttribute('onclick', 'javascript: savePopupData("hdhr", "' + id + '", false, 1)') - content.addInteraction(input) - } - - // Speichern - var input = content.createInput("button", "save", "Save") - input.setAttribute('onclick', 'javascript: savePopupData("hdhr", "' + id + '", false, 0)') - content.addInteraction(input) - break - - case "filter": - content.createHeadline(dataType) - - // Type - var dbKey:string = "type" - var text:string[] = ["M3U: " + "Group Title", "xTeVe: " + "Custom Filter"] - var values:string[] = ["javascript: openPopUp('group-title')", "javascript: openPopUp('custom-filter')"] - var select = content.createSelect(text, values, "javascript: openPopUp('group-title')", dbKey) - select.setAttribute("id", id) - select.setAttribute("onchange", 'javascript: changeButtonAction(this, "next", "onclick");') // changeButtonAction - content.appendRow("Type", select) - - // Interaktion - content.createInteraction() - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Weiter - var input = content.createInput("button", "next", "Next") - input.setAttribute("onclick", 'javascript: openPopUp("group-title")') - input.setAttribute("id", 'next') - content.addInteraction(input) - break - - case "custom-filter": - case "group-title": - - switch (dataType) { - case "custom-filter": - content.createHeadline("Custom") - break; - - case "group-title": - content.createHeadline("Group") - break; - } - - // Name - var dbKey:string = "name" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Filter name") - content.appendRow("Filter Name", input) - - // Beschreibung - var dbKey:string = "description" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Description") - content.appendRow("Description", input) - - // Typ - var dbKey:string = "type" - var input = content.createInput("hidden", dbKey, data[dbKey]) - content.appendRow("", input) - - var filterType = data[dbKey] - - switch (filterType) { - - case "custom-filter": - // Groß- Kleinschreibung beachten - var dbKey:string = "caseSensitive" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - content.appendRow("Case Sensitive", input) - - // Filterregel (Benutzerdefiniert) - var dbKey:string = "filter" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Sport {HD} !{ES,IT}") - content.appendRow("Filter Rule", input) - - break; - - case "group-title": - //alert(dbKey + " " + filterType) - // Filter basierend auf den Gruppen in der M3U - var dbKey:string = "filter" - var groupsM3U = getLocalData("m3uGroups", "") - var text:string[] = groupsM3U["text"] - var values:string[] = groupsM3U["value"] - - var select = content.createSelect(text, values, data[dbKey], dbKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - content.appendRow("Group Title", select) - content.description("Select a M3U group. (Counter)
Changing the group title in the M3U invalidates the filter.", true) - - // Groß- Kleinschreibung beachten - var dbKey:string = "caseSensitive" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - content.appendRow("Case Sensitive", input) - - - var dbKey:string = "include" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "FHD,UHD") - - content.appendRow("Include", input) - content.description("Channel name must include.
(Comma separated) Comma means or", true) - - var dbKey:string = "exclude" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "ES,IT") - content.appendRow("Exclude", input) - content.description("Channel name must not contain.
(Comma separated) Comma means or", true) - - break - - default: - break; - } - - // Interaktion - content.createInteraction() - - // Löschen - var input = content.createInput("button", "delete", "Delete") - input.setAttribute('onclick', 'javascript: savePopupData("filter", "' + id + '", true, 0)') - input.className = "delete" - content.addInteraction(input) - - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Speichern - var input = content.createInput("button", "save", "Save") - input.setAttribute('onclick', 'javascript: savePopupData("filter", "' + id + '", false, 0)') - content.addInteraction(input) - - break - - case "xmltv": - content.createHeadline(dataType) - // Name - var dbKey:string = "name" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Guide name") - content.appendRow("Name", input) - - // Beschreibung - var dbKey:string = "description" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Description") - content.appendRow("Description", input) - - // URL - var dbKey:string = "file.source" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "File path or URL of the XMLTV") - content.appendRow("XMLTV File", input) - - // Interaktion - content.createInteraction() - // Löschen - if (data["id.provider"]!= "-") { - var input = content.createInput("button", "delete", "Delete") - input.setAttribute('onclick', 'javascript: savePopupData("xmltv", "' + id + '", true, 0)') - input.className = "delete" - content.addInteraction(input) - } - - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Aktualisieren - if (data["id.provider"]!= "-") { - var input = content.createInput("button", "update", "Update") - input.setAttribute('onclick', 'javascript: savePopupData("xmltv", "' + id + '", false, 1)') - content.addInteraction(input) - } - - // Speichern - var input = content.createInput("button", "save", "Save") - input.setAttribute('onclick', 'javascript: savePopupData("xmltv", "' + id + '", false, 0)') - content.addInteraction(input) - break - - case "users": - content.createHeadline("Users") - // Benutzername - var dbKey:string = "username" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Username") - content.appendRow("Username", input) - - // Neues Passwort - var dbKey:string = "password" - var input = content.createInput("password", dbKey, "") - input.setAttribute("placeholder", "Password") - content.appendRow("Password", input) - - // Bestätigung - var dbKey:string = "confirm" - var input = content.createInput("password", dbKey, "") - input.setAttribute("placeholder", "Password confirm") - content.appendRow("Confirm", input) - - // Berechtigung WEB - var dbKey:string = "authentication.web" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - if (data["defaultUser"] == true) { - input.setAttribute("onclick", "javascript: return false") - } - content.appendRow("Web Access", input) - - // Berechtigung PMS - var dbKey:string = "authentication.pms" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - content.appendRow("PMS Access", input) - - // Berechtigung M3U - var dbKey:string = "authentication.m3u" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - content.appendRow("M3U Access", input) - - // Berechtigung XML - var dbKey:string = "authentication.xml" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - content.appendRow("XML Access", input) - - // Berechtigung API - var dbKey:string = "authentication.api" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - content.appendRow("API Access", input) - - // Interaktion - content.createInteraction() - - // Löschen - if (data["defaultUser"]!= true && id != "-") { - var input = content.createInput("button", "delete", "Delete") - input.className = "delete" - input.setAttribute('onclick', 'javascript: savePopupData("' + dataType + '", "' + id + '", true, 0)') - content.addInteraction(input) - } - - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Speichern - var input = content.createInput("button", "save", "Save") - input.setAttribute("onclick", 'javascript: savePopupData("' + dataType + '", "' + id + '", "false");') - content.addInteraction(input) - - break - - case "mapping": - content.createHeadline("Mapping") - // Aktiv - var dbKey:string = "x-active" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - input.id = "active" - //input.setAttribute("onchange", "javascript: this.className = 'changed'") - input.setAttribute("onchange", "javascript: toggleChannelStatus('" + id + "', this)") - content.appendRow("Active", input) - - // Kanalname - var dbKey:string = "x-name" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("onchange", "javascript: this.className = 'changed'") - if (BULK_EDIT == true) { - input.style.border = "solid 1px red" - input.setAttribute("readonly", "true") - } - content.appendRow("Channel Name", input) - - content.description(data["name"]) - - // Beschreibung - var dbKey:string = "x-description" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Used by the Dummy as an XML description") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - content.appendRow("Channel Description", input) - - // Aktualisierung des Kanalnamens - if (data.hasOwnProperty("_uuid.key")) { - if (data["_uuid.key"] != "") { - var dbKey:string = "x-update-channel-name" - var input = content.createCheckbox(dbKey) - input.setAttribute("onchange", "javascript: this.className = 'changed'") - input.checked = data[dbKey] - content.appendRow("Update Channel Name", input) - } - } - - // Logo URL (Kanal) - var dbKey:string = "tvg-logo" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("onchange", "javascript: this.className = 'changed'") - input.setAttribute("id", "channel-icon") - content.appendRow("Logo URL", input) - - // Aktualisierung des Kanallogos - var dbKey:string = "x-update-channel-icon" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - input.setAttribute("id", "update-icon") - input.setAttribute("onchange", "javascript: this.className = 'changed'; changeChannelLogo('" + id + "');") - content.appendRow("Update Channel Logo", input) - - // Erweitern der EPG Kategorie - var dbKey:string = "x-category" - var text:string[] = ["-", "Kids (Emby only)", "News", "Movie", "Series", "Sports"] - var values:string[] = ["", "Kids", "News", "Movie", "Series", "Sports"] - var select = content.createSelect(text, values, data[dbKey], dbKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - content.appendRow("EPG Category", select) - - // M3U Gruppentitel - var dbKey:string = "x-group-title" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("onchange", "javascript: this.className = 'changed'") - content.appendRow("Group Title (xteve.m3u)", input) - - if (data["group-title"] != undefined) { - content.description(data["group-title"]) - } - - // XMLTV Datei - var dbKey:string = "x-xmltv-file" - var xmlFile = data[dbKey] - var xmltv:XMLTVFile = new XMLTVFile() - var select = xmltv.getFiles(data[dbKey]) - select.setAttribute("name", dbKey) - select.setAttribute("id", "popup-xmltv") - select.setAttribute("onchange", "javascript: this.className = 'changed'; setXmltvChannel('" + id + "',this);") - content.appendRow("XMLTV File", select) - var file = data[dbKey] - - // XMLTV Mapping - var dbKey:string = "x-mapping" - var xmltv:XMLTVFile = new XMLTVFile() - var select = xmltv.getPrograms(file, data[dbKey]) - select.setAttribute("name", dbKey) - select.setAttribute("id", "popup-mapping") - select.setAttribute("onchange", "javascript: this.className = 'changed'; checkXmltvChannel('" + id + "',this,'" + xmlFile + "');") - - sortSelect(select) - content.appendRow("XMLTV Channel", select) - - // Interaktion - content.createInteraction() - - // Logo hochladen - var input = content.createInput("button", "cancel", "Upload Logo") - input.setAttribute("onclick", 'javascript: uploadLogo();') - content.addInteraction(input) - - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Fertig - var ids:string[] = new Array() - ids = getAllSelectedChannels() - if (ids.length == 0) { - ids.push(id) - } - - var input = content.createInput("button", "save", "Done") - input.setAttribute("onclick", 'javascript: donePopupData("' + dataType + '", "' + ids + '", "false");') - content.addInteraction(input) - break - - default: - break; - } - - showPopUpElement('popup-custom'); -} - -class XMLTVFile { - File:string - - getFiles(set:string):any { - var fileIDs:string[] = getObjKeys(SERVER["xepg"]["xmltvMap"]) - var values = new Array("-"); - var text = new Array("-"); - - for (let i = 0; i < fileIDs.length; i++) { - if (fileIDs[i] != "xTeVe Dummy") { - values.push(getValueFromProviderFile(fileIDs[i], "xmltv", "file.xteve")) - text.push(getValueFromProviderFile(fileIDs[i], "xmltv", "name")) - } else { - values.push(fileIDs[i]) - text.push(fileIDs[i]) - } - - } - - var select = document.createElement("SELECT") - for (let i = 0; i < text.length; i++) { - var option = document.createElement("OPTION") - option.setAttribute("value", values[i]) - option.innerText = text[i] - select.appendChild(option) - } - - if(set != "") { - (select as HTMLSelectElement).value = set - } - - return select - } - - getPrograms(file:string, set:string):any { - //var fileIDs:string[] = getObjKeys(SERVER["xepg"]["xmltvMap"]) - var values = getObjKeys(SERVER["xepg"]["xmltvMap"][file]); - var text = new Array() - var displayName:string - - for (let i = 0; i < values.length; i++) { - if (SERVER["xepg"]["xmltvMap"][file][values[i]].hasOwnProperty('display-name') == true) { - displayName = SERVER["xepg"]["xmltvMap"][file][values[i]]["display-name"]; - } else { - displayName = "-" - } - - text[i] = displayName + " (" + values[i] + ")"; - } - - text.unshift("-"); - values.unshift("-"); - - var select = document.createElement("SELECT") - for (let i = 0; i < text.length; i++) { - var option = document.createElement("OPTION") - option.setAttribute("value", values[i]) - option.innerText = text[i] - select.appendChild(option) - } - - if(set != "") { - (select as HTMLSelectElement).value = set - } - - if ((select as HTMLSelectElement).value != set) { - (select as HTMLSelectElement).value = "-" - } - - return select - } - - return -} - -function getValueFromProviderFile(file:string, fileType, key) { - - if (file == "xTeVe Dummy") { - return file - } - - var fileID:string - var indicator = file.charAt(0) - - switch (indicator) { - case "M": - fileType = "m3u" - fileID = file - break; - - case "H": - fileType = "hdhr" - fileID = file - break; - - case "X": - fileType = "xmltv" - fileID = file.substring(0, file.lastIndexOf('.')) - break; - - } - - if (SERVER["settings"]["files"][fileType].hasOwnProperty(fileID) == true) { - var data = SERVER["settings"]["files"][fileType][fileID]; - return data[key] - } - - return - -} - -function setXmltvChannel(id, element) { - - var xmltv:XMLTVFile = new XMLTVFile() - var xmlFile = element.value - - var tvgId:string = SERVER["xepg"]["epgMapping"][id]["tvg-id"] - var td = document.getElementById("popup-mapping").parentElement - td.innerHTML = "" - - var select = xmltv.getPrograms(element.value, tvgId) - select.setAttribute("name", "x-mapping") - select.setAttribute("id", "popup-mapping") - select.setAttribute("onchange", "javascript: this.className = 'changed'; checkXmltvChannel('" + id + "',this,'" + xmlFile + "');") - select.className = "changed" - sortSelect(select) - td.appendChild(select); - - checkXmltvChannel(id, select, xmlFile) -} - -function checkXmltvChannel(id:string, element:any, xmlFile) { - - var value = (element as HTMLSelectElement).value - var bool:boolean - var checkbox = document.getElementById('active') - var channel:any = SERVER["xepg"]["epgMapping"][id] - var updateLogo:boolean - - - if (value == "-") { - bool = false - } else { - bool = true - } - - (checkbox as HTMLInputElement).checked = bool - checkbox.className = "changed" - console.log(xmlFile); - - // Kanallogo aktualisieren - /* - updateLogo = (document.getElementById("update-icon") as HTMLInputElement).checked - console.log(updateLogo); - */ - - if(xmlFile != "xTeVe Dummy" && bool == true) { - - //(document.getElementById("update-icon") as HTMLInputElement).checked = true; - //(document.getElementById("update-icon") as HTMLInputElement).className = "changed"; - - console.log("ID", id) - changeChannelLogo(id) - - return - } - - if (xmlFile == "xTeVe Dummy") { - (document.getElementById("update-icon") as HTMLInputElement).checked = false; - (document.getElementById("update-icon") as HTMLInputElement).className = "changed"; - } - - return -} - -function changeChannelLogo(id:string) { - - var updateLogo:boolean - var channel:any = SERVER["xepg"]["epgMapping"][id] - - var f = (document.getElementById("popup-xmltv") as HTMLSelectElement); - var xmltvFile = f.options[f.selectedIndex].value; - - var m = (document.getElementById("popup-mapping") as HTMLSelectElement); - var xMapping = m.options[m.selectedIndex].value; - - var xmltvLogo = SERVER["xepg"]["xmltvMap"][xmltvFile][xMapping]["icon"] - updateLogo = (document.getElementById("update-icon") as HTMLInputElement).checked - - if (updateLogo == true && xmltvFile != "xTeVe Dummy") { - - if (SERVER["xepg"]["xmltvMap"][xmltvFile].hasOwnProperty(xMapping)) { - var logo = xmltvLogo - } else { - logo = channel["tvg-logo"] - } - - var logoInput = (document.getElementById("channel-icon") as HTMLInputElement); - logoInput.value = logo - if (BULK_EDIT == false) { - logoInput.className = "changed" - } - - } - -} - -function savePopupData(dataType:string, id:string, remove:Boolean, option:number) { - - if (dataType == "mapping") { - - var data = new Object() - console.log("Save mapping data") - - cmd = "saveEpgMapping" - data["epgMapping"] = SERVER["xepg"]["epgMapping"] - - console.log("SEND TO SERVER"); - - var server:Server = new Server(cmd) - server.request(data) - - delete UNDO["epgMapping"] - - return - } - - console.log("Save popup data") - var div = document.getElementById("popup-custom") - - var inputs = div.getElementsByTagName("TABLE")[0].getElementsByTagName("INPUT"); - var selects = div.getElementsByTagName("TABLE")[0].getElementsByTagName("SELECT"); - - var input = new Object(); - var confirmMsg: string - - for (let i = 0; i < selects.length; i++) { - - var name:string - name = (selects[i] as HTMLSelectElement).name - var value = (selects[i] as HTMLSelectElement).value - - switch (name) { - case "tuner": - input[name] = parseInt(value) - break; - - default: - input[name] = value - break; - } - - } - - for (let i = 0; i < inputs.length; i++) { - - switch ((inputs[i] as HTMLInputElement).type) { - - case "checkbox": - name = (inputs[i] as HTMLInputElement).name - input[name] = (inputs[i] as HTMLInputElement).checked - break - - case "text": - case "hidden": - case "password": - - name = (inputs[i] as HTMLInputElement).name - - switch (name) { - case "tuner": - input[name] = parseInt((inputs[i] as HTMLInputElement).value) - break; - - default: - input[name] = (inputs[i] as HTMLInputElement).value - break; - } - - break - - } - - } - - var data = new Object() - - var cmd:string - - if (remove == true) { - input["delete"] = true - } - - switch (dataType) { - case "users": - - confirmMsg = "Delete this user?" - if (id == "-") { - cmd = "saveNewUser" - data["userData"] = input - } else { - cmd = "saveUserData" - var d = new Object() - d[id] = input - data["userData"] = d - } - - break; - - case "m3u": - - confirmMsg = "Delete this playlist?" - switch (option) { - // Popup: Save - case 0: - cmd = "saveFilesM3U" - break - - // Popup: Update - case 1: - cmd = "updateFileM3U" - break - - } - - data["files"] = new Object - data["files"][dataType] = new Object - data["files"][dataType][id] = input - - break - - case "hdhr": - - confirmMsg = "Delete this HDHomeRun tuner?" - switch (option) { - // Popup: Save - case 0: - cmd = "saveFilesHDHR" - break - - // Popup: Update - case 1: - cmd = "updateFileHDHR" - break - - } - - data["files"] = new Object - data["files"][dataType] = new Object - data["files"][dataType][id] = input - - break - - case "xmltv": - - confirmMsg = "Delete this XMLTV file?" - switch (option) { - // Popup: Save - case 0: - cmd = "saveFilesXMLTV" - break - - // Popup: Update - case 1: - cmd = "updateFileXMLTV" - break - - } - - data["files"] = new Object - data["files"][dataType] = new Object - data["files"][dataType][id] = input - - break - - case "filter": - - confirmMsg = "Delete this filter?" - cmd = "saveFilter" - data["filter"] = new Object - data["filter"][id] = input - break - - default: - console.log(dataType, id); - return - break; - - } - - if (remove == true) { - - if (!confirm(confirmMsg)) { - showElement("popup", false) - return - } - - } - - console.log("SEND TO SERVER"); - - console.log(data); - - var server:Server = new Server(cmd) - server.request(data) - -} - -function donePopupData(dataType:string, idsStr:string) { - - var ids:string[] = idsStr.split(','); - var div = document.getElementById("popup-custom") - var inputs = div.getElementsByClassName("changed") - - ids.forEach(id => { - var input = new Object(); - input = SERVER["xepg"]["epgMapping"][id] - - console.log(input); - - for (let i = 0; i < inputs.length; i++) { - - var name:string - var value:any - - switch (inputs[i].tagName) { - - case "INPUT": - switch ((inputs[i] as HTMLInputElement).type) { - case "checkbox": - name = (inputs[i] as HTMLInputElement).name - value = (inputs[i] as HTMLInputElement).checked - input[name] = value - break - - case "text": - name = (inputs[i] as HTMLInputElement).name - value = (inputs[i] as HTMLInputElement).value - input[name] = value - break - - } - - break - - case "SELECT": - name = (inputs[i] as HTMLSelectElement).name - value = (inputs[i] as HTMLSelectElement).value - input[name] = value - break - - } - - switch (name) { - - - case "tvg-logo": - //(document.getElementById(id).childNodes[2].firstChild as HTMLElement).setAttribute("src", value) - break - - case "x-name": - (document.getElementById(id).childNodes[3].firstChild as HTMLElement).textContent = value - break - - case "x-category": - (document.getElementById(id).childNodes[3].firstChild as HTMLElement).className = value - break - - case "x-group-title": - (document.getElementById(id).childNodes[5].firstChild as HTMLElement).textContent = value - break - - case "x-xmltv-file": - if (value != "xTeVe Dummy" && value != "-") { - value = getValueFromProviderFile(value, "xmltv", "name") - } - - if (value == "-") { - input["x-active"] = false - } - - (document.getElementById(id).childNodes[6].firstChild as HTMLElement).textContent = value - break - - case "x-mapping": - if (value == "-") { - input["x-active"] = false - } - - (document.getElementById(id).childNodes[7].firstChild as HTMLElement).textContent = value - - break - - default: - - } - - createSearchObj() - searchInMapping() - - } - - if (input["x-active"] == false) { - document.getElementById(id).className = "notActiveEPG" - } else { - document.getElementById(id).className = "activeEPG" - } - - console.log(input["tvg-logo"]); - (document.getElementById(id).childNodes[2].firstChild as HTMLElement).setAttribute("src", input["tvg-logo"]) - - - }); - - showElement("popup", false); - - return -} - -function showPreview(element:boolean) { - - var div = document.getElementById("myStreamsBox") - switch (element) { - - case false: - div.className = "notVisible" - return - break; - } - - var streams:string[] = ["activeStreams", "inactiveStreams"] - - streams.forEach(preview => { - - var table = document.getElementById(preview) - table.innerHTML = "" - var obj:string[] = SERVER["data"]["StreamPreviewUI"][preview] - - obj.forEach(channel => { - - var tr = document.createElement("TR") - var tdKey = document.createElement("TD") - var tdVal = document.createElement("TD") - - tdKey.className = "tdKey" - tdVal.className = "tdVal" - - switch (preview) { - case "activeStreams": - tdKey.innerText = "Channel: (+)" - break; - - case "inactiveStreams": - tdKey.innerText = "Channel: (-)" - break; - } - - tdVal.innerText = channel - tr.appendChild(tdKey) - tr.appendChild(tdVal) - - table.appendChild(tr) - - }); - - }); - - showElement("loading", false) - div.className = "visible" - - return -} diff --git a/ts/network.ts b/ts/network.ts index 419c0ec..e7d256b 100644 --- a/ts/network.ts +++ b/ts/network.ts @@ -228,7 +228,7 @@ class Server { if (response["status"] == false) { setConnectionState("offline") - alert(response["err"]) + alertUser(response["err"]) if (response.hasOwnProperty("reload")) { location.reload() } @@ -266,7 +266,7 @@ class Server { } if (response.hasOwnProperty("alert")) { - alert(response["alert"]) + alertUser(response["alert"]) } if (response.hasOwnProperty("reload")) { @@ -295,7 +295,7 @@ class Server { setConnectionState(state) if (WS_AVAILABLE == false && !isLogUpdate && requestCmd != "getServerConfig") { - alert("No websocket connection to xTeVe could be established. Check your network configuration.") + alertUser("No websocket connection to xTeVe could be established. Check your network configuration.") } }) diff --git a/ts/popup.ts b/ts/popup.ts new file mode 100644 index 0000000..8a8a434 --- /dev/null +++ b/ts/popup.ts @@ -0,0 +1,1171 @@ +class PopupWindow { + DocumentID:string = "popup-custom" + InteractionID:string = "interaction" + doc = document.getElementById(this.DocumentID) + + createTitle(title:string):any { + var td = document.createElement("TD") + td.className = "left" + td.innerHTML = title + ":" + return td + } + + createContent(element):any { + var td = document.createElement("TD") + td.appendChild(element) + return td + } + + createInteraction():any { + var div = document.createElement("div") + div.setAttribute("id", "popup-interaction") + div.className = "interaction" + this.doc.appendChild(div) + } +} + +class PopupContent extends PopupWindow{ + + table = document.createElement("TABLE") + + createHeadline(headline):void { + this.doc.innerHTML = "" + var element = document.createElement("H3") + element.innerHTML = headline.toUpperCase() + this.doc.appendChild(element) + + // Tabelle erstellen + this.table = document.createElement("TABLE") + this.doc.appendChild(this.table) + } + + appendRow(title:string, element:any):void { + var tr = document.createElement("TR") + + // Bezeichnung + if (title.length != 0) { + tr.appendChild(this.createTitle(title)) + } + + + // Content + tr.appendChild(this.createContent(element)) + this.table.appendChild(tr) + } + + + createInput(type:string, name:string, value:string):any { + + var input = document.createElement("INPUT") + if (value == undefined) { + value = "" + } + + input.setAttribute("type", type) + input.setAttribute("name", name) + input.setAttribute("value", value) + return input + } + + createCheckbox(name:string):any { + var input = document.createElement("INPUT") + + input.setAttribute("type", "checkbox") + input.setAttribute("name", name) + return input + } + + createSelect(text:string[], values:string[], set:string, dbKey:string):any { + var select = document.createElement("SELECT") + select.setAttribute("name", dbKey) + for (let i = 0; i < text.length; i++) { + var option = document.createElement("OPTION") + option.setAttribute("value", values[i]) + option.innerText = text[i] + select.appendChild(option) + } + if(set != "") { + (select as HTMLSelectElement).value = set + } + + if (set == undefined) { + (select as HTMLSelectElement).value = values[0] + } + + return select + } + + selectOption(select:any, value:string):any { + //select.selectedOptions = value + var s:HTMLSelectElement = (select as HTMLSelectElement) + s.options[s.selectedIndex].value = value + return select + } + + // isHTML must only be true for static text written in the source; never for data. + description(value:string, isHTML:boolean = false):any { + var tr = document.createElement("TR") + var td = document.createElement("TD") + var span = document.createElement("PRE") + + if (isHTML) { + span.innerHTML = value + } else { + span.textContent = value + } + + tr.appendChild(td) + + tr.appendChild(this.createContent(span)) + + this.table.appendChild(tr) + } + + // Interaktion + addInteraction(element:any) { + var interaction = document.getElementById("popup-interaction") + interaction.appendChild(element) + } +} + +// Buffer choices a playlist can make; "default" means the global setting. +const PLAYLIST_BUFFER_OPTIONS = [ + { text: "Default (global setting)", value: "default" }, + { text: "No buffer (pass the URL through)", value: "-" }, + { text: "xTeVe", value: "xteve" }, + { text: "FFmpeg", value: "ffmpeg" }, + { text: "VLC", value: "vlc" } +] + +function createBufferSelect(content:PopupContent, data:object):HTMLSelectElement { + var text:string[] = PLAYLIST_BUFFER_OPTIONS.map(function(o) { return o.text }) + var values:string[] = PLAYLIST_BUFFER_OPTIONS.map(function(o) { return o.value }) + var current:string = data["buffer"] + if (values.indexOf(current) == -1) { + current = "default" + } + return content.createSelect(text, values, current, "buffer") +} + +// playlistBufferActive: is a buffer in use for this playlist (its own choice, +// or the global setting when it has none)? Decides whether the tuner count +// can be edited. +function playlistBufferActive(data:object):boolean { + var own:string = data["buffer"] + if (own != undefined && own != "default" && own != "") { + return own != "-" + } + return SERVER["settings"]["buffer"] != "-" +} + +function openPopUp(dataType, element?) { + + var data:object = new Object(); + var id:any + switch (element) { + case undefined: + + switch (dataType) { + case "group-title": + if (id == undefined) { + id = -1 + } + data = getLocalData("filter", id) + data["type"] = "group-title" + break; + + case "custom-filter": + if (id == undefined) { + id = -1 + } + data = getLocalData("filter", id) + data["type"] = "custom-filter" + break; + + case "regex-filter": + if (id == undefined) { + id = -1 + } + data = getLocalData("filter", id) + data["type"] = "regex-filter" + break; + + default: + data["id.provider"] = "-" + data["type"] = dataType + id = "-" + break; + } + + break + + default: + id = element.id + data = getLocalData(dataType, id) + break; + } + + var content:PopupContent = new PopupContent() + + switch (dataType) { + case "playlist": + content.createHeadline("Playlist type") + // Type + var text:string[] = ["M3U", "HDHomeRun"] + var values:string[] = ["m3u", "hdhr"] + var select = content.createSelect(text, values, "", "type") + select.setAttribute("id", "type") + content.appendRow("Type", select) + const typeSelect:HTMLSelectElement = select + + // Interaktion + content.createInteraction() + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Weiter + var input = content.createInput("button", "next", "Next") + input.addEventListener("click", function() { openPopUp(typeSelect.value) }) + input.setAttribute("id", 'next') + content.addInteraction(input) + break + + case "m3u": + content.createHeadline(dataType) + // Name + var dbKey:string = "name" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Playlist name") + content.appendRow("Name", input) + + // Beschreibung + var dbKey:string = "description" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Description") + content.appendRow("Description", input) + + // URL + var dbKey:string = "file.source" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "File path or URL of the M3U") + content.appendRow("M3U File", input) + + // Buffer (per playlist override of the global setting) + var bufferSelect = createBufferSelect(content, data) + content.appendRow("Buffer", bufferSelect) + + // Tuner + if (playlistBufferActive(data)) { + var text:string[] = new Array() + var values:string[] = new Array() + + for (var i = 1; i <= 100; i++) { + text.push(i.toString()) + values.push(i.toString()) + } + + var dbKey:string = "tuner" + var select = content.createSelect(text, values, data[dbKey], dbKey) + content.appendRow("Tuner / Streams", select) + } else { + var dbKey:string = "tuner" + if (data[dbKey] == undefined) { + data[dbKey] = 1 + } + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("readonly", "true") + input.className = "notAvailable" + content.appendRow("Tuner / Streams", input) + } + + content.description("Number of parallel connections that can be established to the provider.
Only available with activated buffer.
New settings will only be applied after quitting all streams.", true) + + // Interaktion + content.createInteraction() + // Löschen + if (data["id.provider"]!= "-") { + var input = content.createInput("button", "delete", "Delete") + input.className = "delete" + input.addEventListener("click", function() { savePopupData("m3u", String(id), true, 0) }) + content.addInteraction(input) + } else { + var input = content.createInput("button", "back", "Back") + input.addEventListener("click", function() { openPopUp("playlist") }) + content.addInteraction(input) + } + + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Aktualisieren + if (data["id.provider"]!= "-") { + var input = content.createInput("button", "update", "Update") + input.addEventListener("click", function() { savePopupData("m3u", String(id), false, 1) }) + content.addInteraction(input) + } + + // Speichern + var input = content.createInput("button", "save", "Save") + input.addEventListener("click", function() { savePopupData("m3u", String(id), false, 0) }) + content.addInteraction(input) + break + + case "hdhr": + content.createHeadline(dataType) + // Name + var dbKey:string = "name" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Playlist name") + content.appendRow("Name", input) + + // Beschreibung + var dbKey:string = "description" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Description") + content.appendRow("Description", input) + + // URL + var dbKey:string = "file.source" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "IP address and port (192.168.1.10:5004)") + content.appendRow("HDHomeRun IP", input) + + // Buffer (per playlist override of the global setting) + var bufferSelect = createBufferSelect(content, data) + content.appendRow("Buffer", bufferSelect) + + // Tuner + if (playlistBufferActive(data)) { + var text:string[] = new Array() + var values:string[] = new Array() + + for (var i = 1; i <= 100; i++) { + text.push(i.toString()) + values.push(i.toString()) + } + + var dbKey:string = "tuner" + var select = content.createSelect(text, values, data[dbKey], dbKey) + content.appendRow("Tuner / Streams", select) + } else { + var dbKey:string = "tuner" + if (data[dbKey] == undefined) { + data[dbKey] = 1 + } + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("readonly", "true") + input.className = "notAvailable" + content.appendRow("Tuner / Streams", input) + } + + content.description("Number of parallel connections that can be established to the provider.
Only available with activated buffer.
New settings will only be applied after quitting all streams.", true) + + // Interaktion + content.createInteraction() + // Löschen + if (data["id.provider"]!= "-") { + var input = content.createInput("button", "delete", "Delete") + input.addEventListener("click", function() { savePopupData("hdhr", String(id), true, 0) }) + input.className = "delete" + content.addInteraction(input) + } else { + var input = content.createInput("button", "back", "Back") + input.addEventListener("click", function() { openPopUp("playlist") }) + content.addInteraction(input) + } + + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Aktualisieren + if (data["id.provider"]!= "-") { + var input = content.createInput("button", "update", "Update") + input.addEventListener("click", function() { savePopupData("hdhr", String(id), false, 1) }) + content.addInteraction(input) + } + + // Speichern + var input = content.createInput("button", "save", "Save") + input.addEventListener("click", function() { savePopupData("hdhr", String(id), false, 0) }) + content.addInteraction(input) + break + + case "filter": + content.createHeadline(dataType) + + // Type + var dbKey:string = "type" + var text:string[] = ["M3U: " + "Group Title", "xTeVe: " + "Custom Filter", "xTeVe: " + "Regular Expression"] + var values:string[] = ["group-title", "custom-filter", "regex-filter"] + var select = content.createSelect(text, values, "group-title", dbKey) + select.setAttribute("id", id) + content.appendRow("Type", select) + const filterTypeSelect:HTMLSelectElement = select + + // Interaktion + content.createInteraction() + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Weiter + var input = content.createInput("button", "next", "Next") + input.addEventListener("click", function() { openPopUp(filterTypeSelect.value) }) + input.setAttribute("id", 'next') + content.addInteraction(input) + break + + case "custom-filter": + case "group-title": + case "regex-filter": + + switch (dataType) { + case "custom-filter": + content.createHeadline("Custom") + break; + + case "group-title": + content.createHeadline("Group") + break; + + case "regex-filter": + content.createHeadline("Regular Expression") + break; + } + + // Name + var dbKey:string = "name" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Filter name") + content.appendRow("Filter Name", input) + + // Beschreibung + var dbKey:string = "description" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Description") + content.appendRow("Description", input) + + // Typ + var dbKey:string = "type" + var input = content.createInput("hidden", dbKey, data[dbKey]) + content.appendRow("", input) + + var filterType = data[dbKey] + + switch (filterType) { + + case "custom-filter": + // Groß- Kleinschreibung beachten + var dbKey:string = "caseSensitive" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("Case Sensitive", input) + + // Filterregel (Benutzerdefiniert) + var dbKey:string = "filter" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Sport {HD} !{ES,IT}") + content.appendRow("Filter Rule", input) + + break; + + case "regex-filter": + // Groß- Kleinschreibung beachten + var dbKey:string = "caseSensitive" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("Case Sensitive", input) + + // Regulärer Ausdruck + var dbKey:string = "filter" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "^(BBC|ITV) \\d+$") + content.appendRow("Regular Expression", input) + content.description("Go regular expression, tried against the channel name, then the group title, then the raw M3U attributes. Case-insensitive unless Case Sensitive is checked. Invalid patterns are rejected on save.") + + break; + + case "group-title": + //alert(dbKey + " " + filterType) + // Filter basierend auf den Gruppen in der M3U + var dbKey:string = "filter" + var groupsM3U = getLocalData("m3uGroups", "") + var text:string[] = groupsM3U["text"] + var values:string[] = groupsM3U["value"] + + var select = content.createSelect(text, values, data[dbKey], dbKey) + markChangedOnChange(select) + content.appendRow("Group Title", select) + content.description("Select a M3U group. (Counter)
Changing the group title in the M3U invalidates the filter.", true) + + // Groß- Kleinschreibung beachten + var dbKey:string = "caseSensitive" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("Case Sensitive", input) + + + var dbKey:string = "include" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "FHD,UHD") + + content.appendRow("Include", input) + content.description("Channel name must include.
(Comma separated) Comma means or", true) + + var dbKey:string = "exclude" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "ES,IT") + content.appendRow("Exclude", input) + content.description("Channel name must not contain.
(Comma separated) Comma means or", true) + + break + + default: + break; + } + + // Interaktion + content.createInteraction() + + // Löschen + var input = content.createInput("button", "delete", "Delete") + input.addEventListener("click", function() { savePopupData("filter", String(id), true, 0) }) + input.className = "delete" + content.addInteraction(input) + + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Speichern + var input = content.createInput("button", "save", "Save") + input.addEventListener("click", function() { savePopupData("filter", String(id), false, 0) }) + content.addInteraction(input) + + break + + case "xmltv": + content.createHeadline(dataType) + // Name + var dbKey:string = "name" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Guide name") + content.appendRow("Name", input) + + // Beschreibung + var dbKey:string = "description" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Description") + content.appendRow("Description", input) + + // URL + var dbKey:string = "file.source" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "File path or URL of the XMLTV") + content.appendRow("XMLTV File", input) + + // Interaktion + content.createInteraction() + // Löschen + if (data["id.provider"]!= "-") { + var input = content.createInput("button", "delete", "Delete") + input.addEventListener("click", function() { savePopupData("xmltv", String(id), true, 0) }) + input.className = "delete" + content.addInteraction(input) + } + + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Aktualisieren + if (data["id.provider"]!= "-") { + var input = content.createInput("button", "update", "Update") + input.addEventListener("click", function() { savePopupData("xmltv", String(id), false, 1) }) + content.addInteraction(input) + } + + // Speichern + var input = content.createInput("button", "save", "Save") + input.addEventListener("click", function() { savePopupData("xmltv", String(id), false, 0) }) + content.addInteraction(input) + break + + case "users": + content.createHeadline("Users") + // Benutzername + var dbKey:string = "username" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Username") + content.appendRow("Username", input) + + // Neues Passwort + var dbKey:string = "password" + var input = content.createInput("password", dbKey, "") + input.setAttribute("placeholder", "Password") + content.appendRow("Password", input) + + // Bestätigung + var dbKey:string = "confirm" + var input = content.createInput("password", dbKey, "") + input.setAttribute("placeholder", "Password confirm") + content.appendRow("Confirm", input) + + // Berechtigung WEB + var dbKey:string = "authentication.web" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + if (data["defaultUser"] == true) { + input.addEventListener("click", function(event) { event.preventDefault() }) + } + content.appendRow("Web Access", input) + + // Berechtigung PMS + var dbKey:string = "authentication.pms" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("PMS Access", input) + + // Berechtigung M3U + var dbKey:string = "authentication.m3u" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("M3U Access", input) + + // Berechtigung XML + var dbKey:string = "authentication.xml" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("XML Access", input) + + // Berechtigung API + var dbKey:string = "authentication.api" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("API Access", input) + + // Interaktion + content.createInteraction() + + // Löschen + if (data["defaultUser"]!= true && id != "-") { + var input = content.createInput("button", "delete", "Delete") + input.className = "delete" + input.addEventListener("click", function() { savePopupData(dataType, String(id), true, 0) }) + content.addInteraction(input) + } + + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Speichern + var input = content.createInput("button", "save", "Save") + input.addEventListener("click", function() { savePopupData(dataType, String(id), false, 0) }) + content.addInteraction(input) + + break + + case "mapping": + content.createHeadline("Mapping") + // Aktiv + var dbKey:string = "x-active" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + input.id = "active" + //markChangedOnChange(input) + input.addEventListener("change", function() { toggleChannelStatus(id) }) + content.appendRow("Active", input) + + // Kanalname + var dbKey:string = "x-name" + var input = content.createInput("text", dbKey, data[dbKey]) + markChangedOnChange(input) + if (BULK_EDIT == true) { + input.style.border = "solid 1px red" + input.setAttribute("readonly", "true") + } + content.appendRow("Channel Name", input) + + content.description(data["name"]) + + // Beschreibung + var dbKey:string = "x-description" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Used by the Dummy as an XML description") + markChangedOnChange(input) + content.appendRow("Channel Description", input) + + // Aktualisierung des Kanalnamens + if (data.hasOwnProperty("_uuid.key")) { + if (data["_uuid.key"] != "") { + var dbKey:string = "x-update-channel-name" + var input = content.createCheckbox(dbKey) + markChangedOnChange(input) + input.checked = data[dbKey] + content.appendRow("Update Channel Name", input) + } + } + + // Logo URL (Kanal) + var dbKey:string = "tvg-logo" + var input = content.createInput("text", dbKey, data[dbKey]) + markChangedOnChange(input) + input.setAttribute("id", "channel-icon") + content.appendRow("Logo URL", input) + + // Aktualisierung des Kanallogos + var dbKey:string = "x-update-channel-icon" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + input.setAttribute("id", "update-icon") + markChangedOnChange(input, function() { changeChannelLogo(id) }) + content.appendRow("Update Channel Logo", input) + + // Erweitern der EPG Kategorie + var dbKey:string = "x-category" + var text:string[] = ["-", "Kids (Emby only)", "News", "Movie", "Series", "Sports"] + var values:string[] = ["", "Kids", "News", "Movie", "Series", "Sports"] + var select = content.createSelect(text, values, data[dbKey], dbKey) + markChangedOnChange(select) + content.appendRow("EPG Category", select) + + // M3U Gruppentitel + var dbKey:string = "x-group-title" + var input = content.createInput("text", dbKey, data[dbKey]) + markChangedOnChange(input) + content.appendRow("Group Title (xteve.m3u)", input) + + if (data["group-title"] != undefined) { + content.description(data["group-title"]) + } + + // XMLTV Datei + var dbKey:string = "x-xmltv-file" + var xmlFile = data[dbKey] + var xmltv:XMLTVFile = new XMLTVFile() + var select = xmltv.getFiles(data[dbKey]) + select.setAttribute("name", dbKey) + select.setAttribute("id", "popup-xmltv") + markChangedOnChange(select, function(element) { setXmltvChannel(id, element) }) + content.appendRow("XMLTV File", select) + var file = data[dbKey] + + // XMLTV Mapping + var dbKey:string = "x-mapping" + var xmltv:XMLTVFile = new XMLTVFile() + var select = xmltv.getPrograms(file, data[dbKey]) + select.setAttribute("name", dbKey) + select.setAttribute("id", "popup-mapping") + markChangedOnChange(select, function(element) { checkXmltvChannel(id, element, xmlFile) }) + + sortSelect(select) + content.appendRow("XMLTV Channel", select) + + // Interaktion + content.createInteraction() + + // Logo hochladen + var input = content.createInput("button", "cancel", "Upload Logo") + input.addEventListener("click", function() { uploadLogo() }) + content.addInteraction(input) + + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Fertig + var ids:string[] = new Array() + ids = getAllSelectedChannels() + if (ids.length == 0) { + ids.push(id) + } + + var input = content.createInput("button", "save", "Done") + input.addEventListener("click", function() { donePopupData(dataType, ids.join(",")) }) + content.addInteraction(input) + break + + default: + break; + } + + showPopUpElement('popup-custom'); +} + +function savePopupData(dataType:string, id:string, remove:Boolean, option:number) { + + if (dataType == "mapping") { + + var data = new Object() + console.log("Save mapping data") + + cmd = "saveEpgMapping" + data["epgMapping"] = SERVER["xepg"]["epgMapping"] + + console.log("SEND TO SERVER"); + + var server:Server = new Server(cmd) + server.request(data) + + delete UNDO["epgMapping"] + + return + } + + console.log("Save popup data") + var div = document.getElementById("popup-custom") + + var inputs = div.getElementsByTagName("TABLE")[0].getElementsByTagName("INPUT"); + var selects = div.getElementsByTagName("TABLE")[0].getElementsByTagName("SELECT"); + + var input = new Object(); + var confirmMsg: string + + for (let i = 0; i < selects.length; i++) { + + var name:string + name = (selects[i] as HTMLSelectElement).name + var value = (selects[i] as HTMLSelectElement).value + + switch (name) { + case "tuner": + input[name] = parseInt(value) + break; + + default: + input[name] = value + break; + } + + } + + for (let i = 0; i < inputs.length; i++) { + + switch ((inputs[i] as HTMLInputElement).type) { + + case "checkbox": + name = (inputs[i] as HTMLInputElement).name + input[name] = (inputs[i] as HTMLInputElement).checked + break + + case "text": + case "hidden": + case "password": + + name = (inputs[i] as HTMLInputElement).name + + switch (name) { + case "tuner": + input[name] = parseInt((inputs[i] as HTMLInputElement).value) + break; + + default: + input[name] = (inputs[i] as HTMLInputElement).value + break; + } + + break + + } + + } + + var data = new Object() + + var cmd:string + + if (remove == true) { + input["delete"] = true + } + + switch (dataType) { + case "users": + + confirmMsg = "Delete this user?" + if (id == "-") { + cmd = "saveNewUser" + data["userData"] = input + } else { + cmd = "saveUserData" + var d = new Object() + d[id] = input + data["userData"] = d + } + + break; + + case "m3u": + + confirmMsg = "Delete this playlist?" + switch (option) { + // Popup: Save + case 0: + cmd = "saveFilesM3U" + break + + // Popup: Update + case 1: + cmd = "updateFileM3U" + break + + } + + data["files"] = new Object + data["files"][dataType] = new Object + data["files"][dataType][id] = input + + break + + case "hdhr": + + confirmMsg = "Delete this HDHomeRun tuner?" + switch (option) { + // Popup: Save + case 0: + cmd = "saveFilesHDHR" + break + + // Popup: Update + case 1: + cmd = "updateFileHDHR" + break + + } + + data["files"] = new Object + data["files"][dataType] = new Object + data["files"][dataType][id] = input + + break + + case "xmltv": + + confirmMsg = "Delete this XMLTV file?" + switch (option) { + // Popup: Save + case 0: + cmd = "saveFilesXMLTV" + break + + // Popup: Update + case 1: + cmd = "updateFileXMLTV" + break + + } + + data["files"] = new Object + data["files"][dataType] = new Object + data["files"][dataType][id] = input + + break + + case "filter": + + confirmMsg = "Delete this filter?" + cmd = "saveFilter" + data["filter"] = new Object + data["filter"][id] = input + break + + default: + console.log(dataType, id); + return + break; + + } + + if (remove == true) { + + if (!confirm(confirmMsg)) { + showElement("popup", false) + return + } + + } + + console.log("SEND TO SERVER"); + + console.log(data); + + var server:Server = new Server(cmd) + server.request(data) + +} + +function donePopupData(dataType:string, idsStr:string) { + + var ids:string[] = idsStr.split(','); + var div = document.getElementById("popup-custom") + var inputs = div.getElementsByClassName("changed") + + ids.forEach(id => { + var input = new Object(); + input = SERVER["xepg"]["epgMapping"][id] + + console.log(input); + + for (let i = 0; i < inputs.length; i++) { + + var name:string + var value:any + + switch (inputs[i].tagName) { + + case "INPUT": + switch ((inputs[i] as HTMLInputElement).type) { + case "checkbox": + name = (inputs[i] as HTMLInputElement).name + value = (inputs[i] as HTMLInputElement).checked + input[name] = value + break + + case "text": + name = (inputs[i] as HTMLInputElement).name + value = (inputs[i] as HTMLInputElement).value + input[name] = value + break + + } + + break + + case "SELECT": + name = (inputs[i] as HTMLSelectElement).name + value = (inputs[i] as HTMLSelectElement).value + input[name] = value + break + + } + + switch (name) { + + + case "tvg-logo": + //(document.getElementById(id).childNodes[2].firstChild as HTMLElement).setAttribute("src", value) + break + + case "x-name": + (document.getElementById(id).childNodes[3].firstChild as HTMLElement).textContent = value + break + + case "x-category": + (document.getElementById(id).childNodes[3].firstChild as HTMLElement).className = value + break + + case "x-group-title": + (document.getElementById(id).childNodes[5].firstChild as HTMLElement).textContent = value + break + + case "x-xmltv-file": + if (value != "xTeVe Dummy" && value != "-") { + value = getValueFromProviderFile(value, "xmltv", "name") + } + + if (value == "-") { + input["x-active"] = false + } + + (document.getElementById(id).childNodes[6].firstChild as HTMLElement).textContent = value + break + + case "x-mapping": + if (value == "-") { + input["x-active"] = false + } + + (document.getElementById(id).childNodes[7].firstChild as HTMLElement).textContent = value + + break + + default: + + } + + createSearchObj() + searchInMapping() + + } + + if (input["x-active"] == false) { + document.getElementById(id).className = "notActiveEPG" + } else { + document.getElementById(id).className = "activeEPG" + } + + console.log(input["tvg-logo"]); + (document.getElementById(id).childNodes[2].firstChild as HTMLElement).setAttribute("src", input["tvg-logo"]) + + + }); + + showElement("popup", false); + + return +} + +function showPreview(element:boolean) { + + var div = document.getElementById("myStreamsBox") + switch (element) { + + case false: + div.className = "notVisible" + return + break; + } + + var streams:string[] = ["activeStreams", "inactiveStreams"] + + streams.forEach(preview => { + + var table = document.getElementById(preview) + table.innerHTML = "" + var obj:string[] = SERVER["data"]["StreamPreviewUI"][preview] + + obj.forEach(channel => { + + var tr = document.createElement("TR") + var tdKey = document.createElement("TD") + var tdVal = document.createElement("TD") + + tdKey.className = "tdKey" + tdVal.className = "tdVal" + + switch (preview) { + case "activeStreams": + tdKey.innerText = "Channel: (+)" + break; + + case "inactiveStreams": + tdKey.innerText = "Channel: (-)" + break; + } + + tdVal.innerText = channel + tr.appendChild(tdKey) + tr.appendChild(tdVal) + + table.appendChild(tr) + + }); + + }); + + showElement("loading", false) + div.className = "visible" + + return +} diff --git a/ts/settings.ts b/ts/settings.ts index 1ec4838..b1818f8 100644 --- a/ts/settings.ts +++ b/ts/settings.ts @@ -1,3 +1,119 @@ +// One entry per settings key. The categories in base.ts decide which keys +// are shown and in which order; this table decides how each one renders. +interface SettingsFieldOption { + text:string + value:string +} + +interface SettingsField { + key:string + title:string + type:"text" | "checkbox" | "number" | "select" | "password" + placeholder?:string + autocomplete?:string + options?:SettingsFieldOption[] + // Static text written in the source; may contain HTML. + description:string +} + +function settingsOptions(text:string[], values:string[]):SettingsFieldOption[] { + var options:SettingsFieldOption[] = [] + for (var i = 0; i < text.length; i++) { + options.push({text: text[i], value: values[i]}) + } + return options +} + +function settingsNumberOptions(from:number, to:number):SettingsFieldOption[] { + var options:SettingsFieldOption[] = [] + for (var i = from; i <= to; i++) { + options.push({text: i.toString(), value: i.toString()}) + } + return options +} + +const SETTINGS_FIELDS:SettingsField[] = [ + + // Text inputs + {key: "update", title: "Schedule for updating (Playlist, XMLTV, Backup)", type: "text", placeholder: "0000,1000,2000", + description: "Time in 24 hour format (0800 = 8:00 am). More times can be entered comma separated. Leave this field empty if no updates are to be carried out."}, + {key: "backup.path", title: "Location for automatic backups", type: "text", placeholder: "/mnt/data/backup/xteve/", + description: "Before any update of the provider data by the schedule, xTeVe creates a backup. The path for the automatic backups can be changed. xTeVe requires write permission for this folder."}, + {key: "temp.path", title: "Location for the temporary files", type: "text", placeholder: "/tmp/xteve/", + description: "Location for the buffer files."}, + {key: "user.agent", title: "User Agent", type: "text", placeholder: "xTeVe", + description: "User Agent for HTTP requests. For every HTTP connection, this value is used for the user agent. Should only be changed if xTeVe is blocked."}, + {key: "plex.url", title: "Plex Server URL", type: "text", placeholder: "http://plex-host:32400", + description: "Base URL of your Plex server. Example: http://192.168.1.10:32400"}, + // The server sends the mask "********" and treats it coming back as "unchanged". + {key: "plex.token", title: "Plex API Token", type: "password", placeholder: "Plex X-Plex-Token", autocomplete: "off", + description: "Plex token used for authenticated API calls to refresh your DVR guide."}, + {key: "buffer.timeout", title: "Timeout for new client connections", type: "text", placeholder: "100", + description: "The xTeVe buffer waits until new client connections are established. Helpful for fast channel switching. Value in milliseconds."}, + {key: "ffmpeg.path", title: "FFmpeg Binary Path", type: "text", placeholder: "/path/to/ffmpeg", + description: "Path to FFmpeg binary."}, + {key: "ffmpeg.options", title: "FFmpeg Options", type: "text", placeholder: "Leave blank to set default settings", + description: "FFmpeg options.
Only change if you know what you are doing.
Leave blank to set default settings."}, + {key: "vlc.path", title: "VLC / CVLC Binary Path", type: "text", placeholder: "/path/to/cvlc", + description: "Path to VLC / CVLC binary."}, + {key: "vlc.options", title: "VLC / CVLC Options", type: "text", placeholder: "Leave blank to set default settings", + description: "VLC / CVLC options.
Only change if you know what you are doing.
Leave blank to set default settings."}, + {key: "udpxy", title: "UDPxy address", type: "text", placeholder: "host:port", + description: "The address of your UDPxy server. If set, and the channel URLs in the m3u is multicast, xTeVe will rewrite it so that it is accessed via the UDPxy service."}, + + // Checkboxes + {key: "authentication.web", title: "WEB Authentication", type: "checkbox", + description: "Access to the web interface only possible with credentials."}, + {key: "authentication.pms", title: "PMS Authentication", type: "checkbox", + description: "Plex requests are only possible with authentication.
Warning!!! After activating this function xTeVe must be delete in the PMS DVR settings and set up again."}, + {key: "authentication.m3u", title: "M3U Authentication", type: "checkbox", + description: "Downloading the xteve.m3u file via an HTTP request is only possible with authentication."}, + {key: "authentication.xml", title: "XML Authentication", type: "checkbox", + description: "Downloading the xteve.xml file via an HTTP request is only possible with authentication"}, + // Only rendered while web authentication is on (see SettingsCategoryItem.createCategory). + {key: "authentication.api", title: "API Authentication", type: "checkbox", + description: "Access to the API interface is only possible with authentication."}, + {key: "files.update", title: "Updates all files at startup", type: "checkbox", + description: "Updates all playlists, tuner and XMLTV files at startup."}, + {key: "cache.images", title: "Image Caching", type: "checkbox", + description: "All images from the XMLTV file are cached, allowing faster rendering of the grid in the client.
Downloading the images may take a while and will be done in the background."}, + {key: "xepg.replace.missing.images", title: "Replace missing program images", type: "checkbox", + description: "If the poster in the XMLTV program is missing, the channel logo will be used."}, + {key: "api", title: "API Interface", type: "checkbox", + description: "Via API interface it is possible to send commands to xTeVe. API documentation is here"}, + {key: "use_plexAPI", title: "Use Plex API Refresh", type: "checkbox", + description: "When enabled, xTeVe calls Plex directly to refresh DVR guide data after lineup or XEPG updates."}, + + // Selects + {key: "xepg.missing.epg.mode", title: "Missing EPG Handling", type: "select", + options: settingsOptions(["Strict (deactivate channels)", "Relaxed (keep active / dummy guide)"], ["strict", "relaxed"]), + description: "Strict: channels are deactivated when XMLTV mappings disappear.
Relaxed: channels stay active with last-known mappings and fall back to a dummy guide whenever guide data cannot be resolved."}, + {key: "tuner", title: "Number of Tuners", type: "select", + options: settingsNumberOptions(1, 100), + description: "Number of parallel connections that can be established to the provider.
Available for: Plex, Emby (HDHR), M3U (with active buffer).
After a change, xTeVe must be delete in the Plex / Emby DVR settings and set up again."}, + {key: "epgSource", title: "EPG Source", type: "select", + options: settingsOptions(["PMS", "XEPG"], ["PMS", "XEPG"]), + description: "PMS:
- Use EPG data from Plex or Emby

XEPG:
- Use of one or more XMLTV files
- Channel management
- M3U / XMLTV export (HTTP link for IPTV apps)"}, + {key: "backup.keep", title: "Number of backups to keep", type: "select", + options: settingsOptions(["5", "10", "20", "30", "40", "50"], ["5", "10", "20", "30", "40", "50"]), + description: "Number of backups to keep. Older backups are automatically deleted."}, + {key: "buffer.size.kb", title: "Buffer Size", type: "select", + options: settingsOptions(["0.5 MB", "1 MB", "2 MB", "3 MB", "4 MB", "5 MB", "6 MB", "7 MB", "8 MB"], ["512", "1024", "2048", "3072", "4096", "5120", "6144", "7168", "8192"]), + description: "Buffer size in MB.
M3U8: If the TS segment smaller then the buffer size, the file size of the segment is used."}, + {key: "buffer", title: "Stream Buffer", type: "select", + options: settingsOptions(["No Buffer (Client connects to the streaming server)", "xTeVe: (xTeVe connects to the streaming server)", "FFmpeg: (FFmpeg connects to the streaming server)", "VLC: (VLC connects to the streaming server)"], ["-", "xteve", "ffmpeg", "vlc"]), + description: "Functions of the buffer:
- The stream is passed from xTeVe, FFmpeg or VLC to Plex, Emby or M3U Player
- Small jerking of the streams can be compensated
- HLS / M3U8 support
- RTP / RTPS support (only FFmpeg or VLC)
- Re-streaming
- Separate tuner limit for each playlist"}, +] + +function getSettingsField(settingsKey:string):SettingsField { + for (var i = 0; i < SETTINGS_FIELDS.length; i++) { + if (SETTINGS_FIELDS[i].key == settingsKey) { + return SETTINGS_FIELDS[i] + } + } + return undefined +} + class SettingsCategory { DocumentID:string = "content_settings" createCategoryHeadline(value:string):any { @@ -11,558 +127,70 @@ class SettingsCategory { return element } - createSettings(settingsKey:string):any { - var setting = document.createElement("TR") + // createControl builds the input for one field, filled with the current value. + createControl(field:SettingsField, data:any):HTMLElement { var content:PopupContent = new PopupContent() - var data = SERVER["settings"][settingsKey] + var control:HTMLElement - switch (settingsKey) { + switch (field.type) { - // Texteingaben - case "update": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Schedule for updating (Playlist, XMLTV, Backup)" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "update", data.toString()) - input.setAttribute("placeholder", "0000,1000,2000") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) + case "checkbox": + control = content.createCheckbox(field.key); + (control as HTMLInputElement).checked = data break - case "backup.path": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Location for automatic backups" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "backup.path", data) - input.setAttribute("placeholder", "/mnt/data/backup/xteve/") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "temp.path": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Location for the temporary files" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "temp.path", data) - input.setAttribute("placeholder", "/tmp/xteve/") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "user.agent": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "User Agent" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "user.agent", data) - input.setAttribute("placeholder", "xTeVe") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "plex.url": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Plex Server URL" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "plex.url", data) - input.setAttribute("placeholder", "http://plex-host:32400") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "plex.token": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Plex API Token" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("password", "plex.token", data) - input.setAttribute("placeholder", "Plex X-Plex-Token") - input.setAttribute("autocomplete", "off") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "buffer.timeout": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Timeout for new client connections" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "buffer.timeout", data) - input.setAttribute("placeholder", "100") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "ffmpeg.path": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "FFmpeg Binary Path" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "ffmpeg.path", data) - input.setAttribute("placeholder", "/path/to/ffmpeg") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "ffmpeg.options": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "FFmpeg Options" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "ffmpeg.options", data) - input.setAttribute("placeholder", "Leave blank to set default settings") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "vlc.path": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "VLC / CVLC Binary Path" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "vlc.path", data) - input.setAttribute("placeholder", "/path/to/cvlc") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "vlc.options": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "VLC / CVLC Options" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "vlc.options", data) - input.setAttribute("placeholder", "Leave blank to set default settings") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - // Checkboxen - case "authentication.web": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "WEB Authentication" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "authentication.pms": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "PMS Authentication" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "authentication.m3u": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "M3U Authentication" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "authentication.xml": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "XML Authentication" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "authentication.api": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "API Authentication" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "files.update": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Updates all files at startup" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "cache.images": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Image Caching" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "xepg.replace.missing.images": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Replace missing program images" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "api": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "API Interface" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "use_plexAPI": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Use Plex API Refresh" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - // Select - case "xepg.missing.epg.mode": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Missing EPG Handling" + ":" - - var tdRight = document.createElement("TD") - var text:any[] = ["Strict (deactivate channels)", "Relaxed (keep active / dummy guide)"] - var values:any[] = ["strict", "relaxed"] - - var select = content.createSelect(text, values, data, settingsKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(select) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "tuner": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Number of Tuners" + ":" - - var tdRight = document.createElement("TD") - var text = new Array() - var values = new Array() - - for (var i = 1; i <= 100; i++) { - text.push(i) - values.push(i) - } - - var select = content.createSelect(text, values, data, settingsKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(select) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "epgSource": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "EPG Source" + ":" - - var tdRight = document.createElement("TD") - var text:any[] = ["PMS", "XEPG"] - var values:any[] = ["PMS", "XEPG"] - - var select = content.createSelect(text, values, data, settingsKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(select) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "backup.keep": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Number of backups to keep" + ":" - - var tdRight = document.createElement("TD") - var text:any[] = ["5", "10", "20", "30", "40", "50"] - var values:any[] = ["5", "10", "20", "30", "40", "50"] - - var select = content.createSelect(text, values, data, settingsKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(select) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "buffer.size.kb": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Buffer Size" + ":" - - var tdRight = document.createElement("TD") - var text:any[] = ["0.5 MB", "1 MB", "2 MB", "3 MB", "4 MB", "5 MB", "6 MB", "7 MB", "8 MB"] - var values:any[] = ["512", "1024", "2048", "3072", "4096", "5120", "6144", "7168", "8192"] - - var select = content.createSelect(text, values, data, settingsKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(select) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "buffer": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Stream Buffer" + ":" - - var tdRight = document.createElement("TD") - var text:any[] = ["No Buffer (Client connects to the streaming server)", "xTeVe: (xTeVe connects to the streaming server)", "FFmpeg: (FFmpeg connects to the streaming server)", "VLC: (VLC connects to the streaming server)"] - var values:any[] = ["-", "xteve", "ffmpeg", "vlc"] - - var select = content.createSelect(text, values, data, settingsKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(select) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "udpxy": - - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "UDPxy address" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "udpxy", data) - input.setAttribute("placeholder", "host:port") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - } - - return setting - - } - - - createDescription(settingsKey:string):any { - - var description = document.createElement("TR") - var text:string - switch (settingsKey) { - - case "authentication.web": - text = "Access to the web interface only possible with credentials." - break - - case "authentication.m3u": - text = "Downloading the xteve.m3u file via an HTTP request is only possible with authentication." - break - - case "authentication.pms": - text = "Plex requests are only possible with authentication.
Warning!!! After activating this function xTeVe must be delete in the PMS DVR settings and set up again." - break - - case "authentication.xml": - text = "Downloading the xteve.xml file via an HTTP request is only possible with authentication" - break - - case "authentication.api": - if (SERVER["settings"]["authentication.web"] == true) { - text = "Access to the API interface is only possible with authentication." - } - break - - case "backup.keep": - text = "Number of backups to keep. Older backups are automatically deleted." - break - - case "backup.path": - text = "Before any update of the provider data by the schedule, xTeVe creates a backup. The path for the automatic backups can be changed. xTeVe requires write permission for this folder." - break - - case "temp.path": - text = "Location for the buffer files." - break - - case "buffer": - text = "Functions of the buffer:
- The stream is passed from xTeVe, FFmpeg or VLC to Plex, Emby or M3U Player
- Small jerking of the streams can be compensated
- HLS / M3U8 support
- RTP / RTPS support (only FFmpeg or VLC)
- Re-streaming
- Separate tuner limit for each playlist" - break - - case "buffer.size.kb": - text = "Buffer size in MB.
M3U8: If the TS segment smaller then the buffer size, the file size of the segment is used." - break - - case "buffer.timeout": - text = "The xTeVe buffer waits until new client connections are established. Helpful for fast channel switching. Value in milliseconds." - break - - case "user.agent": - text = "User Agent for HTTP requests. For every HTTP connection, this value is used for the user agent. Should only be changed if xTeVe is blocked." - break - - case "plex.url": - text = "Base URL of your Plex server. Example: http://192.168.1.10:32400" - break - - case "plex.token": - text = "Plex token used for authenticated API calls to refresh your DVR guide." - break - - case "ffmpeg.path": - text = "Path to FFmpeg binary." - break - - case "ffmpeg.options": - text = "FFmpeg options.
Only change if you know what you are doing.
Leave blank to set default settings." - break - - case "vlc.path": - text = "Path to VLC / CVLC binary." - break - - case "vlc.options": - text = "VLC / CVLC options.
Only change if you know what you are doing.
Leave blank to set default settings." - break - - case "epgSource": - text = "PMS:
- Use EPG data from Plex or Emby

XEPG:
- Use of one or more XMLTV files
- Channel management
- M3U / XMLTV export (HTTP link for IPTV apps)" - break - - case "tuner": - text = "Number of parallel connections that can be established to the provider.
Available for: Plex, Emby (HDHR), M3U (with active buffer).
After a change, xTeVe must be delete in the Plex / Emby DVR settings and set up again." - break - - case "update": - text = "Time in 24 hour format (0800 = 8:00 am). More times can be entered comma separated. Leave this field empty if no updates are to be carried out." - break - - case "api": - text = "Via API interface it is possible to send commands to xTeVe. API documentation is here" - break - - case "use_plexAPI": - text = "When enabled, xTeVe calls Plex directly to refresh DVR guide data after lineup or XEPG updates." - break - - case "files.update": - text = "Updates all playlists, tuner and XMLTV files at startup." - break - - case "cache.images": - text = "All images from the XMLTV file are cached, allowing faster rendering of the grid in the client.
Downloading the images may take a while and will be done in the background." - break - - case "xepg.replace.missing.images": - text = "If the poster in the XMLTV program is missing, the channel logo will be used." - break - - case "xepg.missing.epg.mode": - text = "Strict: channels are deactivated when XMLTV mappings disappear.
Relaxed: channels stay active with last-known mappings and fall back to a dummy guide whenever guide data cannot be resolved." - break - - case "udpxy": - text = "The address of your UDPxy server. If set, and the channel URLs in the m3u is multicast, xTeVe will rewrite it so that it is accessed via the UDPxy service." + case "select": + var text:string[] = field.options.map(function(option) { return option.text }) + var values:string[] = field.options.map(function(option) { return option.value }) + control = content.createSelect(text, values, data, field.key) break default: - text = "" + // "text", "password", "number": lists (the update schedule) render comma separated. + var value:any = data + if (Array.isArray(data)) { + value = data.join(",") + } + control = content.createInput(field.type, field.key, value) + if (field.placeholder != undefined) { + control.setAttribute("placeholder", field.placeholder) + } + if (field.autocomplete != undefined) { + control.setAttribute("autocomplete", field.autocomplete) + } break + } + markChangedOnChange(control) + return control + } + + createSettings(settingsKey:string):any { + var setting = document.createElement("TR") + var field:SettingsField = getSettingsField(settingsKey) + if (field == undefined) { + return setting + } + + var data = SERVER["settings"][settingsKey] + + var tdLeft = document.createElement("TD") + tdLeft.textContent = field.title + ":" + + var tdRight = document.createElement("TD") + tdRight.appendChild(this.createControl(field, data)) + + setting.appendChild(tdLeft) + setting.appendChild(tdRight) + return setting + } + + createDescription(settingsKey:string):any { + var description = document.createElement("TR") + var field:SettingsField = getSettingsField(settingsKey) + var text:string = "" + if (field != undefined) { + text = field.description } var tdLeft = document.createElement("TD") @@ -577,7 +205,6 @@ class SettingsCategory { description.appendChild(tdRight) return description - } } @@ -669,6 +296,7 @@ function saveSettings() { break case "text": + case "password": name = (settings[i] as HTMLInputElement).name value = (settings[i] as HTMLInputElement).value diff --git a/ts/tsconfig.json b/ts/tsconfig.json index 41433d0..004d754 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -11,12 +11,15 @@ "removeComments": false }, "files": [ - "network_ts.ts", - "menu_ts.ts", - "settings_ts.ts", - "logs_ts.ts", - "base_ts.ts", - "configuration_ts.ts", - "authentication_ts.ts" + "network.ts", + "menu.ts", + "content.ts", + "popup.ts", + "xmltv.ts", + "settings.ts", + "logs.ts", + "base.ts", + "configuration.ts", + "authentication.ts" ] } diff --git a/ts/xmltv.ts b/ts/xmltv.ts new file mode 100644 index 0000000..79adadc --- /dev/null +++ b/ts/xmltv.ts @@ -0,0 +1,206 @@ +class XMLTVFile { + File:string + + getFiles(set:string):any { + var fileIDs:string[] = getObjKeys(SERVER["xepg"]["xmltvMap"]) + var values = new Array("-"); + var text = new Array("-"); + + for (let i = 0; i < fileIDs.length; i++) { + if (fileIDs[i] != "xTeVe Dummy") { + values.push(getValueFromProviderFile(fileIDs[i], "xmltv", "file.xteve")) + text.push(getValueFromProviderFile(fileIDs[i], "xmltv", "name")) + } else { + values.push(fileIDs[i]) + text.push(fileIDs[i]) + } + + } + + var select = document.createElement("SELECT") + for (let i = 0; i < text.length; i++) { + var option = document.createElement("OPTION") + option.setAttribute("value", values[i]) + option.innerText = text[i] + select.appendChild(option) + } + + if(set != "") { + (select as HTMLSelectElement).value = set + } + + return select + } + + getPrograms(file:string, set:string):any { + //var fileIDs:string[] = getObjKeys(SERVER["xepg"]["xmltvMap"]) + var values = getObjKeys(SERVER["xepg"]["xmltvMap"][file]); + var text = new Array() + var displayName:string + + for (let i = 0; i < values.length; i++) { + if (SERVER["xepg"]["xmltvMap"][file][values[i]].hasOwnProperty('display-name') == true) { + displayName = SERVER["xepg"]["xmltvMap"][file][values[i]]["display-name"]; + } else { + displayName = "-" + } + + text[i] = displayName + " (" + values[i] + ")"; + } + + text.unshift("-"); + values.unshift("-"); + + var select = document.createElement("SELECT") + for (let i = 0; i < text.length; i++) { + var option = document.createElement("OPTION") + option.setAttribute("value", values[i]) + option.innerText = text[i] + select.appendChild(option) + } + + if(set != "") { + (select as HTMLSelectElement).value = set + } + + if ((select as HTMLSelectElement).value != set) { + (select as HTMLSelectElement).value = "-" + } + + return select + } + + return +} + +function getValueFromProviderFile(file:string, fileType, key) { + + if (file == "xTeVe Dummy") { + return file + } + + var fileID:string + var indicator = file.charAt(0) + + switch (indicator) { + case "M": + fileType = "m3u" + fileID = file + break; + + case "H": + fileType = "hdhr" + fileID = file + break; + + case "X": + fileType = "xmltv" + fileID = file.substring(0, file.lastIndexOf('.')) + break; + + } + + if (SERVER["settings"]["files"][fileType].hasOwnProperty(fileID) == true) { + var data = SERVER["settings"]["files"][fileType][fileID]; + return data[key] + } + + return + +} + +function setXmltvChannel(id, element) { + + var xmltv:XMLTVFile = new XMLTVFile() + var xmlFile = element.value + + var tvgId:string = SERVER["xepg"]["epgMapping"][id]["tvg-id"] + var td = document.getElementById("popup-mapping").parentElement + td.innerHTML = "" + + var select = xmltv.getPrograms(element.value, tvgId) + select.setAttribute("name", "x-mapping") + select.setAttribute("id", "popup-mapping") + markChangedOnChange(select, function(element) { checkXmltvChannel(id, element, xmlFile) }) + select.className = "changed" + sortSelect(select) + td.appendChild(select); + + checkXmltvChannel(id, select, xmlFile) +} + +function checkXmltvChannel(id:string, element:any, xmlFile) { + + var value = (element as HTMLSelectElement).value + var bool:boolean + var checkbox = document.getElementById('active') + var channel:any = SERVER["xepg"]["epgMapping"][id] + var updateLogo:boolean + + + if (value == "-") { + bool = false + } else { + bool = true + } + + (checkbox as HTMLInputElement).checked = bool + checkbox.className = "changed" + console.log(xmlFile); + + // Kanallogo aktualisieren + /* + updateLogo = (document.getElementById("update-icon") as HTMLInputElement).checked + console.log(updateLogo); + */ + + if(xmlFile != "xTeVe Dummy" && bool == true) { + + //(document.getElementById("update-icon") as HTMLInputElement).checked = true; + //(document.getElementById("update-icon") as HTMLInputElement).className = "changed"; + + console.log("ID", id) + changeChannelLogo(id) + + return + } + + if (xmlFile == "xTeVe Dummy") { + (document.getElementById("update-icon") as HTMLInputElement).checked = false; + (document.getElementById("update-icon") as HTMLInputElement).className = "changed"; + } + + return +} + +function changeChannelLogo(id:string) { + + var updateLogo:boolean + var channel:any = SERVER["xepg"]["epgMapping"][id] + + var f = (document.getElementById("popup-xmltv") as HTMLSelectElement); + var xmltvFile = f.options[f.selectedIndex].value; + + var m = (document.getElementById("popup-mapping") as HTMLSelectElement); + var xMapping = m.options[m.selectedIndex].value; + + var xmltvLogo = SERVER["xepg"]["xmltvMap"][xmltvFile][xMapping]["icon"] + updateLogo = (document.getElementById("update-icon") as HTMLInputElement).checked + + if (updateLogo == true && xmltvFile != "xTeVe Dummy") { + + if (SERVER["xepg"]["xmltvMap"][xmltvFile].hasOwnProperty(xMapping)) { + var logo = xmltvLogo + } else { + logo = channel["tvg-logo"] + } + + var logoInput = (document.getElementById("channel-icon") as HTMLInputElement); + logoInput.value = logo + if (BULK_EDIT == false) { + logoInput.className = "changed" + } + + } + +}