Compare commits
10
Commits
320eaa1b28
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d385e68b31 | ||
|
|
e1a6956657 | ||
|
|
6a23996c38 | ||
|
|
0b26aa2029 | ||
|
|
014a3b04d2 | ||
|
|
3532679235 | ||
|
|
0b7a54d7da | ||
|
|
3a3ba861dc | ||
|
|
cbc5bc5da2 | ||
|
|
3bbea8e952 |
No files matched your search
+19
-5
@@ -8,7 +8,7 @@ steps:
|
||||
commands:
|
||||
- go mod download
|
||||
- go vet ./...
|
||||
- test -z "$(gofmt -l .)" || (echo "gofmt needed:" && gofmt -l . && exit 1)
|
||||
- 'test -z "$(gofmt -l .)" || (echo "gofmt needed" && gofmt -l . && exit 1)'
|
||||
- go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...
|
||||
- go test ./...
|
||||
|
||||
@@ -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
|
||||
@@ -26,7 +27,7 @@ steps:
|
||||
- XTEVE_VERSION="$(grep -m1 '^#### ' changelog-beta.md | cut -d' ' -f2 | sed 's/-beta$//')"
|
||||
- test -n "$XTEVE_VERSION" || (echo "Could not parse version from changelog-beta.md" && exit 1)
|
||||
- SRC_VERSION="$(grep -m1 '^var Version = ' xteve.go | cut -d'"' -f2)"
|
||||
- test "$SRC_VERSION" = "$XTEVE_VERSION" || (echo "Version drift: xteve.go has $SRC_VERSION, changelog-beta.md has $XTEVE_VERSION" && exit 1)
|
||||
- 'test "$SRC_VERSION" = "$XTEVE_VERSION" || (echo "Version drift - xteve.go has $SRC_VERSION, changelog-beta.md has $XTEVE_VERSION" && exit 1)'
|
||||
- echo "Building xTeVe version $XTEVE_VERSION from changelog-beta.md"
|
||||
- go build -v -ldflags "-X main.Version=$XTEVE_VERSION" ./...
|
||||
|
||||
@@ -50,6 +51,22 @@ steps:
|
||||
event:
|
||||
- pull_request
|
||||
|
||||
# plugins/docker reads its tag list from a .tags file when no "tags"
|
||||
# setting is given. Master images get latest, the commit SHA and the
|
||||
# release version from changelog-beta.md, so a release can be pulled by name.
|
||||
- name: image-tags
|
||||
image: cache.coadcorp.com/library/golang:1.27.1
|
||||
commands:
|
||||
- XTEVE_VERSION="$(grep -m1 '^#### ' changelog-beta.md | cut -d' ' -f2 | sed 's/-beta$//')"
|
||||
- test -n "$XTEVE_VERSION"
|
||||
- printf '%s' "latest,${DRONE_COMMIT_SHA},$XTEVE_VERSION" > .tags
|
||||
- cat .tags
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
branch:
|
||||
- master
|
||||
|
||||
- name: docker-publish
|
||||
image: plugins/docker
|
||||
environment:
|
||||
@@ -62,9 +79,6 @@ steps:
|
||||
username: nathan
|
||||
password:
|
||||
from_secret: registry_password
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA}
|
||||
build_args:
|
||||
- TARGETOS=linux
|
||||
- TARGETARCH=amd64
|
||||
|
||||
+3
-1
@@ -19,7 +19,9 @@ RUN XTEVE_VERSION="$(grep -m1 '^#### ' changelog-beta.md | cut -d' ' -f2 | sed '
|
||||
|
||||
# Static ffmpeg/ffprobe binaries. To bump, pick a tag from
|
||||
# https://hub.docker.com/r/mwader/static-ffmpeg/tags and change it here.
|
||||
FROM mwader/static-ffmpeg:7.1.1 AS ffmpeg
|
||||
# Do not go below 8.x: 7.1.1 rejects HLS segments with extensions such as .ec3
|
||||
# ("not in allowed_extensions"), which breaks streams with E-AC-3 audio.
|
||||
FROM mwader/static-ffmpeg:9.0.2 AS ffmpeg
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
|
||||
+6
-3
@@ -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.
|
||||
|
||||
@@ -50,7 +53,7 @@ Add `-dev` to serve the web interface from the local `html/` directory instead o
|
||||
|
||||
The version lives in two places that must agree: `var Version` in `xteve.go` and the first `#### ` heading in `changelog-beta.md` (without the `-beta` suffix). CI fails on drift.
|
||||
|
||||
Drone (`.drone.yml`) runs vet, tests, the bundle check, hadolint and compose validation on every push. Pushes to `master` publish `registry.coadcorp.com/nathan/xteve:latest` and `:<sha>`; other branches publish `:<branch>` and `:<sha>`.
|
||||
Drone (`.drone.yml`) runs vet, tests, the bundle check, hadolint and compose validation on every push. Pushes to `master` publish `registry.coadcorp.com/nathan/xteve:latest`, `:<version>` and `:<sha>`; other branches publish `:<branch>` and `:<sha>`.
|
||||
|
||||
## Endpoints useful for operations
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ registry.coadcorp.com/nathan/xteve
|
||||
|
||||
Tags:
|
||||
|
||||
* `latest` and `<commit sha>` from the `master` branch.
|
||||
* `latest`, the release version (for example `2.3.0.0202`) and `<commit sha>` from the `master` branch. Pin the version tag if you want to control when you upgrade, or to roll back.
|
||||
* `<branch name>` and `<commit sha>` from every other branch.
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
#### 2.3.0.0202-beta
|
||||
```diff
|
||||
+ Security: provider-supplied text is rendered as text in the web UI; websocket requests must come from the same origin; the session cookie is HttpOnly and SameSite=Strict; logo uploads, backup restores, the ffmpeg/VLC path and stream URLs are validated; backups can only be downloaded with a web session.
|
||||
+ Passwords are stored with bcrypt. Existing accounts are upgraded on their next login.
|
||||
+ Streaming: buffer state rewritten around a single lock with an atomic tuner limit; ffmpeg/VLC are stopped when the last client leaves or when no data arrives within 20 seconds; long streams no longer accumulate open handles.
|
||||
+ Per-playlist buffer choice (default, none, xTeVe, FFmpeg, VLC) in the playlist dialog.
|
||||
+ New filter type: regular expression.
|
||||
+ Web UI: one persistent websocket with automatic reconnect, light theme following the OS setting, focus is kept when the menu refreshes, errors are announced to screen readers.
|
||||
+ The web interface is embedded with go:embed and built from a pinned TypeScript toolchain; the language file is gone (English only).
|
||||
+ New /healthz endpoint, used by the container healthcheck.
|
||||
+ Container: PUID/PGID are applied at start-up; ffmpeg 9.0.2; images are also tagged with the release version.
|
||||
+ Settings and other state files are written atomically; settings.json is readable by the owner only.
|
||||
+ HTTP server and provider downloads have timeouts.
|
||||
- Removed the built-in self-updater. Updates are delivered through the container image.
|
||||
- Removed XTEVE_UID/XTEVE_GID as the documented variables (still accepted as a fallback for PUID/PGID).
|
||||
```
|
||||
|
||||
#### 2.2.0.0201-beta
|
||||
Add UI enhancement for range selecting checkboxes
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>xTeVe</title>
|
||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
||||
<link rel="stylesheet" href="css/base.css" type="text/css">
|
||||
<link rel="stylesheet" href="css/tokens.css">
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/components.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<script src="js/app.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -16,7 +18,7 @@
|
||||
</div>
|
||||
|
||||
<div id="header" class="imgCenter"></div>
|
||||
<main id="box" role="main" aria-labelledby="head-text">
|
||||
<main id="box" aria-labelledby="head-text">
|
||||
|
||||
<table id="clientInfo" class="visible" aria-label="Server information">
|
||||
<tr>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>xTeVe</title>
|
||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
||||
<link rel="stylesheet" href="css/base.css" type="text/css">
|
||||
<link rel="stylesheet" href="css/tokens.css">
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/components.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<script src="js/app.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -13,7 +15,7 @@
|
||||
|
||||
<div id="header" class="imgCenter"></div>
|
||||
|
||||
<main id="box" role="main" aria-labelledby="head-text">
|
||||
<main id="box" aria-labelledby="head-text">
|
||||
|
||||
<div id="headline">
|
||||
<h1 id="head-text" class="center">Create user account</h1>
|
||||
|
||||
+46
-492
@@ -1,32 +1,14 @@
|
||||
:root {
|
||||
--bg-0: #07111d;
|
||||
--bg-1: #0c1a2b;
|
||||
--bg-2: #12233a;
|
||||
--panel: #102238;
|
||||
--panel-soft: #0f1f33;
|
||||
--line: #274462;
|
||||
--line-soft: #1b334d;
|
||||
--text: #e9f5ff;
|
||||
--text-muted: #b7cee1;
|
||||
--accent: #35d2ff;
|
||||
--accent-strong: #12b9ff;
|
||||
--accent-soft: rgba(53, 210, 255, 0.2);
|
||||
--ok: #2fd18a;
|
||||
--warn: #ffc15a;
|
||||
--error: #ff6f6f;
|
||||
--radius-s: 10px;
|
||||
--radius-m: 14px;
|
||||
--radius-l: 18px;
|
||||
--shadow-1: 0 20px 45px rgba(0, 0, 0, 0.32);
|
||||
--shadow-2: 0 12px 30px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
/*
|
||||
* base.css - reset, typography, links, focus styles, skip link, generic
|
||||
* form elements and small utility classes. Loaded after tokens.css.
|
||||
*/
|
||||
|
||||
* {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
-ms-appearance: none;
|
||||
box-sizing: border-box;
|
||||
font-family: "Space Grotesk", "Avenir Next", "Trebuchet MS", sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
@@ -37,17 +19,17 @@
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
border-radius: 999px;
|
||||
background: rgba(7, 17, 29, 0.65);
|
||||
background: var(--scroll-track);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(7, 17, 29, 0.6);
|
||||
background: linear-gradient(180deg, #2d597f, #1e4060);
|
||||
border: 2px solid var(--scroll-track);
|
||||
background: linear-gradient(180deg, var(--scroll-thumb-a), var(--scroll-thumb-b));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, #3678ac, #20537c);
|
||||
background: linear-gradient(180deg, var(--scroll-thumb-hover-a), var(--scroll-thumb-hover-b));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
@@ -61,9 +43,9 @@ body {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
background:
|
||||
radial-gradient(circle at 15% 12%, rgba(64, 132, 183, 0.22), transparent 32%),
|
||||
radial-gradient(circle at 88% 2%, rgba(18, 185, 255, 0.16), transparent 30%),
|
||||
linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 56%, #060d17 100%);
|
||||
radial-gradient(circle at 15% 12%, var(--bg-glow-1), transparent 32%),
|
||||
radial-gradient(circle at 88% 2%, var(--bg-glow-2), transparent 30%),
|
||||
linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 56%, var(--bg-deep) 100%);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -75,7 +57,7 @@ a {
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #8de7ff;
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -122,7 +104,7 @@ pre {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.55;
|
||||
letter-spacing: 0.015em;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
hr {
|
||||
@@ -153,7 +135,7 @@ button {
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid #91eaff;
|
||||
outline: 2px solid var(--focus-ring-soft);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -171,7 +153,7 @@ textarea {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-s);
|
||||
border: 1px solid var(--line);
|
||||
background-color: rgba(10, 22, 36, 0.78);
|
||||
background-color: var(--surface-input);
|
||||
color: var(--text);
|
||||
padding: 10px 11px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
@@ -184,45 +166,15 @@ input[type=number]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(53, 210, 255, 0.17);
|
||||
background-color: rgba(9, 25, 40, 0.95);
|
||||
box-shadow: 0 0 0 3px var(--accent-ring);
|
||||
background-color: var(--surface-input-focus);
|
||||
}
|
||||
|
||||
input[type=text]::placeholder,
|
||||
input[type=search]::placeholder,
|
||||
input[type=password]::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #7e9ab4;
|
||||
}
|
||||
|
||||
input[type=button],
|
||||
input[type=submit],
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
color: #03101a;
|
||||
padding: 10px 18px;
|
||||
margin: 8px 8px 8px 0;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
background: linear-gradient(135deg, #67e8ff 0%, #12b9ff 100%);
|
||||
box-shadow: 0 10px 22px rgba(18, 185, 255, 0.3);
|
||||
transition: transform 0.15s ease, box-shadow 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
|
||||
input[type=button]:hover,
|
||||
input[type=submit]:hover,
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.05);
|
||||
box-shadow: 0 12px 24px rgba(18, 185, 255, 0.36);
|
||||
}
|
||||
|
||||
input[type=button]:active,
|
||||
input[type=submit]:active,
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
input[type=button]:focus-visible,
|
||||
@@ -236,80 +188,43 @@ 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: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
input[type=button].delete {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff7f7f 0%, #e94343 100%);
|
||||
box-shadow: 0 10px 20px rgba(233, 67, 67, 0.35);
|
||||
}
|
||||
|
||||
input[type=button].cancel {
|
||||
color: #ffd8d8;
|
||||
border-color: rgba(255, 111, 111, 0.45);
|
||||
background: rgba(87, 22, 22, 0.45);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type=button].black,
|
||||
input[type=submit].black {
|
||||
color: #d8ecff;
|
||||
border-color: var(--line);
|
||||
background: rgba(14, 32, 50, 0.85);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type=button].center {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
input[type=button].save {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
input[type=checkbox] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background-color: rgba(10, 22, 36, 0.86);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
input[type=checkbox]:checked {
|
||||
border-color: #89ebff;
|
||||
background-color: #39d6ff;
|
||||
}
|
||||
|
||||
input[type=checkbox]:before {
|
||||
content: "";
|
||||
}
|
||||
|
||||
input[type=checkbox]:checked:before {
|
||||
content: "\2713";
|
||||
color: #032033;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 3px;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 2600;
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-on-accent);
|
||||
background: var(--accent-gradient);
|
||||
transform: translateY(-180%);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.changed {
|
||||
border-color: #8de7ff !important;
|
||||
box-shadow: 0 0 0 3px rgba(53, 210, 255, 0.2) !important;
|
||||
.skip-link:focus-visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.notAvailable {
|
||||
opacity: 0.58;
|
||||
cursor: not-allowed;
|
||||
border-color: rgba(255, 111, 111, 0.45) !important;
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -364,37 +279,6 @@ 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;
|
||||
}
|
||||
@@ -423,9 +307,6 @@ input[type=checkbox]:checked:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.noBulk {
|
||||
}
|
||||
|
||||
.notVisible {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
@@ -437,259 +318,10 @@ input[type=checkbox]:checked:before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.menu-active {
|
||||
color: #011019;
|
||||
}
|
||||
|
||||
.menu-notActive {
|
||||
}
|
||||
|
||||
.menu {
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.half {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.screenLogHidden {
|
||||
transform: translate(0, -110px);
|
||||
}
|
||||
|
||||
.infoMsg {
|
||||
color: #87b0d1;
|
||||
}
|
||||
|
||||
.errorMsg {
|
||||
color: #ff8d8d;
|
||||
}
|
||||
|
||||
.warningMsg {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.debugMsg {
|
||||
color: #d687ff;
|
||||
}
|
||||
|
||||
.News,
|
||||
.Movie,
|
||||
.Series,
|
||||
.Sports,
|
||||
.Kids {
|
||||
border-left: 3px solid transparent;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.News {
|
||||
border-color: #ff8f6e;
|
||||
}
|
||||
|
||||
.Movie {
|
||||
border-color: #46a4ff;
|
||||
}
|
||||
|
||||
.Series {
|
||||
border-color: #ffd267;
|
||||
}
|
||||
|
||||
.Sports {
|
||||
border-color: #5ee495;
|
||||
}
|
||||
|
||||
.Kids {
|
||||
border-color: #f6a8ff;
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background-color: rgba(5, 12, 20, 0.72);
|
||||
backdrop-filter: blur(1.5px);
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid transparent;
|
||||
border-top-color: #58ddff;
|
||||
border-right-color: #12b9ff;
|
||||
animation: spin 0.9s linear infinite;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
#popup {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(3, 9, 16, 0.72);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 1500;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
#popup-custom,
|
||||
#mapping-detail,
|
||||
#user-detail,
|
||||
#file-detail {
|
||||
margin: 0 auto;
|
||||
max-width: 760px;
|
||||
max-height: calc(100vh - 36px);
|
||||
overflow: auto;
|
||||
border-radius: var(--radius-l);
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(17, 34, 56, 0.97) 0%, rgba(12, 27, 44, 0.97) 100%);
|
||||
box-shadow: var(--shadow-1);
|
||||
padding: 16px;
|
||||
animation: popupIn 0.2s ease;
|
||||
}
|
||||
|
||||
#popup-custom h3 {
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
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,
|
||||
#user-detail-table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 8px;
|
||||
}
|
||||
|
||||
#popup-custom td,
|
||||
#content_settings td,
|
||||
#mapping-detail-table td,
|
||||
#user-detail-table td {
|
||||
padding: 2px 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#popup-custom td.left,
|
||||
#mapping-detail-table td.left,
|
||||
#user-detail-table td.left {
|
||||
width: 38%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
#popup-custom input[type=text],
|
||||
#popup-custom input[type=password],
|
||||
#mapping-detail input[type=text],
|
||||
#content_settings input[type=text],
|
||||
#content_settings input[type=password] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#mapping-detail img {
|
||||
display: block;
|
||||
max-height: 44px;
|
||||
margin: 8px auto 12px;
|
||||
}
|
||||
|
||||
#file-detail input[type=text] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.interaction,
|
||||
#interaction {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.interaction input[type=button],
|
||||
.interaction input[type=submit] {
|
||||
margin: 0;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
#popup-interaction {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#notification {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
width: 260px;
|
||||
max-height: calc(100vh - 24px);
|
||||
overflow: auto;
|
||||
border-radius: var(--radius-m);
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(10, 22, 36, 0.92);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
#notification .element {
|
||||
margin: 8px;
|
||||
border-radius: 10px;
|
||||
border-left: 4px solid var(--ok);
|
||||
background: rgba(17, 35, 56, 0.84);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#notification h5 {
|
||||
padding: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
#notification p {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tableEllipsis {
|
||||
width: 150px;
|
||||
overflow: hidden;
|
||||
@@ -718,81 +350,3 @@ input[type=checkbox]:checked:before {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 619px) {
|
||||
h1 {
|
||||
font-size: 1.46rem;
|
||||
}
|
||||
|
||||
#popup {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#popup-custom,
|
||||
#mapping-detail,
|
||||
#user-detail,
|
||||
#file-detail {
|
||||
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 {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.interaction input[type=button],
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
/*
|
||||
* components.css - buttons, inputs, tables, status cards, connection
|
||||
* indicator, popups, menu, log lines and badges. Loaded after base.css.
|
||||
* Responsive overrides for these components live in layout.css.
|
||||
*/
|
||||
|
||||
/* Buttons */
|
||||
|
||||
input[type=button],
|
||||
input[type=submit],
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
color: var(--text-on-accent);
|
||||
padding: 10px 18px;
|
||||
margin: 8px 8px 8px 0;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
background: var(--accent-gradient);
|
||||
box-shadow: 0 10px 22px var(--accent-shadow);
|
||||
transition: transform 0.15s ease, box-shadow 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
|
||||
input[type=button]:hover,
|
||||
input[type=submit]:hover,
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.05);
|
||||
box-shadow: 0 12px 24px var(--accent-shadow-strong);
|
||||
}
|
||||
|
||||
input[type=button]:active,
|
||||
input[type=submit]:active,
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
input[type=button].delete {
|
||||
color: var(--text-on-error);
|
||||
background: linear-gradient(135deg, var(--error-bright) 0%, var(--error-strong) 100%);
|
||||
box-shadow: 0 10px 20px var(--error-shadow);
|
||||
}
|
||||
|
||||
input[type=button].cancel {
|
||||
color: var(--error-text-soft);
|
||||
border-color: var(--error-soft);
|
||||
background: var(--error-surface);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type=button].black,
|
||||
input[type=submit].black {
|
||||
color: var(--text-heading);
|
||||
border-color: var(--line);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type=button].center {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
input[type=button].save {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
|
||||
input[type=checkbox] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background-color: var(--surface-input);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
input[type=checkbox]:checked {
|
||||
border-color: var(--checkbox-checked-border);
|
||||
background-color: var(--checkbox-checked);
|
||||
}
|
||||
|
||||
input[type=checkbox]:before {
|
||||
content: "";
|
||||
}
|
||||
|
||||
input[type=checkbox]:checked:before {
|
||||
content: "\2713";
|
||||
color: var(--text-on-accent);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
/* Input states */
|
||||
|
||||
.changed {
|
||||
border-color: var(--accent-hover) !important;
|
||||
box-shadow: 0 0 0 3px var(--accent-soft) !important;
|
||||
}
|
||||
|
||||
.notAvailable {
|
||||
opacity: 0.58;
|
||||
cursor: not-allowed;
|
||||
border-color: var(--error-soft) !important;
|
||||
}
|
||||
|
||||
.wizard {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* Sidebar menu */
|
||||
|
||||
#branch {
|
||||
min-height: 18px;
|
||||
margin: 2px 0 6px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--error-text);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#main-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
#main-menu li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
padding: 8px 11px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
#main-menu li:hover {
|
||||
border-color: var(--accent-soft);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
#main-menu li:focus-visible {
|
||||
border-color: var(--accent-border);
|
||||
transform: translateX(2px);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
#main-menu li.menu-active {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-gradient);
|
||||
box-shadow: 0 10px 18px var(--accent-shadow);
|
||||
}
|
||||
|
||||
#main-menu li.menu-active p {
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
#main-menu li.menu-active img {
|
||||
filter: brightness(0.04) saturate(120%);
|
||||
}
|
||||
|
||||
nav img {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
max-width: 18px;
|
||||
max-height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
nav p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-heading);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.menu-active {
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.menu {
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
/* Connection indicator */
|
||||
|
||||
#connection-indicator {
|
||||
margin-left: auto;
|
||||
padding: 5px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#connection-indicator.status-idle {
|
||||
color: var(--text-heading);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
#connection-indicator.status-busy {
|
||||
color: var(--text-on-accent);
|
||||
border: 1px solid var(--accent-bright);
|
||||
background: var(--accent-gradient);
|
||||
}
|
||||
|
||||
#connection-indicator.status-online {
|
||||
color: var(--text-on-ok);
|
||||
border: 1px solid var(--ok-border);
|
||||
background: linear-gradient(135deg, var(--ok-bright) 0%, var(--ok-mid) 100%);
|
||||
}
|
||||
|
||||
#connection-indicator.status-offline {
|
||||
color: var(--text-on-error);
|
||||
border: 1px solid var(--error-border);
|
||||
background: linear-gradient(135deg, var(--error-bright) 0%, var(--error-strong) 100%);
|
||||
}
|
||||
|
||||
/* Server info and stream tables */
|
||||
|
||||
#clientInfo,
|
||||
#activeStreams,
|
||||
#inactiveStreams {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 7px;
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
#clientInfo {
|
||||
padding: 7px 10px 6px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
#clientInfo tr {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
#clientInfo .tdKey,
|
||||
#clientInfo .tdVal,
|
||||
#activeStreams .tdKey,
|
||||
#inactiveStreams .tdKey,
|
||||
#activeStreams .tdVal,
|
||||
#inactiveStreams .tdVal,
|
||||
#logInfo .tdVal,
|
||||
#mappingInfo .tdVal {
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 8px;
|
||||
padding: 7px 8px;
|
||||
vertical-align: middle;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
#clientInfo .tdKey,
|
||||
#activeStreams .tdKey,
|
||||
#inactiveStreams .tdKey {
|
||||
color: var(--accent-text);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#clientInfo .tdVal,
|
||||
#activeStreams .tdVal,
|
||||
#inactiveStreams .tdVal {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#clientInfo .tdVal a {
|
||||
color: var(--accent-text);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--link-underline);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
#clientInfo .tdVal a:hover,
|
||||
#clientInfo .tdVal a:focus {
|
||||
color: var(--accent-text-hover);
|
||||
border-bottom-color: var(--accent-text-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#myStreamsBox {
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: var(--surface-1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#allStreams {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#activeStreams,
|
||||
#inactiveStreams {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#inactiveStreams .tdKey {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
/* Status cards */
|
||||
|
||||
.dashboard-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(130px, 1fr));
|
||||
gap: 9px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-2);
|
||||
padding: 10px;
|
||||
min-height: 74px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-card-label {
|
||||
color: var(--text-soft);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
}
|
||||
|
||||
.status-card-value {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-card.status-card-ok {
|
||||
border-color: var(--ok-soft);
|
||||
background: linear-gradient(180deg, var(--ok-surface-top) 0%, var(--ok-surface-bottom) 100%);
|
||||
}
|
||||
|
||||
.status-card.status-card-warn {
|
||||
border-color: var(--warn-soft);
|
||||
background: linear-gradient(180deg, var(--warn-surface-top) 0%, var(--warn-surface-bottom) 100%);
|
||||
}
|
||||
|
||||
.status-card.status-card-error {
|
||||
border-color: var(--error-soft);
|
||||
background: linear-gradient(180deg, var(--error-surface-top) 0%, var(--error-surface-bottom) 100%);
|
||||
}
|
||||
|
||||
/* Content table */
|
||||
|
||||
#box-wrapper {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
#content_table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
#content_table .content_table_header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: linear-gradient(180deg, var(--table-head-top), var(--table-head-bottom));
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
#content_table .content_table_header td {
|
||||
color: var(--accent-text);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
#content_table tr {
|
||||
border-left: 3px solid transparent;
|
||||
border-bottom: 1px solid var(--row-line);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#content_table tr:hover {
|
||||
background-color: var(--row-hover);
|
||||
}
|
||||
|
||||
#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 var(--focus-ring);
|
||||
outline-offset: -2px;
|
||||
background-color: var(--row-focus);
|
||||
}
|
||||
|
||||
#content_table td {
|
||||
padding: 7px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#content_table img {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 34px;
|
||||
max-height: 30px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#content_table input[type=text] {
|
||||
min-width: 48px;
|
||||
max-width: 76px;
|
||||
margin: 0;
|
||||
padding: 6px 7px;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#content_table input[type=checkbox] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#content_table .mapping-edit-button {
|
||||
min-height: 32px;
|
||||
min-width: 64px;
|
||||
margin: 0;
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#content_table tr.activeEPG {
|
||||
border-left-color: var(--ok);
|
||||
}
|
||||
|
||||
#content_table tr.notActiveEPG {
|
||||
border-left-color: var(--error);
|
||||
}
|
||||
|
||||
/* Settings */
|
||||
|
||||
#settings {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#content_settings h4 {
|
||||
margin: 15px 0 8px;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
#content_settings table {
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#content_settings td {
|
||||
border-bottom: 1px solid var(--row-line);
|
||||
}
|
||||
|
||||
#content_settings tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
#content_settings pre {
|
||||
color: var(--text-soft);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Popup and detail tables */
|
||||
|
||||
#popup-custom table,
|
||||
#content_settings table,
|
||||
#mapping-detail-table,
|
||||
#user-detail-table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 8px;
|
||||
}
|
||||
|
||||
#popup-custom td,
|
||||
#content_settings td,
|
||||
#mapping-detail-table td,
|
||||
#user-detail-table td {
|
||||
padding: 2px 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#popup-custom td.left,
|
||||
#mapping-detail-table td.left,
|
||||
#user-detail-table td.left {
|
||||
width: 38%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
#popup-custom input[type=text],
|
||||
#popup-custom input[type=password],
|
||||
#mapping-detail input[type=text],
|
||||
#content_settings input[type=text],
|
||||
#content_settings input[type=password] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#mapping-detail img {
|
||||
display: block;
|
||||
max-height: 44px;
|
||||
margin: 8px auto 12px;
|
||||
}
|
||||
|
||||
#file-detail input[type=text] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Loading and popups */
|
||||
|
||||
#loading {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background-color: var(--overlay);
|
||||
backdrop-filter: blur(1.5px);
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid transparent;
|
||||
border-top-color: var(--loader);
|
||||
border-right-color: var(--accent-strong);
|
||||
animation: spin 0.9s linear infinite;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
#popup {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: var(--overlay);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 1500;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
#popup-custom,
|
||||
#mapping-detail,
|
||||
#user-detail,
|
||||
#file-detail {
|
||||
margin: 0 auto;
|
||||
max-width: 760px;
|
||||
max-height: calc(100vh - 36px);
|
||||
overflow: auto;
|
||||
border-radius: var(--radius-l);
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, var(--panel-top) 0%, var(--panel-bottom) 100%);
|
||||
box-shadow: var(--shadow-1);
|
||||
padding: 16px;
|
||||
animation: popupIn 0.2s ease;
|
||||
}
|
||||
|
||||
#popup-custom h3 {
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.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: var(--text-heading);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: none;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.popup-close:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.interaction,
|
||||
#interaction {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.interaction input[type=button],
|
||||
.interaction input[type=submit] {
|
||||
margin: 0;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
#popup-interaction {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
|
||||
#notification {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
width: 260px;
|
||||
max-height: calc(100vh - 24px);
|
||||
overflow: auto;
|
||||
border-radius: var(--radius-m);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-solid);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
#notification .element {
|
||||
margin: 8px;
|
||||
border-radius: 10px;
|
||||
border-left: 4px solid var(--ok);
|
||||
background: var(--surface-3);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#notification h5 {
|
||||
padding: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
#notification p {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Log lines */
|
||||
|
||||
#content_log {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#content_log pre {
|
||||
margin-bottom: 5px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-1);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
#logScreen p {
|
||||
white-space: pre;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.screenLogHidden {
|
||||
transform: translate(0, -110px);
|
||||
}
|
||||
|
||||
.infoMsg {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.errorMsg {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.warningMsg {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.debugMsg {
|
||||
color: var(--debug);
|
||||
}
|
||||
|
||||
/* Category badges */
|
||||
|
||||
.News,
|
||||
.Movie,
|
||||
.Series,
|
||||
.Sports,
|
||||
.Kids {
|
||||
border-left: 3px solid transparent;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.News {
|
||||
border-color: #ff8f6e;
|
||||
}
|
||||
|
||||
.Movie {
|
||||
border-color: #46a4ff;
|
||||
}
|
||||
|
||||
.Series {
|
||||
border-color: #ffd267;
|
||||
}
|
||||
|
||||
.Sports {
|
||||
border-color: #5ee495;
|
||||
}
|
||||
|
||||
.Kids {
|
||||
border-color: #f6a8ff;
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
* layout.css - shell layout (sidebar, header, content wrapper), the auth /
|
||||
* wizard box pages and every responsive breakpoint. Loaded last.
|
||||
*/
|
||||
|
||||
/* Application shell */
|
||||
|
||||
#layout {
|
||||
display: grid;
|
||||
grid-template-columns: 290px minmax(0, 1fr);
|
||||
@@ -15,8 +22,8 @@
|
||||
border-radius: var(--radius-l);
|
||||
border: 1px solid var(--line);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(18, 36, 58, 0.95) 0%, rgba(10, 23, 38, 0.95) 100%),
|
||||
radial-gradient(circle at 20% 0%, rgba(53, 210, 255, 0.12), transparent 48%);
|
||||
linear-gradient(180deg, var(--panel-top) 0%, var(--panel-bottom) 100%),
|
||||
radial-gradient(circle at 20% 0%, var(--panel-glow), transparent 48%);
|
||||
box-shadow: var(--shadow-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -28,100 +35,25 @@
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
#branch {
|
||||
min-height: 18px;
|
||||
margin: 2px 0 6px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #ff9f8d;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#logo {
|
||||
width: 100%;
|
||||
height: 76px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(26, 49, 73, 0.55), rgba(13, 28, 45, 0.6)),
|
||||
linear-gradient(180deg, var(--logo-tint-top), var(--logo-tint-bottom)),
|
||||
url("../img/logo_w_600x200.png") center no-repeat;
|
||||
background-size: contain;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#main-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
#main-menu li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
padding: 8px 11px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(11, 25, 40, 0.58);
|
||||
}
|
||||
|
||||
#main-menu li:hover {
|
||||
border-color: var(--accent-soft);
|
||||
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%);
|
||||
box-shadow: 0 10px 18px rgba(28, 197, 255, 0.3);
|
||||
}
|
||||
|
||||
#main-menu li.menu-active p {
|
||||
color: #03111a;
|
||||
}
|
||||
|
||||
#main-menu li.menu-active img {
|
||||
filter: brightness(0.04) saturate(120%);
|
||||
}
|
||||
|
||||
nav img {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
max-width: 18px;
|
||||
max-height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
nav p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #d7eefe;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.layout-right {
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-l);
|
||||
border: 1px solid var(--line);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(17, 34, 54, 0.94) 0%, rgba(10, 20, 34, 0.96) 100%),
|
||||
radial-gradient(circle at 100% 0%, rgba(53, 210, 255, 0.08), transparent 34%);
|
||||
linear-gradient(180deg, var(--panel-top) 0%, var(--panel-bottom) 100%),
|
||||
radial-gradient(circle at 100% 0%, var(--panel-glow), transparent 34%);
|
||||
box-shadow: var(--shadow-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -134,14 +66,14 @@ nav p {
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: rgba(10, 22, 36, 0.52);
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
#shell-title {
|
||||
margin: 0;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
color: #d8f1ff;
|
||||
color: var(--text-heading);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@@ -153,176 +85,7 @@ nav p {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#connection-indicator {
|
||||
margin-left: auto;
|
||||
padding: 5px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#connection-indicator.status-idle {
|
||||
color: #d8edff;
|
||||
border: 1px solid #3a5877;
|
||||
background: rgba(20, 42, 66, 0.65);
|
||||
}
|
||||
|
||||
#connection-indicator.status-busy {
|
||||
color: #072031;
|
||||
border: 1px solid #7ee8ff;
|
||||
background: linear-gradient(135deg, #7be8ff 0%, #33cdff 100%);
|
||||
}
|
||||
|
||||
#connection-indicator.status-online {
|
||||
color: #052219;
|
||||
border: 1px solid #9ff0c8;
|
||||
background: linear-gradient(135deg, #92f0c0 0%, #4fce93 100%);
|
||||
}
|
||||
|
||||
#connection-indicator.status-offline {
|
||||
color: #ffe5e5;
|
||||
border: 1px solid #ff9e9e;
|
||||
background: linear-gradient(135deg, #ff7e7e 0%, #d34d4d 100%);
|
||||
}
|
||||
|
||||
#clientInfo,
|
||||
#activeStreams,
|
||||
#inactiveStreams {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 7px;
|
||||
background: rgba(8, 20, 33, 0.54);
|
||||
}
|
||||
|
||||
#clientInfo {
|
||||
padding: 7px 10px 6px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
#clientInfo tr {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
#clientInfo .tdKey,
|
||||
#clientInfo .tdVal,
|
||||
#activeStreams .tdKey,
|
||||
#inactiveStreams .tdKey,
|
||||
#activeStreams .tdVal,
|
||||
#inactiveStreams .tdVal,
|
||||
#logInfo .tdVal,
|
||||
#mappingInfo .tdVal {
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 8px;
|
||||
padding: 7px 8px;
|
||||
vertical-align: middle;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace;
|
||||
}
|
||||
|
||||
#clientInfo .tdKey,
|
||||
#activeStreams .tdKey,
|
||||
#inactiveStreams .tdKey {
|
||||
color: #53d9ff;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#clientInfo .tdVal,
|
||||
#activeStreams .tdVal,
|
||||
#inactiveStreams .tdVal {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#clientInfo .tdVal a {
|
||||
color: #8fdcff;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted rgba(143, 220, 255, 0.6);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
#clientInfo .tdVal a:hover,
|
||||
#clientInfo .tdVal a:focus {
|
||||
color: #d7f4ff;
|
||||
border-bottom-color: #d7f4ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dashboard-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(130px, 1fr));
|
||||
gap: 9px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: rgba(8, 18, 30, 0.45);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 12px;
|
||||
background: rgba(13, 31, 49, 0.8);
|
||||
padding: 10px;
|
||||
min-height: 74px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-card-label {
|
||||
color: #8fb3cf;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
}
|
||||
|
||||
.status-card-value {
|
||||
color: #e8f5ff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-card.status-card-ok {
|
||||
border-color: rgba(67, 191, 130, 0.48);
|
||||
background: linear-gradient(180deg, rgba(20, 54, 44, 0.9) 0%, rgba(12, 38, 31, 0.95) 100%);
|
||||
}
|
||||
|
||||
.status-card.status-card-warn {
|
||||
border-color: rgba(255, 193, 90, 0.5);
|
||||
background: linear-gradient(180deg, rgba(68, 50, 20, 0.86) 0%, rgba(44, 32, 14, 0.96) 100%);
|
||||
}
|
||||
|
||||
.status-card.status-card-error {
|
||||
border-color: rgba(255, 111, 111, 0.5);
|
||||
background: linear-gradient(180deg, rgba(73, 26, 26, 0.84) 0%, rgba(47, 19, 19, 0.96) 100%);
|
||||
}
|
||||
|
||||
#myStreamsBox {
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: rgba(10, 22, 36, 0.62);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#allStreams {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#activeStreams,
|
||||
#inactiveStreams {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#inactiveStreams .tdKey {
|
||||
color: #ff8f8f;
|
||||
}
|
||||
/* Content wrapper */
|
||||
|
||||
#content {
|
||||
display: block;
|
||||
@@ -332,7 +95,7 @@ nav p {
|
||||
|
||||
#content h3 {
|
||||
margin-bottom: 6px;
|
||||
color: #d4edff;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
#content-interaction {
|
||||
@@ -353,162 +116,14 @@ nav p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#box-wrapper {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 21, 34, 0.72);
|
||||
}
|
||||
|
||||
#content_table,
|
||||
#mapping-detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
#content_table .content_table_header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: linear-gradient(180deg, rgba(23, 46, 72, 0.98), rgba(16, 32, 50, 0.98));
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
#content_table .content_table_header td {
|
||||
color: #8fd8ff;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
#content_table tr {
|
||||
border-left: 3px solid transparent;
|
||||
border-bottom: 1px solid rgba(29, 55, 80, 0.72);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#content_table tr:hover {
|
||||
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;
|
||||
}
|
||||
|
||||
#content_table img {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 34px;
|
||||
max-height: 30px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#content_table input[type=text] {
|
||||
min-width: 48px;
|
||||
max-width: 76px;
|
||||
margin: 0;
|
||||
padding: 6px 7px;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#content_table input[type=checkbox] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#content_table .mapping-edit-button {
|
||||
min-height: 32px;
|
||||
min-width: 64px;
|
||||
margin: 0;
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#content_table tr.activeEPG {
|
||||
border-left-color: #2ed486;
|
||||
}
|
||||
|
||||
#content_table tr.notActiveEPG {
|
||||
border-left-color: #ff6868;
|
||||
}
|
||||
|
||||
#settings {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#content_settings h4 {
|
||||
margin: 15px 0 8px;
|
||||
color: #d6efff;
|
||||
}
|
||||
|
||||
#content_settings table {
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 22, 35, 0.62);
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#content_settings td {
|
||||
padding: 9px 8px;
|
||||
border-bottom: 1px solid rgba(31, 57, 82, 0.62);
|
||||
}
|
||||
|
||||
#content_settings tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
#content_settings pre {
|
||||
color: #9ab7d0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#content_log {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#content_log pre {
|
||||
margin-bottom: 5px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(13, 31, 49, 0.72);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
#logScreen p {
|
||||
white-space: pre;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
/* Login, first-user, wizard and maintenance box */
|
||||
|
||||
#box {
|
||||
width: min(680px, calc(100% - 24px));
|
||||
margin: 14px auto;
|
||||
border-radius: var(--radius-l);
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(16, 35, 56, 0.95) 0%, rgba(11, 24, 38, 0.96) 100%);
|
||||
background: linear-gradient(180deg, var(--panel-top) 0%, var(--panel-bottom) 100%);
|
||||
box-shadow: var(--shadow-2);
|
||||
min-height: 430px;
|
||||
display: flex;
|
||||
@@ -523,14 +138,14 @@ nav p {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(16, 35, 56, 0.84), rgba(11, 24, 38, 0.9)),
|
||||
linear-gradient(180deg, var(--logo-tint-top), var(--logo-tint-bottom)),
|
||||
url("../img/logo_w_600x200.png") center no-repeat;
|
||||
background-size: 300px auto;
|
||||
}
|
||||
|
||||
#headline {
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(10, 22, 37, 0.55);
|
||||
background: var(--surface-0);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
@@ -560,10 +175,6 @@ nav p {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.wizard {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.auth-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -578,6 +189,8 @@ nav p {
|
||||
display: table-cell;
|
||||
}
|
||||
|
||||
/* Breakpoints */
|
||||
|
||||
@media only screen and (max-width: 1300px) {
|
||||
.dashboard-cards {
|
||||
grid-template-columns: repeat(3, minmax(130px, 1fr));
|
||||
@@ -622,7 +235,7 @@ nav p {
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
background: rgba(3, 9, 15, 0.48);
|
||||
background: var(--overlay-soft);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
@@ -698,7 +311,7 @@ nav p {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 12px;
|
||||
background: rgba(9, 22, 36, 0.92);
|
||||
background: var(--surface-solid);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
@@ -771,7 +384,7 @@ nav p {
|
||||
gap: 8px;
|
||||
border-spacing: 0;
|
||||
white-space: normal;
|
||||
background: rgba(8, 20, 33, 0.44);
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
#clientInfo tr {
|
||||
@@ -791,16 +404,16 @@ nav p {
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 10px 9px;
|
||||
background: rgba(17, 34, 53, 0.5);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
#clientInfo .tdVal::before {
|
||||
content: attr(data-label);
|
||||
color: #8bb9d7;
|
||||
color: var(--text-soft);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
font-family: "Space Grotesk", "Avenir Next", "Trebuchet MS", sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@@ -829,7 +442,7 @@ nav p {
|
||||
border-radius: 12px;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px;
|
||||
background: rgba(11, 24, 39, 0.86);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
#content_table td {
|
||||
@@ -841,12 +454,12 @@ nav p {
|
||||
grid-template-columns: minmax(92px, 36%) 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(17, 34, 53, 0.5);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
#content_table td::before {
|
||||
content: attr(data-label);
|
||||
color: #8bb9d7;
|
||||
color: var(--text-soft);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
@@ -967,6 +580,84 @@ nav p {
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 619px) {
|
||||
h1 {
|
||||
font-size: 1.46rem;
|
||||
}
|
||||
|
||||
#popup {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#popup-custom,
|
||||
#mapping-detail,
|
||||
#user-detail,
|
||||
#file-detail {
|
||||
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: var(--surface-2);
|
||||
}
|
||||
|
||||
#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, var(--sticky-fade-from) 0%, var(--sticky-fade-to) 30%);
|
||||
}
|
||||
|
||||
.interaction,
|
||||
#interaction {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.interaction input[type=button],
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 430px) {
|
||||
#clientInfo {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* tokens.css - design tokens (colours, spacing, radii, fonts, shadows).
|
||||
*
|
||||
* Load order: tokens.css, base.css, components.css, layout.css.
|
||||
*
|
||||
* The dark palette is the default. A light palette is applied when the user
|
||||
* agent prefers a light colour scheme, and either palette can be forced with
|
||||
* <html data-theme="dark"> or <html data-theme="light">, which always wins
|
||||
* over the media query. Only colour tokens change between themes.
|
||||
*/
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: "Space Grotesk", "Avenir Next", "Trebuchet MS", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace;
|
||||
|
||||
/* Radii and shadows */
|
||||
--radius-s: 10px;
|
||||
--radius-m: 14px;
|
||||
--radius-l: 18px;
|
||||
--shadow-1: 0 20px 45px rgba(0, 0, 0, 0.32);
|
||||
--shadow-2: 0 12px 30px rgba(0, 0, 0, 0.28);
|
||||
|
||||
/* Page background */
|
||||
--bg-0: #07111d;
|
||||
--bg-1: #0c1a2b;
|
||||
--bg-2: #12233a;
|
||||
--bg-deep: #060d17;
|
||||
--bg-glow-1: rgba(64, 132, 183, 0.22);
|
||||
--bg-glow-2: rgba(18, 185, 255, 0.16);
|
||||
|
||||
/* Panels and surfaces (translucent tints layered over the page) */
|
||||
--panel-top: rgba(17, 34, 56, 0.97);
|
||||
--panel-bottom: rgba(12, 27, 44, 0.97);
|
||||
--panel-glow: rgba(53, 210, 255, 0.12);
|
||||
--surface-0: rgba(8, 20, 33, 0.54);
|
||||
--surface-1: rgba(10, 22, 36, 0.72);
|
||||
--surface-2: rgba(13, 31, 49, 0.8);
|
||||
--surface-3: rgba(17, 35, 56, 0.84);
|
||||
--surface-solid: rgba(10, 22, 36, 0.92);
|
||||
--surface-raised: rgba(14, 32, 50, 0.85);
|
||||
--surface-input: rgba(10, 22, 36, 0.78);
|
||||
--surface-input-focus: rgba(9, 25, 40, 0.95);
|
||||
--logo-tint-top: rgba(26, 49, 73, 0.55);
|
||||
--logo-tint-bottom: rgba(13, 28, 45, 0.6);
|
||||
--table-head-top: rgba(23, 46, 72, 0.98);
|
||||
--table-head-bottom: rgba(16, 32, 50, 0.98);
|
||||
--row-line: rgba(29, 55, 80, 0.72);
|
||||
--row-hover: rgba(28, 53, 79, 0.5);
|
||||
--row-focus: rgba(39, 73, 106, 0.56);
|
||||
--overlay: rgba(3, 9, 16, 0.72);
|
||||
--overlay-soft: rgba(3, 9, 15, 0.48);
|
||||
--sticky-fade-from: rgba(12, 27, 44, 0.1);
|
||||
--sticky-fade-to: rgba(12, 27, 44, 0.96);
|
||||
--scroll-track: rgba(7, 17, 29, 0.65);
|
||||
--scroll-thumb-a: #2d597f;
|
||||
--scroll-thumb-b: #1e4060;
|
||||
--scroll-thumb-hover-a: #3678ac;
|
||||
--scroll-thumb-hover-b: #20537c;
|
||||
|
||||
/* Lines */
|
||||
--line: #274462;
|
||||
--line-soft: #1b334d;
|
||||
--line-strong: #3a5877;
|
||||
|
||||
/* Text */
|
||||
--text: #e9f5ff;
|
||||
--text-heading: #d6efff;
|
||||
--text-muted: #b7cee1;
|
||||
--text-soft: #8fb3cf;
|
||||
--text-placeholder: #7e9ab4;
|
||||
--text-on-accent: #03101a;
|
||||
--text-on-ok: #052219;
|
||||
--text-on-error: #ffe5e5;
|
||||
|
||||
/* Accent */
|
||||
--accent: #35d2ff;
|
||||
--accent-strong: #12b9ff;
|
||||
--accent-bright: #67e8ff;
|
||||
--accent-hover: #8de7ff;
|
||||
--accent-text: #8fd8ff;
|
||||
--accent-text-hover: #d7f4ff;
|
||||
--accent-soft: rgba(53, 210, 255, 0.2);
|
||||
--accent-ring: rgba(53, 210, 255, 0.17);
|
||||
--accent-border: rgba(103, 232, 255, 0.55);
|
||||
--accent-shadow: rgba(18, 185, 255, 0.3);
|
||||
--accent-shadow-strong: rgba(18, 185, 255, 0.36);
|
||||
--accent-gradient: linear-gradient(135deg, var(--accent-bright) 0%, var(--accent-strong) 100%);
|
||||
--link-underline: rgba(143, 220, 255, 0.6);
|
||||
--focus-ring: #7de5ff;
|
||||
--focus-ring-soft: #91eaff;
|
||||
--checkbox-checked: #39d6ff;
|
||||
--checkbox-checked-border: #89ebff;
|
||||
--loader: #58ddff;
|
||||
|
||||
/* Status */
|
||||
--ok: #2fd18a;
|
||||
--ok-bright: #92f0c0;
|
||||
--ok-mid: #4fce93;
|
||||
--ok-border: #9ff0c8;
|
||||
--ok-soft: rgba(67, 191, 130, 0.48);
|
||||
--ok-surface-top: rgba(20, 54, 44, 0.9);
|
||||
--ok-surface-bottom: rgba(12, 38, 31, 0.95);
|
||||
--warn: #ffc15a;
|
||||
--warn-soft: rgba(255, 193, 90, 0.5);
|
||||
--warn-surface-top: rgba(68, 50, 20, 0.86);
|
||||
--warn-surface-bottom: rgba(44, 32, 14, 0.96);
|
||||
--error: #ff6f6f;
|
||||
--error-text: #ff8d8d;
|
||||
--error-text-soft: #ffd8d8;
|
||||
--error-bright: #ff7f7f;
|
||||
--error-strong: #e94343;
|
||||
--error-border: #ff9e9e;
|
||||
--error-soft: rgba(255, 111, 111, 0.45);
|
||||
--error-surface: rgba(87, 22, 22, 0.45);
|
||||
--error-surface-top: rgba(73, 26, 26, 0.84);
|
||||
--error-surface-bottom: rgba(47, 19, 19, 0.96);
|
||||
--error-shadow: rgba(233, 67, 67, 0.35);
|
||||
--info: #87b0d1;
|
||||
--debug: #d687ff;
|
||||
}
|
||||
|
||||
/*
|
||||
* Light palette. Only tokens that differ from the dark palette are listed.
|
||||
* The same block appears twice on purpose: once for the media query (skipped
|
||||
* when dark is forced with data-theme="dark") and once for the explicit
|
||||
* data-theme="light" override. Keep the two copies identical.
|
||||
*/
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme="dark"]) {
|
||||
color-scheme: light;
|
||||
|
||||
--bg-0: #e9eff5;
|
||||
--bg-1: #f5f8fb;
|
||||
--bg-2: #dfe7ef;
|
||||
--bg-deep: #dfe7ef;
|
||||
--bg-glow-1: rgba(64, 132, 183, 0.14);
|
||||
--bg-glow-2: rgba(18, 185, 255, 0.12);
|
||||
|
||||
--panel-top: rgba(255, 255, 255, 0.97);
|
||||
--panel-bottom: rgba(246, 249, 252, 0.97);
|
||||
--panel-glow: rgba(18, 185, 255, 0.08);
|
||||
--surface-0: rgba(255, 255, 255, 0.55);
|
||||
--surface-1: rgba(255, 255, 255, 0.75);
|
||||
--surface-2: rgba(236, 243, 249, 0.85);
|
||||
--surface-3: rgba(232, 240, 247, 0.9);
|
||||
--surface-solid: rgba(255, 255, 255, 0.94);
|
||||
--surface-raised: rgba(228, 236, 244, 0.9);
|
||||
--surface-input: rgba(255, 255, 255, 0.85);
|
||||
--surface-input-focus: rgba(255, 255, 255, 1);
|
||||
--logo-tint-top: rgba(15, 31, 48, 0.96);
|
||||
--logo-tint-bottom: rgba(11, 23, 39, 0.98);
|
||||
--table-head-top: rgba(232, 240, 248, 0.98);
|
||||
--table-head-bottom: rgba(222, 232, 243, 0.98);
|
||||
--row-line: rgba(100, 130, 160, 0.3);
|
||||
--row-hover: rgba(0, 90, 150, 0.07);
|
||||
--row-focus: rgba(0, 90, 150, 0.14);
|
||||
--overlay: rgba(20, 35, 52, 0.5);
|
||||
--overlay-soft: rgba(20, 35, 52, 0.35);
|
||||
--sticky-fade-from: rgba(246, 249, 252, 0.1);
|
||||
--sticky-fade-to: rgba(246, 249, 252, 0.96);
|
||||
--scroll-track: rgba(200, 214, 228, 0.6);
|
||||
--scroll-thumb-a: #9fb6cb;
|
||||
--scroll-thumb-b: #7f9bb3;
|
||||
--scroll-thumb-hover-a: #7f9bb3;
|
||||
--scroll-thumb-hover-b: #61819c;
|
||||
|
||||
--line: #b9cadb;
|
||||
--line-soft: #d3dfea;
|
||||
--line-strong: #9fb4c8;
|
||||
|
||||
--text: #142434;
|
||||
--text-heading: #0f2033;
|
||||
--text-muted: #4a6379;
|
||||
--text-soft: #526a80;
|
||||
--text-placeholder: #58708a;
|
||||
--text-on-error: #4a0c0c;
|
||||
|
||||
--accent: #0069a8;
|
||||
--accent-hover: #00517f;
|
||||
--accent-text: #0b6a95;
|
||||
--accent-text-hover: #003a5c;
|
||||
--accent-soft: rgba(0, 105, 168, 0.2);
|
||||
--accent-ring: rgba(0, 105, 168, 0.18);
|
||||
--accent-border: rgba(0, 105, 168, 0.5);
|
||||
--accent-shadow: rgba(0, 105, 168, 0.22);
|
||||
--accent-shadow-strong: rgba(0, 105, 168, 0.3);
|
||||
--link-underline: rgba(11, 106, 149, 0.5);
|
||||
--focus-ring: #0a7fc0;
|
||||
--focus-ring-soft: #0a7fc0;
|
||||
--checkbox-checked: #12b9ff;
|
||||
--checkbox-checked-border: #0a7fc0;
|
||||
--loader: #0a7fc0;
|
||||
|
||||
--ok: #12734a;
|
||||
--ok-bright: #a9efcc;
|
||||
--ok-mid: #5fd39b;
|
||||
--ok-border: #4fb884;
|
||||
--ok-soft: rgba(18, 115, 74, 0.45);
|
||||
--ok-surface-top: rgba(226, 246, 235, 0.95);
|
||||
--ok-surface-bottom: rgba(208, 238, 222, 0.98);
|
||||
--warn: #7f5300;
|
||||
--warn-soft: rgba(183, 121, 31, 0.5);
|
||||
--warn-surface-top: rgba(253, 243, 224, 0.95);
|
||||
--warn-surface-bottom: rgba(248, 232, 200, 0.98);
|
||||
--error: #c62828;
|
||||
--error-text: #b02020;
|
||||
--error-text-soft: #8a1a1a;
|
||||
--error-bright: #f6a5a5;
|
||||
--error-strong: #e07575;
|
||||
--error-border: #d95c5c;
|
||||
--error-soft: rgba(198, 40, 40, 0.45);
|
||||
--error-surface: rgba(255, 220, 220, 0.6);
|
||||
--error-surface-top: rgba(253, 232, 232, 0.95);
|
||||
--error-surface-bottom: rgba(248, 214, 214, 0.98);
|
||||
--error-shadow: rgba(198, 40, 40, 0.25);
|
||||
--info: #3f6785;
|
||||
--debug: #7a35b5;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
--bg-0: #e9eff5;
|
||||
--bg-1: #f5f8fb;
|
||||
--bg-2: #dfe7ef;
|
||||
--bg-deep: #dfe7ef;
|
||||
--bg-glow-1: rgba(64, 132, 183, 0.14);
|
||||
--bg-glow-2: rgba(18, 185, 255, 0.12);
|
||||
|
||||
--panel-top: rgba(255, 255, 255, 0.97);
|
||||
--panel-bottom: rgba(246, 249, 252, 0.97);
|
||||
--panel-glow: rgba(18, 185, 255, 0.08);
|
||||
--surface-0: rgba(255, 255, 255, 0.55);
|
||||
--surface-1: rgba(255, 255, 255, 0.75);
|
||||
--surface-2: rgba(236, 243, 249, 0.85);
|
||||
--surface-3: rgba(232, 240, 247, 0.9);
|
||||
--surface-solid: rgba(255, 255, 255, 0.94);
|
||||
--surface-raised: rgba(228, 236, 244, 0.9);
|
||||
--surface-input: rgba(255, 255, 255, 0.85);
|
||||
--surface-input-focus: rgba(255, 255, 255, 1);
|
||||
--logo-tint-top: rgba(15, 31, 48, 0.96);
|
||||
--logo-tint-bottom: rgba(11, 23, 39, 0.98);
|
||||
--table-head-top: rgba(232, 240, 248, 0.98);
|
||||
--table-head-bottom: rgba(222, 232, 243, 0.98);
|
||||
--row-line: rgba(100, 130, 160, 0.3);
|
||||
--row-hover: rgba(0, 90, 150, 0.07);
|
||||
--row-focus: rgba(0, 90, 150, 0.14);
|
||||
--overlay: rgba(20, 35, 52, 0.5);
|
||||
--overlay-soft: rgba(20, 35, 52, 0.35);
|
||||
--sticky-fade-from: rgba(246, 249, 252, 0.1);
|
||||
--sticky-fade-to: rgba(246, 249, 252, 0.96);
|
||||
--scroll-track: rgba(200, 214, 228, 0.6);
|
||||
--scroll-thumb-a: #9fb6cb;
|
||||
--scroll-thumb-b: #7f9bb3;
|
||||
--scroll-thumb-hover-a: #7f9bb3;
|
||||
--scroll-thumb-hover-b: #61819c;
|
||||
|
||||
--line: #b9cadb;
|
||||
--line-soft: #d3dfea;
|
||||
--line-strong: #9fb4c8;
|
||||
|
||||
--text: #142434;
|
||||
--text-heading: #0f2033;
|
||||
--text-muted: #4a6379;
|
||||
--text-soft: #526a80;
|
||||
--text-placeholder: #58708a;
|
||||
--text-on-error: #4a0c0c;
|
||||
|
||||
--accent: #0069a8;
|
||||
--accent-hover: #00517f;
|
||||
--accent-text: #0b6a95;
|
||||
--accent-text-hover: #003a5c;
|
||||
--accent-soft: rgba(0, 105, 168, 0.2);
|
||||
--accent-ring: rgba(0, 105, 168, 0.18);
|
||||
--accent-border: rgba(0, 105, 168, 0.5);
|
||||
--accent-shadow: rgba(0, 105, 168, 0.22);
|
||||
--accent-shadow-strong: rgba(0, 105, 168, 0.3);
|
||||
--link-underline: rgba(11, 106, 149, 0.5);
|
||||
--focus-ring: #0a7fc0;
|
||||
--focus-ring-soft: #0a7fc0;
|
||||
--checkbox-checked: #12b9ff;
|
||||
--checkbox-checked-border: #0a7fc0;
|
||||
--loader: #0a7fc0;
|
||||
|
||||
--ok: #12734a;
|
||||
--ok-bright: #a9efcc;
|
||||
--ok-mid: #5fd39b;
|
||||
--ok-border: #4fb884;
|
||||
--ok-soft: rgba(18, 115, 74, 0.45);
|
||||
--ok-surface-top: rgba(226, 246, 235, 0.95);
|
||||
--ok-surface-bottom: rgba(208, 238, 222, 0.98);
|
||||
--warn: #7f5300;
|
||||
--warn-soft: rgba(183, 121, 31, 0.5);
|
||||
--warn-surface-top: rgba(253, 243, 224, 0.95);
|
||||
--warn-surface-bottom: rgba(248, 232, 200, 0.98);
|
||||
--error: #c62828;
|
||||
--error-text: #b02020;
|
||||
--error-text-soft: #8a1a1a;
|
||||
--error-bright: #f6a5a5;
|
||||
--error-strong: #e07575;
|
||||
--error-border: #d95c5c;
|
||||
--error-soft: rgba(198, 40, 40, 0.45);
|
||||
--error-surface: rgba(255, 220, 220, 0.6);
|
||||
--error-surface-top: rgba(253, 232, 232, 0.95);
|
||||
--error-surface-bottom: rgba(248, 214, 214, 0.98);
|
||||
--error-shadow: rgba(198, 40, 40, 0.25);
|
||||
--info: #3f6785;
|
||||
--debug: #7a35b5;
|
||||
}
|
||||
+7
-5
@@ -1,11 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>xTeVe</title>
|
||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
||||
<link rel="stylesheet" href="css/base.css" type="text/css">
|
||||
<link rel="stylesheet" href="css/tokens.css">
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/components.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<script src="js/app.js"></script>
|
||||
|
||||
</head>
|
||||
@@ -37,7 +39,7 @@
|
||||
-->
|
||||
|
||||
<aside id="menu-wrapper" class="layout-left" aria-label="Sidebar menu">
|
||||
<div id= "branch"></div>
|
||||
<div id="branch"></div>
|
||||
<div id="logo"></div>
|
||||
<nav id="main-menu" role="menubar" aria-label="Main navigation"></nav>
|
||||
</aside>
|
||||
@@ -45,7 +47,7 @@
|
||||
<main id="shell-main" class="layout-right">
|
||||
<header id="shell-header">
|
||||
<button id="menu-toggle" type="button" aria-expanded="false" aria-controls="menu-wrapper" aria-label="Toggle navigation menu">Menu</button>
|
||||
<h2 id="shell-title">xTeVe Control Panel</h2>
|
||||
<h1 id="shell-title">xTeVe Control Panel</h1>
|
||||
<p id="connection-indicator" class="status-idle" role="status" aria-live="polite" aria-atomic="true">Connecting...</p>
|
||||
</header>
|
||||
|
||||
|
||||
+1265
-1338
File diff suppressed because it is too large.
Load diff
+6
-4
@@ -1,11 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>xTeVe</title>
|
||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
||||
<link rel="stylesheet" href="css/base.css" type="text/css">
|
||||
<link rel="stylesheet" href="css/tokens.css">
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/components.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<script src="js/app.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -13,7 +15,7 @@
|
||||
|
||||
<div id="header" class="imgCenter"></div>
|
||||
|
||||
<main id="box" role="main" aria-labelledby="head-text">
|
||||
<main id="box" aria-labelledby="head-text">
|
||||
|
||||
<div id="headline">
|
||||
<h1 id="head-text" class="center">Login</h1>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>xTeVe</title>
|
||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
||||
<link rel="stylesheet" href="css/base.css" type="text/css">
|
||||
<link rel="stylesheet" href="css/tokens.css">
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/components.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
</head>
|
||||
|
||||
<body class="auth-screen">
|
||||
|
||||
<div id="header" class="imgCenter"></div>
|
||||
|
||||
<div id="box">
|
||||
<main id="box" aria-labelledby="head-text">
|
||||
|
||||
<div id="headline">
|
||||
<h1 id="head-text" class="center">Maintenance</h1>
|
||||
@@ -24,7 +26,7 @@
|
||||
|
||||
<div id="box-footer"></div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Generated
+532
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -148,8 +148,8 @@ func xteveRestore(archive string) (newWebURL string, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
backupVersion = newConfig["version"].(string)
|
||||
if backupVersion < System.Compatibility {
|
||||
backupVersion, ok := newConfig["version"].(string)
|
||||
if !ok || backupVersion < System.Compatibility {
|
||||
err = errors.New(getErrMsg(1013))
|
||||
return
|
||||
}
|
||||
@@ -167,7 +167,11 @@ func xteveRestore(archive string) (newWebURL string, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
newPort = newConfig["port"].(string)
|
||||
newPort, ok = newConfig["port"].(string)
|
||||
if !ok {
|
||||
err = errors.New(getErrMsg(1030))
|
||||
return
|
||||
}
|
||||
oldPort = Settings.Port
|
||||
|
||||
if newPort == oldPort {
|
||||
|
||||
+5
-5
@@ -63,7 +63,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
|
||||
|
||||
if isNew {
|
||||
|
||||
switch Settings.Buffer {
|
||||
switch bufferModeFor(playlistID) {
|
||||
|
||||
case "xteve":
|
||||
go connectToStreamingServer(streamID, playlistID)
|
||||
@@ -86,7 +86,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
|
||||
return
|
||||
}
|
||||
if s.Err != nil {
|
||||
ShowError(s.Err, 0)
|
||||
// Already logged by the downloader that reported it.
|
||||
return
|
||||
}
|
||||
if s.Status {
|
||||
@@ -127,7 +127,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
|
||||
return
|
||||
}
|
||||
if s.Err != nil {
|
||||
ShowError(s.Err, 0)
|
||||
// Already logged by the downloader that reported it.
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1074,7 +1074,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
|
||||
|
||||
var path, options, bufferType string
|
||||
|
||||
switch Settings.Buffer {
|
||||
switch bufferModeFor(playlistID) {
|
||||
|
||||
case "ffmpeg":
|
||||
path, options, bufferType = Settings.FFmpegPath, Settings.FFmpegOptions, "FFMPEG"
|
||||
@@ -1259,7 +1259,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
|
||||
|
||||
func getTuner(id, playlistType string) (tuner int) {
|
||||
|
||||
switch Settings.Buffer {
|
||||
switch bufferModeFor(id) {
|
||||
|
||||
case "-":
|
||||
tuner = Settings.Tuner
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package src
|
||||
|
||||
// bufferModeFor : buffer used for a playlist. A playlist can override the
|
||||
// global "buffer" setting with its own "buffer" parameter ("-", "xteve",
|
||||
// "ffmpeg" or "vlc"); anything else means "use the global setting".
|
||||
func bufferModeFor(playlistID string) string {
|
||||
|
||||
if len(playlistID) > 0 {
|
||||
|
||||
var playlistType string
|
||||
switch playlistID[0:1] {
|
||||
case "M":
|
||||
playlistType = "m3u"
|
||||
case "H":
|
||||
playlistType = "hdhr"
|
||||
}
|
||||
|
||||
switch v := getProviderParameter(playlistID, playlistType, "buffer"); v {
|
||||
case "-", "xteve", "ffmpeg", "vlc":
|
||||
return v
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return Settings.Buffer
|
||||
}
|
||||
+75
-15
@@ -63,17 +63,31 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e
|
||||
// Leerzeichen aus den Werten entfernen und Formatierung der Uhrzeit überprüfen (0000 - 2359)
|
||||
var newUpdateTimes = make([]string, 0)
|
||||
|
||||
for _, v := range value.([]any) {
|
||||
updateTimes, ok := value.([]any)
|
||||
if !ok {
|
||||
err = errors.New(getErrMsg(1012))
|
||||
ShowError(err, 1012)
|
||||
return Settings, err
|
||||
}
|
||||
|
||||
v = strings.Replace(v.(string), " ", "", -1)
|
||||
for _, v := range updateTimes {
|
||||
|
||||
_, err := time.Parse("1504", v.(string))
|
||||
t, ok := v.(string)
|
||||
if !ok {
|
||||
err = errors.New(getErrMsg(1012))
|
||||
ShowError(err, 1012)
|
||||
return Settings, err
|
||||
}
|
||||
|
||||
t = strings.Replace(t, " ", "", -1)
|
||||
|
||||
_, err := time.Parse("1504", t)
|
||||
if err != nil {
|
||||
ShowError(err, 1012)
|
||||
return Settings, err
|
||||
}
|
||||
|
||||
newUpdateTimes = append(newUpdateTimes, v.(string))
|
||||
newUpdateTimes = append(newUpdateTimes, t)
|
||||
|
||||
}
|
||||
|
||||
@@ -320,21 +334,30 @@ func saveFiles(request RequestStruct, fileType string) (err error) {
|
||||
|
||||
for dataID, data := range newData {
|
||||
|
||||
dataMap, ok := data.(map[string]any)
|
||||
if !ok {
|
||||
err = errors.New(getErrMsg(1020))
|
||||
return
|
||||
}
|
||||
|
||||
if dataID == "-" {
|
||||
|
||||
// Neue Providerdatei
|
||||
dataID = indicator + randomString(19)
|
||||
data.(map[string]any)["new"] = true
|
||||
filesMap[dataID] = data
|
||||
dataMap["new"] = true
|
||||
filesMap[dataID] = dataMap
|
||||
|
||||
} else {
|
||||
|
||||
// Bereits vorhandene Providerdatei
|
||||
for key, value := range data.(map[string]any) {
|
||||
oldData, ok := filesMap[dataID].(map[string]any)
|
||||
if !ok {
|
||||
// Unknown file ID: nothing to update.
|
||||
continue
|
||||
}
|
||||
|
||||
var oldData = filesMap[dataID].(map[string]any)
|
||||
for key, value := range dataMap {
|
||||
oldData[key] = value
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -353,11 +376,11 @@ func saveFiles(request RequestStruct, fileType string) (err error) {
|
||||
}
|
||||
|
||||
// Neue Providerdatei
|
||||
if _, ok := data.(map[string]any)["new"]; ok {
|
||||
if _, ok := dataMap["new"]; ok {
|
||||
|
||||
reloadData = true
|
||||
err = getProviderData(fileType, dataID)
|
||||
delete(data.(map[string]any), "new")
|
||||
delete(dataMap, "new")
|
||||
|
||||
if err != nil {
|
||||
delete(filesMap, dataID)
|
||||
@@ -511,6 +534,23 @@ func saveFilter(request RequestStruct) (settings SettingsStruct, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Regular expressions are checked before anything is saved.
|
||||
var filterType, _ = data.(map[string]any)["type"].(string)
|
||||
if filterType == "" {
|
||||
if old, ok := filterMap[dataID].(map[string]any); ok {
|
||||
filterType, _ = old["type"].(string)
|
||||
}
|
||||
}
|
||||
if filterType == "regex-filter" {
|
||||
if reErr := validateRegexFilter(filter); reErr != nil {
|
||||
err = fmt.Errorf("invalid regular expression: %v", reErr)
|
||||
if newFilter {
|
||||
delete(filterMap, dataID)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if oldData, ok := filterMap[dataID].(map[string]any); ok {
|
||||
@@ -661,8 +701,16 @@ func saveUserData(request RequestStruct) (err error) {
|
||||
func saveNewUser(request RequestStruct) (err error) {
|
||||
|
||||
var data = request.UserData
|
||||
var username = data["username"].(string)
|
||||
var password = data["password"].(string)
|
||||
|
||||
username, ok := data["username"].(string)
|
||||
if !ok {
|
||||
return errors.New("Username is missing")
|
||||
}
|
||||
|
||||
password, ok := data["password"].(string)
|
||||
if !ok {
|
||||
return errors.New("Password is missing")
|
||||
}
|
||||
|
||||
delete(data, "password")
|
||||
delete(data, "confirm")
|
||||
@@ -808,6 +856,17 @@ func createFilterRules() (err error) {
|
||||
|
||||
Data.Filter = append(Data.Filter, dataFilter)
|
||||
|
||||
case "regex-filter":
|
||||
if err = validateRegexFilter(filter.Filter); err != nil {
|
||||
return fmt.Errorf("filter %q: invalid regular expression: %v", filter.Name, err)
|
||||
}
|
||||
|
||||
dataFilter.CaseSensitive = filter.CaseSensitive
|
||||
dataFilter.Rule = filter.Filter
|
||||
dataFilter.Type = filter.Type
|
||||
|
||||
Data.Filter = append(Data.Filter, dataFilter)
|
||||
|
||||
case "group-title":
|
||||
if len(filter.Include) > 0 {
|
||||
include = fmt.Sprintf(" {%s}", filter.Include)
|
||||
@@ -855,7 +914,7 @@ func buildDatabaseDVR() (err error) {
|
||||
|
||||
var playlistFile = getLocalProviderFiles(fileType)
|
||||
|
||||
for n, i := range playlistFile {
|
||||
for _, i := range playlistFile {
|
||||
|
||||
var channels []any
|
||||
var groupTitle, tvgID, uuid = 0, 0, 0
|
||||
@@ -878,7 +937,8 @@ func buildDatabaseDVR() (err error) {
|
||||
ShowError(err, 1005)
|
||||
err = errors.New(playlistName + ": Local copy of the file no longer exists")
|
||||
ShowError(err, 0)
|
||||
playlistFile = append(playlistFile[:n], playlistFile[n+1:]...)
|
||||
// playlistFile is not used after this loop; removing the entry
|
||||
// while ranging over it only skipped the following file.
|
||||
}
|
||||
|
||||
// Streams analysieren
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package src
|
||||
|
||||
import "testing"
|
||||
|
||||
func streamFor(name, group string) map[string]string {
|
||||
return map[string]string{
|
||||
"name": name,
|
||||
"group-title": group,
|
||||
"_values": "tvg-id=\"" + name + "\" group-title=\"" + group + "\"," + name,
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexFilter(t *testing.T) {
|
||||
Data.Filter = []Filter{{Type: "regex-filter", Rule: `^(BBC|ITV) \d+$`, CaseSensitive: false}}
|
||||
regexFilterCache.Range(func(k, v any) bool { regexFilterCache.Delete(k); return true })
|
||||
|
||||
cases := map[string]bool{
|
||||
"BBC 1": true,
|
||||
"itv 2": true, // case-insensitive by default
|
||||
"BBC News": false,
|
||||
"Sky One": false,
|
||||
"BBC 1 HD": false,
|
||||
"Radio BBC": false,
|
||||
}
|
||||
// Group title is matched too.
|
||||
Data.Filter = append(Data.Filter, Filter{Type: "regex-filter", Rule: `^Sports$`})
|
||||
cases["Sports"] = false // name "Sports" is only in group below
|
||||
if !filterThisStream(streamFor("Golf", "Sports")) {
|
||||
t.Error("regex should match the group title")
|
||||
}
|
||||
delete(cases, "Sports")
|
||||
for name, want := range cases {
|
||||
if got := filterThisStream(streamFor(name, "UK")); got != want {
|
||||
t.Errorf("%q: got %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
Data.Filter = []Filter{{Type: "regex-filter", Rule: `^bbc`, CaseSensitive: true}}
|
||||
if filterThisStream(streamFor("BBC 1", "UK")) {
|
||||
t.Error("case-sensitive rule must not match different case")
|
||||
}
|
||||
|
||||
Data.Filter = []Filter{{Type: "regex-filter", Rule: `(`, CaseSensitive: false}}
|
||||
if filterThisStream(streamFor("BBC 1", "UK")) {
|
||||
t.Error("invalid pattern must never match")
|
||||
}
|
||||
if validateRegexFilter(`(`) == nil {
|
||||
t.Error("validateRegexFilter accepted an invalid pattern")
|
||||
}
|
||||
|
||||
Data.Filter = nil
|
||||
}
|
||||
|
||||
func TestBufferModeFor(t *testing.T) {
|
||||
Settings.Buffer = "xteve"
|
||||
Settings.Files.M3U = map[string]any{
|
||||
"M-a": map[string]any{"name": "A", "buffer": "ffmpeg"},
|
||||
"M-b": map[string]any{"name": "B", "buffer": "default"},
|
||||
"M-c": map[string]any{"name": "C"},
|
||||
}
|
||||
Settings.Files.HDHR = map[string]any{
|
||||
"H-d": map[string]any{"name": "D", "buffer": "-"},
|
||||
}
|
||||
t.Cleanup(func() { Settings.Buffer = "-" })
|
||||
|
||||
cases := map[string]string{"M-a": "ffmpeg", "M-b": "xteve", "M-c": "xteve", "H-d": "-", "M-missing": "xteve", "": "xteve"}
|
||||
for id, want := range cases {
|
||||
if got := bufferModeFor(id); got != want {
|
||||
t.Errorf("%q: got %q, want %q", id, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// providerHTTPClient : Downloads of provider files (M3U, XMLTV, HDHR lineup).
|
||||
// Playlists and EPG files can be large, so the overall timeout is generous;
|
||||
// it only exists so a stalled provider cannot hang an update forever.
|
||||
var providerHTTPClient = &http.Client{Timeout: 5 * time.Minute}
|
||||
|
||||
// apiHTTPClient : Short request/response calls to other services (Plex API).
|
||||
var apiHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
@@ -0,0 +1,43 @@
|
||||
package authentication
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteFileAtomic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "authentication.json")
|
||||
|
||||
if err := writeFileAtomic(file, []byte("{\"a\":1}"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeFileAtomic(file, []byte("{\"a\":2}"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "{\"a\":2}" {
|
||||
t.Errorf("content = %q", got)
|
||||
}
|
||||
|
||||
fi, err := os.Stat(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0600 {
|
||||
t.Errorf("mode = %o, want 0600", fi.Mode().Perm())
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("expected only the target file, found %d entries", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -411,11 +411,47 @@ func saveDatabase(tmpMap any) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
err = os.WriteFile(database, []byte(jsonString), 0600)
|
||||
err = writeFileAtomic(database, []byte(jsonString), 0600)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// writeFileAtomic : Temp file + fsync + rename, so authentication.json is
|
||||
// never observed half-written. (Private copy; this package cannot import src.)
|
||||
func writeFileAtomic(name string, data []byte, perm os.FileMode) (err error) {
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(name), ".tmp-*")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var tmpName = tmp.Name()
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err = tmp.Write(data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = tmp.Sync(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = tmp.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = os.Chmod(tmpName, perm); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = os.Rename(tmpName, name)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,14 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// httpClient : Client for logo downloads. Providers occasionally serve
|
||||
// images from hosts that never answer; without a timeout the caching
|
||||
// goroutine would hang forever.
|
||||
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// Cache : Cache strcut
|
||||
type Cache struct {
|
||||
path string
|
||||
@@ -41,8 +47,6 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
|
||||
c.Queue = []string{}
|
||||
c.Cache = []string{}
|
||||
|
||||
var queue []string
|
||||
|
||||
c.Image.GetURL = func(src string) (cacheURL string) {
|
||||
|
||||
c.Lock()
|
||||
@@ -89,50 +93,30 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
|
||||
return src
|
||||
}
|
||||
|
||||
// Caching downloads every queued image. The lock is only held while the
|
||||
// queue is copied and while the map / queue are updated per image; the
|
||||
// HTTP download and file write happen unlocked so GetURL callers (M3U /
|
||||
// XMLTV generation) are not blocked for the duration of the downloads.
|
||||
c.Image.Caching = func() {
|
||||
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
var queue = make([]string, len(c.Queue))
|
||||
copy(queue, c.Queue)
|
||||
c.Unlock()
|
||||
|
||||
var filename string
|
||||
for _, src := range queue {
|
||||
|
||||
for _, src := range c.Queue {
|
||||
|
||||
resp, err := http.Get(src)
|
||||
filename, err := c.download(src)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Stays in the queue and is retried on the next run.
|
||||
continue
|
||||
}
|
||||
|
||||
filename = fmt.Sprintf("%s%s%s%s", c.path, string(os.PathSeparator), strToMD5(src), filepath.Ext(src))
|
||||
c.Lock()
|
||||
c.images[filename] = c.cacheURL + filename
|
||||
c.Queue = removeStringFromSlice(src, c.Queue)
|
||||
c.Unlock()
|
||||
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
u, err := url.Parse(src)
|
||||
if err == nil {
|
||||
c.images[fmt.Sprintf("%s%s", strToMD5(src), filepath.Ext(u.Path))] = c.cacheURL + filename
|
||||
}
|
||||
|
||||
queue = append(queue, src)
|
||||
|
||||
}
|
||||
|
||||
for _, q := range queue {
|
||||
c.Queue = removeStringFromSlice(q, c.Queue)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -175,3 +159,45 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// download : Fetches src into the cache folder and returns the cached file
|
||||
// name (md5 of the URL plus the extension of the URL path). Must be called
|
||||
// without holding the lock.
|
||||
func (c *Cache) download(src string) (filename string, err error) {
|
||||
|
||||
u, err := url.Parse(src)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
filename = fmt.Sprintf("%s%s", strToMD5(src), filepath.Ext(u.Path))
|
||||
|
||||
resp, err := httpClient.Get(src)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
err = fmt.Errorf("%s: %s", src, resp.Status)
|
||||
return
|
||||
}
|
||||
|
||||
var path = filepath.Join(c.path, filename)
|
||||
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if closeErr := file.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
os.Remove(path)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
+51
-6
@@ -8,6 +8,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
m3u "xteve/src/internal/m3u-parser"
|
||||
)
|
||||
@@ -34,12 +35,47 @@ func parsePlaylist(filename, fileType string) (channels []any, err error) {
|
||||
}
|
||||
|
||||
// Streams filtern
|
||||
// Patterns for the {include} and !{exclude} parts of a filter rule.
|
||||
var filterIncludeRe = regexp.MustCompile(`[{]+[^.]+[}]`)
|
||||
var filterExcludeRe = regexp.MustCompile(`!+[{]+[^.]+[}]`)
|
||||
|
||||
// regexFilterCache : compiled "regex-filter" rules, keyed by rule and case mode.
|
||||
var regexFilterCache sync.Map
|
||||
|
||||
// compileRegexFilter : compiles (and caches) a regex-filter rule. Case
|
||||
// insensitive rules get the (?i) flag. Returns nil for an invalid pattern.
|
||||
func compileRegexFilter(rule string, caseSensitive bool) *regexp.Regexp {
|
||||
|
||||
var key = fmt.Sprintf("%t:%s", caseSensitive, rule)
|
||||
|
||||
if v, ok := regexFilterCache.Load(key); ok {
|
||||
return v.(*regexp.Regexp)
|
||||
}
|
||||
|
||||
var pattern = rule
|
||||
if !caseSensitive {
|
||||
pattern = "(?i)" + rule
|
||||
}
|
||||
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
regexFilterCache.Store(key, re)
|
||||
return re
|
||||
}
|
||||
|
||||
// validateRegexFilter : error for an invalid regex-filter rule.
|
||||
func validateRegexFilter(rule string) error {
|
||||
_, err := regexp.Compile(rule)
|
||||
return err
|
||||
}
|
||||
|
||||
func filterThisStream(s any) (status bool) {
|
||||
|
||||
status = false
|
||||
var stream = s.(map[string]string)
|
||||
var regexpYES = `[{]+[^.]+[}]`
|
||||
var regexpNO = `!+[{]+[^.]+[}]`
|
||||
|
||||
for _, filter := range Data.Filter {
|
||||
|
||||
@@ -53,6 +89,17 @@ func filterThisStream(s any) (status bool) {
|
||||
|
||||
var streamValues = strings.Replace(stream["_values"], "\r", "", -1)
|
||||
|
||||
// Regular expression, tried against the channel name, then the group
|
||||
// title, then the raw attribute line (so anchors work on the name).
|
||||
if filter.Type == "regex-filter" {
|
||||
if re := compileRegexFilter(filter.Rule, filter.CaseSensitive); re != nil {
|
||||
if re.MatchString(stream["name"]) || re.MatchString(stream["group-title"]) || re.MatchString(streamValues) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if v, ok := stream["group-title"]; ok {
|
||||
group = v
|
||||
}
|
||||
@@ -62,8 +109,7 @@ func filterThisStream(s any) (status bool) {
|
||||
}
|
||||
|
||||
// Unerwünschte Streams !{DEU}
|
||||
r := regexp.MustCompile(regexpNO)
|
||||
val := r.FindStringSubmatch(filter.Rule)
|
||||
val := filterExcludeRe.FindStringSubmatch(filter.Rule)
|
||||
|
||||
if len(val) == 1 {
|
||||
|
||||
@@ -74,8 +120,7 @@ func filterThisStream(s any) (status bool) {
|
||||
}
|
||||
|
||||
// Muss zusätzlich erfüllt sein {DEU}
|
||||
r = regexp.MustCompile(regexpYES)
|
||||
val = r.FindStringSubmatch(filter.Rule)
|
||||
val = filterIncludeRe.FindStringSubmatch(filter.Rule)
|
||||
|
||||
if len(val) == 1 {
|
||||
|
||||
|
||||
+1
-5
@@ -342,11 +342,7 @@ func doPlexRequest(method, baseURL, endpoint, token string) (status int, body []
|
||||
req.Header.Set("X-Plex-Version", System.Version)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
resp, err := apiHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
+32
-15
@@ -142,8 +142,16 @@ func getProviderData(fileType, fileID string) (err error) {
|
||||
|
||||
for dataID, d := range dataMap {
|
||||
|
||||
var data = d.(map[string]any)
|
||||
var fileSource = data["file.source"].(string)
|
||||
data, ok := d.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
fileSource, ok := data["file.source"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
newProvider = false
|
||||
|
||||
if _, ok := data["new"]; ok {
|
||||
@@ -224,8 +232,8 @@ func getProviderData(fileType, fileID string) (err error) {
|
||||
if value, ok := dataMap[dataID].(map[string]any); ok {
|
||||
|
||||
data = value
|
||||
data["counter.error"] = data["counter.error"].(float64) + 1
|
||||
data["counter.download"] = data["counter.download"].(float64) + 1
|
||||
data["counter.error"] = toFloat64(data["counter.error"]) + 1
|
||||
data["counter.download"] = toFloat64(data["counter.download"]) + 1
|
||||
|
||||
}
|
||||
|
||||
@@ -243,10 +251,13 @@ func getProviderData(fileType, fileID string) (err error) {
|
||||
var data = make(map[string]any)
|
||||
data = value
|
||||
|
||||
if data["counter.error"].(float64) == 0 {
|
||||
var errCount = toFloat64(data["counter.error"])
|
||||
var dlCount = toFloat64(data["counter.download"])
|
||||
|
||||
if errCount == 0 || dlCount == 0 {
|
||||
data["provider.availability"] = 100
|
||||
} else {
|
||||
data["provider.availability"] = int(data["counter.error"].(float64)*100/data["counter.download"].(float64)*-1 + 100)
|
||||
data["provider.availability"] = int(errCount*100/dlCount*-1 + 100)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -289,8 +300,7 @@ func downloadFileFromServer(providerURL string) (filename string, body []byte, e
|
||||
|
||||
req.Header.Set("User-Agent", getUserAgent())
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := providerHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -307,15 +317,22 @@ func downloadFileFromServer(providerURL string) (filename string, body []byte, e
|
||||
|
||||
if index > -1 {
|
||||
|
||||
var headerFilename = resp.Header.Get("Content-Disposition")[index:len(resp.Header.Get("Content-Disposition"))]
|
||||
var value = strings.Split(headerFilename, `=`)
|
||||
var f = strings.Replace(value[1], `"`, "", -1)
|
||||
var headerFilename = resp.Header.Get("Content-Disposition")[index:]
|
||||
var value = strings.SplitN(headerFilename, `=`, 2)
|
||||
|
||||
f = strings.Replace(f, `;`, "", -1)
|
||||
filename = f
|
||||
showInfo("Header filename:" + filename)
|
||||
if len(value) == 2 {
|
||||
|
||||
} else {
|
||||
var f = strings.Replace(value[1], `"`, "", -1)
|
||||
|
||||
f = strings.Replace(f, `;`, "", -1)
|
||||
filename = f
|
||||
showInfo("Header filename:" + filename)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if len(filename) == 0 {
|
||||
|
||||
var cleanFilename = strings.SplitN(getFilenameFromPath(providerURL), "?", 2)
|
||||
filename = cleanFilename[0]
|
||||
|
||||
+144
-39
@@ -10,6 +10,82 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// logMu guards WebScreenLog (its slice and counters) and System.Notification.
|
||||
// Log lines are appended from every goroutine (buffer, maintenance, web
|
||||
// handlers) and read by the websocket handler.
|
||||
var logMu sync.Mutex
|
||||
|
||||
// logAppend : Appends a timestamped line to the in-memory log and trims it
|
||||
// to Settings.LogEntriesRAM.
|
||||
func logAppend(logMsg string) {
|
||||
|
||||
logMu.Lock()
|
||||
defer logMu.Unlock()
|
||||
|
||||
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
|
||||
logCleanUpLocked()
|
||||
}
|
||||
|
||||
// logAppendCounted : Appends a warning / error line and bumps the matching
|
||||
// counter. Like the original code this does not trim the log; the next
|
||||
// info / debug line does.
|
||||
func logAppendCounted(logMsg string, isError bool) {
|
||||
|
||||
logMu.Lock()
|
||||
defer logMu.Unlock()
|
||||
|
||||
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
|
||||
|
||||
if isError {
|
||||
WebScreenLog.Errors++
|
||||
} else {
|
||||
WebScreenLog.Warnings++
|
||||
}
|
||||
}
|
||||
|
||||
// resetWebScreenLog : Clears the in-memory log and its counters.
|
||||
func resetWebScreenLog() {
|
||||
|
||||
logMu.Lock()
|
||||
defer logMu.Unlock()
|
||||
|
||||
WebScreenLog.Log = make([]string, 0)
|
||||
WebScreenLog.Errors = 0
|
||||
WebScreenLog.Warnings = 0
|
||||
}
|
||||
|
||||
// webScreenLogSnapshot : Copy of the log for the web client. The slice is
|
||||
// copied so the JSON encoder never races with a concurrent append.
|
||||
func webScreenLogSnapshot() (snapshot WebScreenLogStruct) {
|
||||
|
||||
logMu.Lock()
|
||||
defer logMu.Unlock()
|
||||
|
||||
snapshot = WebScreenLog
|
||||
snapshot.Log = make([]string, len(WebScreenLog.Log))
|
||||
copy(snapshot.Log, WebScreenLog.Log)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// notificationsSnapshot : Copy of the notifications map for the web client.
|
||||
func notificationsSnapshot() (snapshot map[string]Notification) {
|
||||
|
||||
logMu.Lock()
|
||||
defer logMu.Unlock()
|
||||
|
||||
if System.Notification == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
snapshot = make(map[string]Notification, len(System.Notification))
|
||||
for k, v := range System.Notification {
|
||||
snapshot[k] = v
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func showInfo(str string) {
|
||||
|
||||
if System.Flag.Info {
|
||||
@@ -33,9 +109,7 @@ func showInfo(str string) {
|
||||
|
||||
printLogOnScreen(logMsg, "info")
|
||||
|
||||
logMsg = strings.Replace(logMsg, " ", " ", -1)
|
||||
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
|
||||
logCleanUp()
|
||||
logAppend(logMsg)
|
||||
|
||||
}
|
||||
|
||||
@@ -51,7 +125,6 @@ func showDebug(str string, level int) {
|
||||
var msg = strings.SplitN(str, ":", 2)
|
||||
var length = len(msg[0])
|
||||
var space string
|
||||
var mutex = sync.RWMutex{}
|
||||
|
||||
if len(msg) == 2 {
|
||||
|
||||
@@ -64,11 +137,7 @@ func showDebug(str string, level int) {
|
||||
|
||||
printLogOnScreen(logMsg, "debug")
|
||||
|
||||
mutex.Lock()
|
||||
logMsg = strings.Replace(logMsg, " ", " ", -1)
|
||||
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
|
||||
logCleanUp()
|
||||
mutex.Unlock()
|
||||
logAppend(logMsg)
|
||||
|
||||
}
|
||||
|
||||
@@ -99,7 +168,8 @@ func showHighlight(str string) {
|
||||
}
|
||||
|
||||
notification.Type = "info"
|
||||
notification.Message = msg[1]
|
||||
// Messages without a "key:value" prefix are shown as they are.
|
||||
notification.Message = msg[len(msg)-1]
|
||||
|
||||
addNotification(notification)
|
||||
|
||||
@@ -109,31 +179,22 @@ func showWarning(errCode int) {
|
||||
|
||||
var errMsg = getErrMsg(errCode)
|
||||
var logMsg = fmt.Sprintf("[%s] [WARNING] %s", System.Name, errMsg)
|
||||
var mutex = sync.RWMutex{}
|
||||
|
||||
printLogOnScreen(logMsg, "warning")
|
||||
|
||||
mutex.Lock()
|
||||
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
|
||||
WebScreenLog.Warnings++
|
||||
mutex.Unlock()
|
||||
logAppendCounted(logMsg, false)
|
||||
|
||||
}
|
||||
|
||||
// ShowError : Zeigt die Fehlermeldungen in der Konsole
|
||||
func ShowError(err error, errCode int) {
|
||||
|
||||
var mutex = sync.RWMutex{}
|
||||
|
||||
var errMsg = getErrMsg(errCode)
|
||||
var logMsg = fmt.Sprintf("[%s] [ERROR] %s (%s) - EC: %d", System.Name, err, errMsg, errCode)
|
||||
|
||||
printLogOnScreen(logMsg, "error")
|
||||
|
||||
mutex.Lock()
|
||||
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
|
||||
WebScreenLog.Errors++
|
||||
mutex.Unlock()
|
||||
logAppendCounted(logMsg, true)
|
||||
|
||||
}
|
||||
|
||||
@@ -174,24 +235,17 @@ func printLogOnScreen(logMsg string, logType string) {
|
||||
|
||||
}
|
||||
|
||||
func logCleanUp() {
|
||||
// logCleanUpLocked : Trims the log to the newest Settings.LogEntriesRAM
|
||||
// lines and recounts warnings / errors. The caller must hold logMu.
|
||||
func logCleanUpLocked() {
|
||||
|
||||
WebScreenLog.Log = keepLastEntries(WebScreenLog.Log, Settings.LogEntriesRAM)
|
||||
|
||||
var logEntriesRAM = Settings.LogEntriesRAM
|
||||
var logs = WebScreenLog.Log
|
||||
|
||||
WebScreenLog.Warnings = 0
|
||||
WebScreenLog.Errors = 0
|
||||
|
||||
if len(logs) > logEntriesRAM {
|
||||
|
||||
var tmp = make([]string, 0)
|
||||
for i := len(logs) - logEntriesRAM; i < logEntriesRAM; i++ {
|
||||
tmp = append(tmp, logs[i])
|
||||
}
|
||||
|
||||
logs = tmp
|
||||
}
|
||||
|
||||
for _, log := range logs {
|
||||
|
||||
if strings.Contains(log, "WARNING") {
|
||||
@@ -204,8 +258,25 @@ func logCleanUp() {
|
||||
|
||||
}
|
||||
|
||||
WebScreenLog.Log = logs
|
||||
}
|
||||
|
||||
// keepLastEntries : Returns the last max entries of logs (a copy, so the
|
||||
// old backing array does not keep growing). A max <= 0 keeps nothing, which
|
||||
// matches the original behaviour for an unset LogEntriesRAM.
|
||||
func keepLastEntries(logs []string, max int) []string {
|
||||
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
|
||||
if len(logs) <= max {
|
||||
return logs
|
||||
}
|
||||
|
||||
var tmp = make([]string, max)
|
||||
copy(tmp, logs[len(logs)-max:])
|
||||
|
||||
return tmp
|
||||
}
|
||||
|
||||
// Fehlercodes
|
||||
@@ -386,30 +457,64 @@ func getErrMsg(errCode int) (errMsg string) {
|
||||
return errMsg
|
||||
}
|
||||
|
||||
// maxNotifications : Number of notifications kept for the web interface.
|
||||
const maxNotifications = 10
|
||||
|
||||
func addNotification(notification Notification) (err error) {
|
||||
|
||||
var i int
|
||||
var t = time.Now().UnixNano() / (int64(time.Millisecond) / int64(time.Nanosecond))
|
||||
notification.Time = strconv.FormatInt(t, 10)
|
||||
|
||||
insertNotification(notification)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// insertNotification : Stores the notification under its Time key and evicts
|
||||
// the oldest ones once more than maxNotifications are kept.
|
||||
func insertNotification(notification Notification) {
|
||||
|
||||
notification.New = true
|
||||
|
||||
if len(notification.Headline) == 0 {
|
||||
notification.Headline = strings.ToUpper(notification.Type)
|
||||
}
|
||||
|
||||
logMu.Lock()
|
||||
defer logMu.Unlock()
|
||||
|
||||
if len(System.Notification) == 0 {
|
||||
System.Notification = make(map[string]Notification)
|
||||
}
|
||||
|
||||
System.Notification[notification.Time] = notification
|
||||
|
||||
for key := range System.Notification {
|
||||
for len(System.Notification) > maxNotifications {
|
||||
delete(System.Notification, oldestNotificationKey(System.Notification))
|
||||
}
|
||||
|
||||
if i < len(System.Notification)-10 {
|
||||
delete(System.Notification, key)
|
||||
}
|
||||
|
||||
// oldestNotificationKey : Key of the notification with the smallest
|
||||
// timestamp. Keys are millisecond timestamps as decimal strings; anything
|
||||
// that does not parse sorts before everything that does.
|
||||
func oldestNotificationKey(notifications map[string]Notification) (oldest string) {
|
||||
|
||||
var oldestTime int64
|
||||
var found bool
|
||||
|
||||
for key, n := range notifications {
|
||||
|
||||
t, err := strconv.ParseInt(n.Time, 10, 64)
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
|
||||
i++
|
||||
if !found || t < oldestTime {
|
||||
oldest = key
|
||||
oldestTime = t
|
||||
found = true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKeepLastEntries(t *testing.T) {
|
||||
logs := []string{"1", "2", "3", "4", "5"}
|
||||
|
||||
got := keepLastEntries(logs, 3)
|
||||
if want := []string{"3", "4", "5"}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("keepLastEntries(5 entries, 3) = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
if got := keepLastEntries(logs, 10); !reflect.DeepEqual(got, logs) {
|
||||
t.Errorf("keepLastEntries below the limit = %v, want %v", got, logs)
|
||||
}
|
||||
|
||||
if got := keepLastEntries(logs, 0); len(got) != 0 {
|
||||
t.Errorf("keepLastEntries(.., 0) = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The ring buffer must keep the newest lines once the log is full; the old
|
||||
// loop bound dropped them.
|
||||
func TestLogCleanUpKeepsNewest(t *testing.T) {
|
||||
oldLimit := Settings.LogEntriesRAM
|
||||
resetWebScreenLog()
|
||||
t.Cleanup(func() {
|
||||
Settings.LogEntriesRAM = oldLimit
|
||||
resetWebScreenLog()
|
||||
})
|
||||
|
||||
Settings.LogEntriesRAM = 3
|
||||
|
||||
for i := 1; i <= 5; i++ {
|
||||
logAppend(fmt.Sprintf("line-%d", i))
|
||||
}
|
||||
|
||||
snapshot := webScreenLogSnapshot()
|
||||
if len(snapshot.Log) != 3 {
|
||||
t.Fatalf("log has %d entries, want 3: %v", len(snapshot.Log), snapshot.Log)
|
||||
}
|
||||
for i, want := range []string{"line-3", "line-4", "line-5"} {
|
||||
if !strings.HasSuffix(snapshot.Log[i], want) {
|
||||
t.Errorf("entry %d = %q, want suffix %q", i, snapshot.Log[i], want)
|
||||
}
|
||||
}
|
||||
|
||||
// Counters are recomputed from the retained lines.
|
||||
logAppendCounted("[ERROR] boom", true)
|
||||
logAppend("[WARNING] careful")
|
||||
snapshot = webScreenLogSnapshot()
|
||||
if snapshot.Errors != 1 || snapshot.Warnings != 1 {
|
||||
t.Errorf("errors/warnings = %d/%d, want 1/1 (%v)", snapshot.Errors, snapshot.Warnings, snapshot.Log)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertNotificationEvictsOldest(t *testing.T) {
|
||||
old := System.Notification
|
||||
System.Notification = nil
|
||||
t.Cleanup(func() { System.Notification = old })
|
||||
|
||||
// Insert out of order so eviction cannot rely on insertion order.
|
||||
order := []int64{5, 1, 12, 3, 9, 2, 11, 7, 4, 10, 8, 6}
|
||||
for _, ts := range order {
|
||||
insertNotification(Notification{Type: "info", Message: "n", Time: strconv.FormatInt(1000+ts, 10)})
|
||||
}
|
||||
|
||||
got := notificationsSnapshot()
|
||||
if len(got) != maxNotifications {
|
||||
t.Fatalf("kept %d notifications, want %d", len(got), maxNotifications)
|
||||
}
|
||||
for _, ts := range []int64{1, 2} {
|
||||
if _, ok := got[strconv.FormatInt(1000+ts, 10)]; ok {
|
||||
t.Errorf("oldest notification %d should have been evicted", ts)
|
||||
}
|
||||
}
|
||||
for ts := int64(3); ts <= 12; ts++ {
|
||||
if _, ok := got[strconv.FormatInt(1000+ts, 10)]; !ok {
|
||||
t.Errorf("notification %d missing", ts)
|
||||
}
|
||||
}
|
||||
|
||||
// The web-client copy must be independent of the live map.
|
||||
delete(got, "1012")
|
||||
if _, ok := notificationsSnapshot()["1012"]; !ok {
|
||||
t.Error("snapshot is not a copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowHighlightWithoutColon(t *testing.T) {
|
||||
old := System.Notification
|
||||
System.Notification = nil
|
||||
t.Cleanup(func() { System.Notification = old })
|
||||
|
||||
showHighlight("no separator here") // used to index msg[1] and panic
|
||||
|
||||
for _, n := range notificationsSnapshot() {
|
||||
if n.Message != "no separator here" {
|
||||
t.Errorf("message = %q", n.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The web UI renders log lines as text inside <pre>, so the server must store
|
||||
// plain spaces, not HTML entities.
|
||||
func TestLogLinesContainNoHTMLEntities(t *testing.T) {
|
||||
var oldLimit = Settings.LogEntriesRAM
|
||||
Settings.LogEntriesRAM = 500
|
||||
t.Cleanup(func() { Settings.LogEntriesRAM = oldLimit })
|
||||
|
||||
resetWebScreenLog()
|
||||
showInfo("Version:2.2.0 Build: 0201")
|
||||
showDebug("Buffer Status:some debug text", 0)
|
||||
|
||||
snap := webScreenLogSnapshot()
|
||||
if len(snap.Log) == 0 {
|
||||
t.Fatal("no log lines recorded")
|
||||
}
|
||||
if !strings.Contains(snap.Log[0], "Version: 2.2.0 Build: 0201") {
|
||||
t.Errorf("padding should be plain spaces, got %q", snap.Log[0])
|
||||
}
|
||||
for _, line := range snap.Log {
|
||||
if strings.Contains(line, " ") {
|
||||
t.Errorf("log line contains an HTML entity: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-8
@@ -124,12 +124,7 @@ func maskSettings(s SettingsStruct) SettingsStruct {
|
||||
// writePrivateFile : like writeByteToFile but readable only by the owner.
|
||||
func writePrivateFile(file string, data []byte) error {
|
||||
|
||||
var filename = getPlatformFile(file)
|
||||
|
||||
if err := os.WriteFile(filename, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteFile keeps the mode of an existing file; tighten it.
|
||||
return os.Chmod(filename, 0600)
|
||||
// The temp file is chmod'ed before the rename, so an existing
|
||||
// world-readable file is replaced by a 0600 one.
|
||||
return writeFileAtomic(getPlatformFile(file), data, 0600)
|
||||
}
|
||||
+49
-18
@@ -4,12 +4,38 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/koron/go-ssdp"
|
||||
)
|
||||
|
||||
// ssdpState : The running advertiser, so Shutdown can say goodbye to the
|
||||
// network before the process exits.
|
||||
var ssdpState struct {
|
||||
sync.Mutex
|
||||
adv *ssdp.Advertiser
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Shutdown : Stops background services (currently the SSDP advertiser).
|
||||
// Called from main on SIGINT / SIGTERM.
|
||||
func Shutdown() {
|
||||
|
||||
ssdpState.Lock()
|
||||
defer ssdpState.Unlock()
|
||||
|
||||
if ssdpState.adv == nil {
|
||||
return
|
||||
}
|
||||
|
||||
close(ssdpState.done)
|
||||
ssdpState.adv.Bye()
|
||||
ssdpState.adv.Close()
|
||||
ssdpState.adv = nil
|
||||
|
||||
}
|
||||
|
||||
// SSDP : SSPD / DLNA Server
|
||||
func SSDP() (err error) {
|
||||
|
||||
@@ -19,9 +45,6 @@ func SSDP() (err error) {
|
||||
|
||||
showInfo(fmt.Sprintf("SSDP / DLNA:%t", Settings.SSDP))
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt)
|
||||
|
||||
ad, err := ssdp.Advertise(
|
||||
"upnp:rootdevice", // send as "ST"
|
||||
fmt.Sprintf("uuid:%s::upnp:rootdevice", System.DeviceID), // send as "USN"
|
||||
@@ -38,35 +61,43 @@ func SSDP() (err error) {
|
||||
ssdp.Logger = log.New(os.Stderr, "[SSDP] ", log.LstdFlags)
|
||||
}
|
||||
|
||||
go func(adv *ssdp.Advertiser) {
|
||||
var done = make(chan struct{})
|
||||
|
||||
aliveTick := time.Tick(300 * time.Second)
|
||||
ssdpState.Lock()
|
||||
ssdpState.adv = ad
|
||||
ssdpState.done = done
|
||||
ssdpState.Unlock()
|
||||
|
||||
go func(adv *ssdp.Advertiser, done chan struct{}) {
|
||||
|
||||
aliveTick := time.NewTicker(300 * time.Second)
|
||||
defer aliveTick.Stop()
|
||||
|
||||
loop:
|
||||
for {
|
||||
|
||||
select {
|
||||
|
||||
case <-aliveTick:
|
||||
err = adv.Alive()
|
||||
if err != nil {
|
||||
ShowError(err, 0)
|
||||
case <-aliveTick.C:
|
||||
if aliveErr := adv.Alive(); aliveErr != nil {
|
||||
ShowError(aliveErr, 0)
|
||||
ssdpState.Lock()
|
||||
if ssdpState.adv == adv {
|
||||
ssdpState.adv = nil
|
||||
}
|
||||
ssdpState.Unlock()
|
||||
adv.Bye()
|
||||
adv.Close()
|
||||
break loop
|
||||
return
|
||||
}
|
||||
|
||||
case <-quit:
|
||||
adv.Bye()
|
||||
adv.Close()
|
||||
os.Exit(0)
|
||||
break loop
|
||||
case <-done:
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}(ad)
|
||||
}(ad, done)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -5,6 +5,10 @@ type RequestStruct struct {
|
||||
// Befehle an xTeVe
|
||||
Cmd string `json:"cmd"`
|
||||
|
||||
// Client-chosen request id, echoed in the response so the web UI can
|
||||
// match answers on a long-lived connection.
|
||||
ID string `json:"id,omitempty"`
|
||||
|
||||
// Benutzer
|
||||
DeleteUser bool `json:"deleteUser,omitempty"`
|
||||
UserData map[string]any `json:"userData,omitempty"`
|
||||
@@ -73,6 +77,8 @@ type RequestStruct struct {
|
||||
|
||||
// ResponseStruct : Antworten an den Client (WEB)
|
||||
type ResponseStruct struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
|
||||
ClientInfo struct {
|
||||
ARCH string `json:"arch"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
|
||||
+74
-7
@@ -204,12 +204,7 @@ func saveMapToJSONFile(file string, tmpMap any) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.WriteFile(filename, []byte(jsonString), 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return writeFileAtomic(filename, []byte(jsonString), 0644)
|
||||
}
|
||||
|
||||
func loadJSONFileToMap(file string) (tmpMap map[string]any, err error) {
|
||||
@@ -229,6 +224,37 @@ func loadJSONFileToMap(file string) (tmpMap map[string]any, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// toFloat64 : Numeric value of a JSON decoded field; 0 for anything that
|
||||
// is not a number (missing key, wrong type).
|
||||
func toFloat64(v any) float64 {
|
||||
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// removeStrings : Copy of list without the entries present in remove.
|
||||
// Order of the remaining entries is kept.
|
||||
func removeStrings(list []string, remove map[string]bool) []string {
|
||||
|
||||
var result = make([]string, 0, len(list))
|
||||
|
||||
for _, s := range list {
|
||||
if !remove[s] {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Binary
|
||||
func readByteFromFile(file string) (content []byte, err error) {
|
||||
|
||||
@@ -246,7 +272,48 @@ func readByteFromFile(file string) (content []byte, err error) {
|
||||
func writeByteToFile(file string, data []byte) (err error) {
|
||||
|
||||
var filename = getPlatformFile(file)
|
||||
err = os.WriteFile(filename, data, 0644)
|
||||
err = writeFileAtomic(filename, data, 0644)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// writeFileAtomic : Writes data to a temporary file in the target directory,
|
||||
// fsyncs it and renames it over name, so readers (and a crash mid-write)
|
||||
// never see a truncated or half-written file. The temp file is removed on
|
||||
// any error.
|
||||
func writeFileAtomic(name string, data []byte, perm os.FileMode) (err error) {
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(name), ".tmp-*")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var tmpName = tmp.Name()
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err = tmp.Write(data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = tmp.Sync(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = tmp.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = os.Chmod(tmpName, perm); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = os.Rename(tmpName, name)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteFileAtomic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "settings.json")
|
||||
|
||||
if err := writeFileAtomic(file, []byte("first"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeFileAtomic(file, []byte("second"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "second" {
|
||||
t.Errorf("content = %q, want %q", got, "second")
|
||||
}
|
||||
|
||||
fi, err := os.Stat(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0600 {
|
||||
t.Errorf("mode = %o, want 0600", fi.Mode().Perm())
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() != "settings.json" {
|
||||
t.Errorf("unexpected leftover file %q", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileAtomicMissingDir(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "missing", "settings.json")
|
||||
if err := writeFileAtomic(file, []byte("x"), 0644); err == nil {
|
||||
t.Fatal("expected an error for a missing directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveStrings(t *testing.T) {
|
||||
list := []string{"a.xml", "b.xml", "c.xml", "d.xml"}
|
||||
got := removeStrings(list, map[string]bool{"b.xml": true, "d.xml": true})
|
||||
want := []string{"a.xml", "c.xml"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("removeStrings = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// Nothing to remove: same content, original untouched.
|
||||
got = removeStrings(list, nil)
|
||||
if !reflect.DeepEqual(got, list) {
|
||||
t.Errorf("removeStrings(nil) = %v, want %v", got, list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToFloat64(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want float64
|
||||
}{
|
||||
{float64(3), 3},
|
||||
{int(2), 2},
|
||||
{int64(1), 1},
|
||||
{"x", 0},
|
||||
{nil, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := toFloat64(c.in); got != c.want {
|
||||
t.Errorf("toFloat64(%v) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-33
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"xteve/src/internal/authentication"
|
||||
|
||||
@@ -73,7 +74,17 @@ func StartWebserver() (err error) {
|
||||
|
||||
}
|
||||
|
||||
if err = http.ListenAndServe(":"+port, nil); err != nil {
|
||||
// No ReadTimeout / WriteTimeout on purpose: /stream/ responses are
|
||||
// long-lived. ReadHeaderTimeout still protects against slowloris
|
||||
// clients and IdleTimeout reaps keep-alive connections.
|
||||
var server = &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: nil,
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
if err = server.ListenAndServe(); err != nil {
|
||||
ShowError(err, 1001)
|
||||
return
|
||||
}
|
||||
@@ -153,16 +164,18 @@ func Stream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var bufferMode = bufferModeFor(streamInfo.PlaylistID)
|
||||
|
||||
// If an UDPxy host is set, and the stream URL is multicast (i.e. starts with 'udp://@'),
|
||||
// then streamInfo.URL needs to be rewritten to point to UDPxy.
|
||||
if Settings.UDPxy != "" && strings.HasPrefix(streamInfo.URL, "udp://@") {
|
||||
streamInfo.URL = fmt.Sprintf("http://%s/udp/%s/", Settings.UDPxy, strings.TrimPrefix(streamInfo.URL, "udp://@"))
|
||||
}
|
||||
|
||||
switch Settings.Buffer {
|
||||
switch bufferMode {
|
||||
|
||||
case "-":
|
||||
showInfo(fmt.Sprintf("Buffer:false [%s]", Settings.Buffer))
|
||||
showInfo(fmt.Sprintf("Buffer:false [%s]", bufferMode))
|
||||
|
||||
case "xteve":
|
||||
if strings.Contains(streamInfo.URL, "rtsp://") || strings.Contains(streamInfo.URL, "rtp://") {
|
||||
@@ -176,14 +189,14 @@ func Stream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
showInfo(fmt.Sprintf("Buffer:true [%s]", Settings.Buffer))
|
||||
showInfo(fmt.Sprintf("Buffer:true [%s]", bufferMode))
|
||||
|
||||
default:
|
||||
showInfo(fmt.Sprintf("Buffer:true [%s]", Settings.Buffer))
|
||||
showInfo(fmt.Sprintf("Buffer:true [%s]", bufferMode))
|
||||
|
||||
}
|
||||
|
||||
if Settings.Buffer != "-" {
|
||||
if bufferMode != "-" {
|
||||
showInfo(fmt.Sprintf("Buffer Size:%d KB", Settings.BufferSize))
|
||||
}
|
||||
|
||||
@@ -191,7 +204,7 @@ func Stream(w http.ResponseWriter, r *http.Request) {
|
||||
showInfo(fmt.Sprintf("Client User-Agent:%s", r.Header.Get("User-Agent")))
|
||||
|
||||
// Prüfen ob der Buffer verwendet werden soll
|
||||
switch Settings.Buffer {
|
||||
switch bufferMode {
|
||||
|
||||
case "-":
|
||||
showInfo("Streaming URL:" + streamInfo.URL)
|
||||
@@ -317,36 +330,36 @@ func DataImages(w http.ResponseWriter, r *http.Request) {
|
||||
// WS : Web Sockets /ws/
|
||||
func WS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var request RequestStruct
|
||||
var response ResponseStruct
|
||||
response.Status = true
|
||||
|
||||
var newToken string
|
||||
|
||||
/*
|
||||
if r.Header.Get("Origin") != "http://"+r.Host {
|
||||
httpStatusError(w, r, 403)
|
||||
return
|
||||
}
|
||||
*/
|
||||
|
||||
// Upgrade writes its own error response (e.g. 403 for a bad Origin).
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
ShowError(err, 0)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
setGlobalDomain(r.Host)
|
||||
|
||||
// One connection serves any number of commands, one at a time, until the
|
||||
// client goes away or a write fails.
|
||||
for {
|
||||
|
||||
// Fresh structs per command: a failed command must not leak its
|
||||
// Status / Error / Base64 into the next one on the same connection.
|
||||
var request RequestStruct
|
||||
var response ResponseStruct
|
||||
response.Status = true
|
||||
|
||||
err = conn.ReadJSON(&request)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
response.ID = request.ID
|
||||
|
||||
if !System.ConfigurationWizard {
|
||||
|
||||
switch Settings.AuthenticationWEB {
|
||||
@@ -385,10 +398,9 @@ func WS(w http.ResponseWriter, r *http.Request) {
|
||||
response = setDefaultResponseData(response, false)
|
||||
if err = conn.WriteJSON(response); err != nil {
|
||||
ShowError(err, 1022)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
return
|
||||
continue
|
||||
|
||||
case "loadFiles":
|
||||
//response.Response = Settings.Files
|
||||
@@ -465,9 +477,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
case "resetLogs":
|
||||
WebScreenLog.Log = make([]string, 0)
|
||||
WebScreenLog.Errors = 0
|
||||
WebScreenLog.Warnings = 0
|
||||
resetWebScreenLog()
|
||||
response.OpenMenu = strconv.Itoa(indexOfString("log", System.WEB.Menu))
|
||||
|
||||
case "xteveBackup":
|
||||
@@ -478,9 +488,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
case "xteveRestore":
|
||||
WebScreenLog.Log = make([]string, 0)
|
||||
WebScreenLog.Errors = 0
|
||||
WebScreenLog.Warnings = 0
|
||||
resetWebScreenLog()
|
||||
|
||||
if len(request.Base64) > 0 {
|
||||
|
||||
@@ -563,8 +571,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err = conn.WriteJSON(response); err != nil {
|
||||
ShowError(err, 1022)
|
||||
} else {
|
||||
break
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
@@ -855,8 +862,8 @@ func API(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
default:
|
||||
token, err = tokenAuthentication(request.Token)
|
||||
fmt.Println(err)
|
||||
if err != nil {
|
||||
ShowError(err, 0)
|
||||
responseAPIError(err)
|
||||
return
|
||||
}
|
||||
@@ -926,6 +933,7 @@ func API(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err != nil {
|
||||
responseAPIError(err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte(mapToJSON(response)))
|
||||
@@ -977,10 +985,10 @@ func setDefaultResponseData(response ResponseStruct, data bool) (defaults Respon
|
||||
defaults.ClientInfo.OS = System.OS
|
||||
defaults.ClientInfo.Streams = fmt.Sprintf("%d / %d", len(Data.Streams.Active), len(Data.Streams.All))
|
||||
defaults.ClientInfo.UUID = Settings.UUID
|
||||
defaults.ClientInfo.Errors = WebScreenLog.Errors
|
||||
defaults.ClientInfo.Warnings = WebScreenLog.Warnings
|
||||
defaults.Notification = System.Notification
|
||||
defaults.Log = WebScreenLog
|
||||
defaults.Log = webScreenLogSnapshot()
|
||||
defaults.ClientInfo.Errors = defaults.Log.Errors
|
||||
defaults.ClientInfo.Warnings = defaults.Log.Warnings
|
||||
defaults.Notification = notificationsSnapshot()
|
||||
|
||||
switch System.Branch {
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
@@ -137,3 +138,33 @@ func TestSetCookieTokenFlags(t *testing.T) {
|
||||
t.Error("logout must clear the cookie (negative MaxAge)")
|
||||
}
|
||||
}
|
||||
|
||||
// A browser keeps one socket open and sends commands one after another; the
|
||||
// server must answer each on the same connection and echo the request id.
|
||||
func TestWSServesMultipleCommandsPerConnection(t *testing.T) {
|
||||
srv, token := newAuthenticatedWSServer(t)
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL(srv), http.Header{"Cookie": {"Token=" + token}})
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
id := strings.Repeat("x", i)
|
||||
if err := conn.WriteJSON(map[string]any{"cmd": "noop", "id": id}); err != nil {
|
||||
t.Fatalf("write %d: %v", i, err)
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var response ResponseStruct
|
||||
if err := conn.ReadJSON(&response); err != nil {
|
||||
t.Fatalf("read %d: %v (server stopped answering on the same connection)", i, err)
|
||||
}
|
||||
if !response.Status {
|
||||
t.Fatalf("command %d refused: %s", i, response.Error)
|
||||
}
|
||||
if response.ID != id {
|
||||
t.Fatalf("command %d: id %q not echoed, got %q", i, id, response.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -172,6 +172,11 @@ func createXEPGMapping() {
|
||||
|
||||
if len(Data.XMLTV.Files) > 0 {
|
||||
|
||||
// Files that could not be read are collected here and removed after
|
||||
// the loop; mutating Data.XMLTV.Files while iterating over it used
|
||||
// to duplicate entries instead of removing them.
|
||||
var failedFiles = make(map[string]bool)
|
||||
|
||||
for i := len(Data.XMLTV.Files) - 1; i >= 0; i-- {
|
||||
|
||||
var file = Data.XMLTV.Files[i]
|
||||
@@ -185,7 +190,7 @@ func createXEPGMapping() {
|
||||
|
||||
err = getLocalXMLTV(file, &xmltv)
|
||||
if err != nil {
|
||||
Data.XMLTV.Files = append(Data.XMLTV.Files, Data.XMLTV.Files[i+1:]...)
|
||||
failedFiles[file] = true
|
||||
var errMsg = err.Error()
|
||||
err = errors.New(getProviderParameter(fileID, "xmltv", "name") + ": " + errMsg)
|
||||
ShowError(err, 000)
|
||||
@@ -215,6 +220,7 @@ func createXEPGMapping() {
|
||||
|
||||
}
|
||||
|
||||
Data.XMLTV.Files = removeStrings(Data.XMLTV.Files, failedFiles)
|
||||
Data.XMLTV.Mapping = tmpMap
|
||||
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Date: 2026-09-25
|
||||
Scope: whole repository (Go backend, TypeScript UI, Docker, CI, docs)
|
||||
Status: reviewed with maintainer 2026-09-25/26, all open questions resolved, ready to start Phase 0. Checklist lives in `tasks/todo.md`.
|
||||
Status: implemented on branch `improvements` (2026-09-26). Per-item results, deviations and what was left open are in `tasks/todo.md`.
|
||||
|
||||
## 1. Where the fork stands
|
||||
|
||||
|
||||
+19
-19
@@ -1,7 +1,7 @@
|
||||
# xTeVe improvement checklist
|
||||
|
||||
Detailed rationale, file references and effort estimates: `tasks/improvement-plan.md`.
|
||||
Status: Phase 0 committed on branch `improvements` 2026-09-26.
|
||||
Status: 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`
|
||||
@@ -35,19 +35,19 @@ Status: Phase 0 committed on branch `improvements` 2026-09-26.
|
||||
- [x] `streamingURLsMu` around `Data.Cache.StreamingURLS`; persisted from a snapshot
|
||||
- [x] No more `defer` inside the buffer read/redirect loops (compression done in Phase 1; imgcache in 2b)
|
||||
- [x] `thirdPartyBuffer` rewritten: `exec.CommandContext` cancelled when the last client leaves or the 20 s startup watchdog fires; `buildFFmpegArgs`/`buildVLCArgs` tested; no panic, `cmd.Start` checked, one file handle per segment. Client loop uses `r.Context()` instead of `CloseNotifier`
|
||||
- [ ] Real mutex for screen log
|
||||
- [ ] `http.Server` with timeouts; timeouts on all outbound clients
|
||||
- [ ] imgcache: download outside the lock
|
||||
- [ ] Atomic write helper (temp + fsync + rename)
|
||||
- [x] Buffer: headers set before `WriteHeader`, bogus `Content-Length:` header gone, segments flushed to the client as they arrive. Others in 2b
|
||||
- [ ] Error hygiene: no-op closures, `os.Exit`/`panic` outside main, ignored results
|
||||
- [x] `logMu` guards `WebScreenLog` and notifications; accessors `logAppend`/`webScreenLogSnapshot`/`notificationsSnapshot`; ring buffer keeps the newest N (was dropping them)
|
||||
- [x] `http.Server` with `ReadHeaderTimeout` 15 s and `IdleTimeout` 120 s (no write timeout, streams are long-lived); `providerHTTPClient` 5 min, `apiHTTPClient` 30 s, imgcache client 30 s, stream client with dial/TLS/header timeouts
|
||||
- [x] imgcache downloads outside the lock; cache URL bug fixed (was a filesystem path); query-string URLs no longer produce unservable file names
|
||||
- [x] `writeFileAtomic` (temp + fsync + rename) behind `writeByteToFile`, `saveMapToJSONFile`, `writePrivateFile` and the auth database
|
||||
- [x] Buffer: headers set before `WriteHeader`, bogus `Content-Length:` header gone, segments flushed as they arrive. xepg file-removal bug, dead range mutation, API double write, WS struct reuse, unchecked assertions in data/backup/provider/screen, notification eviction by age: all fixed with tests
|
||||
- [x] Error hygiene: no `panic`/`os.Exit`/`log.Fatal` outside `main`; SIGINT/SIGTERM handled in main via `src.Shutdown()`; fatal start-up errors exit 1
|
||||
|
||||
## Phase 3: Build, embed, CI, Docker
|
||||
- [x] `//go:embed` via `html/embed.go`; deleted `webUI.go`, `html-build.go`, `cmd/webui-gen`; ETag + Cache-Control on static assets
|
||||
- [x] i18n dropped: 254 placeholders inlined, `en.json` deleted, only HTML pages templated (`authenticationErr`)
|
||||
- [x] `package.json` + `ts/tsconfig.json` (tsc 5.9.3, ES2020, single outFile `html/js/app.js`, committed, CI-verified); 10 dead JS files deleted; 6 tsc errors fixed incl. a real ASI bug in the search shortcut
|
||||
- [x] CI: vet, gofmt check, staticcheck v0.8.1 (config in `staticcheck.conf`), bundle freshness check. `go test -race` still to add once tests exist
|
||||
- [ ] Version-tagged images (amd64 only)
|
||||
- [x] Version-tagged images: master publishes `latest`, the commit SHA and the changelog version via a `.tags` file (amd64 only)
|
||||
- [x] PUID/PGID via su-exec at runtime; static-ffmpeg pinned to 7.1.1; `VOLUME /xteve/config`; `/xteve` removed from `LEGACY_CONFIG_DIRS`; compose files pull from the registry and document SSDP/host networking
|
||||
- [x] `/healthz` endpoint; Dockerfile healthcheck uses it
|
||||
- [x] `README-DEV.md` and fork-specific `README.md` (about, container usage, env vars, security notes)
|
||||
@@ -58,19 +58,19 @@ Status: Phase 0 committed on branch `improvements` 2026-09-26.
|
||||
Not planned (lineup is ~170 channels, decided 2026-09-25). See plan for the reference list.
|
||||
|
||||
## Phase 5: Frontend architecture
|
||||
- [ ] Persistent websocket with request IDs, queue, backoff reconnect
|
||||
- [ ] 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] 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`
|
||||
- [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, `<main>` landmark; light theme via `prefers-color-scheme` with `data-theme` override (contrast measured). `<input type=button>` 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
|
||||
|
||||
|
||||
@@ -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("<b>x</b>"), "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 <b>x</b>");
|
||||
});
|
||||
|
||||
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"});
|
||||
});
|
||||
@@ -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, "<b>x</b>"),
|
||||
[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};
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 = `<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>xTeVe</title></head>
|
||||
<body class="app-shell">
|
||||
<div id="loading" class="none"><div class="loader"></div></div>
|
||||
<div id="popup" class="none"><div id="popup-custom"></div></div>
|
||||
<div id="layout-overlay"></div>
|
||||
<div id="layout">
|
||||
<aside id="menu-wrapper">
|
||||
<nav id="main-menu"></nav>
|
||||
</aside>
|
||||
<main id="shell-main">
|
||||
<header id="shell-header">
|
||||
<button id="menu-toggle" type="button">Menu</button>
|
||||
<p id="connection-indicator" class="status-idle">Connecting...</p>
|
||||
</header>
|
||||
<table id="clientInfo">
|
||||
<tr><td id="version"></td><td id="os"></td><td id="DVR"></td></tr>
|
||||
<tr><td id="uuid"></td><td id="arch"></td><td id="m3u-url"></td></tr>
|
||||
<tr><td id="streams"></td><td id="epgSource"></td><td id="xepg-url"></td></tr>
|
||||
<tr><td id="xepg"></td><td id="errors"></td><td id="warnings"></td></tr>
|
||||
</table>
|
||||
<div id="status-cards"></div>
|
||||
<div id="myStreamsBox" class="notVisible">
|
||||
<table id="activeStreams"></table>
|
||||
<table id="inactiveStreams"></table>
|
||||
</div>
|
||||
<div id="content"></div>
|
||||
<p id="sr-announcer" class="sr-only" aria-live="polite" aria-atomic="true"></p>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
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};
|
||||
@@ -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 id="sr-announcer"[^>]*><\/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");
|
||||
});
|
||||
});
|
||||
File renamed without changes.
@@ -3,7 +3,6 @@ var BULK_EDIT:Boolean = false
|
||||
var COLUMN_TO_SORT:number
|
||||
var SEARCH_MAPPING = new Object()
|
||||
var UNDO = new Object()
|
||||
var SERVER_CONNECTION = false
|
||||
var WS_AVAILABLE = false
|
||||
var ACTIVE_MENU_ID:string = ""
|
||||
var LAST_BULK_CHECKBOX:HTMLInputElement = null
|
||||
@@ -61,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
|
||||
@@ -93,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 {
|
||||
@@ -502,7 +538,7 @@ function changeChannelNumber(element) {
|
||||
var channels = getObjKeys(data)
|
||||
|
||||
if (isNaN(newNumber)) {
|
||||
alert("Invalid channel number")
|
||||
alertUser("Invalid channel number")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -585,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
|
||||
}
|
||||
|
||||
@@ -650,7 +686,7 @@ function restore() {
|
||||
};
|
||||
|
||||
} else {
|
||||
alert("File could not be loaded")
|
||||
alertUser("File could not be loaded")
|
||||
}
|
||||
|
||||
restore.remove()
|
||||
@@ -708,7 +744,7 @@ function uploadLogo() {
|
||||
};
|
||||
|
||||
} else {
|
||||
alert("File could not be loaded")
|
||||
alertUser("File could not be loaded")
|
||||
}
|
||||
|
||||
upload.remove()
|
||||
@@ -761,7 +797,6 @@ function sortSelect(elem) {
|
||||
|
||||
function updateLog() {
|
||||
|
||||
console.log("TOKEN")
|
||||
var server:Server = new Server("updateLog")
|
||||
server.request(new Object())
|
||||
|
||||
@@ -144,7 +144,7 @@ function saveWizard() {
|
||||
|
||||
if (value.length == 0) {
|
||||
var msg = name.toUpperCase() + ": " + "Missing input"
|
||||
alert(msg)
|
||||
alertUser(msg)
|
||||
return
|
||||
}
|
||||
|
||||
+764
@@ -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
|
||||
}
|
||||
File renamed without changes.
+414
@@ -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
|
||||
}
|
||||
-2477
File diff suppressed because it is too large.
Load diff
+305
@@ -0,0 +1,305 @@
|
||||
// Websocket transport between the web UI and xTeVe.
|
||||
//
|
||||
// One socket stays open for the life of the page. Requests are queued and
|
||||
// sent one at a time; each carries an id that the server echoes back, and
|
||||
// the response is matched on it (or, for older servers that close after a
|
||||
// single reply, to the oldest in-flight request). Connection loss triggers
|
||||
// a reconnect with exponential backoff, and queued requests survive it.
|
||||
|
||||
interface PendingRequest {
|
||||
id: string
|
||||
payload: Object
|
||||
resolve: (response: any) => void
|
||||
reject: (err: Error) => void
|
||||
timer: number
|
||||
sent: boolean
|
||||
}
|
||||
|
||||
class WSClient {
|
||||
private socket: WebSocket = null
|
||||
private queue: PendingRequest[] = []
|
||||
private inFlight: PendingRequest = null
|
||||
private nextID: number = 1
|
||||
private reconnectDelay: number = 500
|
||||
private reconnectTimer: number = 0
|
||||
private closedByUs: boolean = false
|
||||
|
||||
readonly requestTimeoutMs: number = 12000
|
||||
|
||||
private url(): string {
|
||||
var protocol = window.location.protocol == "https:" ? "wss://" : "ws://"
|
||||
var host = window.location.host
|
||||
if (host == undefined || host.length < 1) {
|
||||
host = window.location.hostname
|
||||
}
|
||||
return protocol + host + "/data/"
|
||||
}
|
||||
|
||||
// send queues a command and resolves with the parsed response.
|
||||
send(payload: Object): Promise<any> {
|
||||
var client = this
|
||||
return new Promise(function(resolve, reject) {
|
||||
var req: PendingRequest = {
|
||||
id: String(client.nextID++),
|
||||
payload: payload,
|
||||
resolve: resolve,
|
||||
reject: reject,
|
||||
timer: 0,
|
||||
sent: false
|
||||
}
|
||||
client.queue.push(req)
|
||||
client.pump()
|
||||
})
|
||||
}
|
||||
|
||||
// hasQueued reports whether a command of this kind is already waiting.
|
||||
hasQueued(cmd: string): boolean {
|
||||
if (this.inFlight != null && this.inFlight.payload["cmd"] == cmd) {
|
||||
return true
|
||||
}
|
||||
for (var i = 0; i < this.queue.length; i++) {
|
||||
if (this.queue[i].payload["cmd"] == cmd) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
if (this.socket != null && (this.socket.readyState == WebSocket.OPEN || this.socket.readyState == WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
|
||||
var client = this
|
||||
var socket = new WebSocket(this.url())
|
||||
this.socket = socket
|
||||
|
||||
socket.onopen = function() {
|
||||
WS_AVAILABLE = true
|
||||
client.reconnectDelay = 500
|
||||
client.pump()
|
||||
}
|
||||
|
||||
socket.onmessage = function(e: MessageEvent) {
|
||||
client.handleMessage(e.data)
|
||||
}
|
||||
|
||||
socket.onerror = function() {
|
||||
// onclose follows; nothing to do here.
|
||||
}
|
||||
|
||||
socket.onclose = function() {
|
||||
if (client.socket == socket) {
|
||||
client.socket = null
|
||||
}
|
||||
// An in-flight request that never got its answer goes back to the
|
||||
// front of the queue and is retried on the next connection.
|
||||
if (client.inFlight != null) {
|
||||
var req = client.inFlight
|
||||
client.inFlight = null
|
||||
req.sent = false
|
||||
client.queue.unshift(req)
|
||||
}
|
||||
client.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.closedByUs || this.reconnectTimer != 0) {
|
||||
return
|
||||
}
|
||||
if (this.queue.length == 0) {
|
||||
// Idle: reconnect lazily on the next request.
|
||||
return
|
||||
}
|
||||
var client = this
|
||||
this.reconnectTimer = window.setTimeout(function() {
|
||||
client.reconnectTimer = 0
|
||||
client.connect()
|
||||
}, this.reconnectDelay)
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 10000)
|
||||
}
|
||||
|
||||
// pump sends the next queued request if the socket is open and idle.
|
||||
private pump(): void {
|
||||
if (this.inFlight != null || this.queue.length == 0) {
|
||||
return
|
||||
}
|
||||
if (this.socket == null || this.socket.readyState != WebSocket.OPEN) {
|
||||
this.connect()
|
||||
return
|
||||
}
|
||||
|
||||
var req = this.queue.shift()
|
||||
this.inFlight = req
|
||||
req.sent = true
|
||||
req.payload["id"] = req.id
|
||||
|
||||
var client = this
|
||||
req.timer = window.setTimeout(function() {
|
||||
if (client.inFlight == req) {
|
||||
client.inFlight = null
|
||||
req.reject(new Error("timeout"))
|
||||
// A socket that stopped answering is not trusted any more.
|
||||
client.resetSocket()
|
||||
client.pump()
|
||||
}
|
||||
}, this.requestTimeoutMs)
|
||||
|
||||
try {
|
||||
this.socket.send(JSON.stringify(req.payload))
|
||||
} catch (err) {
|
||||
window.clearTimeout(req.timer)
|
||||
this.inFlight = null
|
||||
req.sent = false
|
||||
this.queue.unshift(req)
|
||||
this.resetSocket()
|
||||
}
|
||||
}
|
||||
|
||||
private resetSocket(): void {
|
||||
var socket = this.socket
|
||||
this.socket = null
|
||||
if (socket != null) {
|
||||
socket.onclose = null
|
||||
try { socket.close() } catch (err) { /* ignore */ }
|
||||
}
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
|
||||
private handleMessage(raw: string): void {
|
||||
var response: any
|
||||
try {
|
||||
response = JSON.parse(raw)
|
||||
} catch (err) {
|
||||
return
|
||||
}
|
||||
|
||||
var req = this.inFlight
|
||||
if (req == null) {
|
||||
return
|
||||
}
|
||||
// Servers that echo the id let us drop stray late answers.
|
||||
if (response.hasOwnProperty("id") && response["id"] != req.id) {
|
||||
return
|
||||
}
|
||||
|
||||
window.clearTimeout(req.timer)
|
||||
this.inFlight = null
|
||||
req.resolve(response)
|
||||
this.pump()
|
||||
}
|
||||
}
|
||||
|
||||
var WS = new WSClient()
|
||||
|
||||
class Server {
|
||||
cmd: string
|
||||
|
||||
constructor(cmd: string) {
|
||||
this.cmd = cmd
|
||||
}
|
||||
|
||||
request(data: Object): any {
|
||||
|
||||
var isLogUpdate: boolean = this.cmd == "updateLog"
|
||||
|
||||
// Log polling must never pile up behind a stalled connection.
|
||||
if (isLogUpdate && WS.hasQueued("updateLog")) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLogUpdate) {
|
||||
showElement("loading", true)
|
||||
UNDO = new Object()
|
||||
setConnectionState("busy")
|
||||
}
|
||||
|
||||
data["cmd"] = this.cmd
|
||||
var requestCmd: string = this.cmd
|
||||
|
||||
WS.send(data).then(function(response) {
|
||||
|
||||
WS_FAILURE_COUNT = 0
|
||||
if (!isLogUpdate) {
|
||||
showElement("loading", false)
|
||||
}
|
||||
setConnectionState("online")
|
||||
|
||||
if (response["status"] == false) {
|
||||
setConnectionState("offline")
|
||||
alertUser(response["err"])
|
||||
if (response.hasOwnProperty("reload")) {
|
||||
location.reload()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (response.hasOwnProperty("logoURL")) {
|
||||
var div = (document.getElementById("channel-icon") as HTMLInputElement)
|
||||
div.value = response["logoURL"]
|
||||
div.className = "changed"
|
||||
return
|
||||
}
|
||||
|
||||
switch (requestCmd) {
|
||||
case "updateLog":
|
||||
SERVER["log"] = response["log"]
|
||||
if (document.getElementById("content_log")) {
|
||||
showLogs(false)
|
||||
}
|
||||
return
|
||||
|
||||
default:
|
||||
SERVER = response
|
||||
break
|
||||
}
|
||||
|
||||
if (response.hasOwnProperty("openMenu")) {
|
||||
var menu = document.getElementById(response["openMenu"])
|
||||
menu.click()
|
||||
showElement("popup", false)
|
||||
}
|
||||
|
||||
if (response.hasOwnProperty("openLink")) {
|
||||
window.location = response["openLink"]
|
||||
}
|
||||
|
||||
if (response.hasOwnProperty("alert")) {
|
||||
alertUser(response["alert"])
|
||||
}
|
||||
|
||||
if (response.hasOwnProperty("reload")) {
|
||||
location.reload()
|
||||
}
|
||||
|
||||
if (response.hasOwnProperty("wizard")) {
|
||||
createLayout()
|
||||
configurationWizard[response["wizard"]].createWizard()
|
||||
return
|
||||
}
|
||||
|
||||
createLayout()
|
||||
|
||||
}, function(err) {
|
||||
|
||||
WS_FAILURE_COUNT++
|
||||
if (!isLogUpdate) {
|
||||
showElement("loading", false)
|
||||
}
|
||||
|
||||
var state: string = "offline"
|
||||
if (isLogUpdate && WS_FAILURE_COUNT < 2) {
|
||||
state = "idle"
|
||||
}
|
||||
setConnectionState(state)
|
||||
|
||||
if (WS_AVAILABLE == false && !isLogUpdate && requestCmd != "getServerConfig") {
|
||||
alertUser("No websocket connection to xTeVe could be established. Check your network configuration.")
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var WS_FAILURE_COUNT: number = 0
|
||||
@@ -1,202 +0,0 @@
|
||||
class Server {
|
||||
protocol:string
|
||||
cmd:string
|
||||
|
||||
constructor(cmd:string) {
|
||||
this.cmd = cmd
|
||||
}
|
||||
|
||||
request(data:Object):any {
|
||||
|
||||
if (SERVER_CONNECTION == true) {
|
||||
return
|
||||
}
|
||||
|
||||
SERVER_CONNECTION = true
|
||||
|
||||
if (this.cmd != "updateLog") {
|
||||
showElement("loading", true)
|
||||
UNDO = new Object()
|
||||
setConnectionState("busy")
|
||||
}
|
||||
|
||||
switch(window.location.protocol) {
|
||||
case "http:":
|
||||
this.protocol = "ws://"
|
||||
break
|
||||
case "https:":
|
||||
this.protocol = "wss://"
|
||||
break
|
||||
}
|
||||
|
||||
var wsHost:string = window.location.host
|
||||
if (wsHost == undefined || wsHost.length < 1) {
|
||||
wsHost = window.location.hostname
|
||||
}
|
||||
// The session cookie (HttpOnly) is sent with the websocket handshake.
|
||||
var url = this.protocol + wsHost + "/data/"
|
||||
|
||||
data["cmd"] = this.cmd
|
||||
var requestCmd:string = data["cmd"]
|
||||
var ws = new WebSocket(url)
|
||||
var isLogUpdate:boolean = data["cmd"] == "updateLog"
|
||||
var responseReceived:boolean = false
|
||||
var requestFinished:boolean = false
|
||||
var timeoutMs:number = 12000
|
||||
var requestTimeout:number
|
||||
|
||||
var finishRequest = function(state:string, responseSuccess:boolean = false):void {
|
||||
if (requestFinished == true) {
|
||||
return
|
||||
}
|
||||
|
||||
requestFinished = true
|
||||
SERVER_CONNECTION = false
|
||||
window.clearTimeout(requestTimeout)
|
||||
|
||||
if (responseSuccess == true) {
|
||||
if (state == "online") {
|
||||
WS_FAILURE_COUNT = 0
|
||||
}
|
||||
} else {
|
||||
WS_FAILURE_COUNT++
|
||||
}
|
||||
|
||||
if (isLogUpdate == false) {
|
||||
showElement("loading", false)
|
||||
}
|
||||
|
||||
if (state != "") {
|
||||
setConnectionState(state)
|
||||
}
|
||||
}
|
||||
|
||||
requestTimeout = window.setTimeout(function() {
|
||||
console.log("Websocket request timed out.")
|
||||
var timeoutState:string = "offline"
|
||||
if (isLogUpdate == true && WS_FAILURE_COUNT < 2) {
|
||||
timeoutState = "idle"
|
||||
}
|
||||
finishRequest(timeoutState, false)
|
||||
try {
|
||||
ws.close()
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}, timeoutMs)
|
||||
|
||||
ws.onopen = function() {
|
||||
|
||||
WS_AVAILABLE = true
|
||||
if (data["cmd"] != "updateLog") {
|
||||
setConnectionState("busy")
|
||||
}
|
||||
|
||||
this.send(JSON.stringify(data));
|
||||
|
||||
}
|
||||
|
||||
ws.onerror = function(e) {
|
||||
|
||||
console.log("No websocket connection to xTeVe could be established. Check your network configuration.")
|
||||
var errorState:string = "offline"
|
||||
if (isLogUpdate == true && WS_FAILURE_COUNT < 2) {
|
||||
errorState = "idle"
|
||||
}
|
||||
finishRequest(errorState, false)
|
||||
|
||||
if (WS_AVAILABLE == false && isLogUpdate == false && requestCmd != "getServerConfig") {
|
||||
alert("No websocket connection to xTeVe could be established. Check your network configuration.")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
ws.onmessage = function (e) {
|
||||
responseReceived = true
|
||||
finishRequest("online", true)
|
||||
|
||||
var response = JSON.parse(e.data);
|
||||
|
||||
if (response["status"] == false) {
|
||||
setConnectionState("offline")
|
||||
|
||||
alert(response["err"])
|
||||
|
||||
if (response.hasOwnProperty("reload")) {
|
||||
location.reload()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (response.hasOwnProperty("logoURL")) {
|
||||
var div = (document.getElementById("channel-icon") as HTMLInputElement)
|
||||
div.value = response["logoURL"]
|
||||
div.className = "changed"
|
||||
return
|
||||
}
|
||||
|
||||
switch (data["cmd"]) {
|
||||
case "updateLog":
|
||||
SERVER["log"] = response["log"]
|
||||
if (document.getElementById("content_log")) {
|
||||
showLogs(false)
|
||||
}
|
||||
return
|
||||
break;
|
||||
|
||||
default:
|
||||
SERVER = new Object()
|
||||
SERVER = response
|
||||
break;
|
||||
}
|
||||
|
||||
if (response.hasOwnProperty("openMenu")) {
|
||||
var menu = document.getElementById(response["openMenu"])
|
||||
menu.click()
|
||||
showElement("popup", false)
|
||||
}
|
||||
|
||||
if (response.hasOwnProperty("openLink")) {
|
||||
window.location = response["openLink"]
|
||||
}
|
||||
|
||||
if (response.hasOwnProperty("alert")) {
|
||||
alert(response["alert"])
|
||||
}
|
||||
|
||||
if (response.hasOwnProperty("reload")) {
|
||||
location.reload()
|
||||
}
|
||||
|
||||
|
||||
if (response.hasOwnProperty("wizard")) {
|
||||
createLayout()
|
||||
configurationWizard[response["wizard"]].createWizard()
|
||||
return
|
||||
}
|
||||
|
||||
createLayout()
|
||||
|
||||
}
|
||||
|
||||
ws.onclose = function() {
|
||||
if (responseReceived == true) {
|
||||
return
|
||||
}
|
||||
|
||||
var closeState:string = "offline"
|
||||
if (isLogUpdate == true && WS_FAILURE_COUNT < 2) {
|
||||
closeState = "idle"
|
||||
}
|
||||
finishRequest(closeState, false)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var WS_FAILURE_COUNT:number = 0
|
||||
|
||||
+1171
File diff suppressed because it is too large.
Load diff
+342
@@ -0,0 +1,342 @@
|
||||
// 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.<br>Only change if you know what you are doing.<br>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.<br>Only change if you know what you are doing.<br>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. <br><b>Warning!!!</b> 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.<br>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 <a href='https://github.com/xteve-project/xTeVe-Documentation/blob/master/en/configuration.md#api'>here</a>"},
|
||||
{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.<br>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.<br>Available for: Plex, Emby (HDHR), M3U (with active buffer).<br>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:<br>- Use EPG data from Plex or Emby <br><br>XEPG:<br>- Use of one or more XMLTV files<br>- Channel management<br>- 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.<br>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:<br>- The stream is passed from xTeVe, FFmpeg or VLC to Plex, Emby or M3U Player<br>- Small jerking of the streams can be compensated<br>- HLS / M3U8 support<br>- RTP / RTPS support (only FFmpeg or VLC)<br>- Re-streaming<br>- 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 {
|
||||
var element = document.createElement("H4")
|
||||
element.innerHTML = value
|
||||
return element
|
||||
}
|
||||
|
||||
createHR():any {
|
||||
var element = document.createElement("HR")
|
||||
return element
|
||||
}
|
||||
|
||||
// createControl builds the input for one field, filled with the current value.
|
||||
createControl(field:SettingsField, data:any):HTMLElement {
|
||||
var content:PopupContent = new PopupContent()
|
||||
var control:HTMLElement
|
||||
|
||||
switch (field.type) {
|
||||
|
||||
case "checkbox":
|
||||
control = content.createCheckbox(field.key);
|
||||
(control as HTMLInputElement).checked = data
|
||||
break
|
||||
|
||||
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", "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")
|
||||
tdLeft.innerHTML = ""
|
||||
|
||||
var tdRight = document.createElement("TD")
|
||||
var pre = document.createElement("PRE")
|
||||
pre.innerHTML = text
|
||||
tdRight.appendChild(pre)
|
||||
|
||||
description.appendChild(tdLeft)
|
||||
description.appendChild(tdRight)
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SettingsCategoryItem extends SettingsCategory {
|
||||
headline:string
|
||||
settingsKeys:string
|
||||
|
||||
constructor(headline:string, settingsKeys:string) {
|
||||
super()
|
||||
this.headline = headline
|
||||
this.settingsKeys = settingsKeys
|
||||
}
|
||||
|
||||
createCategory():void {
|
||||
var headline = this.createCategoryHeadline(this.headline)
|
||||
var settingsKeys = this.settingsKeys
|
||||
|
||||
var doc = document.getElementById(this.DocumentID)
|
||||
doc.appendChild(headline)
|
||||
|
||||
// Tabelle für die Kategorie erstellen
|
||||
|
||||
var table = document.createElement("TABLE")
|
||||
|
||||
var keys = settingsKeys.split(",")
|
||||
|
||||
keys.forEach(settingsKey => {
|
||||
|
||||
switch (settingsKey) {
|
||||
|
||||
case "authentication.pms":
|
||||
case "authentication.m3u":
|
||||
case "authentication.xml":
|
||||
case "authentication.api":
|
||||
if (SERVER["settings"]["authentication.web"] == false) {
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
var item = this.createSettings(settingsKey)
|
||||
var description = this.createDescription(settingsKey)
|
||||
|
||||
table.appendChild(item)
|
||||
table.appendChild(description)
|
||||
break
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
doc.appendChild(table)
|
||||
doc.appendChild(this.createHR())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function showSettings() {
|
||||
console.log("SETTINGS");
|
||||
|
||||
for (let i = 0; i < settingsCategory.length; i++) {
|
||||
settingsCategory[i].createCategory()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
console.log("Save Settings");
|
||||
|
||||
var cmd = "saveSettings"
|
||||
var div = document.getElementById("content_settings")
|
||||
var settings = div.getElementsByClassName("changed")
|
||||
|
||||
var newSettings = new Object();
|
||||
|
||||
for (let i = 0; i < settings.length; i++) {
|
||||
|
||||
var name:string
|
||||
var value:any
|
||||
|
||||
switch (settings[i].tagName) {
|
||||
case "INPUT":
|
||||
|
||||
switch ((settings[i] as HTMLInputElement).type) {
|
||||
case "checkbox":
|
||||
name = (settings[i] as HTMLInputElement).name
|
||||
value = (settings[i] as HTMLInputElement).checked
|
||||
newSettings[name] = value
|
||||
break
|
||||
|
||||
case "text":
|
||||
case "password":
|
||||
name = (settings[i] as HTMLInputElement).name
|
||||
value = (settings[i] as HTMLInputElement).value
|
||||
|
||||
switch (name) {
|
||||
case "update":
|
||||
value = value.split(",")
|
||||
value = value.filter(function(e:any) { return e})
|
||||
break
|
||||
|
||||
case "buffer.timeout":
|
||||
value = parseFloat(value)
|
||||
|
||||
}
|
||||
|
||||
newSettings[name] = value
|
||||
break
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case "SELECT":
|
||||
name = (settings[i] as HTMLSelectElement).name
|
||||
value = (settings[i] as HTMLSelectElement).value
|
||||
|
||||
// Wenn der Wert eine Zahl ist, wird dieser als Zahl gespeichert
|
||||
if(isNaN(value)){
|
||||
newSettings[name] = value
|
||||
} else {
|
||||
newSettings[name] = parseInt(value)
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var data = new Object()
|
||||
data["settings"] = newSettings
|
||||
|
||||
var server:Server = new Server(cmd)
|
||||
server.request(data)
|
||||
}
|
||||
@@ -1,714 +0,0 @@
|
||||
class SettingsCategory {
|
||||
DocumentID:string = "content_settings"
|
||||
createCategoryHeadline(value:string):any {
|
||||
var element = document.createElement("H4")
|
||||
element.innerHTML = value
|
||||
return element
|
||||
}
|
||||
|
||||
createHR():any {
|
||||
var element = document.createElement("HR")
|
||||
return element
|
||||
}
|
||||
|
||||
createSettings(settingsKey:string):any {
|
||||
var setting = document.createElement("TR")
|
||||
var content:PopupContent = 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)
|
||||
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. <br><b>Warning!!!</b> 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:<br>- The stream is passed from xTeVe, FFmpeg or VLC to Plex, Emby or M3U Player<br>- Small jerking of the streams can be compensated<br>- HLS / M3U8 support<br>- RTP / RTPS support (only FFmpeg or VLC)<br>- Re-streaming<br>- Separate tuner limit for each playlist"
|
||||
break
|
||||
|
||||
case "buffer.size.kb":
|
||||
text = "Buffer size in MB.<br>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.<br>Only change if you know what you are doing.<br>Leave blank to set default settings."
|
||||
break
|
||||
|
||||
case "vlc.path":
|
||||
text = "Path to VLC / CVLC binary."
|
||||
break
|
||||
|
||||
case "vlc.options":
|
||||
text = "VLC / CVLC options.<br>Only change if you know what you are doing.<br>Leave blank to set default settings."
|
||||
break
|
||||
|
||||
case "epgSource":
|
||||
text = "PMS:<br>- Use EPG data from Plex or Emby <br><br>XEPG:<br>- Use of one or more XMLTV files<br>- Channel management<br>- M3U / XMLTV export (HTTP link for IPTV apps)"
|
||||
break
|
||||
|
||||
case "tuner":
|
||||
text = "Number of parallel connections that can be established to the provider.<br>Available for: Plex, Emby (HDHR), M3U (with active buffer).<br>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 <a href='https://github.com/xteve-project/xTeVe-Documentation/blob/master/en/configuration.md#api'>here</a>"
|
||||
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.<br>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.<br>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 tdLeft = document.createElement("TD")
|
||||
tdLeft.innerHTML = ""
|
||||
|
||||
var tdRight = document.createElement("TD")
|
||||
var pre = document.createElement("PRE")
|
||||
pre.innerHTML = text
|
||||
tdRight.appendChild(pre)
|
||||
|
||||
description.appendChild(tdLeft)
|
||||
description.appendChild(tdRight)
|
||||
|
||||
return description
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SettingsCategoryItem extends SettingsCategory {
|
||||
headline:string
|
||||
settingsKeys:string
|
||||
|
||||
constructor(headline:string, settingsKeys:string) {
|
||||
super()
|
||||
this.headline = headline
|
||||
this.settingsKeys = settingsKeys
|
||||
}
|
||||
|
||||
createCategory():void {
|
||||
var headline = this.createCategoryHeadline(this.headline)
|
||||
var settingsKeys = this.settingsKeys
|
||||
|
||||
var doc = document.getElementById(this.DocumentID)
|
||||
doc.appendChild(headline)
|
||||
|
||||
// Tabelle für die Kategorie erstellen
|
||||
|
||||
var table = document.createElement("TABLE")
|
||||
|
||||
var keys = settingsKeys.split(",")
|
||||
|
||||
keys.forEach(settingsKey => {
|
||||
|
||||
switch (settingsKey) {
|
||||
|
||||
case "authentication.pms":
|
||||
case "authentication.m3u":
|
||||
case "authentication.xml":
|
||||
case "authentication.api":
|
||||
if (SERVER["settings"]["authentication.web"] == false) {
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
var item = this.createSettings(settingsKey)
|
||||
var description = this.createDescription(settingsKey)
|
||||
|
||||
table.appendChild(item)
|
||||
table.appendChild(description)
|
||||
break
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
doc.appendChild(table)
|
||||
doc.appendChild(this.createHR())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function showSettings() {
|
||||
console.log("SETTINGS");
|
||||
|
||||
for (let i = 0; i < settingsCategory.length; i++) {
|
||||
settingsCategory[i].createCategory()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
console.log("Save Settings");
|
||||
|
||||
var cmd = "saveSettings"
|
||||
var div = document.getElementById("content_settings")
|
||||
var settings = div.getElementsByClassName("changed")
|
||||
|
||||
var newSettings = new Object();
|
||||
|
||||
for (let i = 0; i < settings.length; i++) {
|
||||
|
||||
var name:string
|
||||
var value:any
|
||||
|
||||
switch (settings[i].tagName) {
|
||||
case "INPUT":
|
||||
|
||||
switch ((settings[i] as HTMLInputElement).type) {
|
||||
case "checkbox":
|
||||
name = (settings[i] as HTMLInputElement).name
|
||||
value = (settings[i] as HTMLInputElement).checked
|
||||
newSettings[name] = value
|
||||
break
|
||||
|
||||
case "text":
|
||||
name = (settings[i] as HTMLInputElement).name
|
||||
value = (settings[i] as HTMLInputElement).value
|
||||
|
||||
switch (name) {
|
||||
case "update":
|
||||
value = value.split(",")
|
||||
value = value.filter(function(e:any) { return e})
|
||||
break
|
||||
|
||||
case "buffer.timeout":
|
||||
value = parseFloat(value)
|
||||
|
||||
}
|
||||
|
||||
newSettings[name] = value
|
||||
break
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case "SELECT":
|
||||
name = (settings[i] as HTMLSelectElement).name
|
||||
value = (settings[i] as HTMLSelectElement).value
|
||||
|
||||
// Wenn der Wert eine Zahl ist, wird dieser als Zahl gespeichert
|
||||
if(isNaN(value)){
|
||||
newSettings[name] = value
|
||||
} else {
|
||||
newSettings[name] = parseInt(value)
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var data = new Object()
|
||||
data["settings"] = newSettings
|
||||
|
||||
var server:Server = new Server(cmd)
|
||||
server.request(data)
|
||||
}
|
||||
+10
-7
@@ -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"
|
||||
]
|
||||
}
|
||||
+206
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"xteve/src"
|
||||
)
|
||||
@@ -21,7 +23,7 @@ const Name = "xTeVe"
|
||||
|
||||
// Version : Version, die Build Nummer wird in der main func geparst.
|
||||
// Can be overwritten at build time: -ldflags "-X main.Version=..."
|
||||
var Version = "2.2.0.0201"
|
||||
var Version = "2.3.0.0202"
|
||||
|
||||
// DBVersion : Datanbank Version
|
||||
const DBVersion = "2.1.0"
|
||||
@@ -111,7 +113,7 @@ func main() {
|
||||
err := src.Init()
|
||||
if err != nil {
|
||||
src.ShowError(err, 0)
|
||||
os.Exit(0)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
src.ShowSystemInfo()
|
||||
@@ -150,12 +152,13 @@ func main() {
|
||||
err := src.Init()
|
||||
if err != nil {
|
||||
src.ShowError(err, 0)
|
||||
os.Exit(0)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = src.XteveRestoreFromCLI(*restore)
|
||||
if err != nil {
|
||||
src.ShowError(err, 0)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
os.Exit(0)
|
||||
@@ -164,25 +167,35 @@ func main() {
|
||||
err := src.Init()
|
||||
if err != nil {
|
||||
src.ShowError(err, 0)
|
||||
os.Exit(0)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = src.StartSystem(false)
|
||||
if err != nil {
|
||||
src.ShowError(err, 0)
|
||||
os.Exit(0)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Graceful stop on SIGINT / SIGTERM: say goodbye on the network (SSDP)
|
||||
// and exit cleanly instead of letting the runtime kill the process.
|
||||
go func() {
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
src.Shutdown()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
err = src.InitMaintenance()
|
||||
if err != nil {
|
||||
src.ShowError(err, 0)
|
||||
os.Exit(0)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = src.StartWebserver()
|
||||
if err != nil {
|
||||
src.ShowError(err, 0)
|
||||
os.Exit(0)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user