10 Commits
Author SHA1 Message Date
nathan d385e68b31 Release 2.3.0.0202: version-tagged images, changelog
continuous-integration/drone/push Build is passing
- Drone: an image-tags step writes .tags (latest, commit SHA, changelog
  version) for the master publish, so each release can be pulled and
  rolled back by version. The master publish step no longer hardcodes tags.
- Version 2.2.0.0201 -> 2.3.0.0202 in xteve.go and changelog-beta.md, with
  a changelog entry covering the improvements branch.
- README / README-DEV describe the version tag.
2026-10-02 10:54:29 +10:00
nathan e1a6956657 Pin static-ffmpeg to 9.0.2; drop duplicate stream error log
continuous-integration/drone/push Build is passing
- ffmpeg 7.1.1's HLS demuxer rejects segment extensions such as .ec3
  ("not in allowed_extensions"), so streams with E-AC-3 audio died a few
  seconds after starting. The image used to track :latest; pinning to
  7.1.1 regressed it. 9.0.2 includes ec3 in the default allow-list.
- The client loop logged the downloader's error a second time (shown as
  "FFMPEG error () - EC: 0") and bumped the error counter twice.
2026-10-02 10:16:14 +10:00
nathan 6a23996c38 Fix log view showing   entities
continuous-integration/drone/push Build is passing
The server padded log lines by replacing every space with  , which
only worked while the UI inserted log lines as HTML. Since the log is now
rendered as text (inside <pre>, so whitespace is preserved anyway), the
entities showed up literally. Store plain spaces instead.

Test: TestLogLinesContainNoHTMLEntities.
2026-10-02 09:54:34 +10:00
nathan 0b26aa2029 Plan status: implemented on branch improvements
continuous-integration/drone/push Build is passing
2026-09-26 13:39:45 +10:00
nathan 014a3b04d2 Phase 5b and Phase 6: TypeScript restructure, jsdom tests, regex filters and per-playlist buffer in the UI
continuous-integration/drone/push Build is passing
TypeScript:
- menu_ts.ts split into menu.ts, content.ts, popup.ts and xmltv.ts; the
  other files drop their _ts suffix. Bundle order fixed in ts/tsconfig.json.
- All 94 string event handlers (setAttribute("onclick", "javascript: ..."))
  replaced with addEventListener closures. changeButtonAction, which
  rewrote an onclick attribute from select values, is gone.
- Settings rows are generated from one SETTINGS_FIELDS table instead of
  ~20 copy-pasted blocks; rendered HTML is byte-identical to before.
  saveSettings now serialises password inputs, so a changed Plex token is
  actually sent (the server treats the mask as unchanged).
- createLayout rebuilds the menu list only when the set of visible items
  changes, so focus in the menu survives a refresh.
- announce()/alertUser(): alerts are mirrored into an aria-live region.
- Filter popup offers a third type, Regular Expression (regex-filter), and
  the filter table labels it. Playlist popups get a Buffer select
  (default / none / xTeVe / FFmpeg / VLC) saved as the playlist's
  "buffer" parameter; the tuner field stays editable when the
  playlist's own buffer is active.

Tests: tests/ holds 27 jsdom tests (Node's built-in runner, jsdom pinned)
that load the built bundle with a fixture server payload: menu visibility
rules, mapping table renders names as text, settings panel fields, popup
flows, sorting, bulk select, layout refresh keeps nodes, live region,
regex option, buffer select. npm test runs in the Drone webui-check step.
README-DEV documents the layout and the test workflow.
2026-09-26 13:39:35 +10:00
nathan 3532679235 Phase 5c: CSS layers and light theme
continuous-integration/drone/push Build is failing
- html/css split into tokens.css (custom properties), base.css (reset,
  typography, focus, skip link), components.css (buttons, inputs, tables,
  status cards, popups, menu, log lines) and layout.css (shell, sidebar,
  header, breakpoints); screen.css removed. Selectors and declarations
  moved as-is; three pairs of conflicting rules resolved in favour of the
  one that already won the cascade, two empty rules dropped.
- Every literal colour outside tokens.css is now a token (category badge
  borders excepted). color-scheme set so native controls follow.
- Light theme under prefers-color-scheme: light, with explicit
  data-theme="light"/"dark" overrides on :root for a future toggle.
  Body text 13.6:1, muted text 5.4:1, accent 5.1:1 on the light background.
- HTML: lang="en" everywhere, one h1 per page, maintenance page uses
  <main>, an id typo fixed.
2026-09-26 13:34:51 +10:00
nathan 0b7a54d7da Fix Drone YAML, add regex filters and per-playlist buffer mode (backend)
continuous-integration/drone/push Build is passing
- .drone.yml: two command strings contained ": " and were parsed as maps
  by Drone's YAML loader ("cannot unmarshal !!map into string"). Quoted.
- Filters: new type "regex-filter". The rule is a Go regular expression
  tried against the channel name, the group title and the raw attribute
  line; case-insensitive unless the filter is marked case sensitive.
  Patterns are validated when the filter is saved and when rules are
  rebuilt, and compiled once (cached). The two fixed {include}/!{exclude}
  patterns are compiled at package level instead of per stream.
- Per-playlist buffer: a playlist's "buffer" parameter ("-", "xteve",
  "ffmpeg", "vlc") overrides the global setting for that playlist in
  the stream handler, the buffer start, the tuner lookup and the external
  process buffer. Anything else falls back to the global setting.
  (UI controls for both follow with the Phase 5 frontend work.)
- Tests: TestRegexFilter, TestBufferModeFor.
2026-09-26 13:24:11 +10:00
nathan 3a3ba861dc Phase 5a: persistent websocket with request ids and reconnect
continuous-integration/drone/push Build encountered an error
Client (ts/network_ts.ts): one WebSocket per page. Commands queue and go
out one at a time with a client-chosen id; the response is matched on the
echoed id (or to the in-flight request for older servers). 12 s timeout
per request, exponential-backoff reconnect (0.5 s to 10 s), the in-flight
request is retried after a reconnect, and log polls are de-duplicated so
they cannot pile up behind a stalled connection. The old global flag that
silently dropped any request made while another was in flight is gone.

Server (src/webserver.go): the /data/ handler now serves any number of
commands on one connection (it used to break out of its loop after the
first reply without closing the socket, leaving it open and deaf; the old
client papered over that by opening a new socket per request). Connection
closed on exit, request id echoed in the response.

Test: TestWSServesMultipleCommandsPerConnection.
2026-09-26 13:19:38 +10:00
nathan cbc5bc5da2 Merge branch 'worktree-agent-a93010566d356e58c' into improvements 2026-09-26 13:18:03 +10:00
nathan 3bbea8e952 Phase 2b: server/client timeouts, atomic state writes, log mutex, panic and shutdown fixes
- http.Server with ReadHeaderTimeout/IdleTimeout (no read/write timeouts: /stream/ is long-lived)
- shared outbound clients: providerHTTPClient (5m), apiHTTPClient (30s), imgcache client (30s)
- imgcache: download outside the lock, per-item helper, cache URL uses the file name not the fs path
- writeFileAtomic (temp + fsync + rename) for settings/xepg/pms/urls/authentication JSON
- one package-level logMu for WebScreenLog and notifications; ring buffer keeps the newest lines
- notifications evict the oldest instead of random map entries
- xepg XMLTV file removal rebuilt after the loop; data.go range-mutation removed
- API handler returns after error body; WS request/response fresh per command
- checked type assertions in data/backup/provider/screen
- SIGINT/SIGTERM handled in main via src.Shutdown(); fatal paths exit 1
2026-09-26 13:17:07 +10:00
61 changed files with 8266 additions and 5910 deletions

No files matched your search

+19 -5
View File
@@ -8,7 +8,7 @@ steps:
commands:
- go mod download
- go vet ./...
- test -z "$(gofmt -l .)" || (echo "gofmt needed:" && gofmt -l . && exit 1)
- 'test -z "$(gofmt -l .)" || (echo "gofmt needed" && gofmt -l . && exit 1)'
- go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...
- go test ./...
@@ -18,6 +18,7 @@ steps:
- cp html/js/app.js /tmp/app.js.committed
- npm ci --no-audit --no-fund
- npm run build
- npm test
- cmp /tmp/app.js.committed html/js/app.js || (echo "html/js/app.js is stale - run 'npm run build' and commit the result" && exit 1)
- name: go-build
@@ -26,7 +27,7 @@ steps:
- XTEVE_VERSION="$(grep -m1 '^#### ' changelog-beta.md | cut -d' ' -f2 | sed 's/-beta$//')"
- test -n "$XTEVE_VERSION" || (echo "Could not parse version from changelog-beta.md" && exit 1)
- SRC_VERSION="$(grep -m1 '^var Version = ' xteve.go | cut -d'"' -f2)"
- test "$SRC_VERSION" = "$XTEVE_VERSION" || (echo "Version drift: xteve.go has $SRC_VERSION, changelog-beta.md has $XTEVE_VERSION" && exit 1)
- 'test "$SRC_VERSION" = "$XTEVE_VERSION" || (echo "Version drift - xteve.go has $SRC_VERSION, changelog-beta.md has $XTEVE_VERSION" && exit 1)'
- echo "Building xTeVe version $XTEVE_VERSION from changelog-beta.md"
- go build -v -ldflags "-X main.Version=$XTEVE_VERSION" ./...
@@ -50,6 +51,22 @@ steps:
event:
- pull_request
# plugins/docker reads its tag list from a .tags file when no "tags"
# setting is given. Master images get latest, the commit SHA and the
# release version from changelog-beta.md, so a release can be pulled by name.
- name: image-tags
image: cache.coadcorp.com/library/golang:1.27.1
commands:
- XTEVE_VERSION="$(grep -m1 '^#### ' changelog-beta.md | cut -d' ' -f2 | sed 's/-beta$//')"
- test -n "$XTEVE_VERSION"
- printf '%s' "latest,${DRONE_COMMIT_SHA},$XTEVE_VERSION" > .tags
- cat .tags
when:
event:
- push
branch:
- master
- name: docker-publish
image: plugins/docker
environment:
@@ -62,9 +79,6 @@ steps:
username: nathan
password:
from_secret: registry_password
tags:
- latest
- ${DRONE_COMMIT_SHA}
build_args:
- TARGETOS=linux
- TARGETARCH=amd64
+3 -1
View File
@@ -19,7 +19,9 @@ RUN XTEVE_VERSION="$(grep -m1 '^#### ' changelog-beta.md | cut -d' ' -f2 | sed '
# Static ffmpeg/ffprobe binaries. To bump, pick a tag from
# https://hub.docker.com/r/mwader/static-ffmpeg/tags and change it here.
FROM mwader/static-ffmpeg:7.1.1 AS ffmpeg
# Do not go below 8.x: 7.1.1 rejects HLS segments with extensions such as .ec3
# ("not in allowed_extensions"), which breaks streams with E-AC-3 audio.
FROM mwader/static-ffmpeg:9.0.2 AS ffmpeg
FROM alpine:3.23
+6 -3
View File
@@ -29,12 +29,15 @@ The web interface is embedded with `go:embed` from `html/`, so a bare clone buil
The UI is plain TypeScript compiled as global scripts (no modules yet) into a single bundle, `html/js/app.js`, which is committed. CI rebuilds it and fails if the committed file is stale.
```bash
npm ci # installs the pinned TypeScript compiler
npm ci # installs the pinned TypeScript compiler and jsdom
npm run build # ts/*.ts -> html/js/app.js
npm run check # type-check only
npm test # jsdom tests under tests/ (Node's built-in runner)
```
File order in the bundle is fixed in `ts/tsconfig.json` and matches the old per-page script order. All pages load the same bundle.
File order in the bundle is fixed in `ts/tsconfig.json`: `network`, `menu`, `content`, `popup`, `xmltv`, `settings`, `logs`, `base`, `configuration`, `authentication`. Classes must be defined before the top-level statements in later files that instantiate them. All pages load the same bundle.
The tests load the built bundle into a jsdom window (`tests/harness.js`) with a fixture server payload (`tests/fixture.js`), so they exercise the real rendering code. Run `npm run build` before `npm test`.
English strings are inlined in the TypeScript. There is no language layer.
@@ -50,7 +53,7 @@ Add `-dev` to serve the web interface from the local `html/` directory instead o
The version lives in two places that must agree: `var Version` in `xteve.go` and the first `#### ` heading in `changelog-beta.md` (without the `-beta` suffix). CI fails on drift.
Drone (`.drone.yml`) runs vet, tests, the bundle check, hadolint and compose validation on every push. Pushes to `master` publish `registry.coadcorp.com/nathan/xteve:latest` and `:<sha>`; other branches publish `:<branch>` and `:<sha>`.
Drone (`.drone.yml`) runs vet, tests, the bundle check, hadolint and compose validation on every push. Pushes to `master` publish `registry.coadcorp.com/nathan/xteve:latest`, `:<version>` and `:<sha>`; other branches publish `:<branch>` and `:<sha>`.
## Endpoints useful for operations
+1 -1
View File
@@ -74,7 +74,7 @@ registry.coadcorp.com/nathan/xteve
Tags:
* `latest` and `<commit sha>` from the `master` branch.
* `latest`, the release version (for example `2.3.0.0202`) and `<commit sha>` from the `master` branch. Pin the version tag if you want to control when you upgrade, or to roll back.
* `<branch name>` and `<commit sha>` from every other branch.
```bash
+17
View File
@@ -1,3 +1,20 @@
#### 2.3.0.0202-beta
```diff
+ Security: provider-supplied text is rendered as text in the web UI; websocket requests must come from the same origin; the session cookie is HttpOnly and SameSite=Strict; logo uploads, backup restores, the ffmpeg/VLC path and stream URLs are validated; backups can only be downloaded with a web session.
+ Passwords are stored with bcrypt. Existing accounts are upgraded on their next login.
+ Streaming: buffer state rewritten around a single lock with an atomic tuner limit; ffmpeg/VLC are stopped when the last client leaves or when no data arrives within 20 seconds; long streams no longer accumulate open handles.
+ Per-playlist buffer choice (default, none, xTeVe, FFmpeg, VLC) in the playlist dialog.
+ New filter type: regular expression.
+ Web UI: one persistent websocket with automatic reconnect, light theme following the OS setting, focus is kept when the menu refreshes, errors are announced to screen readers.
+ The web interface is embedded with go:embed and built from a pinned TypeScript toolchain; the language file is gone (English only).
+ New /healthz endpoint, used by the container healthcheck.
+ Container: PUID/PGID are applied at start-up; ffmpeg 9.0.2; images are also tagged with the release version.
+ Settings and other state files are written atomically; settings.json is readable by the owner only.
+ HTTP server and provider downloads have timeouts.
- Removed the built-in self-updater. Updates are delivered through the container image.
- Removed XTEVE_UID/XTEVE_GID as the documented variables (still accepted as a fallback for PUID/PGID).
```
#### 2.2.0.0201-beta
Add UI enhancement for range selecting checkboxes
+6 -4
View File
@@ -1,11 +1,13 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>xTeVe</title>
<link rel="stylesheet" href="css/screen.css" type="text/css">
<link rel="stylesheet" href="css/base.css" type="text/css">
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/layout.css">
<script src="js/app.js"></script>
</head>
@@ -16,7 +18,7 @@
</div>
<div id="header" class="imgCenter"></div>
<main id="box" role="main" aria-labelledby="head-text">
<main id="box" aria-labelledby="head-text">
<table id="clientInfo" class="visible" aria-label="Server information">
<tr>
+6 -4
View File
@@ -1,11 +1,13 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>xTeVe</title>
<link rel="stylesheet" href="css/screen.css" type="text/css">
<link rel="stylesheet" href="css/base.css" type="text/css">
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/layout.css">
<script src="js/app.js"></script>
</head>
@@ -13,7 +15,7 @@
<div id="header" class="imgCenter"></div>
<main id="box" role="main" aria-labelledby="head-text">
<main id="box" aria-labelledby="head-text">
<div id="headline">
<h1 id="head-text" class="center">Create user account</h1>
+46 -492
View File
@@ -1,32 +1,14 @@
:root {
--bg-0: #07111d;
--bg-1: #0c1a2b;
--bg-2: #12233a;
--panel: #102238;
--panel-soft: #0f1f33;
--line: #274462;
--line-soft: #1b334d;
--text: #e9f5ff;
--text-muted: #b7cee1;
--accent: #35d2ff;
--accent-strong: #12b9ff;
--accent-soft: rgba(53, 210, 255, 0.2);
--ok: #2fd18a;
--warn: #ffc15a;
--error: #ff6f6f;
--radius-s: 10px;
--radius-m: 14px;
--radius-l: 18px;
--shadow-1: 0 20px 45px rgba(0, 0, 0, 0.32);
--shadow-2: 0 12px 30px rgba(0, 0, 0, 0.28);
}
/*
* base.css - reset, typography, links, focus styles, skip link, generic
* form elements and small utility classes. Loaded after tokens.css.
*/
* {
-webkit-appearance: none;
-moz-appearance: none;
-ms-appearance: none;
box-sizing: border-box;
font-family: "Space Grotesk", "Avenir Next", "Trebuchet MS", sans-serif;
font-family: var(--font-sans);
letter-spacing: 0.02em;
}
@@ -37,17 +19,17 @@
::-webkit-scrollbar-track {
border-radius: 999px;
background: rgba(7, 17, 29, 0.65);
background: var(--scroll-track);
}
::-webkit-scrollbar-thumb {
border-radius: 999px;
border: 2px solid rgba(7, 17, 29, 0.6);
background: linear-gradient(180deg, #2d597f, #1e4060);
border: 2px solid var(--scroll-track);
background: linear-gradient(180deg, var(--scroll-thumb-a), var(--scroll-thumb-b));
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #3678ac, #20537c);
background: linear-gradient(180deg, var(--scroll-thumb-hover-a), var(--scroll-thumb-hover-b));
}
::-webkit-scrollbar-corner {
@@ -61,9 +43,9 @@ body {
color: var(--text);
font-size: 14px;
background:
radial-gradient(circle at 15% 12%, rgba(64, 132, 183, 0.22), transparent 32%),
radial-gradient(circle at 88% 2%, rgba(18, 185, 255, 0.16), transparent 30%),
linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 56%, #060d17 100%);
radial-gradient(circle at 15% 12%, var(--bg-glow-1), transparent 32%),
radial-gradient(circle at 88% 2%, var(--bg-glow-2), transparent 30%),
linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 56%, var(--bg-deep) 100%);
}
body {
@@ -75,7 +57,7 @@ a {
}
a:hover {
color: #8de7ff;
color: var(--accent-hover);
}
h1,
@@ -122,7 +104,7 @@ pre {
white-space: pre-wrap;
line-height: 1.55;
letter-spacing: 0.015em;
font-family: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace;
font-family: var(--font-mono);
}
hr {
@@ -153,7 +135,7 @@ button {
}
:focus-visible {
outline: 2px solid #91eaff;
outline: 2px solid var(--focus-ring-soft);
outline-offset: 2px;
}
@@ -171,7 +153,7 @@ textarea {
width: 100%;
border-radius: var(--radius-s);
border: 1px solid var(--line);
background-color: rgba(10, 22, 36, 0.78);
background-color: var(--surface-input);
color: var(--text);
padding: 10px 11px;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
@@ -184,45 +166,15 @@ input[type=number]:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(53, 210, 255, 0.17);
background-color: rgba(9, 25, 40, 0.95);
box-shadow: 0 0 0 3px var(--accent-ring);
background-color: var(--surface-input-focus);
}
input[type=text]::placeholder,
input[type=search]::placeholder,
input[type=password]::placeholder,
textarea::placeholder {
color: #7e9ab4;
}
input[type=button],
input[type=submit],
button {
cursor: pointer;
border: 1px solid transparent;
border-radius: 999px;
color: #03101a;
padding: 10px 18px;
margin: 8px 8px 8px 0;
font-weight: 700;
letter-spacing: 0.03em;
background: linear-gradient(135deg, #67e8ff 0%, #12b9ff 100%);
box-shadow: 0 10px 22px rgba(18, 185, 255, 0.3);
transition: transform 0.15s ease, box-shadow 0.2s ease, filter 0.2s ease;
}
input[type=button]:hover,
input[type=submit]:hover,
button:hover {
transform: translateY(-1px);
filter: brightness(1.05);
box-shadow: 0 12px 24px rgba(18, 185, 255, 0.36);
}
input[type=button]:active,
input[type=submit]:active,
button:active {
transform: translateY(0);
color: var(--text-placeholder);
}
input[type=button]:focus-visible,
@@ -236,80 +188,43 @@ input[type=text]:focus-visible,
input[type=search]:focus-visible,
input[type=password]:focus-visible,
input[type=number]:focus-visible {
outline: 2px solid #7de5ff;
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
input[type=button].delete {
color: #fff;
background: linear-gradient(135deg, #ff7f7f 0%, #e94343 100%);
box-shadow: 0 10px 20px rgba(233, 67, 67, 0.35);
}
input[type=button].cancel {
color: #ffd8d8;
border-color: rgba(255, 111, 111, 0.45);
background: rgba(87, 22, 22, 0.45);
box-shadow: none;
}
input[type=button].black,
input[type=submit].black {
color: #d8ecff;
border-color: var(--line);
background: rgba(14, 32, 50, 0.85);
box-shadow: none;
}
input[type=button].center {
margin-right: auto;
margin-left: auto;
}
input[type=button].save {
margin-left: auto;
}
input[type=checkbox] {
width: 20px;
height: 20px;
border: 1px solid var(--line);
border-radius: 6px;
background-color: rgba(10, 22, 36, 0.86);
cursor: pointer;
position: relative;
}
input[type=checkbox]:checked {
border-color: #89ebff;
background-color: #39d6ff;
}
input[type=checkbox]:before {
content: "";
}
input[type=checkbox]:checked:before {
content: "\2713";
color: #032033;
font-size: 14px;
font-weight: 800;
.skip-link {
position: absolute;
top: -1px;
left: 3px;
top: 10px;
left: 10px;
z-index: 2600;
border-radius: 999px;
padding: 8px 12px;
font-size: 12px;
font-weight: 700;
color: var(--text-on-accent);
background: var(--accent-gradient);
transform: translateY(-180%);
transition: transform 0.2s ease;
}
.changed {
border-color: #8de7ff !important;
box-shadow: 0 0 0 3px rgba(53, 210, 255, 0.2) !important;
.skip-link:focus-visible {
transform: translateY(0);
}
.notAvailable {
opacity: 0.58;
cursor: not-allowed;
border-color: rgba(255, 111, 111, 0.45) !important;
.sr-only {
border: 0 !important;
clip: rect(0 0 0 0) !important;
height: 1px !important;
margin: -1px !important;
overflow: hidden !important;
padding: 0 !important;
position: absolute !important;
white-space: nowrap !important;
width: 1px !important;
}
/* Utility classes */
.pointer {
cursor: pointer;
}
@@ -364,37 +279,6 @@ input[type=checkbox]:checked:before {
text-align: center;
}
.skip-link {
position: absolute;
top: 10px;
left: 10px;
z-index: 2600;
border-radius: 999px;
padding: 8px 12px;
font-size: 12px;
font-weight: 700;
color: #03131f;
background: linear-gradient(135deg, #7be8ff 0%, #19c1ff 100%);
transform: translateY(-180%);
transition: transform 0.2s ease;
}
.skip-link:focus-visible {
transform: translateY(0);
}
.sr-only {
border: 0 !important;
clip: rect(0 0 0 0) !important;
height: 1px !important;
margin: -1px !important;
overflow: hidden !important;
padding: 0 !important;
position: absolute !important;
white-space: nowrap !important;
width: 1px !important;
}
.floatRight {
float: right;
}
@@ -423,9 +307,6 @@ input[type=checkbox]:checked:before {
display: none;
}
.noBulk {
}
.notVisible {
display: none;
opacity: 0;
@@ -437,259 +318,10 @@ input[type=checkbox]:checked:before {
opacity: 1;
}
.menu-active {
color: #011019;
}
.menu-notActive {
}
.menu {
border: 1px solid var(--accent);
}
.half {
width: 45%;
}
.screenLogHidden {
transform: translate(0, -110px);
}
.infoMsg {
color: #87b0d1;
}
.errorMsg {
color: #ff8d8d;
}
.warningMsg {
color: var(--warn);
}
.debugMsg {
color: #d687ff;
}
.News,
.Movie,
.Series,
.Sports,
.Kids {
border-left: 3px solid transparent;
padding-left: 8px;
}
.News {
border-color: #ff8f6e;
}
.Movie {
border-color: #46a4ff;
}
.Series {
border-color: #ffd267;
}
.Sports {
border-color: #5ee495;
}
.Kids {
border-color: #f6a8ff;
}
#loading {
position: fixed;
inset: 0;
z-index: 2000;
background-color: rgba(5, 12, 20, 0.72);
backdrop-filter: blur(1.5px);
}
.loader {
width: 52px;
height: 52px;
border-radius: 50%;
border: 4px solid transparent;
border-top-color: #58ddff;
border-right-color: #12b9ff;
animation: spin 0.9s linear infinite;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
}
#popup {
position: fixed;
inset: 0;
background-color: rgba(3, 9, 16, 0.72);
backdrop-filter: blur(2px);
z-index: 1500;
padding: 16px;
}
#popup-custom,
#mapping-detail,
#user-detail,
#file-detail {
margin: 0 auto;
max-width: 760px;
max-height: calc(100vh - 36px);
overflow: auto;
border-radius: var(--radius-l);
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(17, 34, 56, 0.97) 0%, rgba(12, 27, 44, 0.97) 100%);
box-shadow: var(--shadow-1);
padding: 16px;
animation: popupIn 0.2s ease;
}
#popup-custom h3 {
margin-bottom: 8px;
text-align: center;
color: #d5efff;
}
.popup-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.popup-title h3 {
margin: 0;
text-align: left;
}
#popup-custom .popup-title h3 {
margin: 0;
text-align: left;
}
.popup-close {
min-width: 40px;
min-height: 40px;
margin: 0;
padding: 0;
border-radius: 999px;
border: 1px solid var(--line);
color: #d9f0ff;
background: rgba(14, 32, 50, 0.9);
box-shadow: none;
font-size: 20px;
line-height: 1;
}
.popup-close:hover {
color: #ffffff;
border-color: #5bc8ed;
}
#popup-custom table,
#content_settings table,
#mapping-detail-table,
#user-detail-table {
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0 8px;
}
#popup-custom td,
#content_settings td,
#mapping-detail-table td,
#user-detail-table td {
padding: 2px 6px;
vertical-align: middle;
}
#popup-custom td.left,
#mapping-detail-table td.left,
#user-detail-table td.left {
width: 38%;
color: var(--text-muted);
}
#popup-custom input[type=text],
#popup-custom input[type=password],
#mapping-detail input[type=text],
#content_settings input[type=text],
#content_settings input[type=password] {
width: 100%;
}
#mapping-detail img {
display: block;
max-height: 44px;
margin: 8px auto 12px;
}
#file-detail input[type=text] {
width: 100%;
}
.interaction,
#interaction {
margin-top: 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
float: right;
}
.interaction input[type=button],
.interaction input[type=submit] {
margin: 0;
min-width: 110px;
}
#popup-interaction {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
#notification {
position: fixed;
right: 12px;
top: 12px;
width: 260px;
max-height: calc(100vh - 24px);
overflow: auto;
border-radius: var(--radius-m);
border: 1px solid var(--line);
background: rgba(10, 22, 36, 0.92);
box-shadow: var(--shadow-2);
}
#notification .element {
margin: 8px;
border-radius: 10px;
border-left: 4px solid var(--ok);
background: rgba(17, 35, 56, 0.84);
padding: 8px;
}
#notification h5 {
padding: 0;
margin-bottom: 6px;
}
#notification p {
font-size: 11px;
}
.tableEllipsis {
width: 150px;
overflow: hidden;
@@ -718,81 +350,3 @@ input[type=checkbox]:checked:before {
transform: translateY(0) scale(1);
}
}
@media only screen and (max-width: 619px) {
h1 {
font-size: 1.46rem;
}
#popup {
padding: 0;
}
#popup-custom,
#mapping-detail,
#user-detail,
#file-detail {
max-width: none;
width: 100%;
height: 100%;
max-height: 100%;
border-radius: 0;
border: 0;
padding: 10px 10px calc(14px + env(safe-area-inset-bottom));
}
#popup-custom table {
border-spacing: 0 10px;
}
#popup-custom tr {
display: block;
border: 1px solid var(--line-soft);
border-radius: 10px;
padding: 8px 8px 10px;
background: rgba(12, 26, 42, 0.8);
}
#popup-custom td {
display: block;
width: 100%;
padding: 4px 2px;
}
#popup-custom td.left {
width: 100%;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
#popup-interaction {
position: sticky;
bottom: 0;
z-index: 5;
margin-top: 10px;
padding-top: 10px;
background: linear-gradient(180deg, rgba(12, 27, 44, 0.1) 0%, rgba(12, 27, 44, 0.96) 30%);
}
.interaction,
#interaction {
justify-content: stretch;
}
.interaction input[type=button],
.interaction input[type=submit],
#popup-interaction input[type=button] {
width: 100%;
min-height: 44px;
}
.popup-title h3 {
font-size: 1.02rem;
}
.popup-close {
min-width: 44px;
min-height: 44px;
}
}
+775
View File
@@ -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;
}
+109 -418
View File
@@ -1,3 +1,10 @@
/*
* layout.css - shell layout (sidebar, header, content wrapper), the auth /
* wizard box pages and every responsive breakpoint. Loaded last.
*/
/* Application shell */
#layout {
display: grid;
grid-template-columns: 290px minmax(0, 1fr);
@@ -15,8 +22,8 @@
border-radius: var(--radius-l);
border: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(18, 36, 58, 0.95) 0%, rgba(10, 23, 38, 0.95) 100%),
radial-gradient(circle at 20% 0%, rgba(53, 210, 255, 0.12), transparent 48%);
linear-gradient(180deg, var(--panel-top) 0%, var(--panel-bottom) 100%),
radial-gradient(circle at 20% 0%, var(--panel-glow), transparent 48%);
box-shadow: var(--shadow-2);
overflow: hidden;
}
@@ -28,100 +35,25 @@
padding: 14px;
}
#branch {
min-height: 18px;
margin: 2px 0 6px;
text-align: center;
font-size: 11px;
color: #ff9f8d;
letter-spacing: 0.08em;
text-transform: uppercase;
}
#logo {
width: 100%;
height: 76px;
border-radius: 12px;
border: 1px solid var(--line-soft);
background:
linear-gradient(180deg, rgba(26, 49, 73, 0.55), rgba(13, 28, 45, 0.6)),
linear-gradient(180deg, var(--logo-tint-top), var(--logo-tint-bottom)),
url("../img/logo_w_600x200.png") center no-repeat;
background-size: contain;
margin-bottom: 12px;
}
#main-menu {
display: flex;
flex-direction: column;
gap: 7px;
overflow: auto;
padding-right: 4px;
}
#main-menu li {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
padding: 8px 11px;
border-radius: 12px;
border: 1px solid transparent;
background: rgba(11, 25, 40, 0.58);
}
#main-menu li:hover {
border-color: var(--accent-soft);
transform: translateX(2px);
}
#main-menu li:focus-visible {
border-color: rgba(103, 232, 255, 0.62);
transform: translateX(2px);
box-shadow: 0 0 0 3px rgba(53, 210, 255, 0.23);
}
#main-menu li.menu-active {
border-color: rgba(103, 232, 255, 0.55);
background: linear-gradient(135deg, #6fe6ff 0%, #1cc5ff 100%);
box-shadow: 0 10px 18px rgba(28, 197, 255, 0.3);
}
#main-menu li.menu-active p {
color: #03111a;
}
#main-menu li.menu-active img {
filter: brightness(0.04) saturate(120%);
}
nav img {
display: block;
width: 18px;
height: 18px;
max-width: 18px;
max-height: 18px;
flex: 0 0 auto;
}
nav p {
margin: 0;
padding: 0;
text-align: left;
font-size: 12px;
font-weight: 600;
color: #d7eefe;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layout-right {
min-width: 0;
border-radius: var(--radius-l);
border: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(17, 34, 54, 0.94) 0%, rgba(10, 20, 34, 0.96) 100%),
radial-gradient(circle at 100% 0%, rgba(53, 210, 255, 0.08), transparent 34%);
linear-gradient(180deg, var(--panel-top) 0%, var(--panel-bottom) 100%),
radial-gradient(circle at 100% 0%, var(--panel-glow), transparent 34%);
box-shadow: var(--shadow-2);
display: flex;
flex-direction: column;
@@ -134,14 +66,14 @@ nav p {
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--line-soft);
background: rgba(10, 22, 36, 0.52);
background: var(--surface-0);
}
#shell-title {
margin: 0;
font-size: 1.08rem;
font-weight: 700;
color: #d8f1ff;
color: var(--text-heading);
letter-spacing: 0.04em;
}
@@ -153,176 +85,7 @@ nav p {
font-size: 12px;
}
#connection-indicator {
margin-left: auto;
padding: 5px 11px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
#connection-indicator.status-idle {
color: #d8edff;
border: 1px solid #3a5877;
background: rgba(20, 42, 66, 0.65);
}
#connection-indicator.status-busy {
color: #072031;
border: 1px solid #7ee8ff;
background: linear-gradient(135deg, #7be8ff 0%, #33cdff 100%);
}
#connection-indicator.status-online {
color: #052219;
border: 1px solid #9ff0c8;
background: linear-gradient(135deg, #92f0c0 0%, #4fce93 100%);
}
#connection-indicator.status-offline {
color: #ffe5e5;
border: 1px solid #ff9e9e;
background: linear-gradient(135deg, #ff7e7e 0%, #d34d4d 100%);
}
#clientInfo,
#activeStreams,
#inactiveStreams {
width: 100%;
border-collapse: separate;
border-spacing: 7px;
background: rgba(8, 20, 33, 0.54);
}
#clientInfo {
padding: 7px 10px 6px;
border-bottom: 1px solid var(--line-soft);
}
#clientInfo tr {
vertical-align: top;
}
#clientInfo .tdKey,
#clientInfo .tdVal,
#activeStreams .tdKey,
#inactiveStreams .tdKey,
#activeStreams .tdVal,
#inactiveStreams .tdVal,
#logInfo .tdVal,
#mappingInfo .tdVal {
border: 1px solid var(--line-soft);
border-radius: 8px;
padding: 7px 8px;
vertical-align: middle;
font-family: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace;
}
#clientInfo .tdKey,
#activeStreams .tdKey,
#inactiveStreams .tdKey {
color: #53d9ff;
font-size: 10px;
text-transform: uppercase;
}
#clientInfo .tdVal,
#activeStreams .tdVal,
#inactiveStreams .tdVal {
color: var(--text-muted);
font-size: 11px;
word-break: break-word;
}
#clientInfo .tdVal a {
color: #8fdcff;
text-decoration: none;
border-bottom: 1px dotted rgba(143, 220, 255, 0.6);
word-break: break-all;
}
#clientInfo .tdVal a:hover,
#clientInfo .tdVal a:focus {
color: #d7f4ff;
border-bottom-color: #d7f4ff;
text-decoration: none;
}
.dashboard-cards {
display: grid;
grid-template-columns: repeat(6, minmax(130px, 1fr));
gap: 9px;
padding: 10px 14px;
border-bottom: 1px solid var(--line-soft);
background: rgba(8, 18, 30, 0.45);
}
.status-card {
border: 1px solid var(--line-soft);
border-radius: 12px;
background: rgba(13, 31, 49, 0.8);
padding: 10px;
min-height: 74px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.status-card-label {
color: #8fb3cf;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
}
.status-card-value {
color: #e8f5ff;
font-size: 14px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
}
.status-card.status-card-ok {
border-color: rgba(67, 191, 130, 0.48);
background: linear-gradient(180deg, rgba(20, 54, 44, 0.9) 0%, rgba(12, 38, 31, 0.95) 100%);
}
.status-card.status-card-warn {
border-color: rgba(255, 193, 90, 0.5);
background: linear-gradient(180deg, rgba(68, 50, 20, 0.86) 0%, rgba(44, 32, 14, 0.96) 100%);
}
.status-card.status-card-error {
border-color: rgba(255, 111, 111, 0.5);
background: linear-gradient(180deg, rgba(73, 26, 26, 0.84) 0%, rgba(47, 19, 19, 0.96) 100%);
}
#myStreamsBox {
border-bottom: 1px solid var(--line-soft);
background: rgba(10, 22, 36, 0.62);
transition: all 0.2s ease;
}
#allStreams {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 8px;
}
#activeStreams,
#inactiveStreams {
max-height: 120px;
overflow-y: auto;
}
#inactiveStreams .tdKey {
color: #ff8f8f;
}
/* Content wrapper */
#content {
display: block;
@@ -332,7 +95,7 @@ nav p {
#content h3 {
margin-bottom: 6px;
color: #d4edff;
color: var(--text-heading);
}
#content-interaction {
@@ -353,162 +116,14 @@ nav p {
display: none;
}
#box-wrapper {
width: 100%;
overflow: auto;
border: 1px solid var(--line-soft);
border-radius: 12px;
background: rgba(10, 21, 34, 0.72);
}
#content_table,
#mapping-detail-table {
width: 100%;
border-collapse: collapse;
}
#content_table .content_table_header {
position: sticky;
top: 0;
z-index: 2;
background: linear-gradient(180deg, rgba(23, 46, 72, 0.98), rgba(16, 32, 50, 0.98));
border-bottom: 1px solid var(--line);
}
#content_table .content_table_header td {
color: #8fd8ff;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.07em;
padding: 10px 8px;
}
#content_table tr {
border-left: 3px solid transparent;
border-bottom: 1px solid rgba(29, 55, 80, 0.72);
transition: background-color 0.2s ease;
}
#content_table tr:hover {
background-color: rgba(28, 53, 79, 0.5);
}
#content_table tr[tabindex],
#content_table td[tabindex] {
cursor: pointer;
}
#content_table tr[tabindex]:focus-visible,
#content_table td[tabindex]:focus-visible,
.keyboard-clickable:focus-visible {
outline: 2px solid #7de5ff;
outline-offset: -2px;
background-color: rgba(39, 73, 106, 0.56);
}
#content_table td {
padding: 7px 8px;
vertical-align: middle;
}
#content_table img {
display: block;
width: auto;
max-width: 34px;
max-height: 30px;
margin: 0 auto;
}
#content_table input[type=text] {
min-width: 48px;
max-width: 76px;
margin: 0;
padding: 6px 7px;
border-radius: 8px;
text-align: left;
}
#content_table input[type=checkbox] {
margin: 0 auto;
}
#content_table .mapping-edit-button {
min-height: 32px;
min-width: 64px;
margin: 0;
padding: 5px 10px;
font-size: 11px;
line-height: 1;
}
#content_table tr.activeEPG {
border-left-color: #2ed486;
}
#content_table tr.notActiveEPG {
border-left-color: #ff6868;
}
#settings {
padding: 10px;
}
#content_settings h4 {
margin: 15px 0 8px;
color: #d6efff;
}
#content_settings table {
border: 1px solid var(--line-soft);
border-radius: 12px;
background: rgba(10, 22, 35, 0.62);
border-collapse: separate;
border-spacing: 0;
overflow: hidden;
}
#content_settings td {
padding: 9px 8px;
border-bottom: 1px solid rgba(31, 57, 82, 0.62);
}
#content_settings tr:last-child td {
border-bottom: 0;
}
#content_settings pre {
color: #9ab7d0;
font-size: 11px;
}
#content_log {
padding: 8px;
}
#content_log pre {
margin-bottom: 5px;
padding: 8px;
border-radius: 8px;
background: rgba(13, 31, 49, 0.72);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.45;
}
#logScreen p {
white-space: pre;
font-family: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace;
font-size: 12px;
line-height: 1.55;
}
/* Login, first-user, wizard and maintenance box */
#box {
width: min(680px, calc(100% - 24px));
margin: 14px auto;
border-radius: var(--radius-l);
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(16, 35, 56, 0.95) 0%, rgba(11, 24, 38, 0.96) 100%);
background: linear-gradient(180deg, var(--panel-top) 0%, var(--panel-bottom) 100%);
box-shadow: var(--shadow-2);
min-height: 430px;
display: flex;
@@ -523,14 +138,14 @@ nav p {
border-radius: 12px;
border: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(16, 35, 56, 0.84), rgba(11, 24, 38, 0.9)),
linear-gradient(180deg, var(--logo-tint-top), var(--logo-tint-bottom)),
url("../img/logo_w_600x200.png") center no-repeat;
background-size: 300px auto;
}
#headline {
border-bottom: 1px solid var(--line);
background: rgba(10, 22, 37, 0.55);
background: var(--surface-0);
padding: 14px;
}
@@ -560,10 +175,6 @@ nav p {
min-width: 120px;
}
.wizard {
margin: 10px 0;
}
.auth-screen {
display: flex;
flex-direction: column;
@@ -578,6 +189,8 @@ nav p {
display: table-cell;
}
/* Breakpoints */
@media only screen and (max-width: 1300px) {
.dashboard-cards {
grid-template-columns: repeat(3, minmax(130px, 1fr));
@@ -622,7 +235,7 @@ nav p {
inset: 0;
z-index: 1000;
pointer-events: none;
background: rgba(3, 9, 15, 0.48);
background: var(--overlay-soft);
opacity: 0;
transition: opacity 0.2s ease;
}
@@ -698,7 +311,7 @@ nav p {
padding: 8px;
border: 1px solid var(--line-soft);
border-radius: 12px;
background: rgba(9, 22, 36, 0.92);
background: var(--surface-solid);
backdrop-filter: blur(6px);
}
@@ -771,7 +384,7 @@ nav p {
gap: 8px;
border-spacing: 0;
white-space: normal;
background: rgba(8, 20, 33, 0.44);
background: var(--surface-0);
}
#clientInfo tr {
@@ -791,16 +404,16 @@ nav p {
justify-content: center;
gap: 4px;
padding: 10px 9px;
background: rgba(17, 34, 53, 0.5);
background: var(--surface-2);
}
#clientInfo .tdVal::before {
content: attr(data-label);
color: #8bb9d7;
color: var(--text-soft);
font-size: 9px;
letter-spacing: 0.07em;
text-transform: uppercase;
font-family: "Space Grotesk", "Avenir Next", "Trebuchet MS", sans-serif;
font-family: var(--font-sans);
line-height: 1.2;
}
@@ -829,7 +442,7 @@ nav p {
border-radius: 12px;
margin-bottom: 10px;
padding: 6px;
background: rgba(11, 24, 39, 0.86);
background: var(--surface-3);
}
#content_table td {
@@ -841,12 +454,12 @@ nav p {
grid-template-columns: minmax(92px, 36%) 1fr;
align-items: center;
gap: 8px;
background: rgba(17, 34, 53, 0.5);
background: var(--surface-2);
}
#content_table td::before {
content: attr(data-label);
color: #8bb9d7;
color: var(--text-soft);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -967,6 +580,84 @@ nav p {
}
}
@media only screen and (max-width: 619px) {
h1 {
font-size: 1.46rem;
}
#popup {
padding: 0;
}
#popup-custom,
#mapping-detail,
#user-detail,
#file-detail {
max-width: none;
width: 100%;
height: 100%;
max-height: 100%;
border-radius: 0;
border: 0;
padding: 10px 10px calc(14px + env(safe-area-inset-bottom));
}
#popup-custom table {
border-spacing: 0 10px;
}
#popup-custom tr {
display: block;
border: 1px solid var(--line-soft);
border-radius: 10px;
padding: 8px 8px 10px;
background: var(--surface-2);
}
#popup-custom td {
display: block;
width: 100%;
padding: 4px 2px;
}
#popup-custom td.left {
width: 100%;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
#popup-interaction {
position: sticky;
bottom: 0;
z-index: 5;
margin-top: 10px;
padding-top: 10px;
background: linear-gradient(180deg, var(--sticky-fade-from) 0%, var(--sticky-fade-to) 30%);
}
.interaction,
#interaction {
justify-content: stretch;
}
.interaction input[type=button],
.interaction input[type=submit],
#popup-interaction input[type=button] {
width: 100%;
min-height: 44px;
}
.popup-title h3 {
font-size: 1.02rem;
}
.popup-close {
min-width: 44px;
min-height: 44px;
}
}
@media only screen and (max-width: 430px) {
#clientInfo {
grid-template-columns: 1fr;
+313
View File
@@ -0,0 +1,313 @@
/*
* tokens.css - design tokens (colours, spacing, radii, fonts, shadows).
*
* Load order: tokens.css, base.css, components.css, layout.css.
*
* The dark palette is the default. A light palette is applied when the user
* agent prefers a light colour scheme, and either palette can be forced with
* <html data-theme="dark"> or <html data-theme="light">, which always wins
* over the media query. Only colour tokens change between themes.
*/
:root {
color-scheme: dark;
/* Fonts */
--font-sans: "Space Grotesk", "Avenir Next", "Trebuchet MS", sans-serif;
--font-mono: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace;
/* Radii and shadows */
--radius-s: 10px;
--radius-m: 14px;
--radius-l: 18px;
--shadow-1: 0 20px 45px rgba(0, 0, 0, 0.32);
--shadow-2: 0 12px 30px rgba(0, 0, 0, 0.28);
/* Page background */
--bg-0: #07111d;
--bg-1: #0c1a2b;
--bg-2: #12233a;
--bg-deep: #060d17;
--bg-glow-1: rgba(64, 132, 183, 0.22);
--bg-glow-2: rgba(18, 185, 255, 0.16);
/* Panels and surfaces (translucent tints layered over the page) */
--panel-top: rgba(17, 34, 56, 0.97);
--panel-bottom: rgba(12, 27, 44, 0.97);
--panel-glow: rgba(53, 210, 255, 0.12);
--surface-0: rgba(8, 20, 33, 0.54);
--surface-1: rgba(10, 22, 36, 0.72);
--surface-2: rgba(13, 31, 49, 0.8);
--surface-3: rgba(17, 35, 56, 0.84);
--surface-solid: rgba(10, 22, 36, 0.92);
--surface-raised: rgba(14, 32, 50, 0.85);
--surface-input: rgba(10, 22, 36, 0.78);
--surface-input-focus: rgba(9, 25, 40, 0.95);
--logo-tint-top: rgba(26, 49, 73, 0.55);
--logo-tint-bottom: rgba(13, 28, 45, 0.6);
--table-head-top: rgba(23, 46, 72, 0.98);
--table-head-bottom: rgba(16, 32, 50, 0.98);
--row-line: rgba(29, 55, 80, 0.72);
--row-hover: rgba(28, 53, 79, 0.5);
--row-focus: rgba(39, 73, 106, 0.56);
--overlay: rgba(3, 9, 16, 0.72);
--overlay-soft: rgba(3, 9, 15, 0.48);
--sticky-fade-from: rgba(12, 27, 44, 0.1);
--sticky-fade-to: rgba(12, 27, 44, 0.96);
--scroll-track: rgba(7, 17, 29, 0.65);
--scroll-thumb-a: #2d597f;
--scroll-thumb-b: #1e4060;
--scroll-thumb-hover-a: #3678ac;
--scroll-thumb-hover-b: #20537c;
/* Lines */
--line: #274462;
--line-soft: #1b334d;
--line-strong: #3a5877;
/* Text */
--text: #e9f5ff;
--text-heading: #d6efff;
--text-muted: #b7cee1;
--text-soft: #8fb3cf;
--text-placeholder: #7e9ab4;
--text-on-accent: #03101a;
--text-on-ok: #052219;
--text-on-error: #ffe5e5;
/* Accent */
--accent: #35d2ff;
--accent-strong: #12b9ff;
--accent-bright: #67e8ff;
--accent-hover: #8de7ff;
--accent-text: #8fd8ff;
--accent-text-hover: #d7f4ff;
--accent-soft: rgba(53, 210, 255, 0.2);
--accent-ring: rgba(53, 210, 255, 0.17);
--accent-border: rgba(103, 232, 255, 0.55);
--accent-shadow: rgba(18, 185, 255, 0.3);
--accent-shadow-strong: rgba(18, 185, 255, 0.36);
--accent-gradient: linear-gradient(135deg, var(--accent-bright) 0%, var(--accent-strong) 100%);
--link-underline: rgba(143, 220, 255, 0.6);
--focus-ring: #7de5ff;
--focus-ring-soft: #91eaff;
--checkbox-checked: #39d6ff;
--checkbox-checked-border: #89ebff;
--loader: #58ddff;
/* Status */
--ok: #2fd18a;
--ok-bright: #92f0c0;
--ok-mid: #4fce93;
--ok-border: #9ff0c8;
--ok-soft: rgba(67, 191, 130, 0.48);
--ok-surface-top: rgba(20, 54, 44, 0.9);
--ok-surface-bottom: rgba(12, 38, 31, 0.95);
--warn: #ffc15a;
--warn-soft: rgba(255, 193, 90, 0.5);
--warn-surface-top: rgba(68, 50, 20, 0.86);
--warn-surface-bottom: rgba(44, 32, 14, 0.96);
--error: #ff6f6f;
--error-text: #ff8d8d;
--error-text-soft: #ffd8d8;
--error-bright: #ff7f7f;
--error-strong: #e94343;
--error-border: #ff9e9e;
--error-soft: rgba(255, 111, 111, 0.45);
--error-surface: rgba(87, 22, 22, 0.45);
--error-surface-top: rgba(73, 26, 26, 0.84);
--error-surface-bottom: rgba(47, 19, 19, 0.96);
--error-shadow: rgba(233, 67, 67, 0.35);
--info: #87b0d1;
--debug: #d687ff;
}
/*
* Light palette. Only tokens that differ from the dark palette are listed.
* The same block appears twice on purpose: once for the media query (skipped
* when dark is forced with data-theme="dark") and once for the explicit
* data-theme="light" override. Keep the two copies identical.
*/
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
color-scheme: light;
--bg-0: #e9eff5;
--bg-1: #f5f8fb;
--bg-2: #dfe7ef;
--bg-deep: #dfe7ef;
--bg-glow-1: rgba(64, 132, 183, 0.14);
--bg-glow-2: rgba(18, 185, 255, 0.12);
--panel-top: rgba(255, 255, 255, 0.97);
--panel-bottom: rgba(246, 249, 252, 0.97);
--panel-glow: rgba(18, 185, 255, 0.08);
--surface-0: rgba(255, 255, 255, 0.55);
--surface-1: rgba(255, 255, 255, 0.75);
--surface-2: rgba(236, 243, 249, 0.85);
--surface-3: rgba(232, 240, 247, 0.9);
--surface-solid: rgba(255, 255, 255, 0.94);
--surface-raised: rgba(228, 236, 244, 0.9);
--surface-input: rgba(255, 255, 255, 0.85);
--surface-input-focus: rgba(255, 255, 255, 1);
--logo-tint-top: rgba(15, 31, 48, 0.96);
--logo-tint-bottom: rgba(11, 23, 39, 0.98);
--table-head-top: rgba(232, 240, 248, 0.98);
--table-head-bottom: rgba(222, 232, 243, 0.98);
--row-line: rgba(100, 130, 160, 0.3);
--row-hover: rgba(0, 90, 150, 0.07);
--row-focus: rgba(0, 90, 150, 0.14);
--overlay: rgba(20, 35, 52, 0.5);
--overlay-soft: rgba(20, 35, 52, 0.35);
--sticky-fade-from: rgba(246, 249, 252, 0.1);
--sticky-fade-to: rgba(246, 249, 252, 0.96);
--scroll-track: rgba(200, 214, 228, 0.6);
--scroll-thumb-a: #9fb6cb;
--scroll-thumb-b: #7f9bb3;
--scroll-thumb-hover-a: #7f9bb3;
--scroll-thumb-hover-b: #61819c;
--line: #b9cadb;
--line-soft: #d3dfea;
--line-strong: #9fb4c8;
--text: #142434;
--text-heading: #0f2033;
--text-muted: #4a6379;
--text-soft: #526a80;
--text-placeholder: #58708a;
--text-on-error: #4a0c0c;
--accent: #0069a8;
--accent-hover: #00517f;
--accent-text: #0b6a95;
--accent-text-hover: #003a5c;
--accent-soft: rgba(0, 105, 168, 0.2);
--accent-ring: rgba(0, 105, 168, 0.18);
--accent-border: rgba(0, 105, 168, 0.5);
--accent-shadow: rgba(0, 105, 168, 0.22);
--accent-shadow-strong: rgba(0, 105, 168, 0.3);
--link-underline: rgba(11, 106, 149, 0.5);
--focus-ring: #0a7fc0;
--focus-ring-soft: #0a7fc0;
--checkbox-checked: #12b9ff;
--checkbox-checked-border: #0a7fc0;
--loader: #0a7fc0;
--ok: #12734a;
--ok-bright: #a9efcc;
--ok-mid: #5fd39b;
--ok-border: #4fb884;
--ok-soft: rgba(18, 115, 74, 0.45);
--ok-surface-top: rgba(226, 246, 235, 0.95);
--ok-surface-bottom: rgba(208, 238, 222, 0.98);
--warn: #7f5300;
--warn-soft: rgba(183, 121, 31, 0.5);
--warn-surface-top: rgba(253, 243, 224, 0.95);
--warn-surface-bottom: rgba(248, 232, 200, 0.98);
--error: #c62828;
--error-text: #b02020;
--error-text-soft: #8a1a1a;
--error-bright: #f6a5a5;
--error-strong: #e07575;
--error-border: #d95c5c;
--error-soft: rgba(198, 40, 40, 0.45);
--error-surface: rgba(255, 220, 220, 0.6);
--error-surface-top: rgba(253, 232, 232, 0.95);
--error-surface-bottom: rgba(248, 214, 214, 0.98);
--error-shadow: rgba(198, 40, 40, 0.25);
--info: #3f6785;
--debug: #7a35b5;
}
}
:root[data-theme="light"] {
color-scheme: light;
--bg-0: #e9eff5;
--bg-1: #f5f8fb;
--bg-2: #dfe7ef;
--bg-deep: #dfe7ef;
--bg-glow-1: rgba(64, 132, 183, 0.14);
--bg-glow-2: rgba(18, 185, 255, 0.12);
--panel-top: rgba(255, 255, 255, 0.97);
--panel-bottom: rgba(246, 249, 252, 0.97);
--panel-glow: rgba(18, 185, 255, 0.08);
--surface-0: rgba(255, 255, 255, 0.55);
--surface-1: rgba(255, 255, 255, 0.75);
--surface-2: rgba(236, 243, 249, 0.85);
--surface-3: rgba(232, 240, 247, 0.9);
--surface-solid: rgba(255, 255, 255, 0.94);
--surface-raised: rgba(228, 236, 244, 0.9);
--surface-input: rgba(255, 255, 255, 0.85);
--surface-input-focus: rgba(255, 255, 255, 1);
--logo-tint-top: rgba(15, 31, 48, 0.96);
--logo-tint-bottom: rgba(11, 23, 39, 0.98);
--table-head-top: rgba(232, 240, 248, 0.98);
--table-head-bottom: rgba(222, 232, 243, 0.98);
--row-line: rgba(100, 130, 160, 0.3);
--row-hover: rgba(0, 90, 150, 0.07);
--row-focus: rgba(0, 90, 150, 0.14);
--overlay: rgba(20, 35, 52, 0.5);
--overlay-soft: rgba(20, 35, 52, 0.35);
--sticky-fade-from: rgba(246, 249, 252, 0.1);
--sticky-fade-to: rgba(246, 249, 252, 0.96);
--scroll-track: rgba(200, 214, 228, 0.6);
--scroll-thumb-a: #9fb6cb;
--scroll-thumb-b: #7f9bb3;
--scroll-thumb-hover-a: #7f9bb3;
--scroll-thumb-hover-b: #61819c;
--line: #b9cadb;
--line-soft: #d3dfea;
--line-strong: #9fb4c8;
--text: #142434;
--text-heading: #0f2033;
--text-muted: #4a6379;
--text-soft: #526a80;
--text-placeholder: #58708a;
--text-on-error: #4a0c0c;
--accent: #0069a8;
--accent-hover: #00517f;
--accent-text: #0b6a95;
--accent-text-hover: #003a5c;
--accent-soft: rgba(0, 105, 168, 0.2);
--accent-ring: rgba(0, 105, 168, 0.18);
--accent-border: rgba(0, 105, 168, 0.5);
--accent-shadow: rgba(0, 105, 168, 0.22);
--accent-shadow-strong: rgba(0, 105, 168, 0.3);
--link-underline: rgba(11, 106, 149, 0.5);
--focus-ring: #0a7fc0;
--focus-ring-soft: #0a7fc0;
--checkbox-checked: #12b9ff;
--checkbox-checked-border: #0a7fc0;
--loader: #0a7fc0;
--ok: #12734a;
--ok-bright: #a9efcc;
--ok-mid: #5fd39b;
--ok-border: #4fb884;
--ok-soft: rgba(18, 115, 74, 0.45);
--ok-surface-top: rgba(226, 246, 235, 0.95);
--ok-surface-bottom: rgba(208, 238, 222, 0.98);
--warn: #7f5300;
--warn-soft: rgba(183, 121, 31, 0.5);
--warn-surface-top: rgba(253, 243, 224, 0.95);
--warn-surface-bottom: rgba(248, 232, 200, 0.98);
--error: #c62828;
--error-text: #b02020;
--error-text-soft: #8a1a1a;
--error-bright: #f6a5a5;
--error-strong: #e07575;
--error-border: #d95c5c;
--error-soft: rgba(198, 40, 40, 0.45);
--error-surface: rgba(255, 220, 220, 0.6);
--error-surface-top: rgba(253, 232, 232, 0.95);
--error-surface-bottom: rgba(248, 214, 214, 0.98);
--error-shadow: rgba(198, 40, 40, 0.25);
--info: #3f6785;
--debug: #7a35b5;
}
+7 -5
View File
@@ -1,11 +1,13 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>xTeVe</title>
<link rel="stylesheet" href="css/screen.css" type="text/css">
<link rel="stylesheet" href="css/base.css" type="text/css">
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/layout.css">
<script src="js/app.js"></script>
</head>
@@ -37,7 +39,7 @@
-->
<aside id="menu-wrapper" class="layout-left" aria-label="Sidebar menu">
<div id= "branch"></div>
<div id="branch"></div>
<div id="logo"></div>
<nav id="main-menu" role="menubar" aria-label="Main navigation"></nav>
</aside>
@@ -45,7 +47,7 @@
<main id="shell-main" class="layout-right">
<header id="shell-header">
<button id="menu-toggle" type="button" aria-expanded="false" aria-controls="menu-wrapper" aria-label="Toggle navigation menu">Menu</button>
<h2 id="shell-title">xTeVe Control Panel</h2>
<h1 id="shell-title">xTeVe Control Panel</h1>
<p id="connection-indicator" class="status-idle" role="status" aria-live="polite" aria-atomic="true">Connecting...</p>
</header>
+1265 -1338
View File
File diff suppressed because it is too large. Load diff
+6 -4
View File
@@ -1,11 +1,13 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>xTeVe</title>
<link rel="stylesheet" href="css/screen.css" type="text/css">
<link rel="stylesheet" href="css/base.css" type="text/css">
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/layout.css">
<script src="js/app.js"></script>
</head>
@@ -13,7 +15,7 @@
<div id="header" class="imgCenter"></div>
<main id="box" role="main" aria-labelledby="head-text">
<main id="box" aria-labelledby="head-text">
<div id="headline">
<h1 id="head-text" class="center">Login</h1>
+7 -5
View File
@@ -1,18 +1,20 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>xTeVe</title>
<link rel="stylesheet" href="css/screen.css" type="text/css">
<link rel="stylesheet" href="css/base.css" type="text/css">
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/layout.css">
</head>
<body class="auth-screen">
<div id="header" class="imgCenter"></div>
<div id="box">
<main id="box" aria-labelledby="head-text">
<div id="headline">
<h1 id="head-text" class="center">Maintenance</h1>
@@ -24,7 +26,7 @@
<div id="box-footer"></div>
</div>
</main>
</body>
</html>
+532
View File
@@ -6,9 +6,466 @@
"": {
"name": "xteve-webui",
"devDependencies": {
"jsdom": "30.1.1",
"typescript": "5.9.3"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz",
"integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.4.0",
"@csstools/css-color-parser": "^4.2.3",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz",
"integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz",
"integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.1",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz",
"integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.2",
"@csstools/css-calc": "^3.4.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.1",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz",
"integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-encoding-sniffer": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz",
"integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^7.0.0",
"@asamuzakjp/dom-selector": "^9.2.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^7.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"tough-cookie": "^6.0.2",
"undici": "^8.10.2",
"w3c-xmlserializer": "^6.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tldts": {
"version": "7.4.15",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz",
"integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.15"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.15",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz",
"integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -22,6 +479,81 @@
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz",
"integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.1.2",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz",
"integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
+3 -1
View File
@@ -4,9 +4,11 @@
"description": "Build tooling for the xTeVe web interface (TypeScript -> html/js/app.js)",
"scripts": {
"build": "tsc -p ts/tsconfig.json",
"check": "tsc -p ts/tsconfig.json --noEmit"
"check": "tsc -p ts/tsconfig.json --noEmit",
"test": "node --test \"tests/**/*.test.js\""
},
"devDependencies": {
"jsdom": "30.1.1",
"typescript": "5.9.3"
}
}
+7 -3
View File
@@ -148,8 +148,8 @@ func xteveRestore(archive string) (newWebURL string, err error) {
return
}
backupVersion = newConfig["version"].(string)
if backupVersion < System.Compatibility {
backupVersion, ok := newConfig["version"].(string)
if !ok || backupVersion < System.Compatibility {
err = errors.New(getErrMsg(1013))
return
}
@@ -167,7 +167,11 @@ func xteveRestore(archive string) (newWebURL string, err error) {
return
}
newPort = newConfig["port"].(string)
newPort, ok = newConfig["port"].(string)
if !ok {
err = errors.New(getErrMsg(1030))
return
}
oldPort = Settings.Port
if newPort == oldPort {
+5 -5
View File
@@ -63,7 +63,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
if isNew {
switch Settings.Buffer {
switch bufferModeFor(playlistID) {
case "xteve":
go connectToStreamingServer(streamID, playlistID)
@@ -86,7 +86,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
return
}
if s.Err != nil {
ShowError(s.Err, 0)
// Already logged by the downloader that reported it.
return
}
if s.Status {
@@ -127,7 +127,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
return
}
if s.Err != nil {
ShowError(s.Err, 0)
// Already logged by the downloader that reported it.
return
}
@@ -1074,7 +1074,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
var path, options, bufferType string
switch Settings.Buffer {
switch bufferModeFor(playlistID) {
case "ffmpeg":
path, options, bufferType = Settings.FFmpegPath, Settings.FFmpegOptions, "FFMPEG"
@@ -1259,7 +1259,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
func getTuner(id, playlistType string) (tuner int) {
switch Settings.Buffer {
switch bufferModeFor(id) {
case "-":
tuner = Settings.Tuner
+26
View File
@@ -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
View File
@@ -63,17 +63,31 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e
// Leerzeichen aus den Werten entfernen und Formatierung der Uhrzeit überprüfen (0000 - 2359)
var newUpdateTimes = make([]string, 0)
for _, v := range value.([]any) {
updateTimes, ok := value.([]any)
if !ok {
err = errors.New(getErrMsg(1012))
ShowError(err, 1012)
return Settings, err
}
v = strings.Replace(v.(string), " ", "", -1)
for _, v := range updateTimes {
_, err := time.Parse("1504", v.(string))
t, ok := v.(string)
if !ok {
err = errors.New(getErrMsg(1012))
ShowError(err, 1012)
return Settings, err
}
t = strings.Replace(t, " ", "", -1)
_, err := time.Parse("1504", t)
if err != nil {
ShowError(err, 1012)
return Settings, err
}
newUpdateTimes = append(newUpdateTimes, v.(string))
newUpdateTimes = append(newUpdateTimes, t)
}
@@ -320,21 +334,30 @@ func saveFiles(request RequestStruct, fileType string) (err error) {
for dataID, data := range newData {
dataMap, ok := data.(map[string]any)
if !ok {
err = errors.New(getErrMsg(1020))
return
}
if dataID == "-" {
// Neue Providerdatei
dataID = indicator + randomString(19)
data.(map[string]any)["new"] = true
filesMap[dataID] = data
dataMap["new"] = true
filesMap[dataID] = dataMap
} else {
// Bereits vorhandene Providerdatei
for key, value := range data.(map[string]any) {
oldData, ok := filesMap[dataID].(map[string]any)
if !ok {
// Unknown file ID: nothing to update.
continue
}
var oldData = filesMap[dataID].(map[string]any)
for key, value := range dataMap {
oldData[key] = value
}
}
@@ -353,11 +376,11 @@ func saveFiles(request RequestStruct, fileType string) (err error) {
}
// Neue Providerdatei
if _, ok := data.(map[string]any)["new"]; ok {
if _, ok := dataMap["new"]; ok {
reloadData = true
err = getProviderData(fileType, dataID)
delete(data.(map[string]any), "new")
delete(dataMap, "new")
if err != nil {
delete(filesMap, dataID)
@@ -511,6 +534,23 @@ func saveFilter(request RequestStruct) (settings SettingsStruct, err error) {
return
}
// Regular expressions are checked before anything is saved.
var filterType, _ = data.(map[string]any)["type"].(string)
if filterType == "" {
if old, ok := filterMap[dataID].(map[string]any); ok {
filterType, _ = old["type"].(string)
}
}
if filterType == "regex-filter" {
if reErr := validateRegexFilter(filter); reErr != nil {
err = fmt.Errorf("invalid regular expression: %v", reErr)
if newFilter {
delete(filterMap, dataID)
}
return
}
}
}
if oldData, ok := filterMap[dataID].(map[string]any); ok {
@@ -661,8 +701,16 @@ func saveUserData(request RequestStruct) (err error) {
func saveNewUser(request RequestStruct) (err error) {
var data = request.UserData
var username = data["username"].(string)
var password = data["password"].(string)
username, ok := data["username"].(string)
if !ok {
return errors.New("Username is missing")
}
password, ok := data["password"].(string)
if !ok {
return errors.New("Password is missing")
}
delete(data, "password")
delete(data, "confirm")
@@ -808,6 +856,17 @@ func createFilterRules() (err error) {
Data.Filter = append(Data.Filter, dataFilter)
case "regex-filter":
if err = validateRegexFilter(filter.Filter); err != nil {
return fmt.Errorf("filter %q: invalid regular expression: %v", filter.Name, err)
}
dataFilter.CaseSensitive = filter.CaseSensitive
dataFilter.Rule = filter.Filter
dataFilter.Type = filter.Type
Data.Filter = append(Data.Filter, dataFilter)
case "group-title":
if len(filter.Include) > 0 {
include = fmt.Sprintf(" {%s}", filter.Include)
@@ -855,7 +914,7 @@ func buildDatabaseDVR() (err error) {
var playlistFile = getLocalProviderFiles(fileType)
for n, i := range playlistFile {
for _, i := range playlistFile {
var channels []any
var groupTitle, tvgID, uuid = 0, 0, 0
@@ -878,7 +937,8 @@ func buildDatabaseDVR() (err error) {
ShowError(err, 1005)
err = errors.New(playlistName + ": Local copy of the file no longer exists")
ShowError(err, 0)
playlistFile = append(playlistFile[:n], playlistFile[n+1:]...)
// playlistFile is not used after this loop; removing the entry
// while ranging over it only skipped the following file.
}
// Streams analysieren
+72
View File
@@ -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)
}
}
}
+14
View File
@@ -0,0 +1,14 @@
package src
import (
"net/http"
"time"
)
// providerHTTPClient : Downloads of provider files (M3U, XMLTV, HDHR lineup).
// Playlists and EPG files can be large, so the overall timeout is generous;
// it only exists so a stalled provider cannot hang an update forever.
var providerHTTPClient = &http.Client{Timeout: 5 * time.Minute}
// apiHTTPClient : Short request/response calls to other services (Plex API).
var apiHTTPClient = &http.Client{Timeout: 30 * time.Second}
@@ -0,0 +1,43 @@
package authentication
import (
"os"
"path/filepath"
"testing"
)
func TestWriteFileAtomic(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "authentication.json")
if err := writeFileAtomic(file, []byte("{\"a\":1}"), 0600); err != nil {
t.Fatal(err)
}
if err := writeFileAtomic(file, []byte("{\"a\":2}"), 0600); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if string(got) != "{\"a\":2}" {
t.Errorf("content = %q", got)
}
fi, err := os.Stat(file)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0600 {
t.Errorf("mode = %o, want 0600", fi.Mode().Perm())
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Errorf("expected only the target file, found %d entries", len(entries))
}
}
+37 -1
View File
@@ -411,11 +411,47 @@ func saveDatabase(tmpMap any) (err error) {
return
}
err = os.WriteFile(database, []byte(jsonString), 0600)
err = writeFileAtomic(database, []byte(jsonString), 0600)
return
}
// writeFileAtomic : Temp file + fsync + rename, so authentication.json is
// never observed half-written. (Private copy; this package cannot import src.)
func writeFileAtomic(name string, data []byte, perm os.FileMode) (err error) {
tmp, err := os.CreateTemp(filepath.Dir(name), ".tmp-*")
if err != nil {
return
}
var tmpName = tmp.Name()
defer func() {
if err != nil {
tmp.Close()
os.Remove(tmpName)
}
}()
if _, err = tmp.Write(data); err != nil {
return
}
if err = tmp.Sync(); err != nil {
return
}
if err = tmp.Close(); err != nil {
return
}
if err = os.Chmod(tmpName, perm); err != nil {
return
}
err = os.Rename(tmpName, name)
return
}
+62 -36
View File
@@ -9,8 +9,14 @@ import (
"path/filepath"
"strings"
"sync"
"time"
)
// httpClient : Client for logo downloads. Providers occasionally serve
// images from hosts that never answer; without a timeout the caching
// goroutine would hang forever.
var httpClient = &http.Client{Timeout: 30 * time.Second}
// Cache : Cache strcut
type Cache struct {
path string
@@ -41,8 +47,6 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
c.Queue = []string{}
c.Cache = []string{}
var queue []string
c.Image.GetURL = func(src string) (cacheURL string) {
c.Lock()
@@ -89,50 +93,30 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
return src
}
// Caching downloads every queued image. The lock is only held while the
// queue is copied and while the map / queue are updated per image; the
// HTTP download and file write happen unlocked so GetURL callers (M3U /
// XMLTV generation) are not blocked for the duration of the downloads.
c.Image.Caching = func() {
c.Lock()
defer c.Unlock()
var queue = make([]string, len(c.Queue))
copy(queue, c.Queue)
c.Unlock()
var filename string
for _, src := range queue {
for _, src := range c.Queue {
resp, err := http.Get(src)
filename, err := c.download(src)
if err != nil {
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Stays in the queue and is retried on the next run.
continue
}
filename = fmt.Sprintf("%s%s%s%s", c.path, string(os.PathSeparator), strToMD5(src), filepath.Ext(src))
c.Lock()
c.images[filename] = c.cacheURL + filename
c.Queue = removeStringFromSlice(src, c.Queue)
c.Unlock()
file, err := os.Create(filename)
if err != nil {
continue
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
continue
}
u, err := url.Parse(src)
if err == nil {
c.images[fmt.Sprintf("%s%s", strToMD5(src), filepath.Ext(u.Path))] = c.cacheURL + filename
}
queue = append(queue, src)
}
for _, q := range queue {
c.Queue = removeStringFromSlice(q, c.Queue)
}
}
@@ -175,3 +159,45 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
return
}
// download : Fetches src into the cache folder and returns the cached file
// name (md5 of the URL plus the extension of the URL path). Must be called
// without holding the lock.
func (c *Cache) download(src string) (filename string, err error) {
u, err := url.Parse(src)
if err != nil {
return
}
filename = fmt.Sprintf("%s%s", strToMD5(src), filepath.Ext(u.Path))
resp, err := httpClient.Get(src)
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
err = fmt.Errorf("%s: %s", src, resp.Status)
return
}
var path = filepath.Join(c.path, filename)
file, err := os.Create(path)
if err != nil {
return
}
_, err = io.Copy(file, resp.Body)
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
os.Remove(path)
}
return
}
+51 -6
View File
@@ -8,6 +8,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
m3u "xteve/src/internal/m3u-parser"
)
@@ -34,12 +35,47 @@ func parsePlaylist(filename, fileType string) (channels []any, err error) {
}
// Streams filtern
// Patterns for the {include} and !{exclude} parts of a filter rule.
var filterIncludeRe = regexp.MustCompile(`[{]+[^.]+[}]`)
var filterExcludeRe = regexp.MustCompile(`!+[{]+[^.]+[}]`)
// regexFilterCache : compiled "regex-filter" rules, keyed by rule and case mode.
var regexFilterCache sync.Map
// compileRegexFilter : compiles (and caches) a regex-filter rule. Case
// insensitive rules get the (?i) flag. Returns nil for an invalid pattern.
func compileRegexFilter(rule string, caseSensitive bool) *regexp.Regexp {
var key = fmt.Sprintf("%t:%s", caseSensitive, rule)
if v, ok := regexFilterCache.Load(key); ok {
return v.(*regexp.Regexp)
}
var pattern = rule
if !caseSensitive {
pattern = "(?i)" + rule
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil
}
regexFilterCache.Store(key, re)
return re
}
// validateRegexFilter : error for an invalid regex-filter rule.
func validateRegexFilter(rule string) error {
_, err := regexp.Compile(rule)
return err
}
func filterThisStream(s any) (status bool) {
status = false
var stream = s.(map[string]string)
var regexpYES = `[{]+[^.]+[}]`
var regexpNO = `!+[{]+[^.]+[}]`
for _, filter := range Data.Filter {
@@ -53,6 +89,17 @@ func filterThisStream(s any) (status bool) {
var streamValues = strings.Replace(stream["_values"], "\r", "", -1)
// Regular expression, tried against the channel name, then the group
// title, then the raw attribute line (so anchors work on the name).
if filter.Type == "regex-filter" {
if re := compileRegexFilter(filter.Rule, filter.CaseSensitive); re != nil {
if re.MatchString(stream["name"]) || re.MatchString(stream["group-title"]) || re.MatchString(streamValues) {
return true
}
}
continue
}
if v, ok := stream["group-title"]; ok {
group = v
}
@@ -62,8 +109,7 @@ func filterThisStream(s any) (status bool) {
}
// Unerwünschte Streams !{DEU}
r := regexp.MustCompile(regexpNO)
val := r.FindStringSubmatch(filter.Rule)
val := filterExcludeRe.FindStringSubmatch(filter.Rule)
if len(val) == 1 {
@@ -74,8 +120,7 @@ func filterThisStream(s any) (status bool) {
}
// Muss zusätzlich erfüllt sein {DEU}
r = regexp.MustCompile(regexpYES)
val = r.FindStringSubmatch(filter.Rule)
val = filterIncludeRe.FindStringSubmatch(filter.Rule)
if len(val) == 1 {
+1 -5
View File
@@ -342,11 +342,7 @@ func doPlexRequest(method, baseURL, endpoint, token string) (status int, body []
req.Header.Set("X-Plex-Version", System.Version)
}
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
resp, err := apiHTTPClient.Do(req)
if err != nil {
return
}
+32 -15
View File
@@ -142,8 +142,16 @@ func getProviderData(fileType, fileID string) (err error) {
for dataID, d := range dataMap {
var data = d.(map[string]any)
var fileSource = data["file.source"].(string)
data, ok := d.(map[string]any)
if !ok {
continue
}
fileSource, ok := data["file.source"].(string)
if !ok {
continue
}
newProvider = false
if _, ok := data["new"]; ok {
@@ -224,8 +232,8 @@ func getProviderData(fileType, fileID string) (err error) {
if value, ok := dataMap[dataID].(map[string]any); ok {
data = value
data["counter.error"] = data["counter.error"].(float64) + 1
data["counter.download"] = data["counter.download"].(float64) + 1
data["counter.error"] = toFloat64(data["counter.error"]) + 1
data["counter.download"] = toFloat64(data["counter.download"]) + 1
}
@@ -243,10 +251,13 @@ func getProviderData(fileType, fileID string) (err error) {
var data = make(map[string]any)
data = value
if data["counter.error"].(float64) == 0 {
var errCount = toFloat64(data["counter.error"])
var dlCount = toFloat64(data["counter.download"])
if errCount == 0 || dlCount == 0 {
data["provider.availability"] = 100
} else {
data["provider.availability"] = int(data["counter.error"].(float64)*100/data["counter.download"].(float64)*-1 + 100)
data["provider.availability"] = int(errCount*100/dlCount*-1 + 100)
}
}
@@ -289,8 +300,7 @@ func downloadFileFromServer(providerURL string) (filename string, body []byte, e
req.Header.Set("User-Agent", getUserAgent())
client := &http.Client{}
resp, err := client.Do(req)
resp, err := providerHTTPClient.Do(req)
if err != nil {
return
}
@@ -307,15 +317,22 @@ func downloadFileFromServer(providerURL string) (filename string, body []byte, e
if index > -1 {
var headerFilename = resp.Header.Get("Content-Disposition")[index:len(resp.Header.Get("Content-Disposition"))]
var value = strings.Split(headerFilename, `=`)
var f = strings.Replace(value[1], `"`, "", -1)
var headerFilename = resp.Header.Get("Content-Disposition")[index:]
var value = strings.SplitN(headerFilename, `=`, 2)
f = strings.Replace(f, `;`, "", -1)
filename = f
showInfo("Header filename:" + filename)
if len(value) == 2 {
} else {
var f = strings.Replace(value[1], `"`, "", -1)
f = strings.Replace(f, `;`, "", -1)
filename = f
showInfo("Header filename:" + filename)
}
}
if len(filename) == 0 {
var cleanFilename = strings.SplitN(getFilenameFromPath(providerURL), "?", 2)
filename = cleanFilename[0]
+144 -39
View File
@@ -10,6 +10,82 @@ import (
"time"
)
// logMu guards WebScreenLog (its slice and counters) and System.Notification.
// Log lines are appended from every goroutine (buffer, maintenance, web
// handlers) and read by the websocket handler.
var logMu sync.Mutex
// logAppend : Appends a timestamped line to the in-memory log and trims it
// to Settings.LogEntriesRAM.
func logAppend(logMsg string) {
logMu.Lock()
defer logMu.Unlock()
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
logCleanUpLocked()
}
// logAppendCounted : Appends a warning / error line and bumps the matching
// counter. Like the original code this does not trim the log; the next
// info / debug line does.
func logAppendCounted(logMsg string, isError bool) {
logMu.Lock()
defer logMu.Unlock()
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
if isError {
WebScreenLog.Errors++
} else {
WebScreenLog.Warnings++
}
}
// resetWebScreenLog : Clears the in-memory log and its counters.
func resetWebScreenLog() {
logMu.Lock()
defer logMu.Unlock()
WebScreenLog.Log = make([]string, 0)
WebScreenLog.Errors = 0
WebScreenLog.Warnings = 0
}
// webScreenLogSnapshot : Copy of the log for the web client. The slice is
// copied so the JSON encoder never races with a concurrent append.
func webScreenLogSnapshot() (snapshot WebScreenLogStruct) {
logMu.Lock()
defer logMu.Unlock()
snapshot = WebScreenLog
snapshot.Log = make([]string, len(WebScreenLog.Log))
copy(snapshot.Log, WebScreenLog.Log)
return
}
// notificationsSnapshot : Copy of the notifications map for the web client.
func notificationsSnapshot() (snapshot map[string]Notification) {
logMu.Lock()
defer logMu.Unlock()
if System.Notification == nil {
return nil
}
snapshot = make(map[string]Notification, len(System.Notification))
for k, v := range System.Notification {
snapshot[k] = v
}
return
}
func showInfo(str string) {
if System.Flag.Info {
@@ -33,9 +109,7 @@ func showInfo(str string) {
printLogOnScreen(logMsg, "info")
logMsg = strings.Replace(logMsg, " ", "&nbsp;", -1)
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
logCleanUp()
logAppend(logMsg)
}
@@ -51,7 +125,6 @@ func showDebug(str string, level int) {
var msg = strings.SplitN(str, ":", 2)
var length = len(msg[0])
var space string
var mutex = sync.RWMutex{}
if len(msg) == 2 {
@@ -64,11 +137,7 @@ func showDebug(str string, level int) {
printLogOnScreen(logMsg, "debug")
mutex.Lock()
logMsg = strings.Replace(logMsg, " ", "&nbsp;", -1)
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
logCleanUp()
mutex.Unlock()
logAppend(logMsg)
}
@@ -99,7 +168,8 @@ func showHighlight(str string) {
}
notification.Type = "info"
notification.Message = msg[1]
// Messages without a "key:value" prefix are shown as they are.
notification.Message = msg[len(msg)-1]
addNotification(notification)
@@ -109,31 +179,22 @@ func showWarning(errCode int) {
var errMsg = getErrMsg(errCode)
var logMsg = fmt.Sprintf("[%s] [WARNING] %s", System.Name, errMsg)
var mutex = sync.RWMutex{}
printLogOnScreen(logMsg, "warning")
mutex.Lock()
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
WebScreenLog.Warnings++
mutex.Unlock()
logAppendCounted(logMsg, false)
}
// ShowError : Zeigt die Fehlermeldungen in der Konsole
func ShowError(err error, errCode int) {
var mutex = sync.RWMutex{}
var errMsg = getErrMsg(errCode)
var logMsg = fmt.Sprintf("[%s] [ERROR] %s (%s) - EC: %d", System.Name, err, errMsg, errCode)
printLogOnScreen(logMsg, "error")
mutex.Lock()
WebScreenLog.Log = append(WebScreenLog.Log, time.Now().Format("2006-01-02 15:04:05")+" "+logMsg)
WebScreenLog.Errors++
mutex.Unlock()
logAppendCounted(logMsg, true)
}
@@ -174,24 +235,17 @@ func printLogOnScreen(logMsg string, logType string) {
}
func logCleanUp() {
// logCleanUpLocked : Trims the log to the newest Settings.LogEntriesRAM
// lines and recounts warnings / errors. The caller must hold logMu.
func logCleanUpLocked() {
WebScreenLog.Log = keepLastEntries(WebScreenLog.Log, Settings.LogEntriesRAM)
var logEntriesRAM = Settings.LogEntriesRAM
var logs = WebScreenLog.Log
WebScreenLog.Warnings = 0
WebScreenLog.Errors = 0
if len(logs) > logEntriesRAM {
var tmp = make([]string, 0)
for i := len(logs) - logEntriesRAM; i < logEntriesRAM; i++ {
tmp = append(tmp, logs[i])
}
logs = tmp
}
for _, log := range logs {
if strings.Contains(log, "WARNING") {
@@ -204,8 +258,25 @@ func logCleanUp() {
}
WebScreenLog.Log = logs
}
// keepLastEntries : Returns the last max entries of logs (a copy, so the
// old backing array does not keep growing). A max <= 0 keeps nothing, which
// matches the original behaviour for an unset LogEntriesRAM.
func keepLastEntries(logs []string, max int) []string {
if max < 0 {
max = 0
}
if len(logs) <= max {
return logs
}
var tmp = make([]string, max)
copy(tmp, logs[len(logs)-max:])
return tmp
}
// Fehlercodes
@@ -386,30 +457,64 @@ func getErrMsg(errCode int) (errMsg string) {
return errMsg
}
// maxNotifications : Number of notifications kept for the web interface.
const maxNotifications = 10
func addNotification(notification Notification) (err error) {
var i int
var t = time.Now().UnixNano() / (int64(time.Millisecond) / int64(time.Nanosecond))
notification.Time = strconv.FormatInt(t, 10)
insertNotification(notification)
return
}
// insertNotification : Stores the notification under its Time key and evicts
// the oldest ones once more than maxNotifications are kept.
func insertNotification(notification Notification) {
notification.New = true
if len(notification.Headline) == 0 {
notification.Headline = strings.ToUpper(notification.Type)
}
logMu.Lock()
defer logMu.Unlock()
if len(System.Notification) == 0 {
System.Notification = make(map[string]Notification)
}
System.Notification[notification.Time] = notification
for key := range System.Notification {
for len(System.Notification) > maxNotifications {
delete(System.Notification, oldestNotificationKey(System.Notification))
}
if i < len(System.Notification)-10 {
delete(System.Notification, key)
}
// oldestNotificationKey : Key of the notification with the smallest
// timestamp. Keys are millisecond timestamps as decimal strings; anything
// that does not parse sorts before everything that does.
func oldestNotificationKey(notifications map[string]Notification) (oldest string) {
var oldestTime int64
var found bool
for key, n := range notifications {
t, err := strconv.ParseInt(n.Time, 10, 64)
if err != nil {
return key
}
i++
if !found || t < oldestTime {
oldest = key
oldestTime = t
found = true
}
}
+133
View File
@@ -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, "&nbsp;") {
t.Errorf("log line contains an HTML entity: %q", line)
}
}
}
+3 -8
View File
@@ -124,12 +124,7 @@ func maskSettings(s SettingsStruct) SettingsStruct {
// writePrivateFile : like writeByteToFile but readable only by the owner.
func writePrivateFile(file string, data []byte) error {
var filename = getPlatformFile(file)
if err := os.WriteFile(filename, data, 0600); err != nil {
return err
}
// WriteFile keeps the mode of an existing file; tighten it.
return os.Chmod(filename, 0600)
// The temp file is chmod'ed before the rename, so an existing
// world-readable file is replaced by a 0600 one.
return writeFileAtomic(getPlatformFile(file), data, 0600)
}
+49 -18
View File
@@ -4,12 +4,38 @@ import (
"fmt"
"log"
"os"
"os/signal"
"sync"
"time"
"github.com/koron/go-ssdp"
)
// ssdpState : The running advertiser, so Shutdown can say goodbye to the
// network before the process exits.
var ssdpState struct {
sync.Mutex
adv *ssdp.Advertiser
done chan struct{}
}
// Shutdown : Stops background services (currently the SSDP advertiser).
// Called from main on SIGINT / SIGTERM.
func Shutdown() {
ssdpState.Lock()
defer ssdpState.Unlock()
if ssdpState.adv == nil {
return
}
close(ssdpState.done)
ssdpState.adv.Bye()
ssdpState.adv.Close()
ssdpState.adv = nil
}
// SSDP : SSPD / DLNA Server
func SSDP() (err error) {
@@ -19,9 +45,6 @@ func SSDP() (err error) {
showInfo(fmt.Sprintf("SSDP / DLNA:%t", Settings.SSDP))
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
ad, err := ssdp.Advertise(
"upnp:rootdevice", // send as "ST"
fmt.Sprintf("uuid:%s::upnp:rootdevice", System.DeviceID), // send as "USN"
@@ -38,35 +61,43 @@ func SSDP() (err error) {
ssdp.Logger = log.New(os.Stderr, "[SSDP] ", log.LstdFlags)
}
go func(adv *ssdp.Advertiser) {
var done = make(chan struct{})
aliveTick := time.Tick(300 * time.Second)
ssdpState.Lock()
ssdpState.adv = ad
ssdpState.done = done
ssdpState.Unlock()
go func(adv *ssdp.Advertiser, done chan struct{}) {
aliveTick := time.NewTicker(300 * time.Second)
defer aliveTick.Stop()
loop:
for {
select {
case <-aliveTick:
err = adv.Alive()
if err != nil {
ShowError(err, 0)
case <-aliveTick.C:
if aliveErr := adv.Alive(); aliveErr != nil {
ShowError(aliveErr, 0)
ssdpState.Lock()
if ssdpState.adv == adv {
ssdpState.adv = nil
}
ssdpState.Unlock()
adv.Bye()
adv.Close()
break loop
return
}
case <-quit:
adv.Bye()
adv.Close()
os.Exit(0)
break loop
case <-done:
return
}
}
}(ad)
}(ad, done)
return
}
+6
View File
@@ -5,6 +5,10 @@ type RequestStruct struct {
// Befehle an xTeVe
Cmd string `json:"cmd"`
// Client-chosen request id, echoed in the response so the web UI can
// match answers on a long-lived connection.
ID string `json:"id,omitempty"`
// Benutzer
DeleteUser bool `json:"deleteUser,omitempty"`
UserData map[string]any `json:"userData,omitempty"`
@@ -73,6 +77,8 @@ type RequestStruct struct {
// ResponseStruct : Antworten an den Client (WEB)
type ResponseStruct struct {
ID string `json:"id,omitempty"`
ClientInfo struct {
ARCH string `json:"arch"`
Branch string `json:"branch,omitempty"`
+74 -7
View File
@@ -204,12 +204,7 @@ func saveMapToJSONFile(file string, tmpMap any) error {
return err
}
err = os.WriteFile(filename, []byte(jsonString), 0644)
if err != nil {
return err
}
return nil
return writeFileAtomic(filename, []byte(jsonString), 0644)
}
func loadJSONFileToMap(file string) (tmpMap map[string]any, err error) {
@@ -229,6 +224,37 @@ func loadJSONFileToMap(file string) (tmpMap map[string]any, err error) {
return
}
// toFloat64 : Numeric value of a JSON decoded field; 0 for anything that
// is not a number (missing key, wrong type).
func toFloat64(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
}
return 0
}
// removeStrings : Copy of list without the entries present in remove.
// Order of the remaining entries is kept.
func removeStrings(list []string, remove map[string]bool) []string {
var result = make([]string, 0, len(list))
for _, s := range list {
if !remove[s] {
result = append(result, s)
}
}
return result
}
// Binary
func readByteFromFile(file string) (content []byte, err error) {
@@ -246,7 +272,48 @@ func readByteFromFile(file string) (content []byte, err error) {
func writeByteToFile(file string, data []byte) (err error) {
var filename = getPlatformFile(file)
err = os.WriteFile(filename, data, 0644)
err = writeFileAtomic(filename, data, 0644)
return
}
// writeFileAtomic : Writes data to a temporary file in the target directory,
// fsyncs it and renames it over name, so readers (and a crash mid-write)
// never see a truncated or half-written file. The temp file is removed on
// any error.
func writeFileAtomic(name string, data []byte, perm os.FileMode) (err error) {
tmp, err := os.CreateTemp(filepath.Dir(name), ".tmp-*")
if err != nil {
return
}
var tmpName = tmp.Name()
defer func() {
if err != nil {
tmp.Close()
os.Remove(tmpName)
}
}()
if _, err = tmp.Write(data); err != nil {
return
}
if err = tmp.Sync(); err != nil {
return
}
if err = tmp.Close(); err != nil {
return
}
if err = os.Chmod(tmpName, perm); err != nil {
return
}
err = os.Rename(tmpName, name)
return
}
+86
View File
@@ -0,0 +1,86 @@
package src
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestWriteFileAtomic(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "settings.json")
if err := writeFileAtomic(file, []byte("first"), 0644); err != nil {
t.Fatal(err)
}
if err := writeFileAtomic(file, []byte("second"), 0600); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if string(got) != "second" {
t.Errorf("content = %q, want %q", got, "second")
}
fi, err := os.Stat(file)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0600 {
t.Errorf("mode = %o, want 0600", fi.Mode().Perm())
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.Name() != "settings.json" {
t.Errorf("unexpected leftover file %q", e.Name())
}
}
}
func TestWriteFileAtomicMissingDir(t *testing.T) {
file := filepath.Join(t.TempDir(), "missing", "settings.json")
if err := writeFileAtomic(file, []byte("x"), 0644); err == nil {
t.Fatal("expected an error for a missing directory")
}
}
func TestRemoveStrings(t *testing.T) {
list := []string{"a.xml", "b.xml", "c.xml", "d.xml"}
got := removeStrings(list, map[string]bool{"b.xml": true, "d.xml": true})
want := []string{"a.xml", "c.xml"}
if !reflect.DeepEqual(got, want) {
t.Errorf("removeStrings = %v, want %v", got, want)
}
// Nothing to remove: same content, original untouched.
got = removeStrings(list, nil)
if !reflect.DeepEqual(got, list) {
t.Errorf("removeStrings(nil) = %v, want %v", got, list)
}
}
func TestToFloat64(t *testing.T) {
cases := []struct {
in any
want float64
}{
{float64(3), 3},
{int(2), 2},
{int64(1), 1},
{"x", 0},
{nil, 0},
}
for _, c := range cases {
if got := toFloat64(c.in); got != c.want {
t.Errorf("toFloat64(%v) = %v, want %v", c.in, got, c.want)
}
}
}
+41 -33
View File
@@ -9,6 +9,7 @@ import (
"os"
"strconv"
"strings"
"time"
"xteve/src/internal/authentication"
@@ -73,7 +74,17 @@ func StartWebserver() (err error) {
}
if err = http.ListenAndServe(":"+port, nil); err != nil {
// No ReadTimeout / WriteTimeout on purpose: /stream/ responses are
// long-lived. ReadHeaderTimeout still protects against slowloris
// clients and IdleTimeout reaps keep-alive connections.
var server = &http.Server{
Addr: ":" + port,
Handler: nil,
ReadHeaderTimeout: 15 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err = server.ListenAndServe(); err != nil {
ShowError(err, 1001)
return
}
@@ -153,16 +164,18 @@ func Stream(w http.ResponseWriter, r *http.Request) {
return
}
var bufferMode = bufferModeFor(streamInfo.PlaylistID)
// If an UDPxy host is set, and the stream URL is multicast (i.e. starts with 'udp://@'),
// then streamInfo.URL needs to be rewritten to point to UDPxy.
if Settings.UDPxy != "" && strings.HasPrefix(streamInfo.URL, "udp://@") {
streamInfo.URL = fmt.Sprintf("http://%s/udp/%s/", Settings.UDPxy, strings.TrimPrefix(streamInfo.URL, "udp://@"))
}
switch Settings.Buffer {
switch bufferMode {
case "-":
showInfo(fmt.Sprintf("Buffer:false [%s]", Settings.Buffer))
showInfo(fmt.Sprintf("Buffer:false [%s]", bufferMode))
case "xteve":
if strings.Contains(streamInfo.URL, "rtsp://") || strings.Contains(streamInfo.URL, "rtp://") {
@@ -176,14 +189,14 @@ func Stream(w http.ResponseWriter, r *http.Request) {
return
}
showInfo(fmt.Sprintf("Buffer:true [%s]", Settings.Buffer))
showInfo(fmt.Sprintf("Buffer:true [%s]", bufferMode))
default:
showInfo(fmt.Sprintf("Buffer:true [%s]", Settings.Buffer))
showInfo(fmt.Sprintf("Buffer:true [%s]", bufferMode))
}
if Settings.Buffer != "-" {
if bufferMode != "-" {
showInfo(fmt.Sprintf("Buffer Size:%d KB", Settings.BufferSize))
}
@@ -191,7 +204,7 @@ func Stream(w http.ResponseWriter, r *http.Request) {
showInfo(fmt.Sprintf("Client User-Agent:%s", r.Header.Get("User-Agent")))
// Prüfen ob der Buffer verwendet werden soll
switch Settings.Buffer {
switch bufferMode {
case "-":
showInfo("Streaming URL:" + streamInfo.URL)
@@ -317,36 +330,36 @@ func DataImages(w http.ResponseWriter, r *http.Request) {
// WS : Web Sockets /ws/
func WS(w http.ResponseWriter, r *http.Request) {
var request RequestStruct
var response ResponseStruct
response.Status = true
var newToken string
/*
if r.Header.Get("Origin") != "http://"+r.Host {
httpStatusError(w, r, 403)
return
}
*/
// Upgrade writes its own error response (e.g. 403 for a bad Origin).
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
ShowError(err, 0)
return
}
defer conn.Close()
setGlobalDomain(r.Host)
// One connection serves any number of commands, one at a time, until the
// client goes away or a write fails.
for {
// Fresh structs per command: a failed command must not leak its
// Status / Error / Base64 into the next one on the same connection.
var request RequestStruct
var response ResponseStruct
response.Status = true
err = conn.ReadJSON(&request)
if err != nil {
return
}
response.ID = request.ID
if !System.ConfigurationWizard {
switch Settings.AuthenticationWEB {
@@ -385,10 +398,9 @@ func WS(w http.ResponseWriter, r *http.Request) {
response = setDefaultResponseData(response, false)
if err = conn.WriteJSON(response); err != nil {
ShowError(err, 1022)
} else {
return
}
return
continue
case "loadFiles":
//response.Response = Settings.Files
@@ -465,9 +477,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
}
case "resetLogs":
WebScreenLog.Log = make([]string, 0)
WebScreenLog.Errors = 0
WebScreenLog.Warnings = 0
resetWebScreenLog()
response.OpenMenu = strconv.Itoa(indexOfString("log", System.WEB.Menu))
case "xteveBackup":
@@ -478,9 +488,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
}
case "xteveRestore":
WebScreenLog.Log = make([]string, 0)
WebScreenLog.Errors = 0
WebScreenLog.Warnings = 0
resetWebScreenLog()
if len(request.Base64) > 0 {
@@ -563,8 +571,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
if err = conn.WriteJSON(response); err != nil {
ShowError(err, 1022)
} else {
break
return
}
}
@@ -855,8 +862,8 @@ func API(w http.ResponseWriter, r *http.Request) {
default:
token, err = tokenAuthentication(request.Token)
fmt.Println(err)
if err != nil {
ShowError(err, 0)
responseAPIError(err)
return
}
@@ -926,6 +933,7 @@ func API(w http.ResponseWriter, r *http.Request) {
if err != nil {
responseAPIError(err)
return
}
w.Write([]byte(mapToJSON(response)))
@@ -977,10 +985,10 @@ func setDefaultResponseData(response ResponseStruct, data bool) (defaults Respon
defaults.ClientInfo.OS = System.OS
defaults.ClientInfo.Streams = fmt.Sprintf("%d / %d", len(Data.Streams.Active), len(Data.Streams.All))
defaults.ClientInfo.UUID = Settings.UUID
defaults.ClientInfo.Errors = WebScreenLog.Errors
defaults.ClientInfo.Warnings = WebScreenLog.Warnings
defaults.Notification = System.Notification
defaults.Log = WebScreenLog
defaults.Log = webScreenLogSnapshot()
defaults.ClientInfo.Errors = defaults.Log.Errors
defaults.ClientInfo.Warnings = defaults.Log.Warnings
defaults.Notification = notificationsSnapshot()
switch System.Branch {
+31
View File
@@ -5,6 +5,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
@@ -137,3 +138,33 @@ func TestSetCookieTokenFlags(t *testing.T) {
t.Error("logout must clear the cookie (negative MaxAge)")
}
}
// A browser keeps one socket open and sends commands one after another; the
// server must answer each on the same connection and echo the request id.
func TestWSServesMultipleCommandsPerConnection(t *testing.T) {
srv, token := newAuthenticatedWSServer(t)
conn, _, err := websocket.DefaultDialer.Dial(wsURL(srv), http.Header{"Cookie": {"Token=" + token}})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
for i := 1; i <= 3; i++ {
id := strings.Repeat("x", i)
if err := conn.WriteJSON(map[string]any{"cmd": "noop", "id": id}); err != nil {
t.Fatalf("write %d: %v", i, err)
}
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
var response ResponseStruct
if err := conn.ReadJSON(&response); err != nil {
t.Fatalf("read %d: %v (server stopped answering on the same connection)", i, err)
}
if !response.Status {
t.Fatalf("command %d refused: %s", i, response.Error)
}
if response.ID != id {
t.Fatalf("command %d: id %q not echoed, got %q", i, id, response.ID)
}
}
}
+7 -1
View File
@@ -172,6 +172,11 @@ func createXEPGMapping() {
if len(Data.XMLTV.Files) > 0 {
// Files that could not be read are collected here and removed after
// the loop; mutating Data.XMLTV.Files while iterating over it used
// to duplicate entries instead of removing them.
var failedFiles = make(map[string]bool)
for i := len(Data.XMLTV.Files) - 1; i >= 0; i-- {
var file = Data.XMLTV.Files[i]
@@ -185,7 +190,7 @@ func createXEPGMapping() {
err = getLocalXMLTV(file, &xmltv)
if err != nil {
Data.XMLTV.Files = append(Data.XMLTV.Files, Data.XMLTV.Files[i+1:]...)
failedFiles[file] = true
var errMsg = err.Error()
err = errors.New(getProviderParameter(fileID, "xmltv", "name") + ": " + errMsg)
ShowError(err, 000)
@@ -215,6 +220,7 @@ func createXEPGMapping() {
}
Data.XMLTV.Files = removeStrings(Data.XMLTV.Files, failedFiles)
Data.XMLTV.Mapping = tmpMap
} else {
+1 -1
View File
@@ -2,7 +2,7 @@
Date: 2026-09-25
Scope: whole repository (Go backend, TypeScript UI, Docker, CI, docs)
Status: reviewed with maintainer 2026-09-25/26, all open questions resolved, ready to start Phase 0. Checklist lives in `tasks/todo.md`.
Status: implemented on branch `improvements` (2026-09-26). Per-item results, deviations and what was left open are in `tasks/todo.md`.
## 1. Where the fork stands
+19 -19
View File
@@ -1,7 +1,7 @@
# xTeVe improvement checklist
Detailed rationale, file references and effort estimates: `tasks/improvement-plan.md`.
Status: Phase 0 committed on branch `improvements` 2026-09-26.
Status: all phases done on branch `improvements` (2026-09-26). Open: the `agent.md` design-system reference (user decision) and two items kept by design (Host-derived URLs, no user roles).
## Phase 0: Hygiene
- [x] `.gitignore` (`.gocache/`), deleted `.gocache/`, extended `.dockerignore`
@@ -35,19 +35,19 @@ Status: Phase 0 committed on branch `improvements` 2026-09-26.
- [x] `streamingURLsMu` around `Data.Cache.StreamingURLS`; persisted from a snapshot
- [x] No more `defer` inside the buffer read/redirect loops (compression done in Phase 1; imgcache in 2b)
- [x] `thirdPartyBuffer` rewritten: `exec.CommandContext` cancelled when the last client leaves or the 20 s startup watchdog fires; `buildFFmpegArgs`/`buildVLCArgs` tested; no panic, `cmd.Start` checked, one file handle per segment. Client loop uses `r.Context()` instead of `CloseNotifier`
- [ ] Real mutex for screen log
- [ ] `http.Server` with timeouts; timeouts on all outbound clients
- [ ] imgcache: download outside the lock
- [ ] Atomic write helper (temp + fsync + rename)
- [x] Buffer: headers set before `WriteHeader`, bogus `Content-Length:` header gone, segments flushed to the client as they arrive. Others in 2b
- [ ] Error hygiene: no-op closures, `os.Exit`/`panic` outside main, ignored results
- [x] `logMu` guards `WebScreenLog` and notifications; accessors `logAppend`/`webScreenLogSnapshot`/`notificationsSnapshot`; ring buffer keeps the newest N (was dropping them)
- [x] `http.Server` with `ReadHeaderTimeout` 15 s and `IdleTimeout` 120 s (no write timeout, streams are long-lived); `providerHTTPClient` 5 min, `apiHTTPClient` 30 s, imgcache client 30 s, stream client with dial/TLS/header timeouts
- [x] imgcache downloads outside the lock; cache URL bug fixed (was a filesystem path); query-string URLs no longer produce unservable file names
- [x] `writeFileAtomic` (temp + fsync + rename) behind `writeByteToFile`, `saveMapToJSONFile`, `writePrivateFile` and the auth database
- [x] Buffer: headers set before `WriteHeader`, bogus `Content-Length:` header gone, segments flushed as they arrive. xepg file-removal bug, dead range mutation, API double write, WS struct reuse, unchecked assertions in data/backup/provider/screen, notification eviction by age: all fixed with tests
- [x] Error hygiene: no `panic`/`os.Exit`/`log.Fatal` outside `main`; SIGINT/SIGTERM handled in main via `src.Shutdown()`; fatal start-up errors exit 1
## Phase 3: Build, embed, CI, Docker
- [x] `//go:embed` via `html/embed.go`; deleted `webUI.go`, `html-build.go`, `cmd/webui-gen`; ETag + Cache-Control on static assets
- [x] i18n dropped: 254 placeholders inlined, `en.json` deleted, only HTML pages templated (`authenticationErr`)
- [x] `package.json` + `ts/tsconfig.json` (tsc 5.9.3, ES2020, single outFile `html/js/app.js`, committed, CI-verified); 10 dead JS files deleted; 6 tsc errors fixed incl. a real ASI bug in the search shortcut
- [x] CI: vet, gofmt check, staticcheck v0.8.1 (config in `staticcheck.conf`), bundle freshness check. `go test -race` still to add once tests exist
- [ ] Version-tagged images (amd64 only)
- [x] Version-tagged images: master publishes `latest`, the commit SHA and the changelog version via a `.tags` file (amd64 only)
- [x] PUID/PGID via su-exec at runtime; static-ffmpeg pinned to 7.1.1; `VOLUME /xteve/config`; `/xteve` removed from `LEGACY_CONFIG_DIRS`; compose files pull from the registry and document SSDP/host networking
- [x] `/healthz` endpoint; Dockerfile healthcheck uses it
- [x] `README-DEV.md` and fork-specific `README.md` (about, container usage, env vars, security notes)
@@ -58,19 +58,19 @@ Status: Phase 0 committed on branch `improvements` 2026-09-26.
Not planned (lineup is ~170 channels, decided 2026-09-25). See plan for the reference list.
## Phase 5: Frontend architecture
- [ ] Persistent websocket with request IDs, queue, backoff reconnect
- [ ] Section-level re-render instead of full `createLayout()`
- [ ] Virtualised mapping table
- [ ] Split `menu_ts.ts`; `addEventListener`; data-driven settings rows
- [ ] Accessibility follow-ups; light theme via `prefers-color-scheme`
- [ ] Consolidate CSS layers
- [x] Persistent websocket: one socket per page, queued requests with ids echoed by the server, 12 s per-request timeout, exponential-backoff reconnect, log-poll dedupe. Server keeps serving on one connection and closes it on exit (upstream leaked one open socket per request). Test: `TestWSServesMultipleCommandsPerConnection`
- [x] `createLayout()` rebuilds the menu list only when the set of visible items changes; client info and status cards refresh in place (jsdom test keeps the same nodes and focus)
- [~] Virtualised mapping table skipped: ~170 channels render instantly, and sorting/search operate on DOM rows, so virtualisation would add risk for no gain (same reasoning as dropping Phase 4)
- [x] `menu_ts.ts` split into `menu.ts`, `content.ts`, `popup.ts`, `xmltv.ts` (all `_ts` suffixes dropped); all 94 string `on*` handlers replaced by `addEventListener`; settings rows generated from `SETTINGS_FIELDS`; password fields now saved (Plex token); 27 jsdom tests under `tests/` run by `npm test` in CI
- [x] Live-region announcements alongside alerts, `lang`, single h1, `<main>` landmark; light theme via `prefers-color-scheme` with `data-theme` override (contrast measured). `<input type=button>` kept because every button style keys on it
- [x] CSS split into tokens / base / components / layout, every colour a token, `screen.css` removed
## Phase 6: Optional features
- [ ] Regex filters
- [ ] Per-playlist tuner limit and buffer choice
- [ ] Bulk channel edit
- [ ] Versioned backup/restore
- [ ] Dummy EPG durations
- [x] Regex filters: `regex-filter` type (validated on save, cached compile, matches name, then group, then raw line) with a popup form and table label; tested in Go and jsdom
- [x] Per-playlist tuner limit already existed. Per-playlist buffer: `buffer` select in the playlist popup (default / none / xTeVe / FFmpeg / VLC) backed by `bufferModeFor`; tuner field stays editable when the playlist's own buffer is active
- [x] Already present in the fork (bulk edit with shift-range selection); nothing to add
- [x] Already present: restore checks the backup's version against `System.Compatibility`; writes are now atomic (Phase 2)
- [x] Already present: `xTeVe Dummy` offers several durations in the mapping dropdown
## Review
+218
View File
@@ -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"});
});
+162
View File
@@ -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};
+226
View 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");
});
+127
View File
@@ -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};
+116
View File
@@ -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.
+44 -9
View File
@@ -3,7 +3,6 @@ var BULK_EDIT:Boolean = false
var COLUMN_TO_SORT:number
var SEARCH_MAPPING = new Object()
var UNDO = new Object()
var SERVER_CONNECTION = false
var WS_AVAILABLE = false
var ACTIVE_MENU_ID:string = ""
var LAST_BULK_CHECKBOX:HTMLInputElement = null
@@ -61,6 +60,35 @@ function showElement(elmID, type) {
element.className = cssClass;
}
// announce writes a message into the ARIA live region so assistive
// technology hears it; the region is created when the page has none.
function announce(message:string) {
if (document.body == null) {
return
}
var region = document.getElementById("announcer")
if (region == null) {
region = document.getElementById("sr-announcer")
}
if (region == null) {
region = document.createElement("DIV")
region.id = "announcer"
region.className = "sr-only"
region.setAttribute("aria-live", "polite")
region.setAttribute("aria-atomic", "true")
document.body.appendChild(region)
}
region.textContent = message
}
// alertUser shows the browser alert and mirrors it into the live region.
function alertUser(message:string) {
announce(message)
alert(message)
}
function setConnectionState(state:string, text:string = "") {
var label:string = text
@@ -93,9 +121,17 @@ function setConnectionState(state:string, text:string = "") {
indicator.innerText = label
}
function changeButtonAction(element, buttonID, attribute) {
var value = element.options[element.selectedIndex].value;
document.getElementById(buttonID).setAttribute(attribute, value)
// markChangedOnChange flags a control as edited on "change" so that
// saveSettings / donePopupData pick it up, then runs an optional follow-up.
// Callers build many controls through one reused `var`, so the listener must
// close over this parameter and not over the caller's variable.
function markChangedOnChange(element:HTMLElement, then?:(element:HTMLElement) => void) {
element.addEventListener("change", function() {
element.className = "changed"
if (then != undefined) {
then(element)
}
})
}
function getLocalData(dataType, id):object {
@@ -502,7 +538,7 @@ function changeChannelNumber(element) {
var channels = getObjKeys(data)
if (isNaN(newNumber)) {
alert("Invalid channel number")
alertUser("Invalid channel number")
return
}
@@ -585,7 +621,7 @@ function toggleChannelStatus(id:string) {
if (channel["x-xmltv-file"] == "-" || channel["x-mapping"] == "-") {
if (BULK_EDIT == false) {
alert(channel["x-name"] + ": Missing XMLTV file / channel")
alertUser(channel["x-name"] + ": Missing XMLTV file / channel")
checkbox.checked = false
}
@@ -650,7 +686,7 @@ function restore() {
};
} else {
alert("File could not be loaded")
alertUser("File could not be loaded")
}
restore.remove()
@@ -708,7 +744,7 @@ function uploadLogo() {
};
} else {
alert("File could not be loaded")
alertUser("File could not be loaded")
}
upload.remove()
@@ -761,7 +797,6 @@ function sortSelect(elem) {
function updateLog() {
console.log("TOKEN")
var server:Server = new Server("updateLog")
server.request(new Object())
@@ -144,7 +144,7 @@ function saveWizard() {
if (value.length == 0) {
var msg = name.toUpperCase() + ": " + "Missing input"
alert(msg)
alertUser(msg)
return
}
+764
View File
@@ -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
}
View File
File renamed without changes.
+414
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+305
View File
@@ -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
-202
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+342
View File
@@ -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)
}
-714
View File
@@ -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
View File
@@ -11,12 +11,15 @@
"removeComments": false
},
"files": [
"network_ts.ts",
"menu_ts.ts",
"settings_ts.ts",
"logs_ts.ts",
"base_ts.ts",
"configuration_ts.ts",
"authentication_ts.ts"
"network.ts",
"menu.ts",
"content.ts",
"popup.ts",
"xmltv.ts",
"settings.ts",
"logs.ts",
"base.ts",
"configuration.ts",
"authentication.ts"
]
}
+206
View File
@@ -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"
}
}
}
+20 -7
View File
@@ -9,9 +9,11 @@ import (
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"xteve/src"
)
@@ -21,7 +23,7 @@ const Name = "xTeVe"
// Version : Version, die Build Nummer wird in der main func geparst.
// Can be overwritten at build time: -ldflags "-X main.Version=..."
var Version = "2.2.0.0201"
var Version = "2.3.0.0202"
// DBVersion : Datanbank Version
const DBVersion = "2.1.0"
@@ -111,7 +113,7 @@ func main() {
err := src.Init()
if err != nil {
src.ShowError(err, 0)
os.Exit(0)
os.Exit(1)
}
src.ShowSystemInfo()
@@ -150,12 +152,13 @@ func main() {
err := src.Init()
if err != nil {
src.ShowError(err, 0)
os.Exit(0)
os.Exit(1)
}
err = src.XteveRestoreFromCLI(*restore)
if err != nil {
src.ShowError(err, 0)
os.Exit(1)
}
os.Exit(0)
@@ -164,25 +167,35 @@ func main() {
err := src.Init()
if err != nil {
src.ShowError(err, 0)
os.Exit(0)
os.Exit(1)
}
err = src.StartSystem(false)
if err != nil {
src.ShowError(err, 0)
os.Exit(0)
os.Exit(1)
}
// Graceful stop on SIGINT / SIGTERM: say goodbye on the network (SSDP)
// and exit cleanly instead of letting the runtime kill the process.
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
src.Shutdown()
os.Exit(0)
}()
err = src.InitMaintenance()
if err != nil {
src.ShowError(err, 0)
os.Exit(0)
os.Exit(1)
}
err = src.StartWebserver()
if err != nil {
src.ShowError(err, 0)
os.Exit(0)
os.Exit(1)
}
}