Sign inSign up

purestream711/nostalgiatv-server

By purestream711

•Updated about 23 hours ago

NostalgiaTV server - 7 to 30-day schedules, web UI, HDHR tuner, Plex integration

Image
Content management system
2

50K+

purestream711/nostalgiatv-server repository overview

⁠NostalgiaTV Docker Server

A self-hosted companion server for the NostalgiaTV Android app. Generates each profile's schedule 7, 14 or 30 days ahead, indexes your Plex, Jellyfin, or Emby content, manages channels and commercials, and exposes a browser-based Watch and Configure UI — plus an HDHR/IPTV tuner so other apps (Plex DVR, Channels DVR, Jellyfin, TiviMate, etc.) can tune in too.

Built for amd64 and arm64 — PCs, servers, Raspberry Pi 4/5, Synology/QNAP NAS, Apple Silicon Macs.

⁠What you get

⁠Web UI — open http://<server>:19850 in any browser
  • /watch — Live TV guide with an inline player, plus On Demand, Movies, and TV Shows browsing. Sign in with Plex, Jellyfin, Emby, or a local account.
  • /configure — admin tabs: Channels, Commercials, Groups, Schedule, Design, Profile Configuration, Servers, Admin (External Sources, Automatic Backup, Logs).
⁠Media servers
  • Plex, Jellyfin, and Emby — connect any mix of them, as many as you like, and pick which libraries feed the guide per server (Servers tab).
  • Cross-server duplicate hiding — when the same movie or episode lives on more than one server (e.g. mid-migration), only one copy shows up. Matched by provider ID (TMDB/IMDb/TVDB) for movies and by show + season + episode for episodes; each server carries a preference rank.
  • Content sync — movies, shows, episodes, music, collections, and labels, indexed incrementally and self-healing on deletion.
  • Local folders — the Android app can scan local files on the device. In Docker mode, mirror folders (offline copies of your server library) match against the Docker server's library; local-only libraries are Android-only.
⁠Channels
  • Your libraries — default channels plus custom channels built from libraries, collections, playlists, genres, decades, studios, labels, or hand-picked items, with per-channel weights, time slots, restricted hours, and exclusions.
  • Plex Live TV and Jellyfin / Emby Live TV — your DVR/tuner channels pulled into the lineup with their EPG, playable as live streams.
  • External Sources — import third-party M3U / XMLTV / HDHR lineups (other tuners, IPTV providers), editable and refreshable from the browser.
  • Live Stream channels — point a channel at a stream URL or resolver; finite videos are broadcast-clock aligned so every device lands at the same position.
  • Music "Tune Choice" channels — genre radio stations with a themed visualizer in the Watch UI.
  • Weather — the built-in WeatherStar 4000 channel and the native Storm Channel, which falls back to a deterministic offline weather simulation (season-correct temps, real sunrise/sunset, accurate moon phases) when there's no internet.
  • Trailers ("What's On") — a channel of trailers for what's airing.
  • Content Groups — reusable named sets of content you can drop into channels.
⁠Schedule
  • Schedule Length, per profile — 7 days ahead by default; choose 7, 14 or 30 days for each profile on the Schedule tab of /configure, or in Guide Schedule settings in the app. A longer schedule takes longer to rebuild; a change applies on the next schedule pass (within about 6 hours) or right away with Refresh Schedule.
  • Generation algorithms — RANDOM, CYCLIC_SHUFFLE, BLOCK_SHUFFLE, BLOCK_CYCLIC.
  • Marathon insertion, premiere assignment for recently-added items, commercial padding, mature/kid filtering, recently-scheduled tracking.
  • Per-channel refresh — regenerate one channel's schedule without touching the rest.
⁠Commercials
  • Commercial groups, break sequences (bumper → commercial → …), padding, and mid-program breaks.
  • Sources: a whole library, a collection, a playlist, specific items, a music genre, or trailers — and any source can come from any backend, so a Plex bumper and a Jellyfin commercial can share one break.
  • Mid-break modes: HALFWAY, CHAPTERS (from the item's own server), and SILENCE — real ffmpeg silence detection, which runs on the Docker server only (too CPU-heavy for a TV stick).
⁠Everything else
  • Multi-profile support — separate channel configs, schedules, themes, and settings per profile (kids, adult, guest, etc.), switchable from both UIs.
  • Themes + Design tab — 8 built-in themes (Retro, Vintage, Premium, Rad-Lad, Classic, Modern, Cable Box, Midnight Blue) plus custom themes, fonts, and CRT/bezel effects, all editable from the browser.
  • Watch UI playback — commercials and break screens, trailer pre-rolls, subtitle and audio-track selection, scrubbing, resume / Continue Watching, and CRT effects with bezels.
  • Config import/export — full settings transfer, per-profile or whole-server, and multi-backend aware (server identities travel; tokens never do).
  • Automatic Backup — weekly snapshots of the full config, rotated, restorable in one click from the Admin tab.
  • Authentication — Plex / Jellyfin / Emby / local sign-in for the Watch UI, a local username+password for Configure, and API-key auth for the Android app.
⁠HDHR / IPTV tuner emulator (enabled by default)

Exposes your whole channel lineup to anything that speaks HDHR or M3U:

  • GET /discover.json, /lineup.json, /device.xml, /lineup_status.json
  • GET /channels.m3u (M3U playlist) and GET /xmltv.xml (EPG)
  • GET /stream/{channelId} for tuner playback

Streaming modes (Configure → Admin → Tuner Streaming):

  • Continuous broadcast (default) — the server runs ffmpeg and serves each channel as a never-ending MPEG-TS stream: programs, commercial breaks, and mid-breaks play exactly on the guide clock, and the stream rolls from program to program forever — like a real cable feed (the dizqueTV/Tunarr model). Costs CPU on the server; use hardware acceleration on low-power hosts.
  • Per-program (redirect) — zero server CPU: the tuner is redirected to your media server's own transcoder for the currently-airing program, joined at the live position. The stream ends at each program boundary (clients re-tune or stop), and commercials are skipped.

Hardware acceleration (continuous mode): pick VAAPI / Quick Sync / NVENC / V4L2 in the Admin tab, or leave on Auto-detect. The container must be given the device:

services:
  nostalgiatv:
    # Intel (VAAPI, Quick Sync):
    devices:
      - /dev/dri:/dev/dri
    # Raspberry Pi (V4L2):
    #  - /dev/video11:/dev/video11
    # NVIDIA (NVENC) — requires nvidia-container-toolkit:
    # deploy:
    #   resources:
    #     reservations:
    #       devices:
    #         - driver: nvidia
    #           count: all
    #           capabilities: [gpu]
  • Intel — the image (amd64) ships the Intel VA drivers (iHD + i965) and the Quick Sync runtime, so mapping /dev/dri is all it needs. Quick Sync covers Tiger Lake (11th gen) and newer; older Intel iGPUs should use VAAPI. The server joins the group that owns each mapped device automatically at startup, so no group_add is needed (unless you pin user: in compose, which skips that step — then add the device's group with group_add).
  • NVIDIA — on Unraid, add --runtime=nvidia to Extra Parameters and set NVIDIA_VISIBLE_DEVICES to your GPU's UUID (the image already requests the video driver capability NVENC needs).
  • AMD VAAPI drivers are not bundled.
  • If hardware encoding still fails, the server log says why: tuner hwaccel auto-probe: <backend> unavailable (...) for Auto-detect, or tuner: ... produced no output (hwaccel=...) for an explicit choice.

Max simultaneous tuners caps concurrent continuous streams (default 2) and is advertised to clients via discover.json.

To disable the tuner entirely, set HDHR_ENABLED=false.

⁠Quick Start

Create a folder, save this as docker-compose.yml inside it:

services:
  nostalgiatv:
    image: purestream711/nostalgiatv-server:latest
    container_name: nostalgiatv-server
    ports:
      - "19850:19850"
    volumes:
      - ./data:/app/data
      - ./config:/app/config
      - ./logos:/app/logos
    environment:
      - TZ=America/New_York
      # Run as your host user so the bind-mounted folders above stay writable
      # (find yours with `id -u` / `id -g`). See "Volumes & permissions" below.
      - PUID=1000
      - PGID=1000
    restart: unless-stopped

Then start it:

docker compose up -d

Or pull and run directly, no compose file:

docker pull purestream711/nostalgiatv-server:latest
docker run -d \
  --name nostalgiatv-server \
  -p 19850:19850 \
  -v "$(pwd)/data:/app/data" \
  -v "$(pwd)/config:/app/config" \
  -v "$(pwd)/logos:/app/logos" \
  -e TZ=America/New_York \
  -e PUID=1000 \
  -e PGID=1000 \
  --restart unless-stopped \
  purestream711/nostalgiatv-server:latest

Then open http://localhost:19850 (or http://<your-server-ip>:19850 from another device) and sign in with Plex, Jellyfin, or Emby.

⁠Setup

  1. Start the container.
  2. Open http://<server>:19850 — you'll land on the Watch UI's sign-in screen.
  3. Pick your provider — Plex (account sign-in), Jellyfin, or Emby (server URL + username + password). You can connect more than one before continuing, so a mixed Plex + Jellyfin setup onboards in a single pass.
  4. Choose which libraries feed the guide, let the content index build, and create your first profile.
  5. Click the mode switcher in the top bar to jump to /configure and adjust channels, commercials, profiles, themes, etc.
⁠From the Android app
  1. Start the container.
  2. Open NostalgiaTV on your Android device.
  3. Go to Settings → Admin → Docker Mode (Pro feature).
  4. Enter your server URL (e.g., http://192.168.1.100:19850).
  5. The app registers with the server and offloads schedule generation and content indexing to Docker. All connected devices share the same schedule.
⁠As an HDHR tuner (Plex DVR, Channels DVR, Jellyfin, etc.)

The tuner is enabled by default. In your DVR app, point it at:

  • Tuner URL: http://<server>:19850
  • M3U playlist: http://<server>:19850/channels.m3u
  • EPG (XMLTV): http://<server>:19850/xmltv.xml

To disable the tuner, set HDHR_ENABLED=false.

⁠Synology (DSM / Container Manager)

Three DSM-specific snags account for nearly all Synology setup trouble:

  1. Create the volume folders first. Container Manager doesn't reliably create the relative ./data, ./config, and ./logos bind-mount folders when building a project (and when DSM does create them, they're root-owned). Before the first build, create the three subfolders in File Station inside the folder holding your docker-compose.yml (e.g. docker/nostalgiatv-server/data, …/config, …/logos). A project that crashes within seconds of starting, with logs complaining the app folders don't exist or can't be written, is this.

  2. Set PUID/PGID to a real DSM user. The compose default 1000:1000 usually doesn't exist on DSM. SSH in and run id <your-dsm-username> — use the printed uid and gid (on DSM 7 the first admin account is commonly uid=1026, with gid=100 (users) or 101 (administrators), but trust id over these examples). The container repairs folder ownership on boot by itself; what it can't fix is a PUID/PGID pair that has no access to the share the folders live on.

  3. Container Manager shows a stale version after updating. Its registry check caches the :latest tag, so "Update available"/version labels can lag the actual Docker Hub image. To force a real update, SSH in and run docker compose pull && docker compose up -d from the project folder (or put that in a root Task Scheduler task you trigger on demand). The version the server is actually running is shown on the Configure page — trust that, not Container Manager's label.

⁠Environment Variables

VariableDefaultDescription
TZAmerica/New_YorkTimezone (affects schedule generation and EPG display)
PUID1000Host user ID the server runs as, so files it writes to the bind mounts are owned by you. id -u to find yours
PGID1000Host group ID the server runs as. id -g to find yours
SCHEDULE_HOURS168Default hours of schedule to generate ahead (168 = 7 days), used by every profile that has not picked its own Schedule Length (7, 14 or 30 days — Guide Schedule settings in the app, the Schedule tab on the web). Supported range 24–2160 (90 days)
SCHEDULE_REFRESH_INTERVAL21600Seconds between scheduler ticks (21600 = 6 hours)
SCHEDULE_REGEN_THRESHOLD_HOURS96Extend a channel when its remaining schedule falls below this many hours (96 = 4 days). Keep it well below SCHEDULE_HOURS; the server caps it below each profile's own Schedule Length
PLEX_CONTENT_POLL_INTERVAL_SECONDS300How often to probe your servers for content adds/removals (5 minutes)
HDHR_ENABLEDtrueWhether the HDHR tuner emulator is on
WEATHER_API_KEY(bundled key)WeatherAPI.com key for the native Storm Channel. Defaults to the same key the Android app ships with — override to use your own
LOG_LEVELINFOLogging level — DEBUG, INFO, WARNING, ERROR
FORWARDED_ALLOW_IPS*Which peer addresses may set X-Forwarded-*. See Running behind a reverse proxy⁠

SCHEDULE_HOURS, SCHEDULE_REFRESH_INTERVAL, and HDHR_ENABLED can also be changed at runtime via POST /api/settings, which persists them to config/server_settings.json (they then override the env vars on restart).

⁠Running behind a reverse proxy

Putting the server behind nginx / Caddy / Traefik / SWAG / Nginx Proxy Manager for HTTPS works out of the box, as long as the proxy forwards the standard headers:

HeaderWhy it matters
X-Forwarded-ProtoTells the server the client is on HTTPS. Without it, redirects and tuner URLs come back http:// and the browser blocks them as mixed content
X-Forwarded-HostThe public hostname, used for the HDHR tuner / M3U / XMLTV URLs
X-Forwarded-PortThe public port, when it isn't the default 443/80

nginx sets X-Forwarded-Proto for you with:

location / {
    proxy_pass http://nostalgiatv:19850;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host  $host;
    proxy_set_header X-Forwarded-Port  $server_port;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    # Live TV / HLS streams are long-lived — don't cut them off mid-playback.
    proxy_buffering off;
    proxy_read_timeout 3600s;
}

Caddy and Traefik set these automatically; no extra configuration needed.

The server only honours those headers from peers listed in FORWARDED_ALLOW_IPS, which defaults to * because a containerised deployment never sees its proxy as 127.0.0.1 — the peer is the Docker bridge gateway or the proxy's own container IP. Nothing in the server authenticates on client IP (auth is session token / API key only), so this is safe; the one thing a forged X-Forwarded-For can influence is an access-log line. If you terminate TLS at a known address and also expose port 19850 to an untrusted network, you can pin it:

environment:
  - FORWARDED_ALLOW_IPS=172.18.0.5

Serving from a subpath (https://example.com/nostalgiatv/) is not supported — proxy from the root of a hostname or subdomain instead.

⁠Volumes

PathPurpose
/app/dataSQLite database, content index, registered devices, automatic backups, persistent state
/app/configProfiles, channel overrides, custom channels, themes, server settings
/app/logosCustom channel logos uploaded via the UI

Back up data/ and config/ together — they're a matched pair.

⁠Volumes & permissions

The container starts as root, chowns data/, config/, and logos/ to PUID:PGID, then drops to that unprivileged user before running — so a first run on a native-Linux host (Synology, QNAP, Portainer, Dockhand, Raspberry Pi) works even though the Docker daemon creates those bind-mount folders as root. Set PUID/PGID to your host user (id -u / id -g) if it isn't the default 1000:1000, and the database and config will be created and owned correctly with no manual chown. Docker Desktop on Windows/macOS handles bind-mount ownership through its own file-sharing layer and generally works regardless of PUID/PGID.

⁠API

The full API is documented at http://<server>:19850/docs (FastAPI auto-generated, ~230 endpoints across status, channels, schedule, profiles, servers, design, commercials, groups, content, HDHR, Live TV, trailers, weather, logs, backups, and config import/export).

Key endpoints for orientation:

  • GET /health — container liveness probe (DB-free; the Docker HEALTHCHECK target)
  • GET /api/status — server state and pairing probe
  • POST /api/register — Android device registration (returns API key)
  • POST /api/register/jellyfin — first-run bootstrap for a Jellyfin/Emby-only install
  • GET /api/schedule — bulk schedule download for app playback
  • GET/POST /api/servers/{id}/libraries — backend-neutral per-server library selection
  • GET /api/config/export and POST /api/config/import — full config transfer
  • GET /discover.json, /lineup.json, /channels.m3u, /xmltv.xml, /stream/{id} — HDHR/IPTV

All /api/* endpoints (except /api/status, /api/register*, and /api/auth/*) require either an X-API-Key header or an authenticated browser session. HDHR endpoints are intentionally unauthenticated so legacy tuner apps can reach them.

⁠Updating

Pulling a new image is not enough on its own — a container keeps running the image it was created from, so restarting it after a pull still boots the old version. This is how Docker works for every image, not just this one; the container has to be recreated to pick up the new image:

docker compose pull
docker compose up -d

docker compose up -d detects the newly pulled image and recreates the container automatically (it does nothing if the image didn't change). Your data/, config/, and logos/ folders live outside the container, so settings, channels, and schedules are preserved — though backing up config/ before updating never hurts.

Or with plain Docker (no compose):

docker pull purestream711/nostalgiatv-server:latest
docker stop nostalgiatv-server && docker rm nostalgiatv-server
# re-run your original `docker run` command

Using a GUI manager (Synology Container Manager, Portainer, Unraid, etc.): use its update/recreate action, not restart — restart never changes the image. On Synology specifically, Container Manager's update check caches the :latest tag and can claim you're up to date when you aren't; see the Synology⁠ section for the reliable SSH/Task Scheduler method.

Verifying the update: the version the server is actually running is shown on the Configure page (top bar) and in the first line of docker logs nostalgiatv-server. The version in the top bar (Configure and Watch) also gets a green pill border when it is Docker Hub's latest image and an amber one when a newer image is published — the server compares its own build id with the build tag Docker Hub lists next to :latest (checked once an hour; hover the version for both build ids). Every push is also tagged on Docker Hub with its version (:0.9.65) and build (:build-20260915-0210), so the Hub's Tags page names exactly what latest is. If the page still shows the old build after an update, the container was restarted but not recreated — run the two commands above.

⁠Development

Run from source instead of the published image:

docker compose -f docker-compose.dev.yml up -d --build

Or without Docker:

cd docker
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 19850 --reload

⁠Image Publishing

The latest image is published manually with a multi-arch buildx push — this is the only publish path. It reuses a persistent buildx instance named ntv-builder to avoid accumulating stray builders in Docker Desktop:

docker login -u purestream711
docker buildx use ntv-builder 2>/dev/null || \
  docker buildx create --name ntv-builder --driver docker-container --use
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t purestream711/nostalgiatv-server:latest \
  --push .

The Docker Hub overview page is not linked to a source repo, so it does not auto-sync from this README — update the repository description manually (or via the Hub API) after changing this file.

Tag summary

Content type

Image

Digest

sha256:429e4aa57…

Size

323.5 MB

Last updated

about 23 hours ago

docker pull purestream711/nostalgiatv-server