diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..6fcaf1d --- /dev/null +++ b/.drone.yml @@ -0,0 +1,59 @@ +kind: pipeline +type: docker +name: default + +trigger: + event: + - push + - pull_request + +steps: + - name: go-test + image: cache.coadcorp.com/library/golang + commands: + - go mod download + - go test ./... + + - name: go-build + image: cache.coadcorp.com/library/golang + commands: + - go build -v ./... + + - name: dockerfile-lint + image: cache.coadcorp.com/library/hadolint/hadolint:v2.12.0-alpine + commands: + - hadolint -t error Dockerfile + + - name: compose-validate + image: cache.coadcorp.com/library/docker:27-cli + commands: + - apk add --no-cache docker-cli-compose + - docker compose -f docker-compose.yml config -q + - docker compose -f docker-compose.host.yml config -q + + - name: docker-build-validate + image: gcr.io/kaniko-project/executor:v1.23.2-debug + commands: + - /kaniko/executor --context "${DRONE_WORKSPACE}" --dockerfile "${DRONE_WORKSPACE}/Dockerfile" --no-push --destination xteve:validate --build-arg TARGETOS=linux --build-arg TARGETARCH=amd64 + when: + event: + - pull_request + + - name: docker-publish + image: plugins/docker + settings: + registry: registry.coadcorp.com + repo: registry.coadcorp.com/nathan/xteve + dockerfile: Dockerfile + username: nathan + password: + from_secret: registry_password + tags: + - latest + - ${DRONE_COMMIT_SHA} + build_args: + - TARGETOS=linux + - TARGETARCH=amd64 + when: + event: + - push diff --git a/html/configuration.html b/html/configuration.html index 0f61abd..e3eb31d 100644 --- a/html/configuration.html +++ b/html/configuration.html @@ -15,14 +15,14 @@ -
+
-
+
- +
@@ -46,13 +46,14 @@

Configuration

-

-
+ +
+

-
+ diff --git a/html/create-first-user.html b/html/create-first-user.html index 32ed30b..2d57ca9 100644 --- a/html/create-first-user.html +++ b/html/create-first-user.html @@ -14,34 +14,34 @@ -
+

{{.account.headline}}

-

+
-
+ -
{{.account.username.title}}:
- -
{{.account.password.title}}:
- -
{{.account.confirm.title}}:
- + + + + + +
-
+ diff --git a/html/css/base.css b/html/css/base.css index ce306dc..43f1c7f 100644 --- a/html/css/base.css +++ b/html/css/base.css @@ -7,7 +7,7 @@ --line: #274462; --line-soft: #1b334d; --text: #e9f5ff; - --text-muted: #9db5cb; + --text-muted: #b7cee1; --accent: #35d2ff; --accent-strong: #12b9ff; --accent-soft: rgba(53, 210, 255, 0.2); @@ -152,6 +152,11 @@ button { font-size: 13px; } +:focus-visible { + outline: 2px solid #91eaff; + outline-offset: 2px; +} + input, select { margin: 4px 0; @@ -220,6 +225,21 @@ button:active { transform: translateY(0); } +input[type=button]:focus-visible, +input[type=submit]:focus-visible, +button:focus-visible, +input[type=checkbox]:focus-visible, +a:focus-visible, +select:focus-visible, +textarea:focus-visible, +input[type=text]:focus-visible, +input[type=search]:focus-visible, +input[type=password]:focus-visible, +input[type=number]:focus-visible { + outline: 2px solid #7de5ff; + outline-offset: 2px; +} + input[type=button].delete { color: #fff; background: linear-gradient(135deg, #ff7f7f 0%, #e94343 100%); @@ -344,6 +364,37 @@ input[type=checkbox]:checked:before { text-align: center; } +.skip-link { + position: absolute; + top: 10px; + left: 10px; + z-index: 2600; + border-radius: 999px; + padding: 8px 12px; + font-size: 12px; + font-weight: 700; + color: #03131f; + background: linear-gradient(135deg, #7be8ff 0%, #19c1ff 100%); + transform: translateY(-180%); + transition: transform 0.2s ease; +} + +.skip-link:focus-visible { + transform: translateY(0); +} + +.sr-only { + border: 0 !important; + clip: rect(0 0 0 0) !important; + height: 1px !important; + margin: -1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + white-space: nowrap !important; + width: 1px !important; +} + .floatRight { float: right; } @@ -410,7 +461,7 @@ input[type=checkbox]:checked:before { } .errorMsg { - color: var(--error); + color: #ff8d8d; } .warningMsg { @@ -505,6 +556,43 @@ input[type=checkbox]:checked:before { color: #d5efff; } +.popup-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.popup-title h3 { + margin: 0; + text-align: left; +} + +#popup-custom .popup-title h3 { + margin: 0; + text-align: left; +} + +.popup-close { + min-width: 40px; + min-height: 40px; + margin: 0; + padding: 0; + border-radius: 999px; + border: 1px solid var(--line); + color: #d9f0ff; + background: rgba(14, 32, 50, 0.9); + box-shadow: none; + font-size: 20px; + line-height: 1; +} + +.popup-close:hover { + color: #ffffff; + border-color: #5bc8ed; +} + #popup-custom table, #content_settings table, #mapping-detail-table, @@ -637,20 +725,58 @@ input[type=checkbox]:checked:before { } #popup { - padding: 10px; + padding: 0; } #popup-custom, #mapping-detail, #user-detail, #file-detail { - max-height: calc(100vh - 20px); - padding: 12px; + max-width: none; + width: 100%; + height: 100%; + max-height: 100%; + border-radius: 0; + border: 0; + padding: 10px 10px calc(14px + env(safe-area-inset-bottom)); + } + + #popup-custom table { + border-spacing: 0 10px; + } + + #popup-custom tr { + display: block; + border: 1px solid var(--line-soft); + border-radius: 10px; + padding: 8px 8px 10px; + background: rgba(12, 26, 42, 0.8); + } + + #popup-custom td { + display: block; + width: 100%; + padding: 4px 2px; + } + + #popup-custom td.left { + width: 100%; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + } + + #popup-interaction { + position: sticky; + bottom: 0; + z-index: 5; + margin-top: 10px; + padding-top: 10px; + background: linear-gradient(180deg, rgba(12, 27, 44, 0.1) 0%, rgba(12, 27, 44, 0.96) 30%); } .interaction, - #interaction, - #popup-interaction { + #interaction { justify-content: stretch; } @@ -658,5 +784,15 @@ input[type=checkbox]:checked:before { .interaction input[type=submit], #popup-interaction input[type=button] { width: 100%; + min-height: 44px; + } + + .popup-title h3 { + font-size: 1.02rem; + } + + .popup-close { + min-width: 44px; + min-height: 44px; } } diff --git a/html/css/screen.css b/html/css/screen.css index 3c5c980..78e086b 100644 --- a/html/css/screen.css +++ b/html/css/screen.css @@ -74,6 +74,12 @@ transform: translateX(2px); } +#main-menu li:focus-visible { + border-color: rgba(103, 232, 255, 0.62); + transform: translateX(2px); + box-shadow: 0 0 0 3px rgba(53, 210, 255, 0.23); +} + #main-menu li.menu-active { border-color: rgba(103, 232, 255, 0.55); background: linear-gradient(135deg, #6fe6ff 0%, #1cc5ff 100%); @@ -321,6 +327,7 @@ nav p { flex-wrap: wrap; align-items: center; gap: 8px; + position: relative; } #content-interaction .search { @@ -328,6 +335,10 @@ nav p { margin-left: auto; } +.mobile-only-control { + display: none; +} + #box-wrapper { width: 100%; overflow: auto; @@ -368,6 +379,19 @@ nav p { background-color: rgba(28, 53, 79, 0.5); } +#content_table tr[tabindex], +#content_table td[tabindex] { + cursor: pointer; +} + +#content_table tr[tabindex]:focus-visible, +#content_table td[tabindex]:focus-visible, +.keyboard-clickable:focus-visible { + outline: 2px solid #7de5ff; + outline-offset: -2px; + background-color: rgba(39, 73, 106, 0.56); +} + #content_table td { padding: 7px 8px; vertical-align: middle; @@ -562,6 +586,7 @@ nav p { @media only screen and (max-width: 900px) { #layout { grid-template-columns: 1fr; + padding-top: max(10px, env(safe-area-inset-top)); } #layout-overlay { @@ -582,28 +607,49 @@ nav p { .layout-left { position: fixed; - left: 12px; - top: 12px; - bottom: 12px; - width: min(300px, calc(100vw - 42px)); + left: 0; + top: 0; + bottom: 0; + width: min(320px, calc(100vw - 34px)); max-width: none; z-index: 1100; transform: translateX(-116%); transition: transform 0.25s ease; + border-radius: 0 16px 16px 0; + border-left: 0; } body.menu-open .layout-left { transform: translateX(0); } + #main-menu li { + min-height: 48px; + padding: 10px 12px; + } + + #main-menu li p { + font-size: 13px; + } + .layout-right { min-height: calc(100vh - 24px); } + #shell-header { + position: sticky; + top: 0; + z-index: 8; + backdrop-filter: blur(8px); + } + #menu-toggle { display: inline-flex; align-items: center; justify-content: center; + min-height: 42px; + min-width: 92px; + font-size: 13px; } #clientInfo, @@ -614,6 +660,24 @@ nav p { #content { padding: 10px; + padding-bottom: calc(14px + env(safe-area-inset-bottom)); + } + + #content-interaction { + position: sticky; + top: 10px; + z-index: 6; + margin-bottom: 10px; + padding: 8px; + border: 1px solid var(--line-soft); + border-radius: 12px; + background: rgba(9, 22, 36, 0.92); + backdrop-filter: blur(6px); + } + + #content-interaction input[type=button], + #content-interaction select { + min-height: 42px; } #allStreams { @@ -630,6 +694,118 @@ nav p { } } +@media only screen and (max-width: 760px) { + #shell-header { + padding: 10px 10px; + } + + #clientInfo { + display: grid; + grid-template-columns: minmax(86px, 34%) 1fr; + gap: 7px 8px; + border-spacing: 0; + white-space: normal; + background: rgba(8, 20, 33, 0.44); + } + + #clientInfo tr { + display: contents; + } + + #clientInfo .tdKey { + text-align: right; + font-size: 9px; + letter-spacing: 0.08em; + } + + #clientInfo .tdVal { + font-size: 11px; + min-height: 34px; + display: flex; + align-items: center; + } + + .dashboard-cards { + grid-template-columns: repeat(2, minmax(120px, 1fr)); + } + + #content_table { + display: block; + } + + #content_table .content_table_header { + display: none; + } + + #content_table tbody, + #content_table tr, + #content_table td { + display: block; + width: 100%; + } + + #content_table tr { + border: 1px solid var(--line-soft); + border-left-width: 3px; + border-radius: 12px; + margin-bottom: 10px; + padding: 6px; + background: rgba(11, 24, 39, 0.86); + } + + #content_table td { + border: 0; + border-radius: 10px; + margin: 4px 0; + padding: 7px 8px; + display: grid; + grid-template-columns: minmax(92px, 36%) 1fr; + align-items: center; + gap: 8px; + background: rgba(17, 34, 53, 0.5); + } + + #content_table td::before { + content: attr(data-label); + color: #8bb9d7; + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + #content_table td[data-cell-type="checkbox"], + #content_table td[data-cell-type="image"] { + grid-template-columns: 1fr; + justify-items: start; + } + + #content_table td[data-cell-type="checkbox"]::before, + #content_table td[data-cell-type="image"]::before { + margin-bottom: 4px; + } + + #content_table td[data-cell-type="image"] img { + margin-left: 0; + } + + #content_table input[type=text] { + min-width: 0; + max-width: none; + width: 100%; + min-height: 40px; + } + + #content_table input[type=checkbox] { + width: 24px; + height: 24px; + } + + .mobile-only-control { + display: block; + width: 100%; + } +} + @media only screen and (max-width: 620px) { #layout { padding: 8px; @@ -655,19 +831,10 @@ nav p { } #clientInfo { - display: block; - overflow-x: auto; - white-space: nowrap; - } - - #clientInfo table, - #clientInfo tbody, - #clientInfo tr { - width: auto; - } - - .dashboard-cards { - grid-template-columns: repeat(2, minmax(120px, 1fr)); + grid-template-columns: minmax(78px, 32%) 1fr; + gap: 6px; + padding-left: 8px; + padding-right: 8px; } #content-interaction { @@ -683,6 +850,7 @@ nav p { #content-interaction input[type=button] { width: 100%; margin-right: 0; + min-height: 44px; } #box { diff --git a/html/index.html b/html/index.html index 604d344..20eed3b 100644 --- a/html/index.html +++ b/html/index.html @@ -17,15 +17,17 @@ -
+ + + -
Version:  
+
@@ -92,9 +94,9 @@
xTeVe:
-
+
-
+
@@ -103,7 +105,8 @@
-
+
+

diff --git a/html/js/authentication_ts.js b/html/js/authentication_ts.js index 5201cdf..591c3ee 100644 --- a/html/js/authentication_ts.js +++ b/html/js/authentication_ts.js @@ -1,8 +1,13 @@ function login() { var err = false; + var firstInvalid = null; var data = new Object(); var div = document.getElementById("content"); var form = document.getElementById("authentication"); + var errElement = document.getElementById("err"); + if (errElement != null) { + errElement.innerHTML = ""; + } var inputs = div.getElementsByTagName("INPUT"); console.log(inputs); for (var i = inputs.length - 1; i >= 0; i--) { @@ -10,14 +15,22 @@ function login() { var value = inputs[i].value; if (value.length == 0) { inputs[i].style.borderColor = "red"; + inputs[i].setAttribute("aria-invalid", "true"); + if (firstInvalid == null) { + firstInvalid = inputs[i]; + } err = true; } else { inputs[i].style.borderColor = ""; + inputs[i].setAttribute("aria-invalid", "false"); } data[key] = value; } if (err == true) { + if (firstInvalid != null) { + firstInvalid.focus(); + } data = new Object(); return; } @@ -25,10 +38,31 @@ function login() { if (data["confirm"] != data["password"]) { document.getElementById('password').style.borderColor = "red"; document.getElementById('confirm').style.borderColor = "red"; + document.getElementById('password').setAttribute("aria-invalid", "true"); + document.getElementById('confirm').setAttribute("aria-invalid", "true"); document.getElementById("err").innerHTML = "{{.account.failed}}"; + document.getElementById('password').focus(); return; } } console.log(data); form.submit(); } +document.addEventListener("DOMContentLoaded", function () { + var form = document.getElementById("authentication"); + if (form == null) { + return; + } + var inputs = form.getElementsByTagName("INPUT"); + for (var i = 0; i < inputs.length; i++) { + inputs[i].addEventListener("keydown", function (event) { + if (event.key == "Enter") { + event.preventDefault(); + login(); + } + }); + } + if (inputs.length > 0) { + inputs[0].focus(); + } +}); diff --git a/html/js/base_ts.js b/html/js/base_ts.js index e52a43e..9b492b8 100644 --- a/html/js/base_ts.js +++ b/html/js/base_ts.js @@ -6,6 +6,7 @@ var UNDO = new Object(); var SERVER_CONNECTION = false; var WS_AVAILABLE = false; var ACTIVE_MENU_ID = ""; +var LAST_FOCUSED_ELEMENT = null; // Menü var menuItems = new Array(); menuItems.push(new MainMenuItem("playlist", "{{.mainMenu.item.playlist}}", "m3u.png", "{{.mainMenu.headline.playlist}}")); @@ -19,7 +20,7 @@ menuItems.push(new MainMenuItem("log", "{{.mainMenu.item.log}}", "log.png", "{{. menuItems.push(new MainMenuItem("logout", "{{.mainMenu.item.logout}}", "logout.png", "{{.mainMenu.headline.logout}}")); // Kategorien für die Einstellungen var settingsCategory = new Array(); -settingsCategory.push(new SettingsCategoryItem("{{.settings.category.general}}", "xteveAutoUpdate,tuner,epgSource,api")); +settingsCategory.push(new SettingsCategoryItem("{{.settings.category.general}}", "xteveAutoUpdate,tuner,epgSource,api,use_plexAPI,plex.url,plex.token")); settingsCategory.push(new SettingsCategoryItem("{{.settings.category.files}}", "update,files.update,temp.path,cache.images,xepg.replace.missing.images")); settingsCategory.push(new SettingsCategoryItem("{{.settings.category.streaming}}", "buffer,udpxy,buffer.size.kb,buffer.timeout,user.agent,ffmpeg.path,ffmpeg.options,vlc.path,vlc.options")); settingsCategory.push(new SettingsCategoryItem("{{.settings.category.backup}}", "backup.path,backup.keep")); @@ -50,6 +51,42 @@ function showElement(elmID, type) { return; } element.className = cssClass; + element.setAttribute("aria-hidden", type == true ? "false" : "true"); + if (elmID == "loading" && document.body != null) { + document.body.setAttribute("aria-busy", type == true ? "true" : "false"); + } + if (elmID == "popup") { + var popupContent_1 = document.getElementById("popup-custom"); + if (type == true) { + LAST_FOCUSED_ELEMENT = document.activeElement; + if (popupContent_1 != null) { + setTimeout(function () { + popupContent_1.focus(); + }, 20); + } + } + else { + if (LAST_FOCUSED_ELEMENT != null && LAST_FOCUSED_ELEMENT.focus != undefined) { + setTimeout(function () { + LAST_FOCUSED_ELEMENT.focus(); + }, 20); + } + LAST_FOCUSED_ELEMENT = null; + } + } +} +function announceToScreenReader(message) { + if (message == undefined || message.length == 0) { + return; + } + var region = document.getElementById("sr-announcer"); + if (region == null) { + return; + } + region.innerText = ""; + setTimeout(function () { + region.innerText = message; + }, 20); } function setConnectionState(state, text) { var label = text; @@ -75,6 +112,8 @@ function setConnectionState(state, text) { } indicator.className = "status-" + state; indicator.innerText = label; + indicator.setAttribute("aria-label", "Connection status: " + label); + announceToScreenReader("Connection status " + label); } function changeButtonAction(element, buttonID, attribute) { var value = element.options[element.selectedIndex].value; @@ -190,15 +229,29 @@ function sortTable(column) { var table = document.getElementById("content_table"); var tableHead = table.getElementsByTagName("TR")[0]; var tableItems = tableHead.getElementsByTagName("TD"); + for (var h = 0; h < tableItems.length; h++) { + if (tableItems[h].getAttribute("role") == "columnheader") { + tableItems[h].setAttribute("aria-sort", "none"); + } + } var sortObj = new Object(); var x, xValue; var tableHeader; var sortByString = false; if (column > 0 && COLUMN_TO_SORT > 0) { - tableItems[COLUMN_TO_SORT].className = "pointer"; - tableItems[column].className = "sortThis"; + tableItems[COLUMN_TO_SORT].classList.remove("sortThis"); + tableItems[COLUMN_TO_SORT].classList.add("pointer"); + tableItems[column].classList.remove("pointer"); + tableItems[column].classList.add("sortThis"); } COLUMN_TO_SORT = column; + var mobileSort = document.getElementById("mapping-sort-mobile"); + if (mobileSort != null && (column == 1 || column == 3 || column == 4 || column == 5)) { + mobileSort.value = column.toString(); + } + if (tableItems[column] != undefined && tableItems[column].getAttribute("role") == "columnheader") { + tableItems[column].setAttribute("aria-sort", "ascending"); + } var rows = table.rows; if (rows[1] != undefined) { tableHeader = rows[0]; @@ -258,6 +311,7 @@ function createSearchObj() { var channels = getObjKeys(data); var channelKeys = ["x-active", "x-channelID", "x-name", "_file.m3u.name", "x-group-title", "x-xmltv-file"]; channels.forEach(function (id) { + SEARCH_MAPPING[id] = ""; channelKeys.forEach(function (key) { if (key == "x-active") { switch (data[id][key]) { @@ -290,6 +344,9 @@ function searchInMapping() { for (var i = 1; i < trs.length; ++i) { var id = trs[i].getAttribute("id"); var element = SEARCH_MAPPING[id]; + if (element == undefined) { + continue; + } switch (element.toLowerCase().includes(searchValue.toLowerCase())) { case true: document.getElementById(id).style.display = ""; @@ -299,6 +356,7 @@ function searchInMapping() { break; } } + announceToScreenReader("Search updated"); return; } function calculateWrapperHeight() { diff --git a/html/js/configuration_ts.js b/html/js/configuration_ts.js index 8aa4ee3..03d1d24 100644 --- a/html/js/configuration_ts.js +++ b/html/js/configuration_ts.js @@ -35,7 +35,9 @@ var WizardItem = /** @class */ (function (_super) { var key = this.key; var content = new PopupContent(); var description; + var wizardField = null; var doc = document.getElementById(this.DocumentID); + doc.setAttribute("aria-busy", "true"); doc.innerHTML = ""; doc.appendChild(headline); switch (key) { @@ -50,6 +52,7 @@ var WizardItem = /** @class */ (function (_super) { select.setAttribute("class", "wizard"); select.id = key; doc.appendChild(select); + wizardField = select; description = "{{.wizard.tuner.description}}"; break; case "epgSource": @@ -59,6 +62,7 @@ var WizardItem = /** @class */ (function (_super) { select.setAttribute("class", "wizard"); select.id = key; doc.appendChild(select); + wizardField = select; description = "{{.wizard.epgSource.description}}"; break; case "m3u": @@ -67,6 +71,7 @@ var WizardItem = /** @class */ (function (_super) { input.setAttribute("class", "wizard"); input.id = key; doc.appendChild(input); + wizardField = input; description = "{{.wizard.m3u.description}}"; break; case "xmltv": @@ -75,6 +80,7 @@ var WizardItem = /** @class */ (function (_super) { input.setAttribute("class", "wizard"); input.id = key; doc.appendChild(input); + wizardField = input; description = "{{.wizard.xmltv.description}}"; break; default: @@ -82,8 +88,20 @@ var WizardItem = /** @class */ (function (_super) { break; } var pre = document.createElement("PRE"); + pre.id = "wizard-description-" + key; pre.innerHTML = description; doc.appendChild(pre); + if (wizardField != null) { + wizardField.setAttribute("aria-label", this.headline); + wizardField.setAttribute("aria-describedby", pre.id); + setTimeout(function () { + wizardField.focus(); + }, 20); + } + doc.setAttribute("aria-busy", "false"); + if (typeof announceToScreenReader == "function") { + announceToScreenReader(this.headline + " step"); + } console.log(headline, key); }; return WizardItem; @@ -145,3 +163,20 @@ configurationWizard.push(new WizardItem("tuner", "{{.wizard.tuner.title}}")); configurationWizard.push(new WizardItem("epgSource", "{{.wizard.epgSource.title}}")); configurationWizard.push(new WizardItem("m3u", "{{.wizard.m3u.title}}")); configurationWizard.push(new WizardItem("xmltv", "{{.wizard.xmltv.title}}")); +document.addEventListener("DOMContentLoaded", function () { + var container = document.getElementById("content"); + if (container == null) { + return; + } + container.addEventListener("keydown", function (event) { + if (event.key != "Enter") { + return; + } + var target = event.target; + if (target == null || target.tagName != "INPUT") { + return; + } + event.preventDefault(); + saveWizard(); + }); +}); diff --git a/html/js/menu_ts.js b/html/js/menu_ts.js index 41fb39e..a35911c 100644 --- a/html/js/menu_ts.js +++ b/html/js/menu_ts.js @@ -44,7 +44,13 @@ var MainMenuItem = /** @class */ (function (_super) { item.setAttribute("onclick", "javascript: openThisMenu(this)"); item.setAttribute("id", this.id); item.setAttribute("data-menu", this.menuKey); + item.setAttribute("role", "menuitem"); + item.setAttribute("tabindex", "0"); + item.setAttribute("aria-controls", "content"); + item.setAttribute("aria-label", this.value); + item.setAttribute("onkeydown", "if(event.key==='Enter' || event.key===' '){event.preventDefault();openThisMenu(this);}"); var img = this.createIMG(this.imgSrc); + img.setAttribute("alt", ""); var value = this.createValue(this.value); item.appendChild(img); item.appendChild(value); @@ -452,7 +458,7 @@ var Cell = /** @class */ (function () { break; case "INPUTCHANNEL": element = document.createElement("INPUT"); - element.setAttribute("onchange", "javscript: changeChannelNumber(this)"); + element.setAttribute("onchange", "javascript: changeChannelNumber(this)"); element.value = this.value; element.type = "text"; break; @@ -484,10 +490,18 @@ var Cell = /** @class */ (function () { } if (this.onclick == true) { td.setAttribute("onclick", this.onclickFunktion); - td.className = "pointer"; + td.className = "pointer keyboard-clickable"; + td.setAttribute("tabindex", "0"); + td.setAttribute("role", "button"); + td.setAttribute("onkeydown", "if(event.key==='Enter' || event.key===' '){event.preventDefault();this.click();}"); } if (this.tdClassName != undefined) { - td.className = this.tdClassName; + if (td.className.length > 0) { + td.className = td.className + " " + this.tdClassName; + } + else { + td.className = this.tdClassName; + } } return td; }; @@ -511,6 +525,7 @@ var ShowContent = /** @class */ (function (_super) { COLUMN_TO_SORT = -1; // Alten Inhalt löschen var doc = document.getElementById(this.DocumentID); + doc.setAttribute("aria-busy", "true"); doc.innerHTML = ""; showPreview(false); // Überschrift @@ -557,9 +572,31 @@ var ShowContent = /** @class */ (function (_super) { var input = this.createInput("button", menuKey, "{{.button.bulkEdit}}"); input.setAttribute("onclick", 'javascript: bulkEdit()'); interaction.appendChild(input); + var sortSelect = document.createElement("SELECT"); + sortSelect.setAttribute("id", "mapping-sort-mobile"); + sortSelect.className = "mobile-only-control"; + sortSelect.setAttribute("aria-label", "Sort mapping"); + var sortOptions = [ + { label: "{{.mapping.table.chNo}}", value: "1" }, + { label: "{{.mapping.table.channelName}}", value: "3" }, + { label: "{{.mapping.table.playlist}}", value: "4" }, + { label: "{{.mapping.table.groupTitle}}", value: "5" } + ]; + sortOptions.forEach(function (optionData) { + var option = document.createElement("OPTION"); + option.innerText = optionData.label; + option.value = optionData.value; + sortSelect.appendChild(option); + }); + sortSelect.value = "1"; + sortSelect.onchange = function () { + sortTable(parseInt(this.value, 10)); + }; + interaction.appendChild(sortSelect); var input = this.createInput("search", "search", ""); input.setAttribute("id", "searchMapping"); input.setAttribute("placeholder", "{{.button.search}}"); + input.setAttribute("aria-label", "{{.button.search}}"); input.className = "search"; input.setAttribute("oninput", 'javascript: searchInMapping()'); interaction.appendChild(input); @@ -581,6 +618,7 @@ var ShowContent = /** @class */ (function (_super) { var settings = this.createDIV(); wrapper.appendChild(settings); showSettings(); + finalizeContentAccessibility(headline); return; break; case "log": @@ -594,6 +632,7 @@ var ShowContent = /** @class */ (function (_super) { var logs = this.createDIV(); wrapper.appendChild(logs); showLogs(true); + finalizeContentAccessibility(headline); return; break; case "logout": @@ -666,10 +705,129 @@ var ShowContent = /** @class */ (function (_super) { break; } showElement("loading", false); + finalizeContentAccessibility(headline); }; return ShowContent; }(Content)); var SHELL_LAYOUT_READY = false; +function isKeyboardActivationKey(event) { + return event.key == "Enter" || event.key == " "; +} +function makeKeyboardClickable(element, label) { + if (element == null) { + return; + } + if (element.getAttribute("data-keyboard-ready") == "true") { + return; + } + var tagName = element.tagName.toUpperCase(); + if (tagName == "INPUT" || tagName == "BUTTON" || tagName == "SELECT" || tagName == "TEXTAREA" || tagName == "A") { + return; + } + element.setAttribute("data-keyboard-ready", "true"); + if (element.getAttribute("tabindex") == null) { + element.setAttribute("tabindex", "0"); + } + if (element.getAttribute("role") == null) { + element.setAttribute("role", "button"); + } + element.classList.add("keyboard-clickable"); + if (label != undefined && label.length > 0) { + if (element.getAttribute("aria-label") == null || element.getAttribute("aria-label").length == 0) { + element.setAttribute("aria-label", label); + } + } + element.addEventListener("keydown", function (event) { + if (isKeyboardActivationKey(event) == false) { + return; + } + event.preventDefault(); + this.click(); + }); +} +function applyTableAccessibility(table, sectionName) { + if (table == null) { + return; + } + table.setAttribute("role", "table"); + var rows = table.getElementsByTagName("TR"); + var headerLabels = []; + if (rows.length > 0) { + var headerCells = rows[0].getElementsByTagName("TD"); + for (var h = 0; h < headerCells.length; h++) { + var headerLabel = headerCells[h].innerText; + if (headerLabel == undefined || headerLabel.length == 0) { + headerLabel = "Value"; + } + if (headerLabel == "BULK") { + headerLabel = "Select"; + } + headerLabels.push(headerLabel); + } + } + for (var i = 0; i < rows.length; i++) { + rows[i].setAttribute("role", "row"); + if (rows[i].getAttribute("onclick") != null) { + var rowLabel = rows[i].innerText; + if (rowLabel == undefined || rowLabel.length == 0) { + rowLabel = sectionName + " row"; + } + makeKeyboardClickable(rows[i], rowLabel); + } + var cells = rows[i].getElementsByTagName("TD"); + for (var c = 0; c < cells.length; c++) { + if (i == 0) { + cells[c].setAttribute("role", "columnheader"); + } + else { + cells[c].setAttribute("role", "cell"); + } + var dataLabel = headerLabels[c]; + if (dataLabel == undefined || dataLabel.length == 0) { + dataLabel = "Value"; + } + cells[c].setAttribute("data-label", dataLabel); + var checkbox = cells[c].querySelector('input[type="checkbox"]'); + if (checkbox != null) { + cells[c].setAttribute("data-cell-type", "checkbox"); + } + else { + var image = cells[c].querySelector("img"); + if (image != null) { + cells[c].setAttribute("data-cell-type", "image"); + } + else { + cells[c].removeAttribute("data-cell-type"); + } + } + if (cells[c].getAttribute("onclick") != null) { + var cellLabel = cells[c].innerText; + if (cellLabel == undefined || cellLabel.length == 0) { + cellLabel = sectionName + " details"; + } + makeKeyboardClickable(cells[c], cellLabel); + } + } + } +} +function finalizeContentAccessibility(sectionName) { + var content = document.getElementById("content"); + if (content == null) { + return; + } + content.setAttribute("aria-busy", "false"); + var heading = content.getElementsByTagName("H3")[0]; + if (heading != null) { + heading.setAttribute("tabindex", "-1"); + setTimeout(function () { + heading.focus(); + }, 20); + } + applyTableAccessibility(document.getElementById("content_table"), sectionName); + if (sectionName != undefined && sectionName.length > 0) { + announceToScreenReader(sectionName + " loaded"); + } +} function setLayoutMenuState(open) { if (document.body == null) { return; @@ -680,6 +838,35 @@ function setLayoutMenuState(open) { else { document.body.classList.remove("menu-open"); } + var toggle = document.getElementById("menu-toggle"); + if (toggle != null) { + toggle.setAttribute("aria-expanded", open == true ? "true" : "false"); + toggle.setAttribute("aria-label", open == true ? "Close navigation menu" : "Open navigation menu"); + } + var overlay = document.getElementById("layout-overlay"); + if (overlay != null) { + overlay.setAttribute("aria-hidden", open == true ? "false" : "true"); + } + var wrapper = document.getElementById("menu-wrapper"); + if (wrapper != null) { + if (window.innerWidth <= 900) { + wrapper.setAttribute("aria-hidden", open == true ? "false" : "true"); + } + else { + wrapper.setAttribute("aria-hidden", "false"); + } + } + if (window.innerWidth <= 900 && open == false && toggle != null && wrapper != null && wrapper.contains(document.activeElement)) { + toggle.focus(); + } + if (window.innerWidth <= 900 && open == true && wrapper != null) { + var firstMenuItem = wrapper.querySelector("#main-menu li"); + if (firstMenuItem != null) { + setTimeout(function () { + firstMenuItem.focus(); + }, 30); + } + } } function toggleLayoutMenu() { if (document.body == null) { @@ -702,10 +889,12 @@ function setActiveMenu(menuID) { var items = menu.getElementsByTagName("LI"); for (var i = 0; i < items.length; i++) { items[i].classList.remove("menu-active"); + items[i].removeAttribute("aria-current"); } var activeItem = document.getElementById(ACTIVE_MENU_ID); if (activeItem != null) { activeItem.classList.add("menu-active"); + activeItem.setAttribute("aria-current", "page"); } } function renderStatusCards() { @@ -728,6 +917,7 @@ function renderStatusCards() { cards.forEach(function (card) { var box = document.createElement("DIV"); box.className = "status-card status-card-" + card.tone; + box.setAttribute("role", "listitem"); var label = document.createElement("P"); label.className = "status-card-label"; label.innerText = card.label; @@ -741,6 +931,7 @@ function renderStatusCards() { } box.appendChild(label); box.appendChild(value); + box.setAttribute("aria-label", card.label + ": " + value.innerText); wrapper.appendChild(box); }); } @@ -768,7 +959,8 @@ function initShellLayout() { } if (event.key == "/") { var target = event.target; - var onInput = target.tagName == "INPUT" || target.tagName == "TEXTAREA" || target.tagName == "SELECT"; + var tagName = target != null && target.tagName != undefined ? target.tagName : ""; + var onInput = tagName == "INPUT" || tagName == "TEXTAREA" || tagName == "SELECT"; if (onInput == true) { return; } @@ -779,6 +971,7 @@ function initShellLayout() { } } }); + setLayoutMenuState(false); setConnectionState("idle"); SHELL_LAYOUT_READY = true; } @@ -798,6 +991,10 @@ function PageReady() { return; } function createLayout() { + var contentRegion = document.getElementById("content"); + if (contentRegion != null) { + contentRegion.setAttribute("aria-busy", "true"); + } // Client Info var obj = SERVER["clientInfo"]; var keys = getObjKeys(obj); @@ -808,6 +1005,9 @@ function createLayout() { } renderStatusCards(); if (!document.getElementById("main-menu")) { + if (contentRegion != null) { + contentRegion.setAttribute("aria-busy", "false"); + } return; } // Menü erstellen @@ -835,6 +1035,7 @@ function createLayout() { if (ACTIVE_MENU_ID.length > 0 && document.getElementById(ACTIVE_MENU_ID)) { setActiveMenu(ACTIVE_MENU_ID); } + setLayoutMenuState(document.body.classList.contains("menu-open")); var content = document.getElementById("content"); var menu = document.getElementById("main-menu"); if (ACTIVE_MENU_ID.length == 0 && content != null && menu != null) { @@ -845,6 +1046,9 @@ function createLayout() { } } } + if (contentRegion != null) { + contentRegion.setAttribute("aria-busy", "false"); + } return; } function openThisMenu(element) { @@ -852,6 +1056,10 @@ function openThisMenu(element) { var content = new ShowContent(id); setActiveMenu(id); content.show(); + var contentArea = document.getElementById("content"); + if (contentArea != null) { + contentArea.scrollTop = 0; + } closeLayoutMenuIfMobile(); calculateWrapperHeight(); return; @@ -890,9 +1098,26 @@ var PopupContent = /** @class */ (function (_super) { } PopupContent.prototype.createHeadline = function (headline) { this.doc.innerHTML = ""; + var titleBar = document.createElement("DIV"); + titleBar.className = "popup-title"; var element = document.createElement("H3"); + element.id = "popup-title-text"; element.innerHTML = headline.toUpperCase(); - this.doc.appendChild(element); + titleBar.appendChild(element); + var closeButton = document.createElement("BUTTON"); + closeButton.setAttribute("type", "button"); + closeButton.className = "popup-close"; + closeButton.setAttribute("aria-label", "Close dialog"); + closeButton.innerHTML = "×"; + closeButton.onclick = function () { + showElement("popup", false); + }; + titleBar.appendChild(closeButton); + this.doc.appendChild(titleBar); + var popup = document.getElementById("popup"); + if (popup != null) { + popup.setAttribute("aria-labelledby", "popup-title-text"); + } // Tabelle erstellen this.table = document.createElement("TABLE"); this.doc.appendChild(this.table); diff --git a/html/js/settings_ts.js b/html/js/settings_ts.js index f0f1fdb..cb9611b 100644 --- a/html/js/settings_ts.js +++ b/html/js/settings_ts.js @@ -74,6 +74,29 @@ var SettingsCategory = /** @class */ (function () { setting.appendChild(tdLeft); setting.appendChild(tdRight); break; + case "plex.url": + var tdLeft = document.createElement("TD"); + tdLeft.innerHTML = "{{.settings.plexURL.title}}" + ":"; + var tdRight = document.createElement("TD"); + var input = content.createInput("text", "plex.url", data); + input.setAttribute("placeholder", "{{.settings.plexURL.placeholder}}"); + 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 = "{{.settings.plexToken.title}}" + ":"; + var tdRight = document.createElement("TD"); + var input = content.createInput("password", "plex.token", data); + input.setAttribute("placeholder", "{{.settings.plexToken.placeholder}}"); + 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 = "{{.settings.bufferTimeout.title}}" + ":"; @@ -240,6 +263,17 @@ var SettingsCategory = /** @class */ (function () { setting.appendChild(tdLeft); setting.appendChild(tdRight); break; + case "use_plexAPI": + var tdLeft = document.createElement("TD"); + tdLeft.innerHTML = "{{.settings.usePlexAPI.title}}" + ":"; + 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 "tuner": var tdLeft = document.createElement("TD"); @@ -364,6 +398,12 @@ var SettingsCategory = /** @class */ (function () { case "user.agent": text = "{{.settings.userAgent.description}}"; break; + case "plex.url": + text = "{{.settings.plexURL.description}}"; + break; + case "plex.token": + text = "{{.settings.plexToken.description}}"; + break; case "ffmpeg.path": text = "{{.settings.ffmpegPath.description}}"; break; @@ -388,6 +428,9 @@ var SettingsCategory = /** @class */ (function () { case "api": text = "{{.settings.api.description}}"; break; + case "use_plexAPI": + text = "{{.settings.usePlexAPI.description}}"; + break; case "files.update": text = "{{.settings.filesUpdate.description}}"; break; diff --git a/html/lang/en.json b/html/lang/en.json index 73c3d14..505ddfd 100644 --- a/html/lang/en.json +++ b/html/lang/en.json @@ -355,6 +355,23 @@ "title": "API Interface", "description": "Via API interface it is possible to send commands to xTeVe. API documentation is here" }, + "usePlexAPI": + { + "title": "Use Plex API Refresh", + "description": "When enabled, xTeVe calls Plex directly to refresh DVR guide data after lineup or XEPG updates." + }, + "plexURL": + { + "title": "Plex Server URL", + "description": "Base URL of your Plex server. Example: http://192.168.1.10:32400", + "placeholder": "http://plex-host:32400" + }, + "plexToken": + { + "title": "Plex API Token", + "description": "Plex token used for authenticated API calls to refresh your DVR guide.", + "placeholder": "Plex X-Plex-Token" + }, "epgSource": { "title": "EPG Source", @@ -545,4 +562,4 @@ "placeholder": "Confirm" } } -} \ No newline at end of file +} diff --git a/html/login.html b/html/login.html index 08c31b6..988d338 100644 --- a/html/login.html +++ b/html/login.html @@ -14,32 +14,32 @@ -
+

{{.login.headline}}

-

{{.authenticationErr}}

+
-
+ -
{{.login.username.title}}:
- -
{{.login.password.title}}:
- + + + +
-
+ diff --git a/src/data.go b/src/data.go index 3ea9bdc..2108d5b 100644 --- a/src/data.go +++ b/src/data.go @@ -23,6 +23,7 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e var reloadData = false var cacheImages = false var createXEPGFiles = false + var triggerPlexGuideReload = false var debug string // -vvv [URL] --sout '#transcode{vcodec=mp4v, acodec=mpga} :standard{access=http, mux=ogg}' @@ -36,6 +37,21 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e case "tuner": showWarning(2105) + case "use_plexAPI": + triggerPlexGuideReload = true + + case "plex.url": + if v, ok := value.(string); ok { + value = strings.TrimRight(strings.TrimSpace(v), "/") + } + triggerPlexGuideReload = true + + case "plex.token": + if v, ok := value.(string); ok { + value = strings.TrimSpace(v) + } + triggerPlexGuideReload = true + case "epgSource": reloadData = true @@ -119,22 +135,26 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e oldSettings[key] = value - switch fmt.Sprintf("%T", value) { + if key == "plex.token" { + debug = fmt.Sprintf("Save Setting:Key: %s | Value: ******** (%T)", key, value) + } else { + switch fmt.Sprintf("%T", value) { - case "bool": - debug = fmt.Sprintf("Save Setting:Key: %s | Value: %t (%T)", key, value, value) + case "bool": + debug = fmt.Sprintf("Save Setting:Key: %s | Value: %t (%T)", key, value, value) - case "string": - debug = fmt.Sprintf("Save Setting:Key: %s | Value: %s (%T)", key, value, value) + case "string": + debug = fmt.Sprintf("Save Setting:Key: %s | Value: %s (%T)", key, value, value) - case "[]interface {}": - debug = fmt.Sprintf("Save Setting:Key: %s | Value: %v (%T)", key, value, value) + case "[]interface {}": + debug = fmt.Sprintf("Save Setting:Key: %s | Value: %v (%T)", key, value, value) - case "float64": - debug = fmt.Sprintf("Save Setting:Key: %s | Value: %d (%T)", key, int(value.(float64)), value) + case "float64": + debug = fmt.Sprintf("Save Setting:Key: %s | Value: %d (%T)", key, int(value.(float64)), value) - default: - debug = fmt.Sprintf("%T", value) + default: + debug = fmt.Sprintf("%T", value) + } } showDebug(debug, 1) @@ -250,6 +270,10 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e } + if triggerPlexGuideReload == true { + queuePlexGuideRefresh("settings change") + } + } return @@ -980,6 +1004,10 @@ func buildDatabaseDVR() (err error) { sort.Strings(Data.StreamPreviewUI.Active) sort.Strings(Data.StreamPreviewUI.Inactive) + if Settings.EpgSource != "XEPG" { + queuePlexGuideRefresh("lineup update") + } + return } diff --git a/src/plex_api.go b/src/plex_api.go new file mode 100644 index 0000000..baa64c0 --- /dev/null +++ b/src/plex_api.go @@ -0,0 +1,363 @@ +package src + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +var plexRefreshState = struct { + sync.Mutex + lastRun time.Time + scheduled bool + inProgress bool + queued bool + reason string + missingConfigLogged bool +}{} + +// queuePlexGuideRefresh schedules a debounced Plex DVR guide refresh. +func queuePlexGuideRefresh(reason string) { + + if Settings.UsePlexAPI == false { + return + } + + plexRefreshState.Lock() + + if len(strings.TrimSpace(reason)) > 0 { + plexRefreshState.reason = reason + } + + if plexRefreshState.scheduled == true || plexRefreshState.inProgress == true { + plexRefreshState.queued = true + plexRefreshState.Unlock() + return + } + + plexRefreshState.scheduled = true + delay := plexRefreshDelayLocked() + plexRefreshState.Unlock() + + go runPlexGuideRefresh(delay) +} + +func runPlexGuideRefresh(initialDelay time.Duration) { + + if initialDelay > 0 { + time.Sleep(initialDelay) + } + + for { + + plexRefreshState.Lock() + reason := strings.TrimSpace(plexRefreshState.reason) + if len(reason) == 0 { + reason = "update" + } + plexRefreshState.scheduled = false + plexRefreshState.inProgress = true + plexRefreshState.queued = false + plexRefreshState.Unlock() + + err := refreshPlexGuide(reason) + if err != nil { + ShowError(err, 0) + } + + plexRefreshState.Lock() + plexRefreshState.lastRun = time.Now() + runAgain := plexRefreshState.queued + plexRefreshState.inProgress = false + nextDelay := time.Duration(0) + + if runAgain == true && Settings.UsePlexAPI == true { + plexRefreshState.scheduled = true + nextDelay = plexRefreshDelayLocked() + } + plexRefreshState.Unlock() + + if runAgain == false || Settings.UsePlexAPI == false { + return + } + + time.Sleep(nextDelay) + + } +} + +func plexRefreshDelayLocked() (delay time.Duration) { + + const minDelay = 2 * time.Second + const cooldown = 20 * time.Second + + if plexRefreshState.lastRun.IsZero() == true { + return minDelay + } + + since := time.Since(plexRefreshState.lastRun) + if since >= cooldown { + return minDelay + } + + return cooldown - since +} + +func refreshPlexGuide(reason string) (err error) { + + baseURL, token, ready := getPlexConfig() + if ready == false { + return nil + } + + dvrs, err := discoverPlexDVRs(baseURL, token) + if err != nil { + return + } + + var refreshed int + var failed int + + for _, dvr := range dvrs { + + endpoints := getPlexReloadEndpoints(dvr) + if len(endpoints) == 0 { + failed++ + continue + } + + var endpointErr error + for _, endpoint := range endpoints { + + status, _, requestErr := doPlexRequest("POST", baseURL, endpoint, token) + if requestErr != nil { + endpointErr = requestErr + continue + } + + if status >= 200 && status < 300 { + refreshed++ + endpointErr = nil + break + } + + endpointErr = fmt.Errorf("Plex API returned HTTP %d for %s", status, endpoint) + } + + if endpointErr != nil { + failed++ + } + + } + + if refreshed == 0 { + if failed == 0 { + return errors.New("Plex API guide refresh failed") + } + + return fmt.Errorf("Plex API guide refresh failed for %d DVR(s)", failed) + } + + showInfo(fmt.Sprintf("Plex API:Guide reload requested for %d DVR(s) (%s)", refreshed, reason)) + if failed > 0 { + showInfo(fmt.Sprintf("Plex API:Guide reload failed for %d DVR(s)", failed)) + } + + return nil +} + +func getPlexConfig() (baseURL, token string, ready bool) { + + baseURL = strings.TrimSpace(Settings.PlexURL) + token = strings.TrimSpace(Settings.PlexToken) + + if strings.HasPrefix(baseURL, "http://") == false && strings.HasPrefix(baseURL, "https://") == false && len(baseURL) > 0 { + baseURL = "http://" + baseURL + } + + baseURL = strings.TrimRight(baseURL, "/") + + plexRefreshState.Lock() + defer plexRefreshState.Unlock() + + if len(baseURL) == 0 || len(token) == 0 { + + if plexRefreshState.missingConfigLogged == false { + showInfo("Plex API:Skipped refresh because plex.url or plex.token is empty") + plexRefreshState.missingConfigLogged = true + } + + return "", "", false + } + + plexRefreshState.missingConfigLogged = false + + return baseURL, token, true +} + +func discoverPlexDVRs(baseURL, token string) (dvrs []map[string]interface{}, err error) { + + status, body, err := doPlexRequest("GET", baseURL, "/livetv/dvrs", token) + if err != nil { + return + } + + if status < 200 || status >= 300 { + err = fmt.Errorf("Plex API returned HTTP %d for /livetv/dvrs", status) + return + } + + var payload = make(map[string]interface{}) + err = json.Unmarshal(body, &payload) + if err != nil { + return + } + + mediaContainer, ok := payload["MediaContainer"].(map[string]interface{}) + if ok == false { + err = errors.New("Plex API response missing MediaContainer") + return + } + + for _, key := range []string{"Dvr", "DVR", "Directory", "Metadata"} { + if raw, found := mediaContainer[key]; found == true { + if list, ok := raw.([]interface{}); ok == true { + dvrs = convertToPlexMapSlice(list) + if len(dvrs) > 0 { + return + } + } + } + } + + err = errors.New("Plex API returned no DVR entries") + return +} + +func convertToPlexMapSlice(list []interface{}) (dvrs []map[string]interface{}) { + + dvrs = make([]map[string]interface{}, 0, len(list)) + + for _, item := range list { + if dvr, ok := item.(map[string]interface{}); ok == true { + dvrs = append(dvrs, dvr) + } + } + + return +} + +func getPlexReloadEndpoints(dvr map[string]interface{}) (endpoints []string) { + + endpoints = make([]string, 0, 6) + added := make(map[string]bool) + + add := func(endpoint string) { + endpoint = strings.TrimSpace(endpoint) + if len(endpoint) == 0 { + return + } + if strings.HasPrefix(endpoint, "/") == false { + endpoint = "/" + endpoint + } + if added[endpoint] == true { + return + } + added[endpoint] = true + endpoints = append(endpoints, endpoint) + } + + if key := getPlexMapString(dvr, "key"); len(key) > 0 { + if strings.HasPrefix(key, "/") == true { + add(strings.TrimRight(key, "/") + "/reloadGuide") + } else { + add("/livetv/dvrs/" + url.PathEscape(key) + "/reloadGuide") + } + } + + for _, field := range []string{"uuid", "identifier", "machineIdentifier", "clientIdentifier", "id"} { + if value := getPlexMapString(dvr, field); len(value) > 0 { + add("/livetv/dvrs/" + url.PathEscape(value) + "/reloadGuide") + } + } + + return +} + +func getPlexMapString(data map[string]interface{}, field string) string { + + for key, value := range data { + if strings.EqualFold(key, field) == false { + continue + } + + switch v := value.(type) { + + case string: + return strings.TrimSpace(v) + + case float64: + return strconv.FormatInt(int64(v), 10) + } + } + + return "" +} + +func doPlexRequest(method, baseURL, endpoint, token string) (status int, body []byte, err error) { + + requestURL := strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(endpoint, "/") + + req, err := http.NewRequest(method, requestURL, nil) + if err != nil { + return + } + + query := req.URL.Query() + if len(token) > 0 { + query.Set("X-Plex-Token", token) + req.URL.RawQuery = query.Encode() + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Plex-Token", token) + + if len(System.DeviceID) > 0 { + req.Header.Set("X-Plex-Client-Identifier", System.DeviceID) + } + + if len(System.Name) > 0 { + req.Header.Set("X-Plex-Product", System.Name) + req.Header.Set("X-Plex-Device-Name", System.Name) + } + + if len(System.Version) > 0 { + req.Header.Set("X-Plex-Version", System.Version) + } + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return + } + + defer resp.Body.Close() + + status = resp.StatusCode + body, err = ioutil.ReadAll(resp.Body) + if err != nil { + return + } + + return +} diff --git a/src/struct-system.go b/src/struct-system.go index 595f4d2..3448a24 100644 --- a/src/struct-system.go +++ b/src/struct-system.go @@ -254,6 +254,9 @@ type Notification struct { // SettingsStruct : Inhalt der settings.json type SettingsStruct struct { API bool `json:"api"` + UsePlexAPI bool `json:"use_plexAPI"` + PlexURL string `json:"plex.url"` + PlexToken string `json:"plex.token"` AuthenticationAPI bool `json:"authentication.api"` AuthenticationM3U bool `json:"authentication.m3u"` AuthenticationPMS bool `json:"authentication.pms"` diff --git a/src/struct-webserver.go b/src/struct-webserver.go index e87abcb..5702a71 100644 --- a/src/struct-webserver.go +++ b/src/struct-webserver.go @@ -18,6 +18,9 @@ type RequestStruct struct { // Neue Werte für die Einstellungen (settings.json) Settings struct { API *bool `json:"api,omitempty"` + UsePlexAPI *bool `json:"use_plexAPI,omitempty"` + PlexURL *string `json:"plex.url,omitempty"` + PlexToken *string `json:"plex.token,omitempty"` AuthenticationAPI *bool `json:"authentication.api,omitempty"` AuthenticationM3U *bool `json:"authentication.m3u,omitempty"` AuthenticationPMS *bool `json:"authentication.pms,omitempty"` diff --git a/src/system.go b/src/system.go index 82be0e1..b1ef51c 100644 --- a/src/system.go +++ b/src/system.go @@ -106,6 +106,9 @@ func loadSettings() (settings SettingsStruct, err error) { dataMap["hdhr"] = make(map[string]interface{}) defaults["api"] = false + defaults["use_plexAPI"] = false + defaults["plex.url"] = "" + defaults["plex.token"] = "" defaults["authentication.api"] = false defaults["authentication.m3u"] = false defaults["authentication.pms"] = false diff --git a/src/xepg.go b/src/xepg.go index 97c7734..8ebc6a1 100644 --- a/src/xepg.go +++ b/src/xepg.go @@ -68,6 +68,7 @@ func buildXEPG(background bool) { cleanupXEPG() createXMLTVFile() createM3UFile() + queuePlexGuideRefresh("xepg rebuild") showInfo("XEPG:" + fmt.Sprintf("Ready to use")) @@ -84,6 +85,7 @@ func buildXEPG(background bool) { createXMLTVFile() createM3UFile() + queuePlexGuideRefresh("xepg image cache refresh") System.ImageCachingInProgress = 0 @@ -113,6 +115,7 @@ func buildXEPG(background bool) { createXMLTVFile() createM3UFile() + queuePlexGuideRefresh("xepg rebuild") if Settings.CacheImages == true && System.ImageCachingInProgress == 0 { @@ -127,6 +130,7 @@ func buildXEPG(background bool) { createXMLTVFile() createM3UFile() + queuePlexGuideRefresh("xepg image cache refresh") System.ImageCachingInProgress = 0 @@ -179,6 +183,7 @@ func updateXEPG(background bool) { createXMLTVFile() createM3UFile() + queuePlexGuideRefresh("xepg update") showInfo("XEPG:" + fmt.Sprintf("Ready to use")) System.ScanInProgress = 0 diff --git a/ts/base_ts.ts b/ts/base_ts.ts index 8cb3286..302548a 100644 --- a/ts/base_ts.ts +++ b/ts/base_ts.ts @@ -22,7 +22,7 @@ menuItems.push(new MainMenuItem("logout", "{{.mainMenu.item.logout}}", "logout.p // Kategorien für die Einstellungen var settingsCategory = new Array() -settingsCategory.push(new SettingsCategoryItem("{{.settings.category.general}}", "xteveAutoUpdate,tuner,epgSource,api"));settingsCategory.push(new SettingsCategoryItem("{{.settings.category.files}}", "update,files.update,temp.path,cache.images,xepg.replace.missing.images")) +settingsCategory.push(new SettingsCategoryItem("{{.settings.category.general}}", "xteveAutoUpdate,tuner,epgSource,api,use_plexAPI,plex.url,plex.token"));settingsCategory.push(new SettingsCategoryItem("{{.settings.category.files}}", "update,files.update,temp.path,cache.images,xepg.replace.missing.images")) settingsCategory.push(new SettingsCategoryItem("{{.settings.category.streaming}}", "buffer,udpxy,buffer.size.kb,buffer.timeout,user.agent,ffmpeg.path,ffmpeg.options,vlc.path,vlc.options")) settingsCategory.push(new SettingsCategoryItem("{{.settings.category.backup}}", "backup.path,backup.keep")) settingsCategory.push(new SettingsCategoryItem("{{.settings.category.authentication}}", "authentication.web,authentication.pms,authentication.m3u,authentication.xml,authentication.api")) diff --git a/ts/settings_ts.ts b/ts/settings_ts.ts index 432f91a..518e9bc 100644 --- a/ts/settings_ts.ts +++ b/ts/settings_ts.ts @@ -75,6 +75,35 @@ class SettingsCategory { setting.appendChild(tdRight) break + case "plex.url": + var tdLeft = document.createElement("TD") + tdLeft.innerHTML = "{{.settings.plexURL.title}}" + ":" + + var tdRight = document.createElement("TD") + var input = content.createInput("text", "plex.url", data) + input.setAttribute("placeholder", "{{.settings.plexURL.placeholder}}") + 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 = "{{.settings.plexToken.title}}" + ":" + + var tdRight = document.createElement("TD") + var input = content.createInput("password", "plex.token", data) + input.setAttribute("placeholder", "{{.settings.plexToken.placeholder}}") + 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 = "{{.settings.bufferTimeout.title}}" + ":" @@ -286,6 +315,20 @@ class SettingsCategory { setting.appendChild(tdRight) break + case "use_plexAPI": + var tdLeft = document.createElement("TD") + tdLeft.innerHTML = "{{.settings.usePlexAPI.title}}" + ":" + + 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 "tuner": var tdLeft = document.createElement("TD") @@ -454,6 +497,14 @@ class SettingsCategory { text = "{{.settings.userAgent.description}}" break + case "plex.url": + text = "{{.settings.plexURL.description}}" + break + + case "plex.token": + text = "{{.settings.plexToken.description}}" + break + case "ffmpeg.path": text = "{{.settings.ffmpegPath.description}}" break @@ -486,6 +537,10 @@ class SettingsCategory { text = "{{.settings.api.description}}" break + case "use_plexAPI": + text = "{{.settings.usePlexAPI.description}}" + break + case "files.update": text = "{{.settings.filesUpdate.description}}" break