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:
|
commands:
|
||||||
- go mod download
|
- go mod download
|
||||||
- go vet ./...
|
- 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 run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...
|
||||||
- go test ./...
|
- go test ./...
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ steps:
|
|||||||
- cp html/js/app.js /tmp/app.js.committed
|
- cp html/js/app.js /tmp/app.js.committed
|
||||||
- npm ci --no-audit --no-fund
|
- npm ci --no-audit --no-fund
|
||||||
- npm run build
|
- 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)
|
- 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
|
- name: go-build
|
||||||
@@ -26,7 +27,7 @@ steps:
|
|||||||
- XTEVE_VERSION="$(grep -m1 '^#### ' changelog-beta.md | cut -d' ' -f2 | sed 's/-beta$//')"
|
- 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)
|
- 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)"
|
- 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"
|
- echo "Building xTeVe version $XTEVE_VERSION from changelog-beta.md"
|
||||||
- go build -v -ldflags "-X main.Version=$XTEVE_VERSION" ./...
|
- go build -v -ldflags "-X main.Version=$XTEVE_VERSION" ./...
|
||||||
|
|
||||||
@@ -50,6 +51,22 @@ steps:
|
|||||||
event:
|
event:
|
||||||
- pull_request
|
- 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
|
- name: docker-publish
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
environment:
|
environment:
|
||||||
@@ -62,9 +79,6 @@ steps:
|
|||||||
username: nathan
|
username: nathan
|
||||||
password:
|
password:
|
||||||
from_secret: registry_password
|
from_secret: registry_password
|
||||||
tags:
|
|
||||||
- latest
|
|
||||||
- ${DRONE_COMMIT_SHA}
|
|
||||||
build_args:
|
build_args:
|
||||||
- TARGETOS=linux
|
- TARGETOS=linux
|
||||||
- TARGETARCH=amd64
|
- 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
|
# Static ffmpeg/ffprobe binaries. To bump, pick a tag from
|
||||||
# https://hub.docker.com/r/mwader/static-ffmpeg/tags and change it here.
|
# 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
|
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.
|
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
|
```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 build # ts/*.ts -> html/js/app.js
|
||||||
npm run check # type-check only
|
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.
|
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.
|
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
|
## Endpoints useful for operations
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ registry.coadcorp.com/nathan/xteve
|
|||||||
|
|
||||||
Tags:
|
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.
|
* `<branch name>` and `<commit sha>` from every other branch.
|
||||||
|
|
||||||
```bash
|
```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
|
#### 2.2.0.0201-beta
|
||||||
Add UI enhancement for range selecting checkboxes
|
Add UI enhancement for range selecting checkboxes
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>xTeVe</title>
|
<title>xTeVe</title>
|
||||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
<link rel="stylesheet" href="css/base.css" type="text/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>
|
<script src="js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="header" class="imgCenter"></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">
|
<table id="clientInfo" class="visible" aria-label="Server information">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>xTeVe</title>
|
<title>xTeVe</title>
|
||||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
<link rel="stylesheet" href="css/base.css" type="text/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>
|
<script src="js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
|
|
||||||
<div id="header" class="imgCenter"></div>
|
<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">
|
<div id="headline">
|
||||||
<h1 id="head-text" class="center">Create user account</h1>
|
<h1 id="head-text" class="center">Create user account</h1>
|
||||||
|
|||||||
+46
-492
@@ -1,32 +1,14 @@
|
|||||||
:root {
|
/*
|
||||||
--bg-0: #07111d;
|
* base.css - reset, typography, links, focus styles, skip link, generic
|
||||||
--bg-1: #0c1a2b;
|
* form elements and small utility classes. Loaded after tokens.css.
|
||||||
--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);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
-moz-appearance: none;
|
-moz-appearance: none;
|
||||||
-ms-appearance: none;
|
-ms-appearance: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: "Space Grotesk", "Avenir Next", "Trebuchet MS", sans-serif;
|
font-family: var(--font-sans);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,17 +19,17 @@
|
|||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(7, 17, 29, 0.65);
|
background: var(--scroll-track);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 2px solid rgba(7, 17, 29, 0.6);
|
border: 2px solid var(--scroll-track);
|
||||||
background: linear-gradient(180deg, #2d597f, #1e4060);
|
background: linear-gradient(180deg, var(--scroll-thumb-a), var(--scroll-thumb-b));
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-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 {
|
::-webkit-scrollbar-corner {
|
||||||
@@ -61,9 +43,9 @@ body {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 15% 12%, rgba(64, 132, 183, 0.22), transparent 32%),
|
radial-gradient(circle at 15% 12%, var(--bg-glow-1), transparent 32%),
|
||||||
radial-gradient(circle at 88% 2%, rgba(18, 185, 255, 0.16), transparent 30%),
|
radial-gradient(circle at 88% 2%, var(--bg-glow-2), transparent 30%),
|
||||||
linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 56%, #060d17 100%);
|
linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 56%, var(--bg-deep) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -75,7 +57,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
color: #8de7ff;
|
color: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -122,7 +104,7 @@ pre {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
letter-spacing: 0.015em;
|
letter-spacing: 0.015em;
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
hr {
|
hr {
|
||||||
@@ -153,7 +135,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid #91eaff;
|
outline: 2px solid var(--focus-ring-soft);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +153,7 @@ textarea {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background-color: rgba(10, 22, 36, 0.78);
|
background-color: var(--surface-input);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 10px 11px;
|
padding: 10px 11px;
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
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,
|
select:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px rgba(53, 210, 255, 0.17);
|
box-shadow: 0 0 0 3px var(--accent-ring);
|
||||||
background-color: rgba(9, 25, 40, 0.95);
|
background-color: var(--surface-input-focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=text]::placeholder,
|
input[type=text]::placeholder,
|
||||||
input[type=search]::placeholder,
|
input[type=search]::placeholder,
|
||||||
input[type=password]::placeholder,
|
input[type=password]::placeholder,
|
||||||
textarea::placeholder {
|
textarea::placeholder {
|
||||||
color: #7e9ab4;
|
color: var(--text-placeholder);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=button]:focus-visible,
|
input[type=button]:focus-visible,
|
||||||
@@ -236,80 +188,43 @@ input[type=text]:focus-visible,
|
|||||||
input[type=search]:focus-visible,
|
input[type=search]:focus-visible,
|
||||||
input[type=password]:focus-visible,
|
input[type=password]:focus-visible,
|
||||||
input[type=number]:focus-visible {
|
input[type=number]:focus-visible {
|
||||||
outline: 2px solid #7de5ff;
|
outline: 2px solid var(--focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=button].delete {
|
.skip-link {
|
||||||
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;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -1px;
|
top: 10px;
|
||||||
left: 3px;
|
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 {
|
.skip-link:focus-visible {
|
||||||
border-color: #8de7ff !important;
|
transform: translateY(0);
|
||||||
box-shadow: 0 0 0 3px rgba(53, 210, 255, 0.2) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notAvailable {
|
.sr-only {
|
||||||
opacity: 0.58;
|
border: 0 !important;
|
||||||
cursor: not-allowed;
|
clip: rect(0 0 0 0) !important;
|
||||||
border-color: rgba(255, 111, 111, 0.45) !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 {
|
.pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -364,37 +279,6 @@ input[type=checkbox]:checked:before {
|
|||||||
text-align: center;
|
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 {
|
.floatRight {
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
@@ -423,9 +307,6 @@ input[type=checkbox]:checked:before {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.noBulk {
|
|
||||||
}
|
|
||||||
|
|
||||||
.notVisible {
|
.notVisible {
|
||||||
display: none;
|
display: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -437,259 +318,10 @@ input[type=checkbox]:checked:before {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-active {
|
|
||||||
color: #011019;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-notActive {
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.half {
|
.half {
|
||||||
width: 45%;
|
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 {
|
.tableEllipsis {
|
||||||
width: 150px;
|
width: 150px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -718,81 +350,3 @@ input[type=checkbox]:checked:before {
|
|||||||
transform: translateY(0) scale(1);
|
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 {
|
#layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 290px minmax(0, 1fr);
|
grid-template-columns: 290px minmax(0, 1fr);
|
||||||
@@ -15,8 +22,8 @@
|
|||||||
border-radius: var(--radius-l);
|
border-radius: var(--radius-l);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(18, 36, 58, 0.95) 0%, rgba(10, 23, 38, 0.95) 100%),
|
linear-gradient(180deg, var(--panel-top) 0%, var(--panel-bottom) 100%),
|
||||||
radial-gradient(circle at 20% 0%, rgba(53, 210, 255, 0.12), transparent 48%);
|
radial-gradient(circle at 20% 0%, var(--panel-glow), transparent 48%);
|
||||||
box-shadow: var(--shadow-2);
|
box-shadow: var(--shadow-2);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -28,100 +35,25 @@
|
|||||||
padding: 14px;
|
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 {
|
#logo {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 76px;
|
height: 76px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid var(--line-soft);
|
border: 1px solid var(--line-soft);
|
||||||
background:
|
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;
|
url("../img/logo_w_600x200.png") center no-repeat;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
margin-bottom: 12px;
|
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 {
|
.layout-right {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-radius: var(--radius-l);
|
border-radius: var(--radius-l);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(17, 34, 54, 0.94) 0%, rgba(10, 20, 34, 0.96) 100%),
|
linear-gradient(180deg, var(--panel-top) 0%, var(--panel-bottom) 100%),
|
||||||
radial-gradient(circle at 100% 0%, rgba(53, 210, 255, 0.08), transparent 34%);
|
radial-gradient(circle at 100% 0%, var(--panel-glow), transparent 34%);
|
||||||
box-shadow: var(--shadow-2);
|
box-shadow: var(--shadow-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -134,14 +66,14 @@ nav p {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border-bottom: 1px solid var(--line-soft);
|
border-bottom: 1px solid var(--line-soft);
|
||||||
background: rgba(10, 22, 36, 0.52);
|
background: var(--surface-0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#shell-title {
|
#shell-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.08rem;
|
font-size: 1.08rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #d8f1ff;
|
color: var(--text-heading);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,176 +85,7 @@ nav p {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#connection-indicator {
|
/* Content wrapper */
|
||||||
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 {
|
#content {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -332,7 +95,7 @@ nav p {
|
|||||||
|
|
||||||
#content h3 {
|
#content h3 {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
color: #d4edff;
|
color: var(--text-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
#content-interaction {
|
#content-interaction {
|
||||||
@@ -353,162 +116,14 @@ nav p {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#box-wrapper {
|
/* Login, first-user, wizard and maintenance box */
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
#box {
|
#box {
|
||||||
width: min(680px, calc(100% - 24px));
|
width: min(680px, calc(100% - 24px));
|
||||||
margin: 14px auto;
|
margin: 14px auto;
|
||||||
border-radius: var(--radius-l);
|
border-radius: var(--radius-l);
|
||||||
border: 1px solid var(--line);
|
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);
|
box-shadow: var(--shadow-2);
|
||||||
min-height: 430px;
|
min-height: 430px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -523,14 +138,14 @@ nav p {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background:
|
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;
|
url("../img/logo_w_600x200.png") center no-repeat;
|
||||||
background-size: 300px auto;
|
background-size: 300px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#headline {
|
#headline {
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
background: rgba(10, 22, 37, 0.55);
|
background: var(--surface-0);
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,10 +175,6 @@ nav p {
|
|||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard {
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-screen {
|
.auth-screen {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -578,6 +189,8 @@ nav p {
|
|||||||
display: table-cell;
|
display: table-cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Breakpoints */
|
||||||
|
|
||||||
@media only screen and (max-width: 1300px) {
|
@media only screen and (max-width: 1300px) {
|
||||||
.dashboard-cards {
|
.dashboard-cards {
|
||||||
grid-template-columns: repeat(3, minmax(130px, 1fr));
|
grid-template-columns: repeat(3, minmax(130px, 1fr));
|
||||||
@@ -622,7 +235,7 @@ nav p {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: rgba(3, 9, 15, 0.48);
|
background: var(--overlay-soft);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -698,7 +311,7 @@ nav p {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid var(--line-soft);
|
border: 1px solid var(--line-soft);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(9, 22, 36, 0.92);
|
background: var(--surface-solid);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,7 +384,7 @@ nav p {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
background: rgba(8, 20, 33, 0.44);
|
background: var(--surface-0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#clientInfo tr {
|
#clientInfo tr {
|
||||||
@@ -791,16 +404,16 @@ nav p {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 10px 9px;
|
padding: 10px 9px;
|
||||||
background: rgba(17, 34, 53, 0.5);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#clientInfo .tdVal::before {
|
#clientInfo .tdVal::before {
|
||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
color: #8bb9d7;
|
color: var(--text-soft);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.07em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-family: "Space Grotesk", "Avenir Next", "Trebuchet MS", sans-serif;
|
font-family: var(--font-sans);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,7 +442,7 @@ nav p {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
background: rgba(11, 24, 39, 0.86);
|
background: var(--surface-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#content_table td {
|
#content_table td {
|
||||||
@@ -841,12 +454,12 @@ nav p {
|
|||||||
grid-template-columns: minmax(92px, 36%) 1fr;
|
grid-template-columns: minmax(92px, 36%) 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: rgba(17, 34, 53, 0.5);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#content_table td::before {
|
#content_table td::before {
|
||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
color: #8bb9d7;
|
color: var(--text-soft);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
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) {
|
@media only screen and (max-width: 430px) {
|
||||||
#clientInfo {
|
#clientInfo {
|
||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
+6
-4
@@ -1,11 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>xTeVe</title>
|
<title>xTeVe</title>
|
||||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
<link rel="stylesheet" href="css/base.css" type="text/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>
|
<script src="js/app.js"></script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
@@ -45,7 +47,7 @@
|
|||||||
<main id="shell-main" class="layout-right">
|
<main id="shell-main" class="layout-right">
|
||||||
<header id="shell-header">
|
<header id="shell-header">
|
||||||
<button id="menu-toggle" type="button" aria-expanded="false" aria-controls="menu-wrapper" aria-label="Toggle navigation menu">Menu</button>
|
<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>
|
<p id="connection-indicator" class="status-idle" role="status" aria-live="polite" aria-atomic="true">Connecting...</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
+1262
-1335
File diff suppressed because it is too large.
Load diff
+6
-4
@@ -1,11 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>xTeVe</title>
|
<title>xTeVe</title>
|
||||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
<link rel="stylesheet" href="css/base.css" type="text/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>
|
<script src="js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
|
|
||||||
<div id="header" class="imgCenter"></div>
|
<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">
|
<div id="headline">
|
||||||
<h1 id="head-text" class="center">Login</h1>
|
<h1 id="head-text" class="center">Login</h1>
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>xTeVe</title>
|
<title>xTeVe</title>
|
||||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
<link rel="stylesheet" href="css/base.css" type="text/css">
|
<link rel="stylesheet" href="css/base.css">
|
||||||
|
<link rel="stylesheet" href="css/components.css">
|
||||||
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="auth-screen">
|
<body class="auth-screen">
|
||||||
|
|
||||||
<div id="header" class="imgCenter"></div>
|
<div id="header" class="imgCenter"></div>
|
||||||
|
|
||||||
<div id="box">
|
<main id="box" aria-labelledby="head-text">
|
||||||
|
|
||||||
<div id="headline">
|
<div id="headline">
|
||||||
<h1 id="head-text" class="center">Maintenance</h1>
|
<h1 id="head-text" class="center">Maintenance</h1>
|
||||||
@@ -24,7 +26,7 @@
|
|||||||
|
|
||||||
<div id="box-footer"></div>
|
<div id="box-footer"></div>
|
||||||
|
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Generated
+532
@@ -6,9 +6,466 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "xteve-webui",
|
"name": "xteve-webui",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"jsdom": "30.1.1",
|
||||||
"typescript": "5.9.3"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -22,6 +479,81 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.17"
|
"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)",
|
"description": "Build tooling for the xTeVe web interface (TypeScript -> html/js/app.js)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p ts/tsconfig.json",
|
"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": {
|
"devDependencies": {
|
||||||
|
"jsdom": "30.1.1",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-3
@@ -148,8 +148,8 @@ func xteveRestore(archive string) (newWebURL string, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
backupVersion = newConfig["version"].(string)
|
backupVersion, ok := newConfig["version"].(string)
|
||||||
if backupVersion < System.Compatibility {
|
if !ok || backupVersion < System.Compatibility {
|
||||||
err = errors.New(getErrMsg(1013))
|
err = errors.New(getErrMsg(1013))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,11 @@ func xteveRestore(archive string) (newWebURL string, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newPort = newConfig["port"].(string)
|
newPort, ok = newConfig["port"].(string)
|
||||||
|
if !ok {
|
||||||
|
err = errors.New(getErrMsg(1030))
|
||||||
|
return
|
||||||
|
}
|
||||||
oldPort = Settings.Port
|
oldPort = Settings.Port
|
||||||
|
|
||||||
if newPort == oldPort {
|
if newPort == oldPort {
|
||||||
|
|||||||
+5
-5
@@ -63,7 +63,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
|
|||||||
|
|
||||||
if isNew {
|
if isNew {
|
||||||
|
|
||||||
switch Settings.Buffer {
|
switch bufferModeFor(playlistID) {
|
||||||
|
|
||||||
case "xteve":
|
case "xteve":
|
||||||
go connectToStreamingServer(streamID, playlistID)
|
go connectToStreamingServer(streamID, playlistID)
|
||||||
@@ -86,7 +86,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.Err != nil {
|
if s.Err != nil {
|
||||||
ShowError(s.Err, 0)
|
// Already logged by the downloader that reported it.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.Status {
|
if s.Status {
|
||||||
@@ -127,7 +127,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.Err != nil {
|
if s.Err != nil {
|
||||||
ShowError(s.Err, 0)
|
// Already logged by the downloader that reported it.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1074,7 +1074,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
|
|||||||
|
|
||||||
var path, options, bufferType string
|
var path, options, bufferType string
|
||||||
|
|
||||||
switch Settings.Buffer {
|
switch bufferModeFor(playlistID) {
|
||||||
|
|
||||||
case "ffmpeg":
|
case "ffmpeg":
|
||||||
path, options, bufferType = Settings.FFmpegPath, Settings.FFmpegOptions, "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) {
|
func getTuner(id, playlistType string) (tuner int) {
|
||||||
|
|
||||||
switch Settings.Buffer {
|
switch bufferModeFor(id) {
|
||||||
|
|
||||||
case "-":
|
case "-":
|
||||||
tuner = Settings.Tuner
|
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)
|
// Leerzeichen aus den Werten entfernen und Formatierung der Uhrzeit überprüfen (0000 - 2359)
|
||||||
var newUpdateTimes = make([]string, 0)
|
var newUpdateTimes = make([]string, 0)
|
||||||
|
|
||||||
for _, v := range value.([]any) {
|
updateTimes, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
err = errors.New(getErrMsg(1012))
|
||||||
|
ShowError(err, 1012)
|
||||||
|
return Settings, err
|
||||||
|
}
|
||||||
|
|
||||||
v = strings.Replace(v.(string), " ", "", -1)
|
for _, v := range updateTimes {
|
||||||
|
|
||||||
_, err := time.Parse("1504", v.(string))
|
t, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
err = errors.New(getErrMsg(1012))
|
||||||
|
ShowError(err, 1012)
|
||||||
|
return Settings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
t = strings.Replace(t, " ", "", -1)
|
||||||
|
|
||||||
|
_, err := time.Parse("1504", t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ShowError(err, 1012)
|
ShowError(err, 1012)
|
||||||
return Settings, err
|
return Settings, err
|
||||||
}
|
}
|
||||||
|
|
||||||
newUpdateTimes = append(newUpdateTimes, v.(string))
|
newUpdateTimes = append(newUpdateTimes, t)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,21 +334,30 @@ func saveFiles(request RequestStruct, fileType string) (err error) {
|
|||||||
|
|
||||||
for dataID, data := range newData {
|
for dataID, data := range newData {
|
||||||
|
|
||||||
|
dataMap, ok := data.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
err = errors.New(getErrMsg(1020))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if dataID == "-" {
|
if dataID == "-" {
|
||||||
|
|
||||||
// Neue Providerdatei
|
// Neue Providerdatei
|
||||||
dataID = indicator + randomString(19)
|
dataID = indicator + randomString(19)
|
||||||
data.(map[string]any)["new"] = true
|
dataMap["new"] = true
|
||||||
filesMap[dataID] = data
|
filesMap[dataID] = dataMap
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
// Bereits vorhandene Providerdatei
|
// Bereits vorhandene Providerdatei
|
||||||
for key, value := range data.(map[string]any) {
|
oldData, ok := filesMap[dataID].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
// Unknown file ID: nothing to update.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
var oldData = filesMap[dataID].(map[string]any)
|
for key, value := range dataMap {
|
||||||
oldData[key] = value
|
oldData[key] = value
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -353,11 +376,11 @@ func saveFiles(request RequestStruct, fileType string) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Neue Providerdatei
|
// Neue Providerdatei
|
||||||
if _, ok := data.(map[string]any)["new"]; ok {
|
if _, ok := dataMap["new"]; ok {
|
||||||
|
|
||||||
reloadData = true
|
reloadData = true
|
||||||
err = getProviderData(fileType, dataID)
|
err = getProviderData(fileType, dataID)
|
||||||
delete(data.(map[string]any), "new")
|
delete(dataMap, "new")
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
delete(filesMap, dataID)
|
delete(filesMap, dataID)
|
||||||
@@ -511,6 +534,23 @@ func saveFilter(request RequestStruct) (settings SettingsStruct, err error) {
|
|||||||
return
|
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 {
|
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) {
|
func saveNewUser(request RequestStruct) (err error) {
|
||||||
|
|
||||||
var data = request.UserData
|
var data = request.UserData
|
||||||
var username = data["username"].(string)
|
|
||||||
var password = data["password"].(string)
|
username, ok := data["username"].(string)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("Username is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
password, ok := data["password"].(string)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("Password is missing")
|
||||||
|
}
|
||||||
|
|
||||||
delete(data, "password")
|
delete(data, "password")
|
||||||
delete(data, "confirm")
|
delete(data, "confirm")
|
||||||
@@ -808,6 +856,17 @@ func createFilterRules() (err error) {
|
|||||||
|
|
||||||
Data.Filter = append(Data.Filter, dataFilter)
|
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":
|
case "group-title":
|
||||||
if len(filter.Include) > 0 {
|
if len(filter.Include) > 0 {
|
||||||
include = fmt.Sprintf(" {%s}", filter.Include)
|
include = fmt.Sprintf(" {%s}", filter.Include)
|
||||||
@@ -855,7 +914,7 @@ func buildDatabaseDVR() (err error) {
|
|||||||
|
|
||||||
var playlistFile = getLocalProviderFiles(fileType)
|
var playlistFile = getLocalProviderFiles(fileType)
|
||||||
|
|
||||||
for n, i := range playlistFile {
|
for _, i := range playlistFile {
|
||||||
|
|
||||||
var channels []any
|
var channels []any
|
||||||
var groupTitle, tvgID, uuid = 0, 0, 0
|
var groupTitle, tvgID, uuid = 0, 0, 0
|
||||||
@@ -878,7 +937,8 @@ func buildDatabaseDVR() (err error) {
|
|||||||
ShowError(err, 1005)
|
ShowError(err, 1005)
|
||||||
err = errors.New(playlistName + ": Local copy of the file no longer exists")
|
err = errors.New(playlistName + ": Local copy of the file no longer exists")
|
||||||
ShowError(err, 0)
|
ShowError(err, 0)
|
||||||
playlistFile = append(playlistFile[:n], playlistFile[n+1:]...)
|
// playlistFile is not used after this loop; removing the entry
|
||||||
|
// while ranging over it only skipped the following file.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streams analysieren
|
// Streams analysieren
|
||||||
|
|||||||
@@ -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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.WriteFile(database, []byte(jsonString), 0600)
|
err = writeFileAtomic(database, []byte(jsonString), 0600)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFileAtomic : Temp file + fsync + rename, so authentication.json is
|
||||||
|
// never observed half-written. (Private copy; this package cannot import src.)
|
||||||
|
func writeFileAtomic(name string, data []byte, perm os.FileMode) (err error) {
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(name), ".tmp-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tmpName = tmp.Name()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpName)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err = tmp.Write(data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tmp.Sync(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tmp.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = os.Chmod(tmpName, perm); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Rename(tmpName, name)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,14 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// httpClient : Client for logo downloads. Providers occasionally serve
|
||||||
|
// images from hosts that never answer; without a timeout the caching
|
||||||
|
// goroutine would hang forever.
|
||||||
|
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
|
||||||
// Cache : Cache strcut
|
// Cache : Cache strcut
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
path string
|
path string
|
||||||
@@ -41,8 +47,6 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
|
|||||||
c.Queue = []string{}
|
c.Queue = []string{}
|
||||||
c.Cache = []string{}
|
c.Cache = []string{}
|
||||||
|
|
||||||
var queue []string
|
|
||||||
|
|
||||||
c.Image.GetURL = func(src string) (cacheURL string) {
|
c.Image.GetURL = func(src string) (cacheURL string) {
|
||||||
|
|
||||||
c.Lock()
|
c.Lock()
|
||||||
@@ -89,50 +93,30 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
|
|||||||
return src
|
return src
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Caching downloads every queued image. The lock is only held while the
|
||||||
|
// queue is copied and while the map / queue are updated per image; the
|
||||||
|
// HTTP download and file write happen unlocked so GetURL callers (M3U /
|
||||||
|
// XMLTV generation) are not blocked for the duration of the downloads.
|
||||||
c.Image.Caching = func() {
|
c.Image.Caching = func() {
|
||||||
|
|
||||||
c.Lock()
|
c.Lock()
|
||||||
defer c.Unlock()
|
var queue = make([]string, len(c.Queue))
|
||||||
|
copy(queue, c.Queue)
|
||||||
|
c.Unlock()
|
||||||
|
|
||||||
var filename string
|
for _, src := range queue {
|
||||||
|
|
||||||
for _, src := range c.Queue {
|
filename, err := c.download(src)
|
||||||
|
|
||||||
resp, err := http.Get(src)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
// Stays in the queue and is retried on the next run.
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
filename = fmt.Sprintf("%s%s%s%s", c.path, string(os.PathSeparator), strToMD5(src), filepath.Ext(src))
|
c.Lock()
|
||||||
|
c.images[filename] = c.cacheURL + filename
|
||||||
|
c.Queue = removeStringFromSlice(src, c.Queue)
|
||||||
|
c.Unlock()
|
||||||
|
|
||||||
file, err := os.Create(filename)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(file, resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := url.Parse(src)
|
|
||||||
if err == nil {
|
|
||||||
c.images[fmt.Sprintf("%s%s", strToMD5(src), filepath.Ext(u.Path))] = c.cacheURL + filename
|
|
||||||
}
|
|
||||||
|
|
||||||
queue = append(queue, src)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, q := range queue {
|
|
||||||
c.Queue = removeStringFromSlice(q, c.Queue)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -175,3 +159,45 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
|
|||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// download : Fetches src into the cache folder and returns the cached file
|
||||||
|
// name (md5 of the URL plus the extension of the URL path). Must be called
|
||||||
|
// without holding the lock.
|
||||||
|
func (c *Cache) download(src string) (filename string, err error) {
|
||||||
|
|
||||||
|
u, err := url.Parse(src)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename = fmt.Sprintf("%s%s", strToMD5(src), filepath.Ext(u.Path))
|
||||||
|
|
||||||
|
resp, err := httpClient.Get(src)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
err = fmt.Errorf("%s: %s", src, resp.Status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = filepath.Join(c.path, filename)
|
||||||
|
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(file, resp.Body)
|
||||||
|
if closeErr := file.Close(); err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
+51
-6
@@ -8,6 +8,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
m3u "xteve/src/internal/m3u-parser"
|
m3u "xteve/src/internal/m3u-parser"
|
||||||
)
|
)
|
||||||
@@ -34,12 +35,47 @@ func parsePlaylist(filename, fileType string) (channels []any, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Streams filtern
|
// 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) {
|
func filterThisStream(s any) (status bool) {
|
||||||
|
|
||||||
status = false
|
status = false
|
||||||
var stream = s.(map[string]string)
|
var stream = s.(map[string]string)
|
||||||
var regexpYES = `[{]+[^.]+[}]`
|
|
||||||
var regexpNO = `!+[{]+[^.]+[}]`
|
|
||||||
|
|
||||||
for _, filter := range Data.Filter {
|
for _, filter := range Data.Filter {
|
||||||
|
|
||||||
@@ -53,6 +89,17 @@ func filterThisStream(s any) (status bool) {
|
|||||||
|
|
||||||
var streamValues = strings.Replace(stream["_values"], "\r", "", -1)
|
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 {
|
if v, ok := stream["group-title"]; ok {
|
||||||
group = v
|
group = v
|
||||||
}
|
}
|
||||||
@@ -62,8 +109,7 @@ func filterThisStream(s any) (status bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unerwünschte Streams !{DEU}
|
// Unerwünschte Streams !{DEU}
|
||||||
r := regexp.MustCompile(regexpNO)
|
val := filterExcludeRe.FindStringSubmatch(filter.Rule)
|
||||||
val := r.FindStringSubmatch(filter.Rule)
|
|
||||||
|
|
||||||
if len(val) == 1 {
|
if len(val) == 1 {
|
||||||
|
|
||||||
@@ -74,8 +120,7 @@ func filterThisStream(s any) (status bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Muss zusätzlich erfüllt sein {DEU}
|
// Muss zusätzlich erfüllt sein {DEU}
|
||||||
r = regexp.MustCompile(regexpYES)
|
val = filterIncludeRe.FindStringSubmatch(filter.Rule)
|
||||||
val = r.FindStringSubmatch(filter.Rule)
|
|
||||||
|
|
||||||
if len(val) == 1 {
|
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)
|
req.Header.Set("X-Plex-Version", System.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{
|
resp, err := apiHTTPClient.Do(req)
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-11
@@ -142,8 +142,16 @@ func getProviderData(fileType, fileID string) (err error) {
|
|||||||
|
|
||||||
for dataID, d := range dataMap {
|
for dataID, d := range dataMap {
|
||||||
|
|
||||||
var data = d.(map[string]any)
|
data, ok := d.(map[string]any)
|
||||||
var fileSource = data["file.source"].(string)
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fileSource, ok := data["file.source"].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
newProvider = false
|
newProvider = false
|
||||||
|
|
||||||
if _, ok := data["new"]; ok {
|
if _, ok := data["new"]; ok {
|
||||||
@@ -224,8 +232,8 @@ func getProviderData(fileType, fileID string) (err error) {
|
|||||||
if value, ok := dataMap[dataID].(map[string]any); ok {
|
if value, ok := dataMap[dataID].(map[string]any); ok {
|
||||||
|
|
||||||
data = value
|
data = value
|
||||||
data["counter.error"] = data["counter.error"].(float64) + 1
|
data["counter.error"] = toFloat64(data["counter.error"]) + 1
|
||||||
data["counter.download"] = data["counter.download"].(float64) + 1
|
data["counter.download"] = toFloat64(data["counter.download"]) + 1
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,10 +251,13 @@ func getProviderData(fileType, fileID string) (err error) {
|
|||||||
var data = make(map[string]any)
|
var data = make(map[string]any)
|
||||||
data = value
|
data = value
|
||||||
|
|
||||||
if data["counter.error"].(float64) == 0 {
|
var errCount = toFloat64(data["counter.error"])
|
||||||
|
var dlCount = toFloat64(data["counter.download"])
|
||||||
|
|
||||||
|
if errCount == 0 || dlCount == 0 {
|
||||||
data["provider.availability"] = 100
|
data["provider.availability"] = 100
|
||||||
} else {
|
} else {
|
||||||
data["provider.availability"] = int(data["counter.error"].(float64)*100/data["counter.download"].(float64)*-1 + 100)
|
data["provider.availability"] = int(errCount*100/dlCount*-1 + 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -289,8 +300,7 @@ func downloadFileFromServer(providerURL string) (filename string, body []byte, e
|
|||||||
|
|
||||||
req.Header.Set("User-Agent", getUserAgent())
|
req.Header.Set("User-Agent", getUserAgent())
|
||||||
|
|
||||||
client := &http.Client{}
|
resp, err := providerHTTPClient.Do(req)
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -307,15 +317,22 @@ func downloadFileFromServer(providerURL string) (filename string, body []byte, e
|
|||||||
|
|
||||||
if index > -1 {
|
if index > -1 {
|
||||||
|
|
||||||
var headerFilename = resp.Header.Get("Content-Disposition")[index:len(resp.Header.Get("Content-Disposition"))]
|
var headerFilename = resp.Header.Get("Content-Disposition")[index:]
|
||||||
var value = strings.Split(headerFilename, `=`)
|
var value = strings.SplitN(headerFilename, `=`, 2)
|
||||||
|
|
||||||
|
if len(value) == 2 {
|
||||||
|
|
||||||
var f = strings.Replace(value[1], `"`, "", -1)
|
var f = strings.Replace(value[1], `"`, "", -1)
|
||||||
|
|
||||||
f = strings.Replace(f, `;`, "", -1)
|
f = strings.Replace(f, `;`, "", -1)
|
||||||
filename = f
|
filename = f
|
||||||
showInfo("Header filename:" + filename)
|
showInfo("Header filename:" + filename)
|
||||||
|
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filename) == 0 {
|
||||||
|
|
||||||
var cleanFilename = strings.SplitN(getFilenameFromPath(providerURL), "?", 2)
|
var cleanFilename = strings.SplitN(getFilenameFromPath(providerURL), "?", 2)
|
||||||
filename = cleanFilename[0]
|
filename = cleanFilename[0]
|
||||||
|
|||||||
+145
-40
@@ -10,6 +10,82 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// logMu guards WebScreenLog (its slice and counters) and System.Notification.
|
||||||
|
// Log lines are appended from every goroutine (buffer, maintenance, web
|
||||||
|
// handlers) and read by the websocket handler.
|
||||||
|
var logMu sync.Mutex
|
||||||
|
|
||||||
|
// logAppend : Appends a timestamped line to the in-memory log and trims it
|
||||||
|
// to Settings.LogEntriesRAM.
|
||||||
|
func logAppend(logMsg string) {
|
||||||
|
|
||||||
|
logMu.Lock()
|
||||||
|
defer logMu.Unlock()
|
||||||
|
|
||||||
|
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
|
||||||
|
logCleanUpLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// logAppendCounted : Appends a warning / error line and bumps the matching
|
||||||
|
// counter. Like the original code this does not trim the log; the next
|
||||||
|
// info / debug line does.
|
||||||
|
func logAppendCounted(logMsg string, isError bool) {
|
||||||
|
|
||||||
|
logMu.Lock()
|
||||||
|
defer logMu.Unlock()
|
||||||
|
|
||||||
|
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
|
||||||
|
|
||||||
|
if isError {
|
||||||
|
WebScreenLog.Errors++
|
||||||
|
} else {
|
||||||
|
WebScreenLog.Warnings++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetWebScreenLog : Clears the in-memory log and its counters.
|
||||||
|
func resetWebScreenLog() {
|
||||||
|
|
||||||
|
logMu.Lock()
|
||||||
|
defer logMu.Unlock()
|
||||||
|
|
||||||
|
WebScreenLog.Log = make([]string, 0)
|
||||||
|
WebScreenLog.Errors = 0
|
||||||
|
WebScreenLog.Warnings = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// webScreenLogSnapshot : Copy of the log for the web client. The slice is
|
||||||
|
// copied so the JSON encoder never races with a concurrent append.
|
||||||
|
func webScreenLogSnapshot() (snapshot WebScreenLogStruct) {
|
||||||
|
|
||||||
|
logMu.Lock()
|
||||||
|
defer logMu.Unlock()
|
||||||
|
|
||||||
|
snapshot = WebScreenLog
|
||||||
|
snapshot.Log = make([]string, len(WebScreenLog.Log))
|
||||||
|
copy(snapshot.Log, WebScreenLog.Log)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// notificationsSnapshot : Copy of the notifications map for the web client.
|
||||||
|
func notificationsSnapshot() (snapshot map[string]Notification) {
|
||||||
|
|
||||||
|
logMu.Lock()
|
||||||
|
defer logMu.Unlock()
|
||||||
|
|
||||||
|
if System.Notification == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot = make(map[string]Notification, len(System.Notification))
|
||||||
|
for k, v := range System.Notification {
|
||||||
|
snapshot[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func showInfo(str string) {
|
func showInfo(str string) {
|
||||||
|
|
||||||
if System.Flag.Info {
|
if System.Flag.Info {
|
||||||
@@ -33,9 +109,7 @@ func showInfo(str string) {
|
|||||||
|
|
||||||
printLogOnScreen(logMsg, "info")
|
printLogOnScreen(logMsg, "info")
|
||||||
|
|
||||||
logMsg = strings.Replace(logMsg, " ", " ", -1)
|
logAppend(logMsg)
|
||||||
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
|
|
||||||
logCleanUp()
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +125,6 @@ func showDebug(str string, level int) {
|
|||||||
var msg = strings.SplitN(str, ":", 2)
|
var msg = strings.SplitN(str, ":", 2)
|
||||||
var length = len(msg[0])
|
var length = len(msg[0])
|
||||||
var space string
|
var space string
|
||||||
var mutex = sync.RWMutex{}
|
|
||||||
|
|
||||||
if len(msg) == 2 {
|
if len(msg) == 2 {
|
||||||
|
|
||||||
@@ -64,11 +137,7 @@ func showDebug(str string, level int) {
|
|||||||
|
|
||||||
printLogOnScreen(logMsg, "debug")
|
printLogOnScreen(logMsg, "debug")
|
||||||
|
|
||||||
mutex.Lock()
|
logAppend(logMsg)
|
||||||
logMsg = strings.Replace(logMsg, " ", " ", -1)
|
|
||||||
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
|
|
||||||
logCleanUp()
|
|
||||||
mutex.Unlock()
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +168,8 @@ func showHighlight(str string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
notification.Type = "info"
|
notification.Type = "info"
|
||||||
notification.Message = msg[1]
|
// Messages without a "key:value" prefix are shown as they are.
|
||||||
|
notification.Message = msg[len(msg)-1]
|
||||||
|
|
||||||
addNotification(notification)
|
addNotification(notification)
|
||||||
|
|
||||||
@@ -109,31 +179,22 @@ func showWarning(errCode int) {
|
|||||||
|
|
||||||
var errMsg = getErrMsg(errCode)
|
var errMsg = getErrMsg(errCode)
|
||||||
var logMsg = fmt.Sprintf("[%s] [WARNING] %s", System.Name, errMsg)
|
var logMsg = fmt.Sprintf("[%s] [WARNING] %s", System.Name, errMsg)
|
||||||
var mutex = sync.RWMutex{}
|
|
||||||
|
|
||||||
printLogOnScreen(logMsg, "warning")
|
printLogOnScreen(logMsg, "warning")
|
||||||
|
|
||||||
mutex.Lock()
|
logAppendCounted(logMsg, false)
|
||||||
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
|
|
||||||
WebScreenLog.Warnings++
|
|
||||||
mutex.Unlock()
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShowError : Zeigt die Fehlermeldungen in der Konsole
|
// ShowError : Zeigt die Fehlermeldungen in der Konsole
|
||||||
func ShowError(err error, errCode int) {
|
func ShowError(err error, errCode int) {
|
||||||
|
|
||||||
var mutex = sync.RWMutex{}
|
|
||||||
|
|
||||||
var errMsg = getErrMsg(errCode)
|
var errMsg = getErrMsg(errCode)
|
||||||
var logMsg = fmt.Sprintf("[%s] [ERROR] %s (%s) - EC: %d", System.Name, err, errMsg, errCode)
|
var logMsg = fmt.Sprintf("[%s] [ERROR] %s (%s) - EC: %d", System.Name, err, errMsg, errCode)
|
||||||
|
|
||||||
printLogOnScreen(logMsg, "error")
|
printLogOnScreen(logMsg, "error")
|
||||||
|
|
||||||
mutex.Lock()
|
logAppendCounted(logMsg, true)
|
||||||
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
|
|
||||||
WebScreenLog.Errors++
|
|
||||||
mutex.Unlock()
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,24 +235,17 @@ func printLogOnScreen(logMsg string, logType string) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func logCleanUp() {
|
// logCleanUpLocked : Trims the log to the newest Settings.LogEntriesRAM
|
||||||
|
// lines and recounts warnings / errors. The caller must hold logMu.
|
||||||
|
func logCleanUpLocked() {
|
||||||
|
|
||||||
|
WebScreenLog.Log = keepLastEntries(WebScreenLog.Log, Settings.LogEntriesRAM)
|
||||||
|
|
||||||
var logEntriesRAM = Settings.LogEntriesRAM
|
|
||||||
var logs = WebScreenLog.Log
|
var logs = WebScreenLog.Log
|
||||||
|
|
||||||
WebScreenLog.Warnings = 0
|
WebScreenLog.Warnings = 0
|
||||||
WebScreenLog.Errors = 0
|
WebScreenLog.Errors = 0
|
||||||
|
|
||||||
if len(logs) > logEntriesRAM {
|
|
||||||
|
|
||||||
var tmp = make([]string, 0)
|
|
||||||
for i := len(logs) - logEntriesRAM; i < logEntriesRAM; i++ {
|
|
||||||
tmp = append(tmp, logs[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
logs = tmp
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, log := range logs {
|
for _, log := range logs {
|
||||||
|
|
||||||
if strings.Contains(log, "WARNING") {
|
if strings.Contains(log, "WARNING") {
|
||||||
@@ -204,8 +258,25 @@ func logCleanUp() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
WebScreenLog.Log = logs
|
}
|
||||||
|
|
||||||
|
// keepLastEntries : Returns the last max entries of logs (a copy, so the
|
||||||
|
// old backing array does not keep growing). A max <= 0 keeps nothing, which
|
||||||
|
// matches the original behaviour for an unset LogEntriesRAM.
|
||||||
|
func keepLastEntries(logs []string, max int) []string {
|
||||||
|
|
||||||
|
if max < 0 {
|
||||||
|
max = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(logs) <= max {
|
||||||
|
return logs
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmp = make([]string, max)
|
||||||
|
copy(tmp, logs[len(logs)-max:])
|
||||||
|
|
||||||
|
return tmp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fehlercodes
|
// Fehlercodes
|
||||||
@@ -386,30 +457,64 @@ func getErrMsg(errCode int) (errMsg string) {
|
|||||||
return errMsg
|
return errMsg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxNotifications : Number of notifications kept for the web interface.
|
||||||
|
const maxNotifications = 10
|
||||||
|
|
||||||
func addNotification(notification Notification) (err error) {
|
func addNotification(notification Notification) (err error) {
|
||||||
|
|
||||||
var i int
|
|
||||||
var t = time.Now().UnixNano() / (int64(time.Millisecond) / int64(time.Nanosecond))
|
var t = time.Now().UnixNano() / (int64(time.Millisecond) / int64(time.Nanosecond))
|
||||||
notification.Time = strconv.FormatInt(t, 10)
|
notification.Time = strconv.FormatInt(t, 10)
|
||||||
|
|
||||||
|
insertNotification(notification)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertNotification : Stores the notification under its Time key and evicts
|
||||||
|
// the oldest ones once more than maxNotifications are kept.
|
||||||
|
func insertNotification(notification Notification) {
|
||||||
|
|
||||||
notification.New = true
|
notification.New = true
|
||||||
|
|
||||||
if len(notification.Headline) == 0 {
|
if len(notification.Headline) == 0 {
|
||||||
notification.Headline = strings.ToUpper(notification.Type)
|
notification.Headline = strings.ToUpper(notification.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logMu.Lock()
|
||||||
|
defer logMu.Unlock()
|
||||||
|
|
||||||
if len(System.Notification) == 0 {
|
if len(System.Notification) == 0 {
|
||||||
System.Notification = make(map[string]Notification)
|
System.Notification = make(map[string]Notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
System.Notification[notification.Time] = notification
|
System.Notification[notification.Time] = notification
|
||||||
|
|
||||||
for key := range System.Notification {
|
for len(System.Notification) > maxNotifications {
|
||||||
|
delete(System.Notification, oldestNotificationKey(System.Notification))
|
||||||
if i < len(System.Notification)-10 {
|
|
||||||
delete(System.Notification, key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
i++
|
}
|
||||||
|
|
||||||
|
// oldestNotificationKey : Key of the notification with the smallest
|
||||||
|
// timestamp. Keys are millisecond timestamps as decimal strings; anything
|
||||||
|
// that does not parse sorts before everything that does.
|
||||||
|
func oldestNotificationKey(notifications map[string]Notification) (oldest string) {
|
||||||
|
|
||||||
|
var oldestTime int64
|
||||||
|
var found bool
|
||||||
|
|
||||||
|
for key, n := range notifications {
|
||||||
|
|
||||||
|
t, err := strconv.ParseInt(n.Time, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found || t < oldestTime {
|
||||||
|
oldest = key
|
||||||
|
oldestTime = t
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// writePrivateFile : like writeByteToFile but readable only by the owner.
|
||||||
func writePrivateFile(file string, data []byte) error {
|
func writePrivateFile(file string, data []byte) error {
|
||||||
|
|
||||||
var filename = getPlatformFile(file)
|
// The temp file is chmod'ed before the rename, so an existing
|
||||||
|
// world-readable file is replaced by a 0600 one.
|
||||||
if err := os.WriteFile(filename, data, 0600); err != nil {
|
return writeFileAtomic(getPlatformFile(file), data, 0600)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteFile keeps the mode of an existing file; tighten it.
|
|
||||||
return os.Chmod(filename, 0600)
|
|
||||||
}
|
}
|
||||||
+49
-18
@@ -4,12 +4,38 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/koron/go-ssdp"
|
"github.com/koron/go-ssdp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ssdpState : The running advertiser, so Shutdown can say goodbye to the
|
||||||
|
// network before the process exits.
|
||||||
|
var ssdpState struct {
|
||||||
|
sync.Mutex
|
||||||
|
adv *ssdp.Advertiser
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown : Stops background services (currently the SSDP advertiser).
|
||||||
|
// Called from main on SIGINT / SIGTERM.
|
||||||
|
func Shutdown() {
|
||||||
|
|
||||||
|
ssdpState.Lock()
|
||||||
|
defer ssdpState.Unlock()
|
||||||
|
|
||||||
|
if ssdpState.adv == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
close(ssdpState.done)
|
||||||
|
ssdpState.adv.Bye()
|
||||||
|
ssdpState.adv.Close()
|
||||||
|
ssdpState.adv = nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// SSDP : SSPD / DLNA Server
|
// SSDP : SSPD / DLNA Server
|
||||||
func SSDP() (err error) {
|
func SSDP() (err error) {
|
||||||
|
|
||||||
@@ -19,9 +45,6 @@ func SSDP() (err error) {
|
|||||||
|
|
||||||
showInfo(fmt.Sprintf("SSDP / DLNA:%t", Settings.SSDP))
|
showInfo(fmt.Sprintf("SSDP / DLNA:%t", Settings.SSDP))
|
||||||
|
|
||||||
quit := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(quit, os.Interrupt)
|
|
||||||
|
|
||||||
ad, err := ssdp.Advertise(
|
ad, err := ssdp.Advertise(
|
||||||
"upnp:rootdevice", // send as "ST"
|
"upnp:rootdevice", // send as "ST"
|
||||||
fmt.Sprintf("uuid:%s::upnp:rootdevice", System.DeviceID), // send as "USN"
|
fmt.Sprintf("uuid:%s::upnp:rootdevice", System.DeviceID), // send as "USN"
|
||||||
@@ -38,35 +61,43 @@ func SSDP() (err error) {
|
|||||||
ssdp.Logger = log.New(os.Stderr, "[SSDP] ", log.LstdFlags)
|
ssdp.Logger = log.New(os.Stderr, "[SSDP] ", log.LstdFlags)
|
||||||
}
|
}
|
||||||
|
|
||||||
go func(adv *ssdp.Advertiser) {
|
var done = make(chan struct{})
|
||||||
|
|
||||||
aliveTick := time.Tick(300 * time.Second)
|
ssdpState.Lock()
|
||||||
|
ssdpState.adv = ad
|
||||||
|
ssdpState.done = done
|
||||||
|
ssdpState.Unlock()
|
||||||
|
|
||||||
|
go func(adv *ssdp.Advertiser, done chan struct{}) {
|
||||||
|
|
||||||
|
aliveTick := time.NewTicker(300 * time.Second)
|
||||||
|
defer aliveTick.Stop()
|
||||||
|
|
||||||
loop:
|
|
||||||
for {
|
for {
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|
||||||
case <-aliveTick:
|
case <-aliveTick.C:
|
||||||
err = adv.Alive()
|
if aliveErr := adv.Alive(); aliveErr != nil {
|
||||||
if err != nil {
|
ShowError(aliveErr, 0)
|
||||||
ShowError(err, 0)
|
ssdpState.Lock()
|
||||||
|
if ssdpState.adv == adv {
|
||||||
|
ssdpState.adv = nil
|
||||||
|
}
|
||||||
|
ssdpState.Unlock()
|
||||||
adv.Bye()
|
adv.Bye()
|
||||||
adv.Close()
|
adv.Close()
|
||||||
break loop
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case <-quit:
|
case <-done:
|
||||||
adv.Bye()
|
return
|
||||||
adv.Close()
|
|
||||||
os.Exit(0)
|
|
||||||
break loop
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}(ad)
|
}(ad, done)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,10 @@ type RequestStruct struct {
|
|||||||
// Befehle an xTeVe
|
// Befehle an xTeVe
|
||||||
Cmd string `json:"cmd"`
|
Cmd string `json:"cmd"`
|
||||||
|
|
||||||
|
// Client-chosen request id, echoed in the response so the web UI can
|
||||||
|
// match answers on a long-lived connection.
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
|
||||||
// Benutzer
|
// Benutzer
|
||||||
DeleteUser bool `json:"deleteUser,omitempty"`
|
DeleteUser bool `json:"deleteUser,omitempty"`
|
||||||
UserData map[string]any `json:"userData,omitempty"`
|
UserData map[string]any `json:"userData,omitempty"`
|
||||||
@@ -73,6 +77,8 @@ type RequestStruct struct {
|
|||||||
|
|
||||||
// ResponseStruct : Antworten an den Client (WEB)
|
// ResponseStruct : Antworten an den Client (WEB)
|
||||||
type ResponseStruct struct {
|
type ResponseStruct struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
|
||||||
ClientInfo struct {
|
ClientInfo struct {
|
||||||
ARCH string `json:"arch"`
|
ARCH string `json:"arch"`
|
||||||
Branch string `json:"branch,omitempty"`
|
Branch string `json:"branch,omitempty"`
|
||||||
|
|||||||
+74
-7
@@ -204,12 +204,7 @@ func saveMapToJSONFile(file string, tmpMap any) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.WriteFile(filename, []byte(jsonString), 0644)
|
return writeFileAtomic(filename, []byte(jsonString), 0644)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadJSONFileToMap(file string) (tmpMap map[string]any, err error) {
|
func loadJSONFileToMap(file string) (tmpMap map[string]any, err error) {
|
||||||
@@ -229,6 +224,37 @@ func loadJSONFileToMap(file string) (tmpMap map[string]any, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toFloat64 : Numeric value of a JSON decoded field; 0 for anything that
|
||||||
|
// is not a number (missing key, wrong type).
|
||||||
|
func toFloat64(v any) float64 {
|
||||||
|
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return n
|
||||||
|
case int:
|
||||||
|
return float64(n)
|
||||||
|
case int64:
|
||||||
|
return float64(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeStrings : Copy of list without the entries present in remove.
|
||||||
|
// Order of the remaining entries is kept.
|
||||||
|
func removeStrings(list []string, remove map[string]bool) []string {
|
||||||
|
|
||||||
|
var result = make([]string, 0, len(list))
|
||||||
|
|
||||||
|
for _, s := range list {
|
||||||
|
if !remove[s] {
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// Binary
|
// Binary
|
||||||
func readByteFromFile(file string) (content []byte, err error) {
|
func readByteFromFile(file string) (content []byte, err error) {
|
||||||
|
|
||||||
@@ -246,7 +272,48 @@ func readByteFromFile(file string) (content []byte, err error) {
|
|||||||
func writeByteToFile(file string, data []byte) (err error) {
|
func writeByteToFile(file string, data []byte) (err error) {
|
||||||
|
|
||||||
var filename = getPlatformFile(file)
|
var filename = getPlatformFile(file)
|
||||||
err = os.WriteFile(filename, data, 0644)
|
err = writeFileAtomic(filename, data, 0644)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFileAtomic : Writes data to a temporary file in the target directory,
|
||||||
|
// fsyncs it and renames it over name, so readers (and a crash mid-write)
|
||||||
|
// never see a truncated or half-written file. The temp file is removed on
|
||||||
|
// any error.
|
||||||
|
func writeFileAtomic(name string, data []byte, perm os.FileMode) (err error) {
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(name), ".tmp-*")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmpName = tmp.Name()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpName)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err = tmp.Write(data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tmp.Sync(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tmp.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = os.Chmod(tmpName, perm); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Rename(tmpName, name)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"xteve/src/internal/authentication"
|
"xteve/src/internal/authentication"
|
||||||
|
|
||||||
@@ -73,7 +74,17 @@ func StartWebserver() (err error) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = http.ListenAndServe(":"+port, nil); err != nil {
|
// No ReadTimeout / WriteTimeout on purpose: /stream/ responses are
|
||||||
|
// long-lived. ReadHeaderTimeout still protects against slowloris
|
||||||
|
// clients and IdleTimeout reaps keep-alive connections.
|
||||||
|
var server = &http.Server{
|
||||||
|
Addr: ":" + port,
|
||||||
|
Handler: nil,
|
||||||
|
ReadHeaderTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = server.ListenAndServe(); err != nil {
|
||||||
ShowError(err, 1001)
|
ShowError(err, 1001)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -153,16 +164,18 @@ func Stream(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var bufferMode = bufferModeFor(streamInfo.PlaylistID)
|
||||||
|
|
||||||
// If an UDPxy host is set, and the stream URL is multicast (i.e. starts with 'udp://@'),
|
// 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.
|
// then streamInfo.URL needs to be rewritten to point to UDPxy.
|
||||||
if Settings.UDPxy != "" && strings.HasPrefix(streamInfo.URL, "udp://@") {
|
if Settings.UDPxy != "" && strings.HasPrefix(streamInfo.URL, "udp://@") {
|
||||||
streamInfo.URL = fmt.Sprintf("http://%s/udp/%s/", Settings.UDPxy, strings.TrimPrefix(streamInfo.URL, "udp://@"))
|
streamInfo.URL = fmt.Sprintf("http://%s/udp/%s/", Settings.UDPxy, strings.TrimPrefix(streamInfo.URL, "udp://@"))
|
||||||
}
|
}
|
||||||
|
|
||||||
switch Settings.Buffer {
|
switch bufferMode {
|
||||||
|
|
||||||
case "-":
|
case "-":
|
||||||
showInfo(fmt.Sprintf("Buffer:false [%s]", Settings.Buffer))
|
showInfo(fmt.Sprintf("Buffer:false [%s]", bufferMode))
|
||||||
|
|
||||||
case "xteve":
|
case "xteve":
|
||||||
if strings.Contains(streamInfo.URL, "rtsp://") || strings.Contains(streamInfo.URL, "rtp://") {
|
if strings.Contains(streamInfo.URL, "rtsp://") || strings.Contains(streamInfo.URL, "rtp://") {
|
||||||
@@ -176,14 +189,14 @@ func Stream(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showInfo(fmt.Sprintf("Buffer:true [%s]", Settings.Buffer))
|
showInfo(fmt.Sprintf("Buffer:true [%s]", bufferMode))
|
||||||
|
|
||||||
default:
|
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))
|
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")))
|
showInfo(fmt.Sprintf("Client User-Agent:%s", r.Header.Get("User-Agent")))
|
||||||
|
|
||||||
// Prüfen ob der Buffer verwendet werden soll
|
// Prüfen ob der Buffer verwendet werden soll
|
||||||
switch Settings.Buffer {
|
switch bufferMode {
|
||||||
|
|
||||||
case "-":
|
case "-":
|
||||||
showInfo("Streaming URL:" + streamInfo.URL)
|
showInfo("Streaming URL:" + streamInfo.URL)
|
||||||
@@ -317,36 +330,36 @@ func DataImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
// WS : Web Sockets /ws/
|
// WS : Web Sockets /ws/
|
||||||
func WS(w http.ResponseWriter, r *http.Request) {
|
func WS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
var request RequestStruct
|
|
||||||
var response ResponseStruct
|
|
||||||
response.Status = true
|
|
||||||
|
|
||||||
var newToken string
|
var newToken string
|
||||||
|
|
||||||
/*
|
|
||||||
if r.Header.Get("Origin") != "http://"+r.Host {
|
|
||||||
httpStatusError(w, r, 403)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Upgrade writes its own error response (e.g. 403 for a bad Origin).
|
// Upgrade writes its own error response (e.g. 403 for a bad Origin).
|
||||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ShowError(err, 0)
|
ShowError(err, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
setGlobalDomain(r.Host)
|
setGlobalDomain(r.Host)
|
||||||
|
|
||||||
|
// One connection serves any number of commands, one at a time, until the
|
||||||
|
// client goes away or a write fails.
|
||||||
for {
|
for {
|
||||||
|
|
||||||
|
// Fresh structs per command: a failed command must not leak its
|
||||||
|
// Status / Error / Base64 into the next one on the same connection.
|
||||||
|
var request RequestStruct
|
||||||
|
var response ResponseStruct
|
||||||
|
response.Status = true
|
||||||
|
|
||||||
err = conn.ReadJSON(&request)
|
err = conn.ReadJSON(&request)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
response.ID = request.ID
|
||||||
|
|
||||||
if !System.ConfigurationWizard {
|
if !System.ConfigurationWizard {
|
||||||
|
|
||||||
switch Settings.AuthenticationWEB {
|
switch Settings.AuthenticationWEB {
|
||||||
@@ -385,10 +398,9 @@ func WS(w http.ResponseWriter, r *http.Request) {
|
|||||||
response = setDefaultResponseData(response, false)
|
response = setDefaultResponseData(response, false)
|
||||||
if err = conn.WriteJSON(response); err != nil {
|
if err = conn.WriteJSON(response); err != nil {
|
||||||
ShowError(err, 1022)
|
ShowError(err, 1022)
|
||||||
} else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return
|
continue
|
||||||
|
|
||||||
case "loadFiles":
|
case "loadFiles":
|
||||||
//response.Response = Settings.Files
|
//response.Response = Settings.Files
|
||||||
@@ -465,9 +477,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "resetLogs":
|
case "resetLogs":
|
||||||
WebScreenLog.Log = make([]string, 0)
|
resetWebScreenLog()
|
||||||
WebScreenLog.Errors = 0
|
|
||||||
WebScreenLog.Warnings = 0
|
|
||||||
response.OpenMenu = strconv.Itoa(indexOfString("log", System.WEB.Menu))
|
response.OpenMenu = strconv.Itoa(indexOfString("log", System.WEB.Menu))
|
||||||
|
|
||||||
case "xteveBackup":
|
case "xteveBackup":
|
||||||
@@ -478,9 +488,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "xteveRestore":
|
case "xteveRestore":
|
||||||
WebScreenLog.Log = make([]string, 0)
|
resetWebScreenLog()
|
||||||
WebScreenLog.Errors = 0
|
|
||||||
WebScreenLog.Warnings = 0
|
|
||||||
|
|
||||||
if len(request.Base64) > 0 {
|
if len(request.Base64) > 0 {
|
||||||
|
|
||||||
@@ -563,8 +571,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if err = conn.WriteJSON(response); err != nil {
|
if err = conn.WriteJSON(response); err != nil {
|
||||||
ShowError(err, 1022)
|
ShowError(err, 1022)
|
||||||
} else {
|
return
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -855,8 +862,8 @@ func API(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
token, err = tokenAuthentication(request.Token)
|
token, err = tokenAuthentication(request.Token)
|
||||||
fmt.Println(err)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
ShowError(err, 0)
|
||||||
responseAPIError(err)
|
responseAPIError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -926,6 +933,7 @@ func API(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
responseAPIError(err)
|
responseAPIError(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Write([]byte(mapToJSON(response)))
|
w.Write([]byte(mapToJSON(response)))
|
||||||
@@ -977,10 +985,10 @@ func setDefaultResponseData(response ResponseStruct, data bool) (defaults Respon
|
|||||||
defaults.ClientInfo.OS = System.OS
|
defaults.ClientInfo.OS = System.OS
|
||||||
defaults.ClientInfo.Streams = fmt.Sprintf("%d / %d", len(Data.Streams.Active), len(Data.Streams.All))
|
defaults.ClientInfo.Streams = fmt.Sprintf("%d / %d", len(Data.Streams.Active), len(Data.Streams.All))
|
||||||
defaults.ClientInfo.UUID = Settings.UUID
|
defaults.ClientInfo.UUID = Settings.UUID
|
||||||
defaults.ClientInfo.Errors = WebScreenLog.Errors
|
defaults.Log = webScreenLogSnapshot()
|
||||||
defaults.ClientInfo.Warnings = WebScreenLog.Warnings
|
defaults.ClientInfo.Errors = defaults.Log.Errors
|
||||||
defaults.Notification = System.Notification
|
defaults.ClientInfo.Warnings = defaults.Log.Warnings
|
||||||
defaults.Log = WebScreenLog
|
defaults.Notification = notificationsSnapshot()
|
||||||
|
|
||||||
switch System.Branch {
|
switch System.Branch {
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
@@ -137,3 +138,33 @@ func TestSetCookieTokenFlags(t *testing.T) {
|
|||||||
t.Error("logout must clear the cookie (negative MaxAge)")
|
t.Error("logout must clear the cookie (negative MaxAge)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A browser keeps one socket open and sends commands one after another; the
|
||||||
|
// server must answer each on the same connection and echo the request id.
|
||||||
|
func TestWSServesMultipleCommandsPerConnection(t *testing.T) {
|
||||||
|
srv, token := newAuthenticatedWSServer(t)
|
||||||
|
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL(srv), http.Header{"Cookie": {"Token=" + token}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
id := strings.Repeat("x", i)
|
||||||
|
if err := conn.WriteJSON(map[string]any{"cmd": "noop", "id": id}); err != nil {
|
||||||
|
t.Fatalf("write %d: %v", i, err)
|
||||||
|
}
|
||||||
|
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
var response ResponseStruct
|
||||||
|
if err := conn.ReadJSON(&response); err != nil {
|
||||||
|
t.Fatalf("read %d: %v (server stopped answering on the same connection)", i, err)
|
||||||
|
}
|
||||||
|
if !response.Status {
|
||||||
|
t.Fatalf("command %d refused: %s", i, response.Error)
|
||||||
|
}
|
||||||
|
if response.ID != id {
|
||||||
|
t.Fatalf("command %d: id %q not echoed, got %q", i, id, response.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -172,6 +172,11 @@ func createXEPGMapping() {
|
|||||||
|
|
||||||
if len(Data.XMLTV.Files) > 0 {
|
if len(Data.XMLTV.Files) > 0 {
|
||||||
|
|
||||||
|
// Files that could not be read are collected here and removed after
|
||||||
|
// the loop; mutating Data.XMLTV.Files while iterating over it used
|
||||||
|
// to duplicate entries instead of removing them.
|
||||||
|
var failedFiles = make(map[string]bool)
|
||||||
|
|
||||||
for i := len(Data.XMLTV.Files) - 1; i >= 0; i-- {
|
for i := len(Data.XMLTV.Files) - 1; i >= 0; i-- {
|
||||||
|
|
||||||
var file = Data.XMLTV.Files[i]
|
var file = Data.XMLTV.Files[i]
|
||||||
@@ -185,7 +190,7 @@ func createXEPGMapping() {
|
|||||||
|
|
||||||
err = getLocalXMLTV(file, &xmltv)
|
err = getLocalXMLTV(file, &xmltv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Data.XMLTV.Files = append(Data.XMLTV.Files, Data.XMLTV.Files[i+1:]...)
|
failedFiles[file] = true
|
||||||
var errMsg = err.Error()
|
var errMsg = err.Error()
|
||||||
err = errors.New(getProviderParameter(fileID, "xmltv", "name") + ": " + errMsg)
|
err = errors.New(getProviderParameter(fileID, "xmltv", "name") + ": " + errMsg)
|
||||||
ShowError(err, 000)
|
ShowError(err, 000)
|
||||||
@@ -215,6 +220,7 @@ func createXEPGMapping() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Data.XMLTV.Files = removeStrings(Data.XMLTV.Files, failedFiles)
|
||||||
Data.XMLTV.Mapping = tmpMap
|
Data.XMLTV.Mapping = tmpMap
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Date: 2026-09-25
|
Date: 2026-09-25
|
||||||
Scope: whole repository (Go backend, TypeScript UI, Docker, CI, docs)
|
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
|
## 1. Where the fork stands
|
||||||
|
|
||||||
|
|||||||
+19
-19
@@ -1,7 +1,7 @@
|
|||||||
# xTeVe improvement checklist
|
# xTeVe improvement checklist
|
||||||
|
|
||||||
Detailed rationale, file references and effort estimates: `tasks/improvement-plan.md`.
|
Detailed rationale, file references and effort estimates: `tasks/improvement-plan.md`.
|
||||||
Status: Phase 0 committed on branch `improvements` 2026-09-26.
|
Status: 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
|
## Phase 0: Hygiene
|
||||||
- [x] `.gitignore` (`.gocache/`), deleted `.gocache/`, extended `.dockerignore`
|
- [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] `streamingURLsMu` around `Data.Cache.StreamingURLS`; persisted from a snapshot
|
||||||
- [x] No more `defer` inside the buffer read/redirect loops (compression done in Phase 1; imgcache in 2b)
|
- [x] No more `defer` inside the buffer read/redirect loops (compression done in Phase 1; imgcache in 2b)
|
||||||
- [x] `thirdPartyBuffer` rewritten: `exec.CommandContext` cancelled when the last client leaves or the 20 s startup watchdog fires; `buildFFmpegArgs`/`buildVLCArgs` tested; no panic, `cmd.Start` checked, one file handle per segment. Client loop uses `r.Context()` instead of `CloseNotifier`
|
- [x] `thirdPartyBuffer` rewritten: `exec.CommandContext` cancelled when the last client leaves or the 20 s startup watchdog fires; `buildFFmpegArgs`/`buildVLCArgs` tested; no panic, `cmd.Start` checked, one file handle per segment. Client loop uses `r.Context()` instead of `CloseNotifier`
|
||||||
- [ ] Real mutex for screen log
|
- [x] `logMu` guards `WebScreenLog` and notifications; accessors `logAppend`/`webScreenLogSnapshot`/`notificationsSnapshot`; ring buffer keeps the newest N (was dropping them)
|
||||||
- [ ] `http.Server` with timeouts; timeouts on all outbound clients
|
- [x] `http.Server` with `ReadHeaderTimeout` 15 s and `IdleTimeout` 120 s (no write timeout, streams are long-lived); `providerHTTPClient` 5 min, `apiHTTPClient` 30 s, imgcache client 30 s, stream client with dial/TLS/header timeouts
|
||||||
- [ ] imgcache: download outside the lock
|
- [x] imgcache downloads outside the lock; cache URL bug fixed (was a filesystem path); query-string URLs no longer produce unservable file names
|
||||||
- [ ] Atomic write helper (temp + fsync + rename)
|
- [x] `writeFileAtomic` (temp + fsync + rename) behind `writeByteToFile`, `saveMapToJSONFile`, `writePrivateFile` and the auth database
|
||||||
- [x] Buffer: headers set before `WriteHeader`, bogus `Content-Length:` header gone, segments flushed to the client as they arrive. Others in 2b
|
- [x] Buffer: headers set before `WriteHeader`, bogus `Content-Length:` header gone, segments flushed as they arrive. xepg file-removal bug, dead range mutation, API double write, WS struct reuse, unchecked assertions in data/backup/provider/screen, notification eviction by age: all fixed with tests
|
||||||
- [ ] Error hygiene: no-op closures, `os.Exit`/`panic` outside main, ignored results
|
- [x] Error hygiene: no `panic`/`os.Exit`/`log.Fatal` outside `main`; SIGINT/SIGTERM handled in main via `src.Shutdown()`; fatal start-up errors exit 1
|
||||||
|
|
||||||
## Phase 3: Build, embed, CI, Docker
|
## Phase 3: Build, embed, CI, Docker
|
||||||
- [x] `//go:embed` via `html/embed.go`; deleted `webUI.go`, `html-build.go`, `cmd/webui-gen`; ETag + Cache-Control on static assets
|
- [x] `//go:embed` via `html/embed.go`; deleted `webUI.go`, `html-build.go`, `cmd/webui-gen`; ETag + Cache-Control on static assets
|
||||||
- [x] i18n dropped: 254 placeholders inlined, `en.json` deleted, only HTML pages templated (`authenticationErr`)
|
- [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] `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
|
- [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] 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] `/healthz` endpoint; Dockerfile healthcheck uses it
|
||||||
- [x] `README-DEV.md` and fork-specific `README.md` (about, container usage, env vars, security notes)
|
- [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.
|
Not planned (lineup is ~170 channels, decided 2026-09-25). See plan for the reference list.
|
||||||
|
|
||||||
## Phase 5: Frontend architecture
|
## Phase 5: Frontend architecture
|
||||||
- [ ] Persistent websocket with request IDs, queue, backoff reconnect
|
- [x] Persistent websocket: one socket per page, queued requests with ids echoed by the server, 12 s per-request timeout, exponential-backoff reconnect, log-poll dedupe. Server keeps serving on one connection and closes it on exit (upstream leaked one open socket per request). Test: `TestWSServesMultipleCommandsPerConnection`
|
||||||
- [ ] Section-level re-render instead of full `createLayout()`
|
- [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
|
- [~] 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)
|
||||||
- [ ] Split `menu_ts.ts`; `addEventListener`; data-driven settings rows
|
- [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
|
||||||
- [ ] Accessibility follow-ups; light theme via `prefers-color-scheme`
|
- [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
|
||||||
- [ ] Consolidate CSS layers
|
- [x] CSS split into tokens / base / components / layout, every colour a token, `screen.css` removed
|
||||||
|
|
||||||
## Phase 6: Optional features
|
## Phase 6: Optional features
|
||||||
- [ ] Regex filters
|
- [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
|
||||||
- [ ] Per-playlist tuner limit and buffer choice
|
- [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
|
||||||
- [ ] Bulk channel edit
|
- [x] Already present in the fork (bulk edit with shift-range selection); nothing to add
|
||||||
- [ ] Versioned backup/restore
|
- [x] Already present: restore checks the backup's version against `System.Compatibility`; writes are now atomic (Phase 2)
|
||||||
- [ ] Dummy EPG durations
|
- [x] Already present: `xTeVe Dummy` offers several durations in the mapping dropdown
|
||||||
|
|
||||||
## Review
|
## 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 COLUMN_TO_SORT:number
|
||||||
var SEARCH_MAPPING = new Object()
|
var SEARCH_MAPPING = new Object()
|
||||||
var UNDO = new Object()
|
var UNDO = new Object()
|
||||||
var SERVER_CONNECTION = false
|
|
||||||
var WS_AVAILABLE = false
|
var WS_AVAILABLE = false
|
||||||
var ACTIVE_MENU_ID:string = ""
|
var ACTIVE_MENU_ID:string = ""
|
||||||
var LAST_BULK_CHECKBOX:HTMLInputElement = null
|
var LAST_BULK_CHECKBOX:HTMLInputElement = null
|
||||||
@@ -61,6 +60,35 @@ function showElement(elmID, type) {
|
|||||||
element.className = cssClass;
|
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 = "") {
|
function setConnectionState(state:string, text:string = "") {
|
||||||
|
|
||||||
var label:string = text
|
var label:string = text
|
||||||
@@ -93,9 +121,17 @@ function setConnectionState(state:string, text:string = "") {
|
|||||||
indicator.innerText = label
|
indicator.innerText = label
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeButtonAction(element, buttonID, attribute) {
|
// markChangedOnChange flags a control as edited on "change" so that
|
||||||
var value = element.options[element.selectedIndex].value;
|
// saveSettings / donePopupData pick it up, then runs an optional follow-up.
|
||||||
document.getElementById(buttonID).setAttribute(attribute, value)
|
// 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 {
|
function getLocalData(dataType, id):object {
|
||||||
@@ -502,7 +538,7 @@ function changeChannelNumber(element) {
|
|||||||
var channels = getObjKeys(data)
|
var channels = getObjKeys(data)
|
||||||
|
|
||||||
if (isNaN(newNumber)) {
|
if (isNaN(newNumber)) {
|
||||||
alert("Invalid channel number")
|
alertUser("Invalid channel number")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +621,7 @@ function toggleChannelStatus(id:string) {
|
|||||||
if (channel["x-xmltv-file"] == "-" || channel["x-mapping"] == "-") {
|
if (channel["x-xmltv-file"] == "-" || channel["x-mapping"] == "-") {
|
||||||
|
|
||||||
if (BULK_EDIT == false) {
|
if (BULK_EDIT == false) {
|
||||||
alert(channel["x-name"] + ": Missing XMLTV file / channel")
|
alertUser(channel["x-name"] + ": Missing XMLTV file / channel")
|
||||||
checkbox.checked = false
|
checkbox.checked = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +686,7 @@ function restore() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
alert("File could not be loaded")
|
alertUser("File could not be loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
restore.remove()
|
restore.remove()
|
||||||
@@ -708,7 +744,7 @@ function uploadLogo() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
alert("File could not be loaded")
|
alertUser("File could not be loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
upload.remove()
|
upload.remove()
|
||||||
@@ -761,7 +797,6 @@ function sortSelect(elem) {
|
|||||||
|
|
||||||
function updateLog() {
|
function updateLog() {
|
||||||
|
|
||||||
console.log("TOKEN")
|
|
||||||
var server:Server = new Server("updateLog")
|
var server:Server = new Server("updateLog")
|
||||||
server.request(new Object())
|
server.request(new Object())
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ function saveWizard() {
|
|||||||
|
|
||||||
if (value.length == 0) {
|
if (value.length == 0) {
|
||||||
var msg = name.toUpperCase() + ": " + "Missing input"
|
var msg = name.toUpperCase() + ": " + "Missing input"
|
||||||
alert(msg)
|
alertUser(msg)
|
||||||
return
|
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
|
"removeComments": false
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"network_ts.ts",
|
"network.ts",
|
||||||
"menu_ts.ts",
|
"menu.ts",
|
||||||
"settings_ts.ts",
|
"content.ts",
|
||||||
"logs_ts.ts",
|
"popup.ts",
|
||||||
"base_ts.ts",
|
"xmltv.ts",
|
||||||
"configuration_ts.ts",
|
"settings.ts",
|
||||||
"authentication_ts.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"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"xteve/src"
|
"xteve/src"
|
||||||
)
|
)
|
||||||
@@ -21,7 +23,7 @@ const Name = "xTeVe"
|
|||||||
|
|
||||||
// Version : Version, die Build Nummer wird in der main func geparst.
|
// Version : Version, die Build Nummer wird in der main func geparst.
|
||||||
// Can be overwritten at build time: -ldflags "-X main.Version=..."
|
// 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
|
// DBVersion : Datanbank Version
|
||||||
const DBVersion = "2.1.0"
|
const DBVersion = "2.1.0"
|
||||||
@@ -111,7 +113,7 @@ func main() {
|
|||||||
err := src.Init()
|
err := src.Init()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
src.ShowError(err, 0)
|
src.ShowError(err, 0)
|
||||||
os.Exit(0)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
src.ShowSystemInfo()
|
src.ShowSystemInfo()
|
||||||
@@ -150,12 +152,13 @@ func main() {
|
|||||||
err := src.Init()
|
err := src.Init()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
src.ShowError(err, 0)
|
src.ShowError(err, 0)
|
||||||
os.Exit(0)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = src.XteveRestoreFromCLI(*restore)
|
err = src.XteveRestoreFromCLI(*restore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
src.ShowError(err, 0)
|
src.ShowError(err, 0)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
@@ -164,25 +167,35 @@ func main() {
|
|||||||
err := src.Init()
|
err := src.Init()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
src.ShowError(err, 0)
|
src.ShowError(err, 0)
|
||||||
os.Exit(0)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = src.StartSystem(false)
|
err = src.StartSystem(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
src.ShowError(err, 0)
|
src.ShowError(err, 0)
|
||||||
os.Exit(0)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graceful stop on SIGINT / SIGTERM: say goodbye on the network (SSDP)
|
||||||
|
// and exit cleanly instead of letting the runtime kill the process.
|
||||||
|
go func() {
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
src.Shutdown()
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
err = src.InitMaintenance()
|
err = src.InitMaintenance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
src.ShowError(err, 0)
|
src.ShowError(err, 0)
|
||||||
os.Exit(0)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = src.StartWebserver()
|
err = src.StartWebserver()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
src.ShowError(err, 0)
|
src.ShowError(err, 0)
|
||||||
os.Exit(0)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user