Phase 3: Go hygiene pass, runtime PUID/PGID, fork README
continuous-integration/drone/push Build encountered an error

Go:
- staticcheck 399 -> 0 with staticcheck.conf (style checks ST1000/1003/
  1005/1016/1020/1021/1022 excluded; error strings are shown in the UI).
- io/ioutil and rand.Seed removed; CloseNotifier kept with a lint-ignore
  until the Phase 2 context rewrite.
- Dead code deleted: Auto handler, getStreamByChannelID, updateXEPG,
  indexOfInt, jsonToMapInt64, removeOldSystemData, randomTime, and the
  commented-out blocks in struct-buffer.go and internal/authentication.
- Duplicates folded: cacheImagesInBackground(), one addErrorToStream().
- Bugs found by SA4006/SA5001: os.Create handle leaked per ffmpeg segment
  (buffer.go), http.NewRequest error unchecked (buffer.go), xepg.json
  migration wrote null on read error (migrate.go), WriteUserData errors
  silently dropped (authentication.go), defer Close before error check
  (buffer.go, toolchain.go). checkFilePermission results were discarded;
  an unwritable config or temp dir is now fatal at start-up.
- gofmt applied repo-wide; Drone runs gofmt check and staticcheck.

Docker:
- Entrypoint starts as root, applies PUID/PGID (falls back to XTEVE_UID/
  XTEVE_GID, then image defaults), fixes config ownership only when it
  differs, then drops to xteve via su-exec. --user starts skip all of it.
- /xteve removed from LEGACY_CONFIG_DIRS (it is the parent of the default).
- mwader/static-ffmpeg pinned to 7.1.1; VOLUME /xteve/config.
- Compose files pull registry.coadcorp.com/nathan/xteve:latest, use
  PUID/PGID/TZ, and explain that SSDP needs host networking.
- .dockerignore excludes the npm toolchain (bundle stays in html/js).

Docs: README rewritten for the fork (about, registry, compose, env vars,
security notes); README-DEV gains a container section.
This commit is contained in:
2026-09-26 12:57:43 +10:00
parent ddc70e170a
commit 504ea3f9f4
38 changed files with 1034 additions and 1382 deletions
+7
View File
@@ -16,3 +16,10 @@ agent.md
skill.md
*.md
!changelog-beta.md
# Web UI toolchain. The compiled bundle is committed under html/js and is
# embedded by the Go build, so the TypeScript sources and npm tree are not
# needed in the build context. html/ itself must stay in the context.
node_modules/
package.json
package-lock.json
+2
View File
@@ -8,6 +8,8 @@ steps:
commands:
- go mod download
- go vet ./...
- test -z "$(gofmt -l .)" || (echo "gofmt needed:" && gofmt -l . && exit 1)
- go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...
- go test ./...
- name: webui-check
+10 -3
View File
@@ -17,14 +17,18 @@ RUN XTEVE_VERSION="$(grep -m1 '^#### ' changelog-beta.md | cut -d' ' -f2 | sed '
&& CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w -X main.Version=$XTEVE_VERSION" -o /out/xteve ./xteve.go
FROM mwader/static-ffmpeg:latest AS ffmpeg
# 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
FROM alpine:3.23
# Build-time defaults for the xteve user. The entrypoint can change them at
# runtime with PUID/PGID, so these only matter when neither is set.
ARG XTEVE_UID=1000
ARG XTEVE_GID=1000
RUN apk add --no-cache ca-certificates tzdata \
RUN apk add --no-cache ca-certificates tzdata su-exec \
&& addgroup -S -g "${XTEVE_GID}" xteve \
&& adduser -S -D -H -u "${XTEVE_UID}" -G xteve xteve \
&& mkdir -p /xteve/config \
@@ -37,13 +41,16 @@ COPY --from=ffmpeg /ffmpeg /usr/local/bin/ffmpeg
COPY --from=ffmpeg /ffprobe /usr/local/bin/ffprobe
COPY docker/entrypoint.sh /usr/local/bin/docker-entrypoint.sh
USER xteve
# The entrypoint starts as root so it can apply PUID/PGID and fix ownership of
# the config directory, then drops to the xteve user with su-exec.
EXPOSE 34400/tcp
ENV XTEVE_CONFIG=/xteve/config
ENV XTEVE_PORT=34400
VOLUME ["/xteve/config"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${XTEVE_PORT}/healthz" > /dev/null || exit 1
+4
View File
@@ -55,3 +55,7 @@ Drone (`.drone.yml`) runs vet, tests, the bundle check, hadolint and compose val
## Endpoints useful for operations
* `GET /healthz` returns `{"status":"ok","version":...,"scanInProgress":...}` with no authentication. The Docker healthcheck uses it.
## Container image
`Dockerfile` builds the binary in a `golang:1.27.1-alpine` stage, copies static `ffmpeg`/`ffprobe` from a pinned `mwader/static-ffmpeg` tag (bump the tag in the Dockerfile; there is no other place to change) and runs on `alpine`. The runtime stage installs `su-exec` and does not set `USER`: `docker/entrypoint.sh` starts as root, applies `PUID`/`PGID` to the `xteve` user by editing `/etc/passwd` and `/etc/group`, fixes ownership of the config directory, and execs `su-exec xteve:xteve xteve`. With `--user` it skips all of that. `ARG XTEVE_UID`/`XTEVE_GID` remain the build-time defaults so Drone's build args still work. Check the script with `sh -n` and `shellcheck -s sh` after editing.
+70 -138
View File
@@ -8,9 +8,23 @@
Documentation for setup and configuration is [here](https://github.com/xteve-project/xTeVe-Documentation/blob/master/en/configuration.md).
#### Donation
* **Bitcoin:** 1c1iCe4CJPfNUXtqxKBbW2Qd2EtqRPWme
![Bitcoin](html/img/BC-QR.jpg "Bitcoin - xTeVe")
## About this fork
The upstream xTeVe project has been inactive since 2021. This fork is maintained privately for a single trusted LAN. It is Docker-only and built for amd64 only. There are no release archives and no self-update.
What it adds over upstream:
* Web UI redesign with a mobile and accessibility pass (responsive navigation, keyboard flow, focus visibility, ARIA announcements, contrast).
* Plex API guide refresh: after a lineup or XEPG update xTeVe can ask Plex to reload the DVR guide. New settings: `use_plexAPI`, `plex.url`, `plex.token`.
* Strict or relaxed handling of channels whose EPG source went missing in XEPG, with automatic re-mapping.
* A wizard-completed flag so the setup wizard is not shown again on restart.
* First-party Docker image with a static ffmpeg, runtime `PUID`/`PGID`, and a healthcheck.
* Drone CI: vet, tests, web bundle check, Dockerfile lint, compose validation, image publishing.
* Web UI embedded in the binary with `go:embed`.
* Unauthenticated `GET /healthz` endpoint for container health checks.
* The self-updater and the GitHub branch switching are removed.
The plan for further work is in `tasks/improvement-plan.md`.
## Requirements
### Plex
@@ -48,164 +62,82 @@ Documentation for setup and configuration is [here](https://github.com/xteve-pro
---
## Project Analysis (UI + Operations)
## Running the container
The core architecture is strong: a Go backend with websocket-driven UI updates, filesystem-based state, and very low runtime overhead.
The weakest points are mostly operational and UX-focused:
### Image
* UI was historically utility-first and desktop-biased, with limited responsive behavior and visual hierarchy.
* Container usage was documented externally but there was no first-party Dockerfile/compose setup in this repository.
* Static web assets are generated into `src/webUI.go`, which works, but creates large diffs and a heavier edit/build cycle.
The image is built by Drone and pushed to a private registry:
### Recommended next technical improvements
1. Replace generated `src/webUI.go` with Go `embed` for simpler static asset management and cleaner PR diffs.
2. Add CI checks (`go test ./...`, build on Linux/arm64/amd64, docker build smoke test).
3. Add a dedicated health endpoint (for example `/healthz`) to decouple health checks from HDHomeRun endpoints.
4. Add integration tests around websocket commands that mutate settings/files to reduce regression risk.
---
## Container-First Run (Included In This Repo)
### Build image
```bash
docker build -t xteve:local .
```
registry.coadcorp.com/nathan/xteve
```
### Run with Docker Compose (bridge mode)
Tags:
* `latest` and `<commit sha>` from the `master` branch.
* `<branch name>` and `<commit sha>` from every other branch.
```bash
docker pull registry.coadcorp.com/nathan/xteve:latest
```
The image is linux/amd64 only.
### Start with Docker Compose
Bridge mode (default). The web UI is on port 34400.
```bash
docker compose up -d
```
Compose file: `docker-compose.yml`
Persistent config volume: `./docker-data/config:/xteve/config`
Host networking. Use this if you want Plex or Emby to discover the tuner by SSDP/DLNA. Multicast does not cross the Docker bridge, so publishing UDP 1900 in bridge mode is not enough on its own. Linux only.
### Run with Docker Compose (host networking, Linux recommended for discovery)
```bash
docker compose -f docker-compose.host.yml up -d
```
Host networking improves LAN discovery behavior (SSDP/DLNA) for Plex/Emby in many setups.
In bridge mode you can still add the tuner manually in Plex or Emby with `http://<host-ip>:34400`.
### Container environment variables
Both compose files pull `registry.coadcorp.com/nathan/xteve:latest`. To build locally instead, uncomment the `build:` block in the compose file, or run `docker build -t xteve:local .`.
* `XTEVE_CONFIG` (default: `/xteve/config`)
* `XTEVE_PORT` (default: `34400`)
### Environment variables
### Image details
| Variable | Default | Meaning |
|---|---|---|
| `XTEVE_CONFIG` | `/xteve/config` | Config directory inside the container. |
| `XTEVE_PORT` | `34400` | HTTP port xTeVe listens on. |
| `PUID` | `1000` | User id xTeVe runs as. Set it to the owner of the host config directory. |
| `PGID` | `1000` | Group id xTeVe runs as. |
| `TZ` | unset (UTC) | Time zone, for example `Australia/Sydney`. |
* Multi-stage build (Go builder + minimal Alpine runtime)
* Runs as non-root user (`xteve`)
* Built-in healthcheck against `http://127.0.0.1:${XTEVE_PORT}/healthz`
The container starts as root, sets the `xteve` user to `PUID`/`PGID`, fixes the ownership of the config directory if needed, and then drops to that user before starting xTeVe. If the container is started with `--user`, the ids are left alone and xTeVe runs as the given user. The old `XTEVE_UID`/`XTEVE_GID` variables are still accepted as aliases.
### Volume
The config lives in `/xteve/config` (declared as a volume in the image). The compose files bind it to `./docker-data/config`. It holds `settings.json`, the mapping and EPG data, backups and the cache, so it is small enough to back up as a whole.
If an older container kept its config in `/config` or `/home/xteve/.xteve`, the entrypoint copies it to the new location on first start.
### Healthcheck
The image has a `HEALTHCHECK` that requests `http://127.0.0.1:${XTEVE_PORT}/healthz` every 30 seconds. The endpoint needs no authentication and returns the version and whether a scan is in progress. `docker ps` shows the state as `healthy` or `unhealthy`.
### ffmpeg
A static ffmpeg and ffprobe are in the image at `/usr/local/bin/ffmpeg` and `/usr/local/bin/ffprobe`, taken from the pinned `mwader/static-ffmpeg` image. In a container xTeVe defaults `ffmpeg.path` to that location. VLC is not included.
---
## Downloads v2 | 64 Bit only
#### 64 Bit Intel / AMD
## Security notes
* [Windows](https://github.com/xteve-project/xTeVe-Downloads/blob/master/xteve_windows_amd64.zip?raw=true)
* [OS X](https://github.com/xteve-project/xTeVe-Downloads/blob/master/xteve_darwin_amd64.zip?raw=true)
* [Linux](https://github.com/xteve-project/xTeVe-Downloads/blob/master/xteve_linux_amd64.zip?raw=true)
* [FreeBSD](https://github.com/xteve-project/xTeVe-Downloads/blob/master/xteve_freebsd_amd64.zip?raw=true)
#### 64 Bit ARM
* [Linux](https://github.com/xteve-project/xTeVe-Downloads/blob/master/xteve_linux_arm64.zip?raw=true)
#### Recommended Docker Image (Linux 64 Bit)
Thanks to @alturismo and @LeeD for creating the Docker Images.
**Created by alturismo:**
[xTeVe](https://hub.docker.com/r/alturismo/xteve)
[xTeVe / Guide2go](https://hub.docker.com/r/alturismo/xteve_guide2go)
[xTeVe / Guide2go / owi2plex](https://hub.docker.com/r/alturismo/xteve_g2g_owi)
Including:
- Guide2go: XMLTV grabber for Schedules Direct
- owi2plex: XMLTV file grabber for Enigma receivers
**Created by LeeD:**
[xTeVe / Guide2go / Zap2XML](https://hub.docker.com/r/dnsforge/xteve)
Including:
- Guide2go: XMLTV grabber for Schedules Direct
- Zap2XML: Perl based zap2it XMLTV grabber
- Bash: A Unix / Linux shell
- Crond: Daemon to execute scheduled commands
- Perl: Programming language
* This fork is meant for a trusted LAN. Do not put it on the internet.
* Web authentication is off by default. Turn it on under Settings > Authentication before you expose it any further than the LAN, for example through a reverse proxy.
* `/healthz` is always unauthenticated. It reveals the version and scan state only.
* The container starts as root to apply `PUID`/`PGID` and then drops privileges. Start it with `--user` if you prefer it never to run as root.
---
### xTeVe Beta branch
New features and bug fixes are only available in beta branch. Only after successful testing are they are merged into the master branch.
**It is not recommended to use the beta version in a production system.**
With the command line argument `branch` the Git Branch can be changed. xTeVe must be started via the terminal.
#### Switch from master to beta branch:
```
xteve -branch beta
...
[xTeVe] GitHub: https://github.com/xteve-project
[xTeVe] Git Branch: beta [xteve-project]
...
```
#### Switch from beta to master branch:
```
xteve -branch master
...
[xTeVe] GitHub: https://github.com/xteve-project
[xTeVe] Git Branch: master [xteve-project]
...
```
When the branch is changed, an update is only performed if there is a new version and the update function is activated in the settings.
---
## Build from source code [Go / Golang]
#### Requirements
* [Go](https://golang.org) (go1.16.2 or newer)
#### Dependencies
* [go-ssdp](https://github.com/koron/go-ssdp)
* [websocket](https://github.com/gorilla/websocket)
* [osext](https://github.com/kardianos/osext)
#### Build
1. Download source code
2. Install dependencies
```
go get github.com/koron/go-ssdp
go get github.com/gorilla/websocket
go get github.com/kardianos/osext
```
3. Build xTeVe
```
go build xteve.go
```
---
## Fork without pull request :mega:
When creating a fork, the xTeVe GitHub account must be changed from the source code or the update function disabled.
Future updates of the xteve-project would update your fork. :wink:
xteve.go - Line: 29
```Go
var GitHub = GitHubStruct{Branch: "master", User: "xteve-project", Repo: "xTeVe-Downloads", Update: true}
/*
Branch: GitHub Branch
User: GitHub Username
Repo: GitHub Repository
Update: Automatic updates from the GitHub repository [true|false]
*/
```
## Development
See [README-DEV.md](README-DEV.md) for the layout, how to build the binary and the web UI, and how CI publishes the image.
+16 -8
View File
@@ -1,18 +1,26 @@
# Host-network compose file for the private xTeVe image.
#
# Host networking lets SSDP/DLNA multicast reach the container, so Plex and
# Emby can discover the tuner automatically. Ports are not published in this
# mode; xTeVe listens on XTEVE_PORT directly on the host. Linux only.
services:
xteve:
build:
context: .
dockerfile: Dockerfile
args:
XTEVE_UID: ${XTEVE_UID:-1000}
XTEVE_GID: ${XTEVE_GID:-1000}
image: registry.coadcorp.com/nathan/xteve:latest
# Uncomment to build locally instead of pulling from the registry.
# build:
# context: .
# dockerfile: Dockerfile
container_name: xteve
restart: unless-stopped
network_mode: host
environment:
XTEVE_CONFIG: /xteve/config
XTEVE_PORT: "34400"
XTEVE_UID: ${XTEVE_UID:-1000}
XTEVE_GID: ${XTEVE_GID:-1000}
# Owner of the config directory inside the container. Set these to the
# uid/gid that owns ./docker-data/config on the host.
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
# Time zone for log timestamps and the EPG, for example Australia/Sydney.
TZ: ${TZ:-UTC}
volumes:
- ./docker-data/config:/xteve/config
+19 -8
View File
@@ -1,18 +1,29 @@
# Bridge-mode compose file for the private xTeVe image.
#
# SSDP/DLNA discovery (how Plex and Emby find the tuner automatically) uses
# multicast on UDP 1900. Multicast does not cross the Docker bridge, so
# publishing 1900/udp below is not enough on its own. If you want discovery
# to work, use host networking instead: docker-compose.host.yml. In bridge
# mode you can still add the tuner in Plex/Emby by entering
# http://<host-ip>:34400 manually.
services:
xteve:
build:
context: .
dockerfile: Dockerfile
args:
XTEVE_UID: ${XTEVE_UID:-1000}
XTEVE_GID: ${XTEVE_GID:-1000}
image: registry.coadcorp.com/nathan/xteve:latest
# Uncomment to build locally instead of pulling from the registry.
# build:
# context: .
# dockerfile: Dockerfile
container_name: xteve
restart: unless-stopped
environment:
XTEVE_CONFIG: /xteve/config
XTEVE_PORT: "34400"
XTEVE_UID: ${XTEVE_UID:-1000}
XTEVE_GID: ${XTEVE_GID:-1000}
# Owner of the config directory inside the container. Set these to the
# uid/gid that owns ./docker-data/config on the host.
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
# Time zone for log timestamps and the EPG, for example Australia/Sydney.
TZ: ${TZ:-UTC}
ports:
- "34400:34400/tcp"
- "1900:1900/udp"
+114 -10
View File
@@ -2,7 +2,16 @@
set -eu
DEFAULT_CONFIG_DIR="/xteve/config"
LEGACY_CONFIG_DIRS="/config /xteve /home/xteve/.xteve"
# Older images and other Docker images kept the config in one of these.
# /xteve itself is not listed because it is the parent of the default dir.
LEGACY_CONFIG_DIRS="/config /home/xteve/.xteve"
RUN_USER="xteve"
XTEVE_BIN="/usr/local/bin/xteve"
log() {
echo "[entrypoint] $*"
}
resolve_config_dir() {
if [ -n "${XTEVE_CONFIG:-}" ] && [ "${XTEVE_CONFIG}" != "${DEFAULT_CONFIG_DIR}" ]; then
@@ -48,11 +57,75 @@ settings_initialized() {
return 1
}
is_number() {
case "$1" in
''|*[!0-9]*) return 1 ;;
*) return 0 ;;
esac
}
# Apply PUID/PGID to the xteve user and group. Only called when running as root.
# Falls back to XTEVE_UID/XTEVE_GID (the old variable names), then to the ids
# baked into the image at build time.
apply_ids() {
current_uid="$(id -u "${RUN_USER}")"
current_gid="$(id -g "${RUN_USER}")"
want_uid="${PUID:-${XTEVE_UID:-${current_uid}}}"
want_gid="${PGID:-${XTEVE_GID:-${current_gid}}}"
if ! is_number "${want_uid}" || ! is_number "${want_gid}"; then
log "ERROR: PUID/PGID must be numeric (got PUID=${want_uid} PGID=${want_gid})" >&2
exit 1
fi
if [ "${want_gid}" != "${current_gid}" ]; then
# Edit /etc/group in place. deluser/delgroup on busybox refuse to remove
# a group that still has members, so sed is the safest option.
sed -i "s/^${RUN_USER}:\([^:]*\):${current_gid}:/${RUN_USER}:\1:${want_gid}:/" /etc/group
fi
if [ "${want_uid}" != "${current_uid}" ] || [ "${want_gid}" != "${current_gid}" ]; then
sed -i "s/^${RUN_USER}:\([^:]*\):${current_uid}:${current_gid}:/${RUN_USER}:\1:${want_uid}:${want_gid}:/" /etc/passwd
fi
new_uid="$(id -u "${RUN_USER}")"
new_gid="$(id -g "${RUN_USER}")"
if [ "${new_uid}" != "${want_uid}" ] || [ "${new_gid}" != "${want_gid}" ]; then
log "ERROR: failed to set ${RUN_USER} to UID:GID ${want_uid}:${want_gid} (now ${new_uid}:${new_gid})" >&2
exit 1
fi
}
# Make sure the config directory belongs to the xteve user. The directory is
# small (settings, cache, backups), so a recursive chown is acceptable, but it
# is skipped when the ownership is already right.
fix_config_owner() {
dir="$1"
uid="$2"
gid="$3"
owner="$(stat -c '%u:%g' "${dir}" 2>/dev/null || echo "")"
if [ "${owner}" != "${uid}:${gid}" ] || [ "${MIGRATED}" = "1" ]; then
log "Setting ownership of ${dir} to ${uid}:${gid}"
chown -R "${uid}:${gid}" "${dir}" || log "WARNING: chown of ${dir} failed; continuing"
fi
}
CONFIG_DIR="$(resolve_config_dir)"
PORT="${XTEVE_PORT:-34400}"
# The binary uses XTEVE_CONFIG to detect that it runs in a container and to
# default ffmpeg.path to /usr/local/bin/ffmpeg. Keep it in sync with the
# resolved directory so both agree.
export XTEVE_CONFIG="${CONFIG_DIR}"
mkdir -p "${CONFIG_DIR}"
# Set to 1 when files were copied from a legacy directory. When that happens as
# root the copies are root-owned, so the ownership fix below must not be skipped.
MIGRATED=0
if ! settings_initialized "${CONFIG_DIR}/settings.json"; then
for legacy_dir in ${LEGACY_CONFIG_DIRS}; do
if [ "${legacy_dir}" = "${CONFIG_DIR}" ]; then
@@ -60,7 +133,7 @@ if ! settings_initialized "${CONFIG_DIR}/settings.json"; then
fi
if settings_initialized "${legacy_dir}/settings.json"; then
echo "[entrypoint] Migrating existing configuration from ${legacy_dir} to ${CONFIG_DIR}"
log "Migrating existing configuration from ${legacy_dir} to ${CONFIG_DIR}"
for file in authentication.json pms.json settings.json xepg.json urls.json; do
if [ "${file}" = "settings.json" ] || [ "${file}" = "xepg.json" ] || [ "${file}" = "urls.json" ]; then
@@ -74,24 +147,55 @@ if ! settings_initialized "${CONFIG_DIR}/settings.json"; then
copy_if_missing "${legacy_dir}/${dir_name}" "${CONFIG_DIR}/${dir_name}"
done
MIGRATED=1
break
fi
done
fi
if ! touch "${CONFIG_DIR}/.xteve-write-test" 2>/dev/null; then
echo "[entrypoint] ERROR: Config directory is not writable: ${CONFIG_DIR}" >&2
echo "[entrypoint] Running as UID:GID $(id -u):$(id -g)" >&2
if [ "$(id -u)" = "0" ]; then
# Started as root (the default): apply PUID/PGID, fix ownership, drop privileges.
apply_ids
RUN_UID="$(id -u "${RUN_USER}")"
RUN_GID="$(id -g "${RUN_USER}")"
fix_config_owner "${CONFIG_DIR}" "${RUN_UID}" "${RUN_GID}"
if ! su-exec "${RUN_USER}:${RUN_USER}" touch "${CONFIG_DIR}/.xteve-write-test" 2>/dev/null; then
log "ERROR: Config directory is not writable: ${CONFIG_DIR}" >&2
log "xteve would run as UID:GID ${RUN_UID}:${RUN_GID}" >&2
ls -ld "${CONFIG_DIR}" >&2 || true
echo "[entrypoint] Hint: ensure host path ownership/permissions allow this UID:GID to write, or set matching container UID/GID at build time." >&2
log "Hint: set PUID/PGID to the owner of the host directory, or fix its ownership/permissions on the host." >&2
exit 1
fi
rm -f "${CONFIG_DIR}/.xteve-write-test"
echo "[entrypoint] Using config directory: ${CONFIG_DIR}"
echo "[entrypoint] Running as UID:GID $(id -u):$(id -g)"
log "Using config directory: ${CONFIG_DIR}"
log "Running as UID:GID ${RUN_UID}:${RUN_GID} (PUID/PGID)"
if [ -f "${CONFIG_DIR}/settings.json" ]; then
echo "[entrypoint] settings.json details: $(ls -l "${CONFIG_DIR}/settings.json" | awk '{print $1, $3, $4, $5, $9}')"
log "settings.json details: $(ls -l "${CONFIG_DIR}/settings.json" | awk '{print $1, $3, $4, $5, $9}')"
fi
exec /usr/local/bin/xteve -config "${CONFIG_DIR}" -port "${PORT}" "$@"
exec su-exec "${RUN_USER}:${RUN_USER}" "${XTEVE_BIN}" -config "${CONFIG_DIR}" -port "${PORT}" "$@"
fi
# Started with --user: keep the given identity, no id or ownership changes.
if [ -n "${PUID:-}${PGID:-}${XTEVE_UID:-}${XTEVE_GID:-}" ]; then
log "Container started as non-root; PUID/PGID are ignored"
fi
if ! touch "${CONFIG_DIR}/.xteve-write-test" 2>/dev/null; then
log "ERROR: Config directory is not writable: ${CONFIG_DIR}" >&2
log "Running as UID:GID $(id -u):$(id -g)" >&2
ls -ld "${CONFIG_DIR}" >&2 || true
log "Hint: ensure the host path ownership/permissions allow this UID:GID to write, or drop --user and set PUID/PGID instead." >&2
exit 1
fi
rm -f "${CONFIG_DIR}/.xteve-write-test"
log "Using config directory: ${CONFIG_DIR}"
log "Running as UID:GID $(id -u):$(id -g)"
if [ -f "${CONFIG_DIR}/settings.json" ]; then
log "settings.json details: $(ls -l "${CONFIG_DIR}/settings.json" | awk '{print $1, $3, $4, $5, $9}')"
fi
exec "${XTEVE_BIN}" -config "${CONFIG_DIR}" -port "${PORT}" "$@"
+10 -6
View File
@@ -63,7 +63,7 @@ func createFirstUserForAuthentication(username, password string) (token string,
func tokenAuthentication(token string) (newToken string, err error) {
if System.ConfigurationWizard == true {
if System.ConfigurationWizard {
return
}
@@ -109,7 +109,7 @@ func urlAuth(r *http.Request, requestType string) (err error) {
case "m3u":
level = "authentication.m3u"
if Settings.AuthenticationM3U == true {
if Settings.AuthenticationM3U {
token, err = authentication.UserAuthentication(username, password)
if err != nil {
return
@@ -119,7 +119,7 @@ func urlAuth(r *http.Request, requestType string) (err error) {
case "xml":
level = "authentication.xml"
if Settings.AuthenticationXML == true {
if Settings.AuthenticationXML {
token, err = authentication.UserAuthentication(username, password)
if err != nil {
return
@@ -150,18 +150,22 @@ func checkAuthorizationLevel(token, level string) (err error) {
if v, ok := userData[level].(bool); ok {
if v == false {
if !v {
err = errors.New("No authorization")
}
} else {
userData[level] = false
err = authentication.WriteUserData(userID, userData)
if err = authentication.WriteUserData(userID, userData); err != nil {
ShowError(err, 0)
}
err = errors.New("No authorization")
}
} else {
err = authentication.WriteUserData(userID, userData)
if err = authentication.WriteUserData(userID, userData); err != nil {
ShowError(err, 0)
}
err = errors.New("No authorization")
}
+1 -2
View File
@@ -4,7 +4,6 @@ import (
b64 "encoding/base64"
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
@@ -32,7 +31,7 @@ func xTeVeAutoBackup() (err error) {
}
// Alte Backups löschen
files, err := ioutil.ReadDir(System.Folder.Backup)
files, err := os.ReadDir(System.Folder.Backup)
if err == nil {
+77 -86
View File
@@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
@@ -146,7 +145,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
}
// Neuer Stream bei einer bereits aktiven Playlist
if newStream == true {
if newStream {
// Prüfen ob die Playlist noch einen weiteren Stream erlaubt (Tuner)
if len(playlist.Streams) >= playlist.Tuner {
@@ -193,7 +192,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
}
// Überprüfen ob der Stream breits von einem anderen Client abgespielt wird
if playlist.Streams[streamID].Status == false && newStream == true {
if !playlist.Streams[streamID].Status && newStream {
// Neuer Buffer wird benötigt
stream = playlist.Streams[streamID]
@@ -235,7 +234,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
if stream, ok := playlist.Streams[streamID]; ok {
if stream.Status == false {
if !stream.Status {
timeOut++
@@ -261,6 +260,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
// HTTP Clientverbindung überwachen
//lint:ignore SA1019 replaced by request context in Phase 2
cn, ok := w.(http.CloseNotifier)
if ok {
@@ -310,10 +310,11 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
var fileName = stream.Folder + f
file, err := os.Open(fileName)
defer file.Close()
if err == nil {
defer file.Close()
l, err := file.Stat()
if err == nil {
@@ -327,7 +328,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
file.Seek(0, 0)
if streaming == false {
if !streaming {
contentType := http.DetectContentType(buffer)
_ = contentType
@@ -406,7 +407,7 @@ func getTmpFiles(stream *ThisStream) (tmpFiles []string) {
if _, err := os.Stat(tmpFolder); !os.IsNotExist(err) {
files, err := ioutil.ReadDir(getPlatformPath(tmpFolder))
files, err := os.ReadDir(getPlatformPath(tmpFolder))
if err != nil {
ShowError(err, 000)
return
@@ -455,7 +456,7 @@ func killClientConnection(streamID int, playlistID string, force bool) {
var playlist = p.(Playlist)
if force == true {
if force {
delete(playlist.Streams, streamID)
showInfo(fmt.Sprintf("Streaming Status:Playlist: %s - Tuner: %d / %d", playlist.PlaylistName, len(playlist.Streams), playlist.Tuner))
return
@@ -531,6 +532,21 @@ func clientConnection(stream ThisStream) (status bool) {
return
}
// addErrorToStream : Fehler an die Clientverbindung des Streams weitergeben
func addErrorToStream(playlist Playlist, streamID int, playlistID string, err error) {
var stream = playlist.Streams[streamID]
if c, ok := BufferClients.Load(playlistID + stream.MD5); ok {
var clients = c.(ClientConnection)
clients.Error = err
BufferClients.Store(playlistID+stream.MD5, clients)
}
}
func connectToStreamingServer(streamID int, playlistID string) {
if p, ok := BufferInformation.Load(playlistID); ok {
@@ -575,26 +591,12 @@ func connectToStreamingServer(streamID int, playlistID string) {
}
var addErrorToStream = func(err error) {
var stream = playlist.Streams[streamID]
if c, ok := BufferClients.Load(playlistID + stream.MD5); ok {
var clients = c.(ClientConnection)
clients.Error = err
BufferClients.Store(playlistID+stream.MD5, clients)
}
}
os.RemoveAll(getPlatformPath(tmpFolder))
err := checkFolder(tmpFolder)
if err != nil {
ShowError(err, 0)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
return
}
@@ -609,11 +611,11 @@ func connectToStreamingServer(streamID int, playlistID string) {
return
}
var stream ThisStream = playlist.Streams[streamID]
var stream = playlist.Streams[streamID]
if stream.Status == false {
if !stream.Status {
if strings.Index(stream.URL, ".m3u8") != -1 {
if strings.Contains(stream.URL, ".m3u8") {
showInfo("Streaming Type:" + "[HLS / M3U8]")
} else {
showInfo("Streaming Type:" + "[TS]")
@@ -631,7 +633,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
for {
if clientConnection(stream) == false {
if !clientConnection(stream) {
return
}
@@ -654,6 +656,14 @@ func connectToStreamingServer(streamID int, playlistID string) {
Redirect:
req, err := http.NewRequest("GET", currentURL, nil)
if err != nil {
ShowError(err, 0)
addErrorToStream(playlist, streamID, playlistID, err)
killClientConnection(streamID, playlistID, true)
clientConnection(stream)
return
}
req.Header.Set("User-Agent", getUserAgent())
req.Header.Set("Connection", "close")
//req.Header.Set("Range", "bytes=0-")
@@ -679,7 +689,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
fmt.Println("Current URL:", currentURL)
ShowError(err, 0)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
killClientConnection(streamID, playlistID, true)
clientConnection(stream)
@@ -708,7 +718,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
err = errors.New("Streaming server")
ShowError(err, 4002)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
defer resp.Body.Close()
@@ -719,7 +729,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
} else {
ShowError(err, 0)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
defer resp.Body.Close()
@@ -749,7 +759,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
showDebug(debug, 1)
BufferInformation.Store(playlist.PlaylistID, playlist)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
killClientConnection(streamID, playlistID, true)
clientConnection(stream)
@@ -759,7 +769,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
}
// Informationen über den Streamingserver auslesen
if stream.Status == false {
if !stream.Status {
if len(stream.URLStreamingServer) == 0 {
@@ -802,10 +812,10 @@ func connectToStreamingServer(streamID int, playlistID string) {
// M3U8 Playlist
case "application/x-mpegurl", "application/vnd.apple.mpegurl", "audio/mpegurl", "audio/x-mpegurl":
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
ShowError(err, 0)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
}
stream.Body = string(body)
@@ -815,7 +825,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
err = parseM3U8(&stream)
if err != nil {
ShowError(err, 4050)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
}
// Video Stream (TS)
@@ -835,7 +845,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
var tmpFile = fmt.Sprintf("%s%d.ts", tmpFolder, tmpSegment)
if clientConnection(stream) == false {
if !clientConnection(stream) {
resp.Body.Close()
return
}
@@ -843,7 +853,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
bufferFile, err := os.Create(tmpFile)
if err != nil {
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
bufferFile.Close()
resp.Body.Close()
return
@@ -866,7 +876,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
if err != nil && err != io.EOF {
ShowError(err, 0)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
resp.Body.Close()
return
@@ -877,7 +887,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
if _, err := bufferFile.Write(buffer[:n]); err != nil {
ShowError(err, 0)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
resp.Body.Close()
return
@@ -887,7 +897,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
fileSize = fileSize + n
if clientConnection(stream) == false {
if !clientConnection(stream) {
resp.Body.Close()
bufferFile.Close()
@@ -929,7 +939,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
tmpFile = fmt.Sprintf("%s%d.ts", tmpFolder, tmpSegment)
if clientConnection(stream) == false {
if !clientConnection(stream) {
bufferFile.Close()
resp.Body.Close()
@@ -944,7 +954,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
bufferFile, err = os.Create(tmpFile)
if err != nil {
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
resp.Body.Close()
return
}
@@ -970,7 +980,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
err = errors.New("Streaming error")
ShowError(err, 4003)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
resp.Body.Close()
return
}
@@ -978,7 +988,7 @@ func connectToStreamingServer(streamID int, playlistID string) {
s++
// Wartezeit für den Download das nächste Segments berechnen
if stream.HLS == true {
if stream.HLS {
var sleep float64
@@ -1143,14 +1153,13 @@ func parseM3U8(stream *ThisStream) (err error) {
}
return
}
if strings.Contains(stream.Body, "#EXTM3U") {
var lines = strings.Split(strings.Replace(stream.Body, "\r\n", "\n", -1), "\n")
if stream.DynamicBandwidth == false {
if !stream.DynamicBandwidth {
stream.DynamicStream = make(map[int]DynamicStream)
}
@@ -1173,7 +1182,7 @@ func parseM3U8(stream *ThisStream) (err error) {
}
// M3U8 enthällt mehrere Links zu weiteren M3U8 Wiedergabelisten (Bandbreitenoption)
if segment.Info == true && len(line) > 0 && line[0:1] != "#" {
if segment.Info && len(line) > 0 && line[0:1] != "#" {
var dynamicStream DynamicStream
@@ -1217,7 +1226,7 @@ func parseM3U8(stream *ThisStream) (err error) {
noNewSegment = true
if stream.Status == false {
if !stream.Status {
if len(m3u8Segments) >= 2 {
m3u8Segments = m3u8Segments[0 : len(m3u8Segments)-1]
@@ -1229,7 +1238,7 @@ func parseM3U8(stream *ThisStream) (err error) {
segment = s
if stream.Status == false {
if !stream.Status {
noNewSegment = false
stream.LastSequence = segment.Sequence
@@ -1255,9 +1264,9 @@ func parseM3U8(stream *ThisStream) (err error) {
}
if noNewSegment == false {
if !noNewSegment {
if stream.DynamicBandwidth == true {
if stream.DynamicBandwidth {
switchBandwidth(stream)
} else {
stream.Segment = append(stream.Segment, segment)
@@ -1265,7 +1274,7 @@ func parseM3U8(stream *ThisStream) (err error) {
}
if noNewSegment == true {
if noNewSegment {
var sleep = lastSegmentDuration * 0.5
@@ -1371,33 +1380,19 @@ func thirdPartyBuffer(streamID int, playlistID string) {
return
}
var addErrorToStream = func(err error) {
var stream = playlist.Streams[streamID]
if c, ok := BufferClients.Load(playlistID + stream.MD5); ok {
var clients = c.(ClientConnection)
clients.Error = err
BufferClients.Store(playlistID+stream.MD5, clients)
}
}
os.RemoveAll(getPlatformPath(tmpFolder))
err := checkFolder(tmpFolder)
if err != nil {
ShowError(err, 0)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
return
}
err = checkFile(path)
if err != nil {
ShowError(err, 0)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
return
}
@@ -1409,7 +1404,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
f, err := os.Create(tmpFile)
f.Close()
if err != nil {
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
return
}
@@ -1461,7 +1456,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
ShowError(err, 0)
cmd.Process.Kill()
cmd.Wait()
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
return
}
@@ -1471,11 +1466,11 @@ func thirdPartyBuffer(streamID int, playlistID string) {
ShowError(err, 0)
cmd.Process.Kill()
cmd.Wait()
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
return
}
if len(buf.Bytes()) == 0 && stream.Status == false {
if len(buf.Bytes()) == 0 && !stream.Status {
showInfo(bufferType + ":Processing data")
}
@@ -1543,7 +1538,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
cmd.Process.Kill()
err = errors.New("Timout")
ShowError(err, 4006)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
cmd.Wait()
f.Close()
return
@@ -1553,11 +1548,11 @@ func thirdPartyBuffer(streamID int, playlistID string) {
}
if fileSize == 0 && stream.Status == false {
if fileSize == 0 && !stream.Status {
showInfo("Streaming Status:Receive data from " + bufferType)
}
if clientConnection(stream) == false {
if !clientConnection(stream) {
cmd.Process.Kill()
f.Close()
cmd.Wait()
@@ -1574,7 +1569,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
if _, err := f.Write(buffer[:n]); err != nil {
cmd.Process.Kill()
ShowError(err, 0)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
cmd.Wait()
f.Close()
return
@@ -1582,7 +1577,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
if fileSize >= bufferSize/2 {
if tmpSegment == 1 && stream.Status == false {
if tmpSegment == 1 && !stream.Status {
close(t)
close(streamStatus)
showInfo(fmt.Sprintf("Streaming Status:Buffering data from %s", bufferType))
@@ -1591,7 +1586,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
f.Close()
tmpSegment++
if stream.Status == false {
if !stream.Status {
Lock.Lock()
stream.Status = true
playlist.Streams[streamID] = stream
@@ -1603,13 +1598,11 @@ func thirdPartyBuffer(streamID int, playlistID string) {
fileSize = 0
var errCreate, errOpen error
f, errCreate = os.Create(tmpFile)
f, errOpen = os.OpenFile(tmpFile, os.O_APPEND|os.O_WRONLY, 0600)
if errCreate != nil || errOpen != nil {
f, err = os.OpenFile(tmpFile, os.O_CREATE|os.O_TRUNC|os.O_APPEND|os.O_WRONLY, 0666)
if err != nil {
cmd.Process.Kill()
ShowError(err, 0)
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
cmd.Wait()
f.Close()
return
@@ -1623,7 +1616,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
cmd.Wait()
err = errors.New(bufferType + " error")
addErrorToStream(err)
addErrorToStream(playlist, streamID, playlistID, err)
ShowError(err, 1204)
time.Sleep(time.Duration(500) * time.Millisecond)
@@ -1694,7 +1687,6 @@ func debugRequest(req *http.Request) {
debug = "Request:* * * * * * END HTTP(S) REQUEST * * * * * *"
showDebug(debug, debugLevel)
return
}
func debugResponse(resp *http.Response) {
@@ -1739,5 +1731,4 @@ func debugResponse(resp *http.Response) {
debug = "Pesponse:* * * * * * END RESPONSE * * * * * * "
showDebug(debug, debugLevel)
return
}
+11 -4
View File
@@ -152,8 +152,9 @@ func Init() (err error) {
// Berechtigung aller Ordner überprüfen
err = checkFilePermission(System.Folder.Config)
if err == nil {
err = checkFilePermission(System.Folder.Temp)
if err != nil {
ShowError(err, 0)
return
}
// Separaten tmp Ordner für jede Instanz
@@ -165,6 +166,12 @@ func Init() (err error) {
return
}
err = checkFilePermission(System.Folder.Temp)
if err != nil {
ShowError(err, 0)
return
}
err = removeChildItems(getPlatformPath(System.Folder.Temp))
if err != nil {
return
@@ -173,7 +180,7 @@ func Init() (err error) {
// Branch festlegen
System.Branch = Settings.Branch
if System.Dev == true {
if System.Dev {
System.Branch = "Development"
}
@@ -217,7 +224,7 @@ func StartSystem(updateProviderFiles bool) (err error) {
showInfo(fmt.Sprintf("Unfiltered Chan. Limit:%d", System.UnfilteredChannelLimit))
// Providerdaten aktualisieren
if len(Settings.Files.M3U) > 0 && Settings.FilesUpdate == true || updateProviderFiles == true {
if len(Settings.Files.M3U) > 0 && Settings.FilesUpdate || updateProviderFiles {
err = xTeVeAutoBackup()
if err != nil {
+12 -21
View File
@@ -73,10 +73,6 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e
}
if len(newUpdateTimes) == 0 {
//newUpdateTimes = append(newUpdateTimes, "0000")
}
value = newUpdateTimes
case "cache.images":
@@ -179,7 +175,7 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e
return
}
if Settings.AuthenticationWEB == false {
if !Settings.AuthenticationWEB {
Settings.AuthenticationAPI = false
Settings.AuthenticationM3U = false
@@ -221,7 +217,7 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e
settings = Settings
if reloadData == true {
if reloadData {
err = buildDatabaseDVR()
if err != nil {
@@ -232,7 +228,7 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e
}
if cacheImages == true {
if cacheImages {
if Settings.EpgSource == "XEPG" && System.ImageCachingInProgress == 0 {
@@ -271,7 +267,7 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e
}
if createXEPGFiles == true {
if createXEPGFiles {
go func() {
createXMLTVFile()
@@ -280,7 +276,7 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e
}
if triggerPlexGuideReload == true {
if triggerPlexGuideReload {
queuePlexGuideRefresh("settings change")
}
@@ -378,7 +374,7 @@ func saveFiles(request RequestStruct, fileType string) (err error) {
return
}
if reloadData == true {
if reloadData {
err = buildDatabaseDVR()
if err != nil {
@@ -452,7 +448,6 @@ func deleteLocalProviderFiles(dataID, fileType string) {
os.RemoveAll(System.Folder.Data + dataID + fileExtension)
}
return
}
// Filtereinstellungen speichern (WebUI)
@@ -505,7 +500,7 @@ func saveFilter(request RequestStruct) (settings SettingsStruct, err error) {
if len(filter) == 0 {
err = errors.New(getErrMsg(1014))
if newFilter == true {
if newFilter {
delete(filterMap, dataID)
}
@@ -573,7 +568,7 @@ func saveXEpgMapping(request RequestStruct) (err error) {
// Wenn während des erstellen der Datanbank das Mapping erneut gespeichert wird, wird die Datenbank erst später erneut aktualisiert.
go func() {
if System.BackgroundProcess == true {
if System.BackgroundProcess {
return
}
@@ -591,7 +586,7 @@ func saveXEpgMapping(request RequestStruct) (err error) {
cleanupXEPG()
System.ScanInProgress = 0
buildXEPG(false)
showInfo("XEPG:" + fmt.Sprintf("Ready to use"))
showInfo("XEPG:" + "Ready to use")
System.BackgroundProcess = false
@@ -632,7 +627,7 @@ func saveUserData(request RequestStruct) (err error) {
return
}
if request.DeleteUser == true {
if request.DeleteUser {
err = authentication.RemoveUser(userID)
return
}
@@ -859,7 +854,7 @@ func buildDatabaseDVR() (err error) {
for n, i := range playlistFile {
var channels []any
var groupTitle, tvgID, uuid int = 0, 0, 0
var groupTitle, tvgID, uuid = 0, 0, 0
var keys = []string{"group-title", "tvg-id", "uuid"}
var compatibility = make(map[string]int)
@@ -905,11 +900,7 @@ func buildDatabaseDVR() (err error) {
if value, ok := s[key]; ok {
if len(value) > 0 {
if _, ok := tmpGroupsM3U[value]; ok {
tmpGroupsM3U[value]++
} else {
tmpGroupsM3U[value] = 1
}
groupTitle++
}
@@ -985,7 +976,7 @@ func buildDatabaseDVR() (err error) {
for group, count := range tmpGroupsM3U {
var text = fmt.Sprintf("%s (%d)", group, count)
var value = fmt.Sprintf("%s", group)
var value = group
Data.Playlist.M3U.Groups.Text = append(Data.Playlist.M3U.Groups.Text, text)
Data.Playlist.M3U.Groups.Value = append(Data.Playlist.M3U.Groups.Value, value)
}
+1 -1
View File
@@ -154,7 +154,7 @@ func getLineup() (jsonContent []byte, err error) {
return
}
if xepgChannel.XActive == true {
if xepgChannel.XActive {
var stream LineupStream
stream.GuideName = xepgChannel.XName
stream.GuideNumber = xepgChannel.XChannelID
+28 -32
View File
@@ -20,45 +20,41 @@ func ShowSystemInfo() {
fmt.Println("OK")
println()
fmt.Println(fmt.Sprintf("Version: %s %s.%s", System.Name, System.Version, System.Build))
fmt.Println(fmt.Sprintf("Branch: %s", System.Branch))
fmt.Println(fmt.Sprintf("Folder (config): %s", System.Folder.Config))
fmt.Printf("Version: %s %s.%s\n", System.Name, System.Version, System.Build)
fmt.Printf("Branch: %s\n", System.Branch)
fmt.Printf("Folder (config): %s\n", System.Folder.Config)
fmt.Println(fmt.Sprintf("Streams: %d / %d", len(Data.Streams.Active), len(Data.Streams.All)))
fmt.Println(fmt.Sprintf("Filter: %d", len(Data.Filter)))
fmt.Println(fmt.Sprintf("XEPG Chanels: %d", int(Data.XEPG.XEPGCount)))
fmt.Printf("Streams: %d / %d\n", len(Data.Streams.Active), len(Data.Streams.All))
fmt.Printf("Filter: %d\n", len(Data.Filter))
fmt.Printf("XEPG Chanels: %d\n", int(Data.XEPG.XEPGCount))
println()
fmt.Println(fmt.Sprintf("IPv4 Addresses:"))
fmt.Println("IPv4 Addresses:")
for i, ipv4 := range System.IPAddressesV4 {
switch count := i; {
case count < 10:
fmt.Println(fmt.Sprintf(" %d. %s", count, ipv4))
break
fmt.Printf(" %d. %s\n", count, ipv4)
case count < 100:
fmt.Println(fmt.Sprintf(" %d. %s", count, ipv4))
break
fmt.Printf(" %d. %s\n", count, ipv4)
}
}
println()
fmt.Println(fmt.Sprintf("IPv6 Addresses:"))
fmt.Println("IPv6 Addresses:")
for i, ipv4 := range System.IPAddressesV6 {
switch count := i; {
case count < 10:
fmt.Println(fmt.Sprintf(" %d. %s", count, ipv4))
break
fmt.Printf(" %d. %s\n", count, ipv4)
case count < 100:
fmt.Println(fmt.Sprintf(" %d. %s", count, ipv4))
break
fmt.Printf(" %d. %s\n", count, ipv4)
}
@@ -67,33 +63,33 @@ func ShowSystemInfo() {
println("---")
fmt.Println("Settings [General]")
fmt.Println(fmt.Sprintf("UUID: %s", Settings.UUID))
fmt.Println(fmt.Sprintf("Tuner (Plex / Emby): %d", Settings.Tuner))
fmt.Println(fmt.Sprintf("EPG Source: %s", Settings.EpgSource))
fmt.Printf("UUID: %s\n", Settings.UUID)
fmt.Printf("Tuner (Plex / Emby): %d\n", Settings.Tuner)
fmt.Printf("EPG Source: %s\n", Settings.EpgSource)
println("---")
fmt.Println("Settings [Files]")
fmt.Println(fmt.Sprintf("Schedule: %s", strings.Join(Settings.Update, ",")))
fmt.Println(fmt.Sprintf("Files Update: %t", Settings.FilesUpdate))
fmt.Println(fmt.Sprintf("Folder (tmp): %s", Settings.TempPath))
fmt.Println(fmt.Sprintf("Image Chaching: %t", Settings.CacheImages))
fmt.Println(fmt.Sprintf("Missing EPG Mode: %s", Settings.XepgMissingEPGMode))
fmt.Println(fmt.Sprintf("Replace EPG Image: %t", Settings.XepgReplaceMissingImages))
fmt.Printf("Schedule: %s\n", strings.Join(Settings.Update, ","))
fmt.Printf("Files Update: %t\n", Settings.FilesUpdate)
fmt.Printf("Folder (tmp): %s\n", Settings.TempPath)
fmt.Printf("Image Chaching: %t\n", Settings.CacheImages)
fmt.Printf("Missing EPG Mode: %s\n", Settings.XepgMissingEPGMode)
fmt.Printf("Replace EPG Image: %t\n", Settings.XepgReplaceMissingImages)
println("---")
fmt.Println("Settings [Streaming]")
fmt.Println(fmt.Sprintf("Buffer: %s", Settings.Buffer))
fmt.Println(fmt.Sprintf("UDPxy: %s", Settings.UDPxy))
fmt.Println(fmt.Sprintf("Buffer Size: %d KB", Settings.BufferSize))
fmt.Println(fmt.Sprintf("Timeout: %d ms", int(Settings.BufferTimeout)))
fmt.Println(fmt.Sprintf("User Agent: %s", Settings.UserAgent))
fmt.Printf("Buffer: %s\n", Settings.Buffer)
fmt.Printf("UDPxy: %s\n", Settings.UDPxy)
fmt.Printf("Buffer Size: %d KB\n", Settings.BufferSize)
fmt.Printf("Timeout: %d ms\n", int(Settings.BufferTimeout))
fmt.Printf("User Agent: %s\n", Settings.UserAgent)
println("---")
fmt.Println("Settings [Backup]")
fmt.Println(fmt.Sprintf("Folder (backup): %s", Settings.BackupPath))
fmt.Println(fmt.Sprintf("Backup Keep: %d", Settings.BackupKeep))
fmt.Printf("Folder (backup): %s\n", Settings.BackupPath)
fmt.Printf("Backup Keep: %d\n", Settings.BackupKeep)
}
+3 -96
View File
@@ -3,7 +3,6 @@ package authentication
import (
"encoding/json"
"errors"
"io/ioutil"
"net/http"
"os"
"path/filepath"
@@ -44,90 +43,6 @@ type Cookie struct {
RawExpires string
}
// Framework examples
/*
func main() {
var err error
var checkErr = func(err error) {
log.Println(err)
os.Exit(0)
}
err = Init("", 10) // Path to save the data, Validity of tokens in minutes | (error)
if err != nil {
checkErr(err)
}
err = CreateDefaultUser("admin", "123")
if err != nil {
checkErr(err)
}
err = CreateNewUser("xteve", "xteve") // Username, Password | (error)
if err != nil {
checkErr(err)
}
err, token := UserAuthentication("xteve", "xteve") // Username, Password | (error, token)
if err != nil {
checkErr(err)
} else {
fmt.Println("UserAuthentication()")
fmt.Println("Token:", token)
fmt.Println("---")
}
err, newToken := CheckTheValidityOfTheToken(token) // Current token | (error, new token)
if err != nil {
checkErr(err)
} else {
fmt.Println("CheckTheValidityOfTheToken()")
fmt.Println("New Token:", newToken)
fmt.Println("---")
}
err, userID := GetUserID(newToken) // Current token | (error, user id)
if err != nil {
checkErr(err)
} else {
fmt.Println("GetUserID()")
fmt.Println("User ID:", userID)
fmt.Println("---")
}
var userData = make(map[string]interface{})
userData["type"] = "Administrator"
err = WriteUserData(userID, userData) // User id, user data | (error)
if err != nil {
checkErr(err)
}
err, userData = ReadUserData(userID) // User id | (error, userData)
if err != nil {
checkErr(err)
} else {
fmt.Println("ReadUserData()")
fmt.Println("User data:", userData)
fmt.Println("---")
}
err = RemoveUser(userID)
if err != nil {
checkErr(err)
}
}
*/
// Init : databasePath = Path to authentication.json
func Init(databasePath string, validity int) (err error) {
database = filepath.Dir(databasePath) + string(os.PathSeparator) + databaseFile
@@ -457,7 +372,7 @@ func CheckTheValidityOfTheTokenFromHTTPHeader(w http.ResponseWriter, r *http.Req
// Framework tools
func checkInit() (err error) {
if initAuthentication == false {
if !initAuthentication {
err = createError(000)
}
@@ -472,7 +387,7 @@ func saveDatabase(tmpMap any) (err error) {
return
}
err = ioutil.WriteFile(database, []byte(jsonString), 0600)
err = os.WriteFile(database, []byte(jsonString), 0600)
if err != nil {
return
}
@@ -481,7 +396,7 @@ func saveDatabase(tmpMap any) (err error) {
}
func loadDatabase() (err error) {
jsonString, err := ioutil.ReadFile(database)
jsonString, err := os.ReadFile(database)
if err != nil {
return
}
@@ -587,14 +502,6 @@ loopToken:
return
}
func mapToJSON(tmpMap any) string {
jsonString, err := json.MarshalIndent(tmpMap, "", " ")
if err != nil {
return "{}"
}
return string(jsonString)
}
// SetCookieToken : set cookie
func SetCookieToken(w http.ResponseWriter, token string) http.ResponseWriter {
expiration := time.Now().Add(time.Minute * time.Duration(tokenValidity))
+3 -4
View File
@@ -3,7 +3,6 @@ package imgcache
import (
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
@@ -51,7 +50,7 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
src = strings.Trim(src, "\r\n")
if c.caching == false {
if !c.caching {
return src
}
@@ -143,7 +142,7 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
c.Lock()
defer c.Unlock()
files, err := ioutil.ReadDir(c.path)
files, err := os.ReadDir(c.path)
if err != nil {
return
}
@@ -165,7 +164,7 @@ func New(path, chacheURL string, caching bool) (c *Cache, err error) {
}
files, err := ioutil.ReadDir(c.path)
files, err := os.ReadDir(c.path)
if err != nil {
return
}
+19 -19
View File
@@ -3,18 +3,18 @@ package m3u
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"os"
"testing"
)
type M3UStream struct {
GroupTitle string `json:"group-title,required"`
Name string `json:"name,required"`
TvgID string `json:"tvg-id,required"`
TvgLogo string `json:"tvg-logo,required"`
TvgName string `json:"tvg-name,required"`
URL string `json:"url,required"`
GroupTitle string `json:"group-title"`
Name string `json:"name"`
TvgID string `json:"tvg-id"`
TvgLogo string `json:"tvg-logo"`
TvgName string `json:"tvg-name"`
URL string `json:"url"`
UUIDKey string `json:"_uuid.key,omitempty"`
UUIDValue string `json:"_uuid.value,omitempty"`
}
@@ -22,7 +22,7 @@ type M3UStream struct {
func TestStream1(t *testing.T) {
var file = "test_list_1.m3u"
var content, err = ioutil.ReadFile(file)
var content, err = os.ReadFile(file)
if err != nil {
t.Error(err)
return
@@ -58,26 +58,26 @@ func checkStream(streamInterface []any) (err error) {
err = json.Unmarshal(jsonString, &m3uStream)
if err == nil {
log.Print(fmt.Sprintf("Stream: %d", i))
log.Print(fmt.Sprintf("Name*: %s", m3uStream.Name))
log.Print(fmt.Sprintf("URL*: %s", m3uStream.URL))
log.Print(fmt.Sprintf("tvg-name: %s", m3uStream.TvgName))
log.Print(fmt.Sprintf("tvg-id**: %s", m3uStream.TvgID))
log.Print(fmt.Sprintf("tvg-logo: %s", m3uStream.TvgLogo))
log.Print(fmt.Sprintf("group-title**: %s", m3uStream.GroupTitle))
log.Printf("Stream: %d", i)
log.Printf("Name*: %s", m3uStream.Name)
log.Printf("URL*: %s", m3uStream.URL)
log.Printf("tvg-name: %s", m3uStream.TvgName)
log.Printf("tvg-id**: %s", m3uStream.TvgID)
log.Printf("tvg-logo: %s", m3uStream.TvgLogo)
log.Printf("group-title**: %s", m3uStream.GroupTitle)
if len(m3uStream.UUIDKey) > 0 {
log.Print(fmt.Sprintf("UUID key***: %s", m3uStream.UUIDKey))
log.Print(fmt.Sprintf("UUID value: %s", m3uStream.UUIDValue))
log.Printf("UUID key***: %s", m3uStream.UUIDKey)
log.Printf("UUID value: %s", m3uStream.UUIDValue)
} else {
log.Print(fmt.Sprintf("UUID key: false"))
log.Print("UUID key: false")
}
}
}
log.Println(fmt.Sprintf("- - - - - (*: Required) | (**: Nice to have) | (***: Love it) - - - - -"))
log.Println("- - - - - (*: Required) | (**: Nice to have) | (***: Love it) - - - - -")
}
return
+1 -2
View File
@@ -2,7 +2,6 @@ package m3u
import (
"errors"
"fmt"
"log"
"net/url"
"regexp"
@@ -124,7 +123,7 @@ func MakeInterfaceFromM3U(byteStream []byte) (allChannels []any, err error) {
if strings.Contains(strings.ToLower(key), "id") {
if indexOfString(value, uuids) != -1 {
log.Println(fmt.Sprintf("Channel: %s - %s = %s ", stream["name"], key, value))
log.Printf("Channel: %s - %s = %s ", stream["name"], key, value)
break
}
+4 -4
View File
@@ -114,18 +114,18 @@ func filterThisStream(s any) (status bool) {
}
}
if match == true {
if match {
if len(exclude) > 0 {
var status = checkConditions(search, exclude, "exclude")
if status == false {
if !status {
return false
}
}
if len(include) > 0 {
var status = checkConditions(search, include, "include")
if status == false {
if !status {
return false
}
}
@@ -191,7 +191,7 @@ func buildM3U(groups []string) (m3u string, err error) {
err := json.Unmarshal([]byte(mapToJSON(dxc)), &xepgChannel)
if err == nil {
if xepgChannel.XActive == true {
if xepgChannel.XActive {
if len(groups) > 0 {
+1 -9
View File
@@ -1,15 +1,12 @@
package src
import (
"math/rand"
"time"
)
// InitMaintenance : Wartungsprozess initialisieren
func InitMaintenance() (err error) {
rand.Seed(time.Now().Unix())
go maintenance()
return
@@ -50,7 +47,7 @@ func maintenance() {
ShowError(err, 000)
}
if Settings.CacheImages == false && System.ImageCachingInProgress == 0 {
if !Settings.CacheImages && System.ImageCachingInProgress == 0 {
removeChildItems(System.Folder.ImagesCache)
}
@@ -68,8 +65,3 @@ func maintenance() {
}
}
func randomTime(min, max int) int {
rand.Seed(time.Now().Unix())
return rand.Intn(max-min) + min
}
+3
View File
@@ -131,6 +131,9 @@ func convertToNewFilter(oldFilter []any) (newFilterMap map[int]any) {
func setValueForUUID() (err error) {
xepg, err := loadJSONFileToMap(System.File.XEPG)
if err != nil {
return
}
for _, c := range xepg {
+18 -18
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"io"
"net/http"
"net/url"
"strconv"
@@ -26,7 +26,7 @@ var plexRefreshState = struct {
// queuePlexGuideRefresh schedules a debounced Plex DVR guide refresh.
func queuePlexGuideRefresh(reason string) {
if Settings.UsePlexAPI == false {
if !Settings.UsePlexAPI {
return
}
@@ -36,7 +36,7 @@ func queuePlexGuideRefresh(reason string) {
plexRefreshState.reason = reason
}
if plexRefreshState.scheduled == true || plexRefreshState.inProgress == true {
if plexRefreshState.scheduled || plexRefreshState.inProgress {
plexRefreshState.queued = true
plexRefreshState.Unlock()
return
@@ -78,13 +78,13 @@ func runPlexGuideRefresh(initialDelay time.Duration) {
plexRefreshState.inProgress = false
nextDelay := time.Duration(0)
if runAgain == true && Settings.UsePlexAPI == true {
if runAgain && Settings.UsePlexAPI {
plexRefreshState.scheduled = true
nextDelay = plexRefreshDelayLocked()
}
plexRefreshState.Unlock()
if runAgain == false || Settings.UsePlexAPI == false {
if !runAgain || !Settings.UsePlexAPI {
return
}
@@ -98,7 +98,7 @@ func plexRefreshDelayLocked() (delay time.Duration) {
const minDelay = 2 * time.Second
const cooldown = 20 * time.Second
if plexRefreshState.lastRun.IsZero() == true {
if plexRefreshState.lastRun.IsZero() {
return minDelay
}
@@ -113,7 +113,7 @@ func plexRefreshDelayLocked() (delay time.Duration) {
func refreshPlexGuide(reason string) (err error) {
baseURL, token, ready := getPlexConfig()
if ready == false {
if !ready {
return nil
}
@@ -178,7 +178,7 @@ func getPlexConfig() (baseURL, token string, ready bool) {
baseURL = strings.TrimSpace(Settings.PlexURL)
token = strings.TrimSpace(Settings.PlexToken)
if strings.HasPrefix(baseURL, "http://") == false && strings.HasPrefix(baseURL, "https://") == false && len(baseURL) > 0 {
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") && len(baseURL) > 0 {
baseURL = "http://" + baseURL
}
@@ -189,7 +189,7 @@ func getPlexConfig() (baseURL, token string, ready bool) {
if len(baseURL) == 0 || len(token) == 0 {
if plexRefreshState.missingConfigLogged == false {
if !plexRefreshState.missingConfigLogged {
showInfo("Plex API:Skipped refresh because plex.url or plex.token is empty")
plexRefreshState.missingConfigLogged = true
}
@@ -221,14 +221,14 @@ func discoverPlexDVRs(baseURL, token string) (dvrs []map[string]any, err error)
}
mediaContainer, ok := payload["MediaContainer"].(map[string]any)
if ok == false {
if !ok {
err = errors.New("Plex API response missing MediaContainer")
return
}
for _, key := range []string{"Dvr", "DVR", "Directory", "Metadata"} {
if raw, found := mediaContainer[key]; found == true {
if list, ok := raw.([]any); ok == true {
if raw, found := mediaContainer[key]; found {
if list, ok := raw.([]any); ok {
dvrs = convertToPlexMapSlice(list)
if len(dvrs) > 0 {
return
@@ -246,7 +246,7 @@ func convertToPlexMapSlice(list []any) (dvrs []map[string]any) {
dvrs = make([]map[string]any, 0, len(list))
for _, item := range list {
if dvr, ok := item.(map[string]any); ok == true {
if dvr, ok := item.(map[string]any); ok {
dvrs = append(dvrs, dvr)
}
}
@@ -264,10 +264,10 @@ func getPlexReloadEndpoints(dvr map[string]any) (endpoints []string) {
if len(endpoint) == 0 {
return
}
if strings.HasPrefix(endpoint, "/") == false {
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
if added[endpoint] == true {
if added[endpoint] {
return
}
added[endpoint] = true
@@ -275,7 +275,7 @@ func getPlexReloadEndpoints(dvr map[string]any) (endpoints []string) {
}
if key := getPlexMapString(dvr, "key"); len(key) > 0 {
if strings.HasPrefix(key, "/") == true {
if strings.HasPrefix(key, "/") {
add(strings.TrimRight(key, "/") + "/reloadGuide")
} else {
add("/livetv/dvrs/" + url.PathEscape(key) + "/reloadGuide")
@@ -294,7 +294,7 @@ func getPlexReloadEndpoints(dvr map[string]any) (endpoints []string) {
func getPlexMapString(data map[string]any, field string) string {
for key, value := range data {
if strings.EqualFold(key, field) == false {
if !strings.EqualFold(key, field) {
continue
}
@@ -354,7 +354,7 @@ func doPlexRequest(method, baseURL, endpoint, token string) (status int, body []
defer resp.Body.Close()
status = resp.StatusCode
body, err = ioutil.ReadAll(resp.Body)
body, err = io.ReadAll(resp.Body)
if err != nil {
return
}
+5 -5
View File
@@ -2,7 +2,7 @@ package src
import (
"fmt"
"io/ioutil"
"io"
"net/http"
"net/url"
"strings"
@@ -152,7 +152,7 @@ func getProviderData(fileType, fileID string) (err error) {
}
// Wenn eine ID vorhanden ist und nicht mit der aus der Datanbank übereinstimmt, wird die Aktualisierung übersprungen (goto)
if len(fileID) > 0 && newProvider == false {
if len(fileID) > 0 && !newProvider {
if dataID != fileID {
goto Done
}
@@ -204,7 +204,7 @@ func getProviderData(fileType, fileID string) (err error) {
ShowError(err, 000)
var downloadErr = err
if newProvider == false {
if !newProvider {
// Prüfen ob ältere Datei vorhanden ist
var file = System.Folder.Data + dataID + fileExtension
@@ -236,7 +236,7 @@ func getProviderData(fileType, fileID string) (err error) {
}
// Berechnen der Fehlerquote
if newProvider == false {
if !newProvider {
if value, ok := dataMap[dataID].(map[string]any); ok {
@@ -322,7 +322,7 @@ func downloadFileFromServer(providerURL string) (filename string, body []byte, e
}
body, err = ioutil.ReadAll(resp.Body)
body, err = io.ReadAll(resp.Body)
if err != nil {
return
}
+58 -64
View File
@@ -12,7 +12,7 @@ import (
func showInfo(str string) {
if System.Flag.Info == true {
if System.Flag.Info {
return
}
@@ -39,7 +39,6 @@ func showInfo(str string) {
}
return
}
func showDebug(str string, level int) {
@@ -73,7 +72,6 @@ func showDebug(str string, level int) {
}
return
}
func showHighlight(str string) {
@@ -105,7 +103,6 @@ func showHighlight(str string) {
addNotification(notification)
return
}
func showWarning(errCode int) {
@@ -121,7 +118,6 @@ func showWarning(errCode int) {
WebScreenLog.Warnings++
mutex.Unlock()
return
}
// ShowError : Zeigt die Fehlermeldungen in der Konsole
@@ -139,7 +135,6 @@ func ShowError(err error, errCode int) {
WebScreenLog.Errors++
mutex.Unlock()
return
}
func printLogOnScreen(logMsg string, logType string) {
@@ -211,7 +206,6 @@ func logCleanUp() {
WebScreenLog.Log = logs
return
}
// Fehlercodes
@@ -224,79 +218,79 @@ func getErrMsg(errCode int) (errMsg string) {
// Errors
case 1001:
errMsg = fmt.Sprintf("Web server could not be started.")
errMsg = "Web server could not be started."
case 1002:
errMsg = fmt.Sprintf("No local IP address found.")
errMsg = "No local IP address found."
case 1003:
errMsg = fmt.Sprintf("Invalid xml")
errMsg = "Invalid xml"
case 1004:
errMsg = fmt.Sprintf("File not found")
errMsg = "File not found"
case 1005:
errMsg = fmt.Sprintf("Invalid M3U file, an extended M3U file is required.")
errMsg = "Invalid M3U file, an extended M3U file is required."
case 1006:
errMsg = fmt.Sprintf("No playlist!")
errMsg = "No playlist!"
case 1007:
errMsg = fmt.Sprintf("XEPG requires an XMLTV file.")
errMsg = "XEPG requires an XMLTV file."
case 1010:
errMsg = fmt.Sprintf("Invalid file compression")
errMsg = "Invalid file compression"
case 1011:
errMsg = fmt.Sprintf("Data is corrupt or unavailable, %s now uses an older version of this file", System.Name)
case 1012:
errMsg = fmt.Sprintf("Invalid formatting of the time")
errMsg = "Invalid formatting of the time"
case 1013:
errMsg = fmt.Sprintf("Invalid settings file (settings.json), file must be at least version %s", System.Compatibility)
case 1014:
errMsg = fmt.Sprintf("Invalid filter rule")
errMsg = "Invalid filter rule"
case 1020:
errMsg = fmt.Sprintf("Data could not be saved, invalid keyword")
errMsg = "Data could not be saved, invalid keyword"
// Datenbank Update
case 1030:
errMsg = fmt.Sprintf("Invalid settings file (%s)", System.File.Settings)
case 1031:
errMsg = fmt.Sprintf("Database error. The database version of your settings is not compatible with this version.")
errMsg = "Database error. The database version of your settings is not compatible with this version."
// M3U Parser
case 1050:
errMsg = fmt.Sprintf("Invalid duration specification in the M3U8 playlist.")
errMsg = "Invalid duration specification in the M3U8 playlist."
case 1060:
errMsg = fmt.Sprintf("Invalid characters found in the tvg parameters, streams with invalid parameters were skipped.")
errMsg = "Invalid characters found in the tvg parameters, streams with invalid parameters were skipped."
// Dateisystem
case 1070:
errMsg = fmt.Sprintf("Folder could not be created.")
errMsg = "Folder could not be created."
case 1071:
errMsg = fmt.Sprintf("File could not be created")
errMsg = "File could not be created"
case 1072:
errMsg = fmt.Sprintf("File not found")
errMsg = "File not found"
// Backup
case 1090:
errMsg = fmt.Sprintf("Automatic backup failed")
errMsg = "Automatic backup failed"
// Websockets
case 1100:
errMsg = fmt.Sprintf("WebUI build error")
errMsg = "WebUI build error"
case 1101:
errMsg = fmt.Sprintf("WebUI request error")
errMsg = "WebUI request error"
case 1102:
errMsg = fmt.Sprintf("WebUI response error")
errMsg = "WebUI response error"
// PMS Guide Numbers
case 1200:
errMsg = fmt.Sprintf("Could not create file")
errMsg = "Could not create file"
// Stream URL Fehler
case 1201:
errMsg = fmt.Sprintf("Plex stream error")
errMsg = "Plex stream error"
case 1202:
errMsg = fmt.Sprintf("Steaming URL could not be found in any playlist")
errMsg = "Steaming URL could not be found in any playlist"
case 1203:
errMsg = fmt.Sprintf("Steaming URL could not be found in any playlist")
errMsg = "Steaming URL could not be found in any playlist"
case 1204:
errMsg = fmt.Sprintf("Streaming was stopped by third party transcoder (FFmpeg / VLC)")
errMsg = "Streaming was stopped by third party transcoder (FFmpeg / VLC)"
// Warnings
case 2000:
@@ -304,86 +298,86 @@ func getErrMsg(errCode int) (errMsg string) {
case 2001:
errMsg = fmt.Sprintf("%s has loaded more than %d streams. Use the filter to reduce the number of streams.", System.Name, System.UnfilteredChannelLimit)
case 2002:
errMsg = fmt.Sprintf("PMS can not play m3u8 streams")
errMsg = "PMS can not play m3u8 streams"
case 2003:
errMsg = fmt.Sprintf("PMS can not play streams over RTSP.")
errMsg = "PMS can not play streams over RTSP."
case 2004:
errMsg = fmt.Sprintf("Buffer is disabled for this stream.")
errMsg = "Buffer is disabled for this stream."
case 2005:
errMsg = fmt.Sprintf("There are no channels mapped, use the mapping menu to assign EPG data to the channels.")
errMsg = "There are no channels mapped, use the mapping menu to assign EPG data to the channels."
case 2010:
errMsg = fmt.Sprintf("No valid streaming URL")
errMsg = "No valid streaming URL"
case 2020:
errMsg = fmt.Sprintf("FFmpeg binary was not found. Check the FFmpeg binary path in the xTeVe settings.")
errMsg = "FFmpeg binary was not found. Check the FFmpeg binary path in the xTeVe settings."
case 2021:
errMsg = fmt.Sprintf("VLC binary was not found. Check the VLC path binary in the xTeVe settings.")
errMsg = "VLC binary was not found. Check the VLC path binary in the xTeVe settings."
case 2099:
errMsg = fmt.Sprintf("Updates have been disabled by the developer")
errMsg = "Updates have been disabled by the developer"
// Tuner
case 2105:
errMsg = "The number of tuners has changed, you have to delete " + System.Name + " in Plex / Emby HDHR and set it up again."
case 2106:
errMsg = fmt.Sprintf("This function is only available with XEPG as EPG source")
errMsg = "This function is only available with XEPG as EPG source"
case 2110:
errMsg = fmt.Sprintf("Don't run this as Root!")
errMsg = "Don't run this as Root!"
case 2300:
errMsg = fmt.Sprintf("No channel logo found in the XMLTV or M3U file.")
errMsg = "No channel logo found in the XMLTV or M3U file."
case 2301:
errMsg = fmt.Sprintf("XMLTV file no longer available, channel has been deactivated.")
errMsg = "XMLTV file no longer available, channel has been deactivated."
case 2302:
errMsg = fmt.Sprintf("Channel ID in the XMLTV file has changed. Channel has been deactivated.")
errMsg = "Channel ID in the XMLTV file has changed. Channel has been deactivated."
// Benutzerauthentifizierung
case 3000:
errMsg = fmt.Sprintf("Database for user authentication could not be initialized.")
errMsg = "Database for user authentication could not be initialized."
case 3001:
errMsg = fmt.Sprintf("The user has no authorization to load the channels.")
errMsg = "The user has no authorization to load the channels."
// Buffer
case 4000:
errMsg = fmt.Sprintf("Connection to streaming source was interrupted.")
errMsg = "Connection to streaming source was interrupted."
case 4001:
errMsg = fmt.Sprintf("Too many errors connecting to the provider. Streaming is canceled.")
errMsg = "Too many errors connecting to the provider. Streaming is canceled."
case 4002:
errMsg = fmt.Sprintf("New URL for the redirect to the streaming server is missing")
errMsg = "New URL for the redirect to the streaming server is missing"
case 4003:
errMsg = fmt.Sprintf("Server sends an incompatible content-type")
errMsg = "Server sends an incompatible content-type"
case 4004:
errMsg = fmt.Sprintf("This error message comes from the provider")
errMsg = "This error message comes from the provider"
case 4005:
errMsg = fmt.Sprintf("Temporary buffer files could not be deleted")
errMsg = "Temporary buffer files could not be deleted"
case 4006:
errMsg = fmt.Sprintf("Server connection timeout")
errMsg = "Server connection timeout"
// Buffer (M3U8)
case 4050:
errMsg = fmt.Sprintf("Invalid M3U8 file")
errMsg = "Invalid M3U8 file"
case 4051:
errMsg = fmt.Sprintf("#EXTM3U header is missing")
errMsg = "#EXTM3U header is missing"
// Caching
case 4100:
errMsg = fmt.Sprintf("Unknown content type for downloaded image")
errMsg = "Unknown content type for downloaded image"
case 4101:
errMsg = fmt.Sprintf("Invalid URL, original URL is used for this image")
errMsg = "Invalid URL, original URL is used for this image"
// API
case 5000:
errMsg = fmt.Sprintf("Invalid API command")
errMsg = "Invalid API command"
// Update Server
case 6001:
errMsg = fmt.Sprintf("Ivalid key")
errMsg = "Ivalid key"
case 6002:
errMsg = fmt.Sprintf("Update failed")
errMsg = "Update failed"
case 6003:
errMsg = fmt.Sprintf("Update server not available")
errMsg = "Update server not available"
case 6004:
errMsg = fmt.Sprintf("xTeVe update available")
errMsg = "xTeVe update available"
default:
errMsg = fmt.Sprintf("Unknown error / warning (%d)", errCode)
+2 -2
View File
@@ -13,7 +13,7 @@ import (
// SSDP : SSPD / DLNA Server
func SSDP() (err error) {
if Settings.SSDP == false || System.Flag.Info == true {
if !Settings.SSDP || System.Flag.Info {
return
}
@@ -23,7 +23,7 @@ func SSDP() (err error) {
signal.Notify(quit, os.Interrupt)
ad, err := ssdp.Advertise(
fmt.Sprintf("upnp:rootdevice"), // send as "ST"
"upnp:rootdevice", // send as "ST"
fmt.Sprintf("uuid:%s::upnp:rootdevice", System.DeviceID), // send as "USN"
fmt.Sprintf("%s/device.xml", System.URLBase), // send as "LOCATION"
System.AppName, // send as "SERVER"
-144
View File
@@ -108,147 +108,3 @@ type BandwidthCalculation struct {
Stop time.Time
TimeDiff float64
}
/*
var args = "-hide_banner -loglevel panic -re -i " + url + " -codec copy -f mpegts pipe:1"
//var args = "-re -i " + url + " -codec copy -f mpegts pipe:1"
cmd := exec.Command("/usr/local/bin/ffmpeg", strings.Split(args, " ")...)
//run := exec.Command("/usr/local/bin/ffmpeg", "-hide_banner", "-loglevel", "panic", "-re", "-i", url, "-codec", "copy", "-f", "mpegts", "pipe:1")
//run := exec.Command("/usr/local/bin/ffmpeg", "-re", "-i", url, "-codec", "copy", "-f", "mpegts", "pipe:1")
stderr, _ := cmd.StderrPipe()
cmd.Start()
scanner := bufio.NewScanner(stderr)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
m := scanner.Text()
fmt.Println(m)
}
cmd.Wait()
os.Exit(0)
*/
/*
ffmpegOut, _ := run.StderrPipe()
//run.Start()
scanner = bufio.NewScanner(ffmpegOut)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
m := scanner.Text()
fmt.Println(m)
}
ffmpegOut, err = run.StdoutPipe()
if err != nil {
ShowError(err, 0)
return
}
stderr, stderrErr := run.StderrPipe()
if stderrErr != nil {
fmt.Println(stderrErr)
}
_ = stderr
if startErr := run.Start(); startErr != nil {
fmt.Println(startErr)
return
}
n, err := ffmpegOut.Read(buffer)
_ = n
_ = stream
_ = fileSize
if err != nil && err != io.EOF {
ShowError(err, 0)
addErrorToStream(err)
return
}
defer bufferFile.Close()
scanner = bufio.NewScanner(ffmpegOut)
for scanner.Scan() {
//fmt.Printf("%s\n", scanner.Text())
//fmt.Println(scanner)
thisLine := scanner.Text()
line := make([]byte, len(thisLine))
buffer = append(buffer, line...)
fmt.Println(len(buffer))
if len(buffer) > tmpFileSize {
if _, err := bufferFile.Write(buffer[:]); err != nil {
ShowError(err, 0)
addErrorToStream(err)
run.Process.Kill()
return
}
buffer = make([]byte, 1024*Settings.BufferSize*2)
debug = fmt.Sprintf("Buffer Status:Done (%s)", tmpFile)
showDebug(debug, 2)
bufferFile.Close()
stream.Status = true
playlist.Streams[streamID] = stream
BufferInformation.Store(playlistID, playlist)
tmpSegment++
tmpFile = fmt.Sprintf("%s%d.ts", tmpFolder, tmpSegment)
if clientConnection(stream) == false {
bufferFile.Close()
run.Process.Kill()
err = os.RemoveAll(stream.Folder)
if err != nil {
ShowError(err, 4005)
}
return
}
bufferFile, err = os.Create(tmpFile)
if err != nil {
addErrorToStream(err)
run.Process.Kill()
return
}
fileSize = 0
if n == 0 {
bufferFile.Close()
run.Process.Kill()
break
}
os.Exit(0)
}
}
*/
+53 -54
View File
@@ -155,81 +155,81 @@ type Filter struct {
// XEPGChannelStruct : XEPG Struktur
type XEPGChannelStruct struct {
FileM3UID string `json:"_file.m3u.id,required"`
FileM3UName string `json:"_file.m3u.name,required"`
FileM3UPath string `json:"_file.m3u.path,required"`
GroupTitle string `json:"group-title,required"`
Name string `json:"name,required"`
TvgID string `json:"tvg-id,required"`
TvgLogo string `json:"tvg-logo,required"`
TvgName string `json:"tvg-name,required"`
URL string `json:"url,required"`
UUIDKey string `json:"_uuid.key,required"`
FileM3UID string `json:"_file.m3u.id"`
FileM3UName string `json:"_file.m3u.name"`
FileM3UPath string `json:"_file.m3u.path"`
GroupTitle string `json:"group-title"`
Name string `json:"name"`
TvgID string `json:"tvg-id"`
TvgLogo string `json:"tvg-logo"`
TvgName string `json:"tvg-name"`
URL string `json:"url"`
UUIDKey string `json:"_uuid.key"`
UUIDValue string `json:"_uuid.value,omitempty"`
Values string `json:"_values,required"`
XActive bool `json:"x-active,required"`
XCategory string `json:"x-category,required"`
XChannelID string `json:"x-channelID,required"`
XEPG string `json:"x-epg,required"`
XGroupTitle string `json:"x-group-title,required"`
XMapping string `json:"x-mapping,required"`
XmltvFile string `json:"x-xmltv-file,required"`
XName string `json:"x-name,required"`
XUpdateChannelIcon bool `json:"x-update-channel-icon,required"`
XUpdateChannelName bool `json:"x-update-channel-name,required"`
XDescription string `json:"x-description,required"`
Values string `json:"_values"`
XActive bool `json:"x-active"`
XCategory string `json:"x-category"`
XChannelID string `json:"x-channelID"`
XEPG string `json:"x-epg"`
XGroupTitle string `json:"x-group-title"`
XMapping string `json:"x-mapping"`
XmltvFile string `json:"x-xmltv-file"`
XName string `json:"x-name"`
XUpdateChannelIcon bool `json:"x-update-channel-icon"`
XUpdateChannelName bool `json:"x-update-channel-name"`
XDescription string `json:"x-description"`
}
// M3UChannelStructXEPG : M3U Struktur für XEPG
type M3UChannelStructXEPG struct {
FileM3UID string `json:"_file.m3u.id,required"`
FileM3UName string `json:"_file.m3u.name,required"`
FileM3UPath string `json:"_file.m3u.path,required"`
GroupTitle string `json:"group-title,required"`
Name string `json:"name,required"`
TvgID string `json:"tvg-id,required"`
TvgLogo string `json:"tvg-logo,required"`
TvgName string `json:"tvg-name,required"`
URL string `json:"url,required"`
UUIDKey string `json:"_uuid.key,required"`
UUIDValue string `json:"_uuid.value,required"`
Values string `json:"_values,required"`
FileM3UID string `json:"_file.m3u.id"`
FileM3UName string `json:"_file.m3u.name"`
FileM3UPath string `json:"_file.m3u.path"`
GroupTitle string `json:"group-title"`
Name string `json:"name"`
TvgID string `json:"tvg-id"`
TvgLogo string `json:"tvg-logo"`
TvgName string `json:"tvg-name"`
URL string `json:"url"`
UUIDKey string `json:"_uuid.key"`
UUIDValue string `json:"_uuid.value"`
Values string `json:"_values"`
}
// FilterStruct : Filter Struktur
type FilterStruct struct {
Active bool `json:"active,required"`
CaseSensitive bool `json:"caseSensitive,required"`
Description string `json:"description,required"`
Exclude string `json:"exclude,required"`
Filter string `json:"filter,required"`
Include string `json:"include,required"`
Name string `json:"name,required"`
Active bool `json:"active"`
CaseSensitive bool `json:"caseSensitive"`
Description string `json:"description"`
Exclude string `json:"exclude"`
Filter string `json:"filter"`
Include string `json:"include"`
Name string `json:"name"`
Rule string `json:"rule,omitempty"`
Type string `json:"type,required"`
Type string `json:"type"`
}
// StreamingURLS : Informationen zu allen streaming URL's
type StreamingURLS struct {
Streams map[string]StreamInfo `json:"channels,required"`
Streams map[string]StreamInfo `json:"channels"`
}
// StreamInfo : Informationen zum Kanal für die streaming URL
type StreamInfo struct {
ChannelNumber string `json:"channelNumber,required"`
Name string `json:"name,required"`
PlaylistID string `json:"playlistID,required"`
URL string `json:"url,required"`
URLid string `json:"urlID,required"`
ChannelNumber string `json:"channelNumber"`
Name string `json:"name"`
PlaylistID string `json:"playlistID"`
URL string `json:"url"`
URLid string `json:"urlID"`
}
// Notification : Notifikationen im Webinterface
type Notification struct {
Headline string `json:"headline,required"`
Message string `json:"message,required"`
New bool `json:"new,required"`
Time string `json:"time,required"`
Type string `json:"type,required"`
Headline string `json:"headline"`
Message string `json:"message"`
New bool `json:"new"`
Time string `json:"time"`
Type string `json:"type"`
}
// SettingsStruct : Inhalt der settings.json
@@ -283,4 +283,3 @@ type SettingsStruct struct {
XepgReplaceMissingImages bool `json:"xepg.replace.missing.images"`
WizardCompleted bool `json:"wizard.completed"`
}
+20 -20
View File
@@ -3,7 +3,7 @@ package src
// RequestStruct : Anfragen über die Websocket Schnittstelle
type RequestStruct struct {
// Befehle an xTeVe
Cmd string `json:"cmd,required"`
Cmd string `json:"cmd"`
// Benutzer
DeleteUser bool `json:"deleteUser,omitempty"`
@@ -79,46 +79,46 @@ type ResponseStruct struct {
DVR string `json:"DVR"`
EpgSource string `json:"epgSource"`
Errors int `json:"errors"`
M3U string `json:"m3u-url,required"`
M3U string `json:"m3u-url"`
OS string `json:"os"`
Streams string `json:"streams"`
UUID string `json:"uuid"`
Version string `json:"version"`
Warnings int `json:"warnings"`
XEPGCount int64 `json:"xepg"`
XML string `json:"xepg-url,required"`
XML string `json:"xepg-url"`
} `json:"clientInfo"`
Data struct {
Playlist struct {
M3U struct {
Groups struct {
Text []string `json:"text,required"`
Value []string `json:"value,required"`
} `json:"groups,required"`
} `json:"m3u,required"`
} `json:"playlist,required"`
Text []string `json:"text"`
Value []string `json:"value"`
} `json:"groups"`
} `json:"m3u"`
} `json:"playlist"`
StreamPreviewUI struct {
Active []string `json:"activeStreams,required"`
Inactive []string `json:"inactiveStreams,required"`
Active []string `json:"activeStreams"`
Inactive []string `json:"inactiveStreams"`
}
} `json:"data,required"`
} `json:"data"`
Alert string `json:"alert,omitempty"`
ConfigurationWizard bool `json:"configurationWizard,required"`
ConfigurationWizard bool `json:"configurationWizard"`
Error string `json:"err,omitempty"`
Log WebScreenLogStruct `json:"log,required"`
Log WebScreenLogStruct `json:"log"`
LogoURL string `json:"logoURL,omitempty"`
OpenLink string `json:"openLink,omitempty"`
OpenMenu string `json:"openMenu,omitempty"`
Reload bool `json:"reload,omitempty"`
Settings SettingsStruct `json:"settings,required"`
Status bool `json:"status,required"`
Settings SettingsStruct `json:"settings"`
Status bool `json:"status"`
Token string `json:"token,omitempty"`
Users map[string]any `json:"users,omitempty"`
Wizard int `json:"wizard,omitempty"`
XEPG map[string]any `json:"xepg,required"`
XEPG map[string]any `json:"xepg"`
Notification map[string]Notification `json:"notification,omitempty"`
}
@@ -135,7 +135,7 @@ type APIRequestStruct struct {
type APIResponseStruct struct {
EpgSource string `json:"epg.source,omitempty"`
Error string `json:"err,omitempty"`
Status bool `json:"status,required"`
Status bool `json:"status"`
StreamsActive int64 `json:"streams.active,omitempty"`
StreamsAll int64 `json:"streams.all,omitempty"`
StreamsXepg int64 `json:"streams.xepg,omitempty"`
@@ -149,7 +149,7 @@ type APIResponseStruct struct {
// WebScreenLogStruct : Logs werden im RAM gespeichert und für das Webinterface bereitgestellt
type WebScreenLogStruct struct {
Errors int `json:"errors,required"`
Log []string `json:"log,required"`
Warnings int `json:"warnings,required"`
Errors int `json:"errors"`
Log []string `json:"log"`
Warnings int `json:"warnings"`
}
+2 -5
View File
@@ -13,7 +13,7 @@ import (
// Entwicklerinfos anzeigen
func showDevInfo() {
if System.Dev == true {
if System.Dev {
fmt.Print("\033[31m")
fmt.Println("* * * * * D E V M O D E * * * * *")
@@ -25,7 +25,6 @@ func showDevInfo() {
}
return
}
// Alle Systemordner erstellen
@@ -261,7 +260,7 @@ func saveSettings(settings SettingsStruct) (err error) {
Settings = settings
if System.Dev == true {
if System.Dev {
Settings.UUID = "2019-01-DEV-xTeVe!"
}
@@ -301,7 +300,6 @@ func setGlobalDomain(domain string) {
System.Addresses.XML = getErrMsg(2106)
}
return
}
// UUID generieren
@@ -323,7 +321,6 @@ func setDeviceID() {
System.DeviceID = fmt.Sprintf("%s:%d", id, Settings.Tuner)
}
return
}
// Provider Streaming-URL zu xTeVe Streaming-URL konvertieren
+14 -39
View File
@@ -7,7 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"io"
"net"
"os"
"os/exec"
@@ -59,11 +59,8 @@ func checkFile(filename string) (err error) {
return err
}
switch mode := fi.Mode(); {
case mode.IsDir():
if fi.Mode().IsDir() {
err = fmt.Errorf("%s: %s", file, getErrMsg(1072))
case mode.IsRegular():
break
}
return
@@ -97,7 +94,7 @@ func checkFilePermission(dir string) (err error) {
var filename = dir + "permission.test"
err = ioutil.WriteFile(filename, []byte(""), 0644)
err = os.WriteFile(filename, []byte(""), 0644)
if err == nil {
err = os.RemoveAll(filename)
}
@@ -125,12 +122,6 @@ func getFilenameFromPath(path string) (file string) {
return filepath.Base(path)
}
// Nicht mehr verwendete Systemdaten löschen
func removeOldSystemData() {
// Temporären Ordner löschen
os.RemoveAll(System.Folder.Temp)
}
// Sucht eine Datei im OS
func searchFileInOS(file string) (path string) {
@@ -197,14 +188,6 @@ func jsonToMap(content string) map[string]any {
return (tmpMap)
}
func jsonToMapInt64(content string) map[int64]any {
var tmpMap = make(map[int64]any)
json.Unmarshal([]byte(content), &tmpMap)
return (tmpMap)
}
func jsonToInterface(content string) (tmpMap any, err error) {
err = json.Unmarshal([]byte(content), &tmpMap)
@@ -221,7 +204,7 @@ func saveMapToJSONFile(file string, tmpMap any) error {
return err
}
err = ioutil.WriteFile(filename, []byte(jsonString), 0644)
err = os.WriteFile(filename, []byte(jsonString), 0644)
if err != nil {
return err
}
@@ -232,16 +215,17 @@ func saveMapToJSONFile(file string, tmpMap any) error {
func loadJSONFileToMap(file string) (tmpMap map[string]any, err error) {
f, err := os.Open(getPlatformFile(file))
if err != nil {
return
}
defer f.Close()
content, err := ioutil.ReadAll(f)
content, err := io.ReadAll(f)
if err == nil {
err = json.Unmarshal([]byte(content), &tmpMap)
}
f.Close()
return
}
@@ -249,10 +233,12 @@ func loadJSONFileToMap(file string) (tmpMap map[string]any, err error) {
func readByteFromFile(file string) (content []byte, err error) {
f, err := os.Open(getPlatformFile(file))
if err != nil {
return
}
defer f.Close()
content, err = ioutil.ReadAll(f)
f.Close()
content, err = io.ReadAll(f)
return
}
@@ -260,7 +246,7 @@ func readByteFromFile(file string) (content []byte, err error) {
func writeByteToFile(file string, data []byte) (err error) {
var filename = getPlatformFile(file)
err = ioutil.WriteFile(filename, data, 0644)
err = os.WriteFile(filename, data, 0644)
return
}
@@ -275,7 +261,7 @@ func readStringFromFile(file string) (str string, err error) {
return
}
content, err = ioutil.ReadFile(filename)
content, err = os.ReadFile(filename)
if err != nil {
ShowError(err, 0)
return
@@ -395,17 +381,6 @@ func indexOfFloat64(element float64, data []float64) int {
return -1
}
func indexOfInt(element int, data []int) int {
for k, v := range data {
if element == v {
return (k)
}
}
return -1
}
func getMD5(str string) string {
md5Hasher := md5.New()
+20 -57
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"io"
"net/http"
"os"
"strconv"
@@ -40,8 +40,6 @@ func StartWebserver() (err error) {
http.HandleFunc("/data_images/", DataImages)
http.HandleFunc("/healthz", Healthz)
//http.HandleFunc("/auto/", Auto)
showInfo("DVR IP:" + System.IPAddress + ":" + Settings.Port)
var ips = len(System.IPAddressesV4) + len(System.IPAddressesV6) - 1
@@ -90,7 +88,7 @@ func Index(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
case "/lineup.json":
if Settings.AuthenticationPMS == true {
if Settings.AuthenticationPMS {
_, err := basicAuth(r, "authentication.pms")
if err != nil {
@@ -123,7 +121,6 @@ func Index(w http.ResponseWriter, r *http.Request) {
httpStatusError(w, r, 500)
return
}
// Stream : Web Server /stream/
@@ -151,12 +148,12 @@ func Stream(w http.ResponseWriter, r *http.Request) {
showInfo(fmt.Sprintf("Buffer:false [%s]", Settings.Buffer))
case "xteve":
if strings.Index(streamInfo.URL, "rtsp://") != -1 || strings.Index(streamInfo.URL, "rtp://") != -1 {
if strings.Contains(streamInfo.URL, "rtsp://") || strings.Contains(streamInfo.URL, "rtp://") {
err = errors.New("RTSP and RTP streams are not supported")
ShowError(err, 2004)
showInfo("Streaming URL:" + streamInfo.URL)
http.Redirect(w, r, streamInfo.URL, 302)
http.Redirect(w, r, streamInfo.URL, http.StatusFound)
showInfo("Streaming Info:URL was passed to the client")
return
@@ -181,7 +178,7 @@ func Stream(w http.ResponseWriter, r *http.Request) {
case "-":
showInfo("Streaming URL:" + streamInfo.URL)
http.Redirect(w, r, streamInfo.URL, 302)
http.Redirect(w, r, streamInfo.URL, http.StatusFound)
showInfo("Streaming Info:URL was passed to the client.")
showInfo("Streaming Info:xTeVe is no longer involved, the client connects directly to the streaming server.")
@@ -191,32 +188,6 @@ func Stream(w http.ResponseWriter, r *http.Request) {
}
return
}
// Auto : HDHR routing (wird derzeit nicht benutzt)
func Auto(w http.ResponseWriter, r *http.Request) {
var channelID = strings.Replace(r.RequestURI, "/auto/v", "", 1)
fmt.Println(channelID)
/*
switch Settings.Buffer {
case true:
var playlistID, streamURL, err = getStreamByChannelID(channelID)
if err == nil {
bufferingStream(playlistID, streamURL, w, r)
} else {
httpStatusError(w, r, 404)
}
case false:
httpStatusError(w, r, 423)
}
*/
return
}
// xTeVe : Web Server /xmltv/ und /m3u/
@@ -250,7 +221,7 @@ func xTeVe(w http.ResponseWriter, r *http.Request) {
requestType = "m3u"
groupTitle = r.URL.Query().Get("group-title")
if System.Dev == false {
if !System.Dev {
// false: Dateiname wird im Header gesetzt
// true: M3U wird direkt im Browser angezeigt
w.Header().Set("Content-Disposition", "attachment; filename="+getFilenameFromPath(path))
@@ -286,7 +257,6 @@ func xTeVe(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(content))
}
return
}
// Images : Image Cache /images/
@@ -306,7 +276,6 @@ func Images(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(content)
return
}
// DataImages : Image Pfad für Logos / Bilder die hochgeladen wurden /data_images/
@@ -326,7 +295,6 @@ func DataImages(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(content)
return
}
// WS : Web Sockets /ws/
@@ -362,7 +330,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
return
}
if System.ConfigurationWizard == false {
if !System.ConfigurationWizard {
switch Settings.AuthenticationWEB {
@@ -425,7 +393,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
response.OpenMenu = strconv.Itoa(indexOfString("settings", System.WEB.Menu))
if Settings.AuthenticationWEB == true && authenticationUpdate == false {
if Settings.AuthenticationWEB && !authenticationUpdate {
response.Reload = true
}
@@ -568,7 +536,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
var requestMap = make(map[string]any) // Debug
_ = requestMap
if System.Dev == true {
if System.Dev {
fmt.Println(mapToJSON(requestMap))
}
@@ -581,7 +549,7 @@ func WS(w http.ResponseWriter, r *http.Request) {
}
response = setDefaultResponseData(response, true)
if System.ConfigurationWizard == true {
if System.ConfigurationWizard {
response.ConfigurationWizard = System.ConfigurationWizard
}
@@ -593,7 +561,6 @@ func WS(w http.ResponseWriter, r *http.Request) {
}
return
}
// Healthz : Liveness endpoint for container healthchecks and monitoring.
@@ -629,7 +596,7 @@ func Web(w http.ResponseWriter, r *http.Request) {
if System.ScanInProgress == 0 {
if Settings.WizardCompleted == false && len(Settings.Files.M3U) == 0 && len(Settings.Files.HDHR) == 0 {
if !Settings.WizardCompleted && len(Settings.Files.M3U) == 0 && len(Settings.Files.HDHR) == 0 {
System.ConfigurationWizard = true
}
@@ -675,7 +642,7 @@ func Web(w http.ResponseWriter, r *http.Request) {
}
// Redirect, damit die Daten aus dem Browser gelöscht werden.
w = authentication.SetCookieToken(w, token)
http.Redirect(w, r, "/web", 301)
http.Redirect(w, r, "/web", http.StatusMovedPermanently)
return
}
@@ -691,11 +658,11 @@ func Web(w http.ResponseWriter, r *http.Request) {
}
w = authentication.SetCookieToken(w, token)
http.Redirect(w, r, "/web", 301) // Redirect, damit die Daten aus dem Browser gelöscht werden.
http.Redirect(w, r, "/web", http.StatusMovedPermanently) // Redirect, damit die Daten aus dem Browser gelöscht werden.
} else {
w = authentication.SetCookieToken(w, "-")
http.Redirect(w, r, "/web", 301) // Redirect, damit die Daten aus dem Browser gelöscht werden.
http.Redirect(w, r, "/web", http.StatusMovedPermanently) // Redirect, damit die Daten aus dem Browser gelöscht werden.
}
return
@@ -723,7 +690,7 @@ func Web(w http.ResponseWriter, r *http.Request) {
return
}
if len(allUserData) == 0 && Settings.AuthenticationWEB == true {
if len(allUserData) == 0 && Settings.AuthenticationWEB {
file = "create-first-user.html"
}
@@ -828,13 +795,12 @@ func API(w http.ResponseWriter, r *http.Request) {
response.Status = false
response.Error = err.Error()
w.Write([]byte(mapToJSON(response)))
return
}
response.Status = true
if Settings.API == false {
if !Settings.API {
httpStatusError(w, r, 423)
return
}
@@ -844,7 +810,7 @@ func API(w http.ResponseWriter, r *http.Request) {
return
}
b, err := ioutil.ReadAll(r.Body)
b, err := io.ReadAll(r.Body)
defer r.Body.Close()
if err != nil {
httpStatusError(w, r, 400)
@@ -860,7 +826,7 @@ func API(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
if Settings.AuthenticationAPI == true {
if Settings.AuthenticationAPI {
var token string
switch len(request.Token) {
case 0:
@@ -957,7 +923,6 @@ func API(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(mapToJSON(response)))
return
}
// Download : Datei Download
@@ -975,7 +940,6 @@ func Download(w http.ResponseWriter, r *http.Request) {
os.RemoveAll(System.Folder.Temp + getFilenameFromPath(path))
w.Write([]byte(content))
return
}
func setDefaultResponseData(response ResponseStruct, data bool) (defaults ResponseStruct) {
@@ -999,7 +963,7 @@ func setDefaultResponseData(response ResponseStruct, data bool) (defaults Respon
switch System.Branch {
case "master":
defaults.ClientInfo.Version = fmt.Sprintf("%s", System.Version)
defaults.ClientInfo.Version = System.Version
default:
defaults.ClientInfo.Version = fmt.Sprintf("%s (%s)", System.Version, System.Build)
@@ -1007,7 +971,7 @@ func setDefaultResponseData(response ResponseStruct, data bool) (defaults Respon
}
if data == true {
if data {
defaults.Users, _ = authentication.GetAllUserData()
//defaults.DVR = System.DVRAddress
@@ -1048,7 +1012,6 @@ func setDefaultResponseData(response ResponseStruct, data bool) (defaults Respon
func httpStatusError(w http.ResponseWriter, r *http.Request, httpStatusCode int) {
http.Error(w, fmt.Sprintf("%s [%d]", http.StatusText(httpStatusCode), httpStatusCode), httpStatusCode)
return
}
func getContentType(filename string) (contentType string) {
+39 -138
View File
@@ -5,7 +5,7 @@ import (
"encoding/xml"
"errors"
"fmt"
"io/ioutil"
"os"
"path"
"runtime"
"sort"
@@ -71,28 +71,9 @@ func buildXEPG(background bool) {
createM3UFile()
queuePlexGuideRefresh("xepg rebuild")
showInfo("XEPG:" + fmt.Sprintf("Ready to use"))
showInfo("XEPG:" + "Ready to use")
if Settings.CacheImages == true && System.ImageCachingInProgress == 0 {
go func() {
System.ImageCachingInProgress = 1
showInfo(fmt.Sprintf("Image Caching:Images are cached (%d)", len(Data.Cache.Images.Queue)))
Data.Cache.Images.Image.Caching()
Data.Cache.Images.Image.Remove()
showInfo("Image Caching:Done")
createXMLTVFile()
createM3UFile()
queuePlexGuideRefresh("xepg image cache refresh")
System.ImageCachingInProgress = 0
}()
}
cacheImagesInBackground()
System.ScanInProgress = 0
@@ -118,28 +99,9 @@ func buildXEPG(background bool) {
createM3UFile()
queuePlexGuideRefresh("xepg rebuild")
if Settings.CacheImages == true && System.ImageCachingInProgress == 0 {
cacheImagesInBackground()
go func() {
System.ImageCachingInProgress = 1
showInfo(fmt.Sprintf("Image Caching:Images are cached (%d)", len(Data.Cache.Images.Queue)))
Data.Cache.Images.Image.Caching()
Data.Cache.Images.Image.Remove()
showInfo("Image Caching:Done")
createXMLTVFile()
createM3UFile()
queuePlexGuideRefresh("xepg image cache refresh")
System.ImageCachingInProgress = 0
}()
}
showInfo("XEPG:" + fmt.Sprintf("Ready to use"))
showInfo("XEPG:" + "Ready to use")
System.ScanInProgress = 0
@@ -161,52 +123,30 @@ func buildXEPG(background bool) {
}
// XEPG Daten aktualisieren
func updateXEPG(background bool) {
// cacheImagesInBackground : Bilder im Hintergrund cachen, danach XMLTV- und M3U-Datei neu erstellen
func cacheImagesInBackground() {
if System.ScanInProgress == 1 {
if !Settings.CacheImages || System.ImageCachingInProgress != 0 {
return
}
System.ScanInProgress = 1
if Settings.EpgSource == "XEPG" {
switch background {
case false:
createXEPGDatabase()
mapping()
cleanupXEPG()
go func() {
System.ImageCachingInProgress = 1
showInfo(fmt.Sprintf("Image Caching:Images are cached (%d)", len(Data.Cache.Images.Queue)))
Data.Cache.Images.Image.Caching()
Data.Cache.Images.Image.Remove()
showInfo("Image Caching:Done")
createXMLTVFile()
createM3UFile()
queuePlexGuideRefresh("xepg update")
showInfo("XEPG:" + fmt.Sprintf("Ready to use"))
queuePlexGuideRefresh("xepg image cache refresh")
System.ScanInProgress = 0
System.ImageCachingInProgress = 0
}()
case true:
System.ScanInProgress = 0
}
} else {
System.ScanInProgress = 0
}
// Cache löschen
//Data.Cache.XMLTV = nil //make(map[string]XMLTV)
//Data.Cache.XMLTV = make(map[string]XMLTV)
return
}
// Mapping Menü für die XMLTV Dateien erstellen
@@ -219,7 +159,6 @@ func createXEPGMapping() {
var friendlyDisplayName = func(channel Channel) (displayName string) {
var dn = channel.DisplayName
displayName = dn[0].Value
switch len(dn) {
case 1:
@@ -277,11 +216,10 @@ func createXEPGMapping() {
}
Data.XMLTV.Mapping = tmpMap
tmpMap = make(map[string]any)
} else {
if System.ConfigurationWizard == false {
if !System.ConfigurationWizard {
showWarning(1007)
}
@@ -304,7 +242,6 @@ func createXEPGMapping() {
Data.XMLTV.Mapping["xTeVe Dummy"] = dummy
return
}
// XEPG Datenbank erstellen / aktualisieren
@@ -339,7 +276,7 @@ func createXEPGDatabase() (err error) {
sort.Float64s(allChannelNumbers)
var firstFreeNumber float64 = Settings.MappingFirstChannel
var firstFreeNumber = Settings.MappingFirstChannel
for {
@@ -469,14 +406,14 @@ func createXEPGDatabase() (err error) {
xepgChannel.Name = m3uChannel.Name
// Kanalname aktualisieren, nur mit Kanal ID's möglich
if channelHasUUID == true {
if xepgChannel.XUpdateChannelName == true {
if channelHasUUID {
if xepgChannel.XUpdateChannelName {
xepgChannel.XName = m3uChannel.Name
}
}
// Kanallogo aktualisieren. Wird bei vorhandenem Logo in der XMLTV Datei wieder überschrieben
if xepgChannel.XUpdateChannelIcon == true {
if xepgChannel.XUpdateChannelIcon {
xepgChannel.TvgLogo = m3uChannel.TvgLogo
}
@@ -602,7 +539,7 @@ func findXEPGReplacementChannel(xmltvChannels map[string]any, xepgChannel XEPGCh
for id, data := range xmltvChannels {
xmltvChannel, castOK := data.(map[string]any)
if castOK == false {
if !castOK {
continue
}
@@ -615,7 +552,7 @@ func findXEPGReplacementChannel(xmltvChannels map[string]any, xepgChannel XEPGCh
}
displayName, hasDisplayName := xmltvChannel["display-name"].(string)
if hasDisplayName == false {
if !hasDisplayName {
continue
}
@@ -660,7 +597,7 @@ func mapping() (err error) {
}
// Automatische Mapping für neue Kanäle. Wird nur ausgeführt, wenn der Kanal deaktiviert ist und keine XMLTV Datei und kein XMLTV Kanal zugeordnet ist.
if xepgChannel.XActive == false {
if !xepgChannel.XActive {
// Werte kann "-" sein, deswegen len < 1
if len(xepgChannel.XmltvFile) < 1 && len(xepgChannel.XMapping) < 1 {
@@ -704,7 +641,7 @@ func mapping() (err error) {
}
// Überprüfen, ob die zugeordneten XMLTV Dateien und Kanäle noch existieren.
if xepgChannel.XActive == true {
if xepgChannel.XActive {
var mapping = xepgChannel.XMapping
var file = xepgChannel.XmltvFile
@@ -718,7 +655,7 @@ func mapping() (err error) {
// Kanallogo aktualisieren
if logo, ok := channel["icon"].(string); ok {
if xepgChannel.XUpdateChannelIcon == true && len(logo) > 0 {
if xepgChannel.XUpdateChannelIcon && len(logo) > 0 {
xepgChannel.TvgLogo = logo
}
@@ -729,7 +666,7 @@ func mapping() (err error) {
xepgChannel.XMapping = channelID
if logo, ok := replacementChannel["icon"].(string); ok {
if xepgChannel.XUpdateChannelIcon == true && len(logo) > 0 {
if xepgChannel.XUpdateChannelIcon && len(logo) > 0 {
xepgChannel.TvgLogo = logo
}
}
@@ -752,7 +689,7 @@ func mapping() (err error) {
name = xepg
}
if strictMissingEPGMode == true {
if strictMissingEPGMode {
missingEPGCount++
appendXEPGIssueSample(missingEPGSamples, name)
xepgChannel.XActive = false
@@ -772,7 +709,7 @@ func mapping() (err error) {
providerName = file
}
if strictMissingEPGMode == true {
if strictMissingEPGMode {
missingXMLTVCount++
appendXEPGIssueSample(missingXMLTVSamples, providerName)
xepgChannel.XActive = false
@@ -849,7 +786,7 @@ func createXMLTVFile() (err error) {
Data.Cache.ImagesURLS = []string{}
Data.Cache.ImagesCache = []string{}
files, err := ioutil.ReadDir(System.Folder.ImagesCache)
files, err := os.ReadDir(System.Folder.ImagesCache)
if err == nil {
for _, file := range files {
@@ -887,7 +824,7 @@ func createXMLTVFile() (err error) {
err := json.Unmarshal([]byte(mapToJSON(dxc)), &xepgChannel)
if err == nil {
if xepgChannel.XActive == true {
if xepgChannel.XActive {
// Kanäle
var channel Channel
@@ -902,9 +839,7 @@ func createXMLTVFile() (err error) {
*tmpProgram, err = getProgramData(xepgChannel)
if err == nil {
for _, program := range tmpProgram.Program {
xepgXML.Program = append(xepgXML.Program, program)
}
xepgXML.Program = append(xepgXML.Program, tmpProgram.Program...)
}
@@ -947,7 +882,7 @@ func getProgramData(xepgChannel XEPGChannelStruct) (xepgXML XMLTV, err error) {
err = getLocalXMLTV(xmltvFile, &xmltv)
if err != nil {
if relaxedMissingEPGMode == true {
if relaxedMissingEPGMode {
fallbackToDummy()
err = nil
}
@@ -1024,7 +959,7 @@ func getProgramData(xepgChannel XEPGChannelStruct) (xepgXML XMLTV, err error) {
}
if len(xepgXML.Program) == 0 && relaxedMissingEPGMode == true && xmltvFile != System.Folder.Data+"xTeVe Dummy" {
if len(xepgXML.Program) == 0 && relaxedMissingEPGMode && xmltvFile != System.Folder.Data+"xTeVe Dummy" {
fallbackToDummy()
}
@@ -1072,7 +1007,7 @@ func createDummyProgram(xepgChannel XEPGChannelStruct) (dummyXMLTV XMLTV) {
epg.Desc = append(epg.Desc, &Desc{Value: xepgChannel.XDescription, Lang: "en"})
}
if Settings.XepgReplaceMissingImages == true {
if Settings.XepgReplaceMissingImages {
poster.Src = imgc.Image.GetURL(xepgChannel.TvgLogo)
epg.Poster = append(epg.Poster, poster)
}
@@ -1114,7 +1049,6 @@ func getCategory(program *Program, xmltvProgram *Program, xepgChannel XEPGChanne
}
return
}
// Programm Poster Cover aus der XMLTV Datei laden
@@ -1127,7 +1061,7 @@ func getPoster(program *Program, xmltvProgram *Program, xepgChannel XEPGChannelS
program.Poster = append(program.Poster, poster)
}
if Settings.XepgReplaceMissingImages == true {
if Settings.XepgReplaceMissingImages {
if len(xmltvProgram.Poster) == 0 {
var poster Poster
@@ -1161,7 +1095,6 @@ func getEpisodeNum(program *Program, xmltvProgram *Program, xepgChannel XEPGChan
}
return
}
// Videoparameter erstellen (createXMLTVFile)
@@ -1187,7 +1120,6 @@ func getVideo(program *Program, xmltvProgram *Program, xepgChannel XEPGChannelSt
program.Video = video
return
}
// Lokale Provider XMLTV Datei laden
@@ -1236,7 +1168,6 @@ func createM3UFile() {
saveMapToJSONFile(System.File.URLS, Data.Cache.StreamingURLS)
return
}
// XEPG Datenbank bereinigen
@@ -1254,7 +1185,7 @@ func cleanupXEPG() {
sourceIDs = append(sourceIDs, source)
}
showInfo("XEPG:" + fmt.Sprintf("Cleanup database"))
showInfo("XEPG:" + "Cleanup database")
Data.XEPG.XEPGCount = 0
for id, dxc := range Data.XEPG.Channels {
@@ -1266,7 +1197,7 @@ func cleanupXEPG() {
if indexOfString(xepgChannel.Name+xepgChannel.FileM3UID, Data.Cache.Streams.Active) == -1 {
delete(Data.XEPG.Channels, id)
} else {
if xepgChannel.XActive == true {
if xepgChannel.XActive {
Data.XEPG.XEPGCount++
}
}
@@ -1291,34 +1222,4 @@ func cleanupXEPG() {
showWarning(2005)
}
return
}
// Streaming URL für die Channels App generieren
func getStreamByChannelID(channelID string) (playlistID, streamURL string, err error) {
err = errors.New("Channel not found")
for _, dxc := range Data.XEPG.Channels {
var xepgChannel XEPGChannelStruct
err := json.Unmarshal([]byte(mapToJSON(dxc)), &xepgChannel)
fmt.Println(xepgChannel.XChannelID)
if err == nil {
if channelID == xepgChannel.XChannelID {
playlistID = xepgChannel.FileM3UID
streamURL = xepgChannel.URL
return playlistID, streamURL, nil
}
}
}
return
}
+4
View File
@@ -0,0 +1,4 @@
# Style checks deliberately kept out:
# ST1005 - error strings are shown verbatim in the web UI; wording is intentional
# ST1000/ST1003/ST1016/ST1020/ST1021/ST1022 - staticcheck's own defaults
checks = ["all", "-ST1000", "-ST1003", "-ST1005", "-ST1016", "-ST1020", "-ST1021", "-ST1022"]
+5 -5
View File
@@ -46,13 +46,13 @@ Status: Phase 0 committed on branch `improvements` 2026-09-26.
- [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
- [ ] CI: vet (done), lint, gofmt, `test -race`; bundle freshness check (done)
- [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)
- [ ] PUID/PGID via su-exec; pin static-ffmpeg; `VOLUME`; fix `LEGACY_CONFIG_DIRS`
- [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
- [ ] `README-DEV.md` (done) and fork section in `README.md`
- [ ] Deduplicate and delete dead code; drop `ioutil`, `osext`, `rand.Seed` (after go:embed)
- [ ] Resolve missing `docs/design-system/` referenced by `agent.md`
- [x] `README-DEV.md` and fork-specific `README.md` (about, container usage, env vars, security notes)
- [x] staticcheck 399 -> 0; dead code and duplicates removed; `ioutil`/`rand.Seed` gone. Real bugs fixed on the way: leaked file handle per ffmpeg segment, unchecked `http.NewRequest`, migration writing `null` xepg.json, silent user-write failures, unwritable config/temp dir now fatal at start
- [ ] Resolve missing `docs/design-system/` referenced by `agent.md` (user decision: agent.md is a personal, git-ignored file)
## Phase 4: Data model and performance
Not planned (lineup is ~170 channels, decided 2026-09-25). See plan for the reference list.