ChannelHub

ChannelHub

Docker app from allornothing's Repository

Overview

Self-hosted, open clone of Curacast's feature set: turn your Plex/Jellyfin/Emby library or local media folders into 24/7 broadcast-style channels with a real EPG (XMLTV), M3U playlist, and HDHomeRun emulation for Plex Live TV auto-discovery. Includes dayparting/appointment scheduling, bumper and mid-show commercial breaks (with an animated ffmpeg-rendered "Returning Soon" countdown filler as an alternative to pre-recorded clips), series-order playback, DVR-style recording, an in-browser Watch player with Chromecast/ AirPlay casting, PIN-protected viewer profiles, and a public-domain commercials importer from archive.org. No license server, nothing phones home -- point it at your media, add channels, and go.

Channel-Hub (self-hosted)

tests

Source: github.com/aon082910/channel-hub · Image: hub.docker.com/r/allornothing/channel-hub

Self-hosted 24/7 "live TV" channels built from your own media library — inspired by the feature set of Curacast (turn Plex/Jellyfin/Emby/local files into broadcast-style channels with a real EPG). Built independently as an MVP-scoped clone; no Curacast code, assets, or branding are used.

Feature scope (v0.2)

Core loop:

  • Connect Plex, Jellyfin, Emby, or a local media folder as a source
  • Scan a source's library into Channel-Hub's catalog (title, genres, rating, year, duration)
  • Build channels two ways:
    • Manual: hand-pick and order specific items
    • Rule-based: filter by genre, rating, library, source, recency, and a max-rating parental filter (items with no rating on file are excluded when a cap is set)
  • Three playback orderings: sequential, shuffle (deterministic per-day seed), round-robin (interleave across libraries)
  • A true "live" schedule: every viewer landing on a channel sees the same thing at the same time, resolved fresh from "what should be airing right now" — no per-viewer session state, and it self-corrects for any drift instead of drifting out of sync over hours of uptime

Standout features beyond the MVP:

  • HDHomeRun emulation/discover.json, /lineup.json, /lineup_status.json, and /device.xml, plus an SSDP responder (lib/ssdp.ts) so Plex Live TV / other tuner-aware clients can auto-discover Channel-Hub without manually adding an M3U
  • TV Guide grid — a real channel-by-time grid at /tv-guide (not just raw M3U/XMLTV links)
  • Dayparting / appointment scheduling — pin a rule or item pool to a specific day-of-week + time window per channel (e.g. cartoons every weekday morning), overriding the channel's default pool for that block; higher-priority dayparts win on overlap
  • Bumpers / station IDs — mark any scanned item as a bumper, then have a channel insert one from a chosen bumper source every N programs
  • Mid-show commercial breaks — set "commercial breaks every N minutes" on a channel and any program longer than that gets split into segments with a bumper break between them (like Plex Live TV's mid-show ad breaks), not just a bumper between one program and the next; each segment resumes the source file at the exact right point afterward
  • Config export/import — full JSON backup/restore of sources, catalog, channels, and dayparts (Settings page); no license server, nothing phones home, and the export can double as a shareable "channel pack" for another Channel-Hub instance
  • Adaptive HLS/hls/:channelId/playlist.m3u8 segmented output alongside the raw MPEG-TS /stream/:channelId, for clients that handle segmented delivery better
  • Exact time-slot padding — enable "pad to slot" on a channel to fill any gap before a daypart boundary with bumper/filler clips instead of overrunning it, so blocks start on time like real broadcast TV
  • Series-order sort mode — episodes with season/episode metadata (parsed from Plex/Jellyfin/ Emby, or from Show - S01E02 - Title style local filenames) air in order per series, so a channel plays a show like appointment TV instead of shuffling episodes
  • Searchable catalog/api/catalog full-text search across every source's items by title/ series/genre, used by the channel builder's item picker instead of one long per-source list
  • In-browser "Watch" player/watch/:channelNumber plays a channel live via hls.js with Prev/Next and arrow-key channel surfing, so you don't need an external IPTV client just to check a channel
  • Encrypted-at-rest credentials — Plex/Jellyfin/Emby tokens are stored AES-256-GCM encrypted, keyed by an auto-generated (or operator-supplied) key; API responses never return the raw token
  • Dashboard Basic Auth + stream tokens — optional ADMIN_USER/ADMIN_PASSWORD gate the dashboard/API, and an optional STREAM_TOKEN gates /stream and /hls (auto-embedded into generated M3U/lineup URLs so configured players don't need reconfiguring)

Operational / "runs itself" features:

  • Auto-rescan — set a cron expression per source (scan_cron) and it rescans on schedule via the cron package, updating last_scanned_at; still scannable manually anytime
  • Health dashboard/health page: server uptime, whether ffmpeg is actually reachable, per-source scan schedule/last-run, and per-channel live viewer counts + HLS publisher status
  • DVR-style recording/recordings: record a channel for N minutes by having ffmpeg capture the channel's own live stream (so a recording sees exactly what viewers saw), then download or delete the file
  • Chromecast + AirPlay — the Watch page can cast the live HLS stream to a Chromecast (Google Cast Sender SDK, default media receiver) or trigger the native AirPlay picker in Safari

Content & discovery:

  • Animated "Returning Soon" filler — set a channel's filler style to "returning soon" and gaps before a time-slot boundary are filled with a live-rendered "<Next Show> returns in MM:SS" screen (animated background, real ffmpeg drawtext countdown) instead of needing any pre-recorded bumper clips — can also combine with real bumpers ("both": tile bumpers, then use the countdown for whatever remainder is too short for another bumper)
  • Archive.org commercials importer — search and import from archive.org's public-domain Prelinger/Classic-TV-Commercials collections directly into a bumper source (deliberately scoped to those collections, not a generic "scrape any free site" tool — see below)
  • Natural-language channel creation — describe a channel ("90s action movies", "family- friendly cartoons") and a small built-in keyword parser (no external AI API/key) fills in the genre/decade/rating rule fields for you to review
  • Multi-profile parental locks — optional PIN-protected "who's watching" profiles on the Watch page, each with its own channel allowlist; see the scope note below

Not implemented (still Curacast-only): watermarks/burn-in subtitles, per-channel viewer analytics, licensing.

Scope note: profiles vs. dashboard auth

Profiles are a convenience lock inside an already-authenticated dashboard session — a "who's watching" shortcut for household members sharing one browser, not a substitute for ADMIN_USER/ADMIN_PASSWORD. Anyone who can reach the dashboard can see the profile list; profiles just gate which channels they can pick and optionally require a PIN to switch to a given one. If you want a kiosk-style setup where family members watch without knowing the admin password, run without ADMIN_USER set (optionally still set STREAM_TOKEN for the media endpoints) rather than relying on profiles as a security boundary.

Scope note: Archive.org commercials, not a general scraper

The commercials importer calls archive.org's own public search/metadata API, filtered to its Prelinger Archive and Classic TV Commercials collections — vintage material that is genuinely public domain. It intentionally does not scrape arbitrary "free" ad sites or ad networks, which would carry real copyright/ToS risk; that scope boundary is enforced in lib/archiveOrg.ts, not just documentation.

Project layout

Channel-Hub/
  server/             Express + TypeScript API, scheduler, FFmpeg/HLS streaming, HDHomeRun emulation, SQLite (node:sqlite)
  web/                React + Vite dashboard (sources, channels, dayparts, TV guide grid, settings)
  unraid/             Unraid Community Applications template (channel-hub.xml) + icon
  Dockerfile, docker-compose.yml
  .github/workflows/  CI (build check) + release (build & publish to Docker Hub on a version tag)
  data/               SQLite database lives here in local dev (mounted as /config in Docker)

Local development

Requires Node 22.5+ (uses the built-in node:sqlite module — no native build step) and ffmpeg/ffprobe on PATH.

npm install
npm run dev:server   # http://localhost:8085 (API + streaming)
npm run dev:web      # http://localhost:5173 (dashboard, proxies API calls to :8085)

Running with Docker

docker compose up -d --build

Then open http://<host>:8085. Mount your real media path in docker-compose.yml:

volumes:
  - ./config:/config
  - /path/to/your/media:/media:ro

For NVIDIA/VAAPI/QSV hardware transcoding, uncomment the devices section and set HW_ENCODER accordingly. PUBLIC_URL should be set to the LAN address IPTV clients will use, so generated M3U/HDHomeRun links are correct. SSDP discovery needs UDP 1900 reachable on the container's network — on Docker's default bridge mode this usually only works for clients on the same host; for cross-device auto-discovery on Unraid, run the container with --network host (or set Network Type = Host in the Unraid template) instead. Auto-discovery is a convenience only — /lineup.json and /stream/:id work regardless, so you can always add Channel-Hub manually as a tuner if SSDP doesn't reach a client.

Deploying on Unraid

  1. Image is published at allornothing/channel-hub on Docker Hub — Unraid can pull it directly, no build step needed. (To build your own fork instead: docker build -t <you>/channel-hub:latest . && docker push <you>/channel-hub:latest, then edit unraid/channel-hub.xml to match.)
  2. Add the Channel-Hub template: Docker tab → Add Container → toggle "Template" mode off → in the Template field near the top paste: https://raw.githubusercontent.com/aon082910/channel-hub/master/unraid/channel-hub.xml
  3. Set the Media path to your existing media share (read-only is sufficient).
  4. Set App Config/DB to a persistent appdata path, e.g. /mnt/user/appdata/channel-hub.
  5. If you want Plex to auto-discover channels via HDHomeRun emulation, set the container's network type to Host (bridge mode blocks the SSDP multicast responder from reaching other devices on your LAN).
  6. Start the container and open the WebUI on port 8085.

Using it

  1. Media Sources → add your Plex/Jellyfin/Emby server (base URL + token/API key) or a local folder path (must be inside the container, e.g. /media/movies), then Scan. Optionally add a small folder of station IDs as its own local source and mark those items as bumpers from the "View items" panel.
  2. Channels → create a channel: manual (pick items) or rule-based (genre/rating/library/ recency filters, plus a max-rating parental cap); optionally set a bumper source and "insert every N items".
  3. Click into a channel to add dayparts — pin a time block on specific days to its own rule/pool, overriding the channel default for that window.
  4. TV Guide → see the live channel-by-time grid.
  5. Watch → play any channel live in the browser (arrow keys or Prev/Next to surf channels).
  6. Links → M3U, XMLTV, HDHomeRun discovery/lineup, and HLS URLs to paste into your player.
  7. Settings → export/import your whole config as JSON for backup or sharing, plus security options (dashboard login, stream tokens, credential encryption key).

Security options

All optional and off by default so a fresh install stays simple:

  • ADMIN_USER + ADMIN_PASSWORD — require Basic Auth for the dashboard and its /api/* routes. IPTV/HDHomeRun-facing routes (/stream, /hls, /playlist.m3u, /epg.xml, /discover.json, /lineup*.json) are intentionally left outside this gate, since most players and Plex can't answer an auth prompt.
  • STREAM_TOKEN — requires ?token=... on /stream/:id and /hls/:id/*. Generated M3U and HDHomeRun lineup URLs embed it automatically; the in-app Watch page fetches it from the (Basic-Auth-protected) /api/config/stream-token endpoint so it keeps working too.
  • ENCRYPTION_KEY — 32 bytes, base64. Encrypts Plex/Jellyfin/Emby tokens at rest (AES-256-GCM). If omitted, a key is generated on first run and stored as secret.key next to the database (back it up together — losing the key makes stored tokens unrecoverable, just re-enter them).

Notes on how the "live" scheduling works

scheduler.ts resolves "what item is airing on this channel at timestamp X" from scratch each time it's asked (itemAtTime), rather than precomputing an infinite loop index. It walks a day at a time, checking for an active daypart at that moment (by day-of-week bitmask + minute- of-day range, highest priority wins); the winning pool (daypart's or the channel's default) is deterministically ordered and has bumpers interleaved, then filled forward from that segment's start. Because the XMLTV generator, the guide grid, and the two streaming engines (raw MPEG-TS chaining and the HLS muxer feed) all call the same function, the guide always matches what's actually playing — and because playback re-resolves "now" after every item instead of following a stale precomputed list, it self-heals from any ffmpeg startup drift instead of drifting further out of sync over time.

Every segment walk is anchored to a fixed reference point — local midnight, or a daypart's own start minute — never to whatever timestamp the caller happened to pass in. This matters because itemAtTime (used for live playback) and upcomingItems/the XMLTV generator (used for the guide) start their walks from different points in time; if the walk's "phase" depended on where it started rather than on a fixed anchor, the guide could show a different schedule than what's actually airing whenever a channel's full loop length doesn't evenly divide the gap between those two starting points. Segments before the caller's actual window are still walked through (to keep the cycle correctly phased) but simply aren't returned.

Notes on how mid-show commercial breaks work

When a channel's mid_break_minutes is set, a program longer than that many minutes is split at schedule-build time into several ScheduledItem segments that all share the same underlying file but carry different sourceOffsetMs values (where in the file that segment starts) and a duration_ms that's just that segment's length — with a bumper entry inserted between them. The streaming engines (raw MPEG-TS and the HLS feed) already resume any item mid-way via a seek offset for viewers who tune in partway through; mid-show breaks reuse exactly that mechanism by combining sourceOffsetMs with that seek offset, and additionally pass ffmpeg a hard -t cutoff so a segment can never run past its scheduled length regardless of probe-duration imprecision.

Live end-to-end test performed during development

Before calling any of this done, it was run against real (synthetically generated, but real) video files rather than just type-checked:

  • Local-folder source scan found actual files via ffprobe and recorded correct durations
  • A channel with two programs + a bumper (insert every 1 item) produced the interleaved schedule Program → Bumper → Program → Bumper → … identically across /api/channels/:id/now, /epg.xml, and /api/guide/grid
  • /stream/:id was captured with curl and the bytes verified with ffprobe as real H.264/ AAC video
  • /hls/:id/playlist.m3u8 was requested, its listed segment fetched over HTTP, and also verified as real H.264/AAC video
  • A daypart override was created and confirmed to immediately change what /now and /epg.xml reported
  • /api/config/export/api/config/import round-tripped without data loss
  • /discover.json, /lineup.json, /lineup_status.json, /device.xml all returned spec-shaped HDHomeRun responses
  • The dashboard itself was driven in a real browser (channel creation, channel detail/daypart page, and the TV Guide grid rendering distinct per-program blocks for a realistic 20-minute clip)

Not covered by this test pass: Plex's actual SSDP auto-discovery (needs a real Plex Server on the LAN), real Plex/Jellyfin/Emby connectors (only the local-folder connector was exercised), and hardware encoders (HW_ENCODER=nvenc|vaapi|qsv), which need real GPU hardware.

Second pass: security, search, series ordering, padding, and the Watch page

Also run against real video files, with ADMIN_USER/ADMIN_PASSWORD/STREAM_TOKEN all set:

  • Dashboard/API confirmed to 401 without Basic Auth credentials and 200 with them; /playlist.m3u and /epg.xml confirmed to stay open (no dashboard gate)
  • A fake Plex token was stored, and the raw SQLite file (main db, -wal, and -shm) was byte-scanned to confirm the plaintext token appears nowhere on disk — only the AES-GCM ciphertext (marked with a version prefix) does
  • /stream/:id confirmed to 401 without ?token=, 200 with the correct one; the generated M3U embeds the token automatically
  • series_order verified against three real S01E01–E03 clips (with deliberately shuffled filesystem order) — playback and /now both aired them E1→E2→E3→wrap, confirmed directly against the scheduler function as well as over HTTP
  • pad_to_slot verified against a real 1-minute daypart boundary: the schedule filled with the program until it would overrun the boundary, then swapped to bumper filler and landed exactly on the boundary (down to the millisecond) on both the entry and exit side of the daypart
  • The Watch page was driven in a real browser and genuinely played live HLS video via hls.js, including switching channels with the Prev/Next buttons and confirming the new channel's correct now-playing title

Bugs this pass caught and fixed (left in this README because it's the honest reason to trust "tested" claims over "compiles" claims):

  • /api/catalog threw Unknown named parameter 'limit' on its count query — limit/offset were bound into a query that never referenced them; fixed by using separate bound-parameter objects for the search query and the count query.
  • itemAtTime could return null ("no content playing") for a channel with an unusually dense catalog of very short items, because the shared 5000-item timeline cap could exhaust itself before the walk ever reached "now" inside its 6-hour lookback window. Fixed by giving the "find what's airing right now" code path its own much larger, self-terminating cap (it already stops as soon as it reaches the target timestamp, so real catalogs are unaffected either way).
  • The HLS player showed fragLoadError under STREAM_TOKEN: ffmpeg's playlist.m3u8 lists bare segment filenames, so the browser's follow-up segment requests carried no token and hit
    1. Fixed by rewriting the playlist response to append the token to each segment line before serving it, mirroring how the M3U/lineup URLs already embed it.

Third pass: ops/DVR/casting/profiles/smart fillers

Also run for real, not just compiled — including one live network call to archive.org:

  • Auto-rescan: created a source with scan_cron: "* * * * *", waited on a log-line monitor for the actual cron fire (not a fixed sleep), and confirmed last_scanned_at updated on its own without any manual scan call.
  • "Returning Soon" filler: first proved the ffmpeg drawtext countdown expression and title- escaping standalone (tested percent signs, colons, apostrophes, backslashes against the real ffmpeg binary — % turned out to have no working escape in this build and is now stripped rather than escaped), then created a channel with a real 1-minute daypart boundary, captured the actual live /stream/:id HTTP output across the transition, and pixel-inspected extracted frames: the countdown showed the correct next-program title and correct time remaining, then cut cleanly back to the program at 00:00.
  • Health dashboard: confirmed activeViewers goes 0 → 1 while a stream is open and back to 0 after it closes, by curling /stream/:id in the background and polling /api/health.
  • DVR recording: confirmed the recorded file was real H.264/AAC (ffprobe) and downloadable via the API — see the duration bug below, which this same test caught.
  • Profiles: created a PIN-protected profile scoped to one channel; verified wrong PIN rejects, correct PIN accepts, and the PIN hash never appears in the list API response. Also drove the Watch page's profile picker in a real browser (profile tile → PIN prompt → unlock → scoped channel view).
  • Natural-language channel suggestion: tested against real phrases, which is exactly what surfaced the two bugs below.
  • Archive.org importer: called the real archive.org search API (not mocked), then imported one real result end-to-end and confirmed the stored catalog row had a genuine archive.org download URL and a real ffprobe-measured duration (30.6s).

Bugs this pass caught and fixed:

  • PATCH endpoints for sources and channels threw "Unknown named parameter" errors — the same root cause as the catalog bug above, reintroduced: both handlers spread `` and passed the whole object (now carrying fields like type/created_at/number the UPDATE statement never references) straight to .run(). node:sqlite rejects any bound object key the SQL doesn't reference. Fixed by passing an explicit object with only the updated columns in both handlers, and grepped the rest of the codebase for the same pattern (every other .run() call turned out to insert a freshly-built literal object matching its INSERT exactly, so those were already safe).
  • Recordings were cut short — a 1-minute recording finished in ~3 seconds. Root cause: it used ffmpeg's -t 60 against a copy of the channel's own live stream, but that stream's PTS resets at every item boundary (by design, so live viewers stay in sync); ffmpeg's discontinuity compensation can make its corrected timeline jump forward several seconds at once, so -t (which limits by that corrected timeline, not wall clock) fired far too early. Fixed by dropping -t entirely and stopping the recording with a real Node wall-clock setTimeout instead — verified the fix by re-running the same 1-minute recording and confirming it actually took 60.16 real seconds.
  • The natural-language parser had two false-positive bugs, both caught by testing real phrases rather than crafted ones: "90s action movies" produced no year range at all (the decade regex required a full 4-digit century prefix like "1990s"; colloquial "90s" has none — added a shorthand pattern that infers the century). Separately, "sci-fi thriller" was tagged maxRating: "R" — the rating matcher used a plain .includes("r"), which matches the letter "r" inside "thriller" or almost any sentence; switched every keyword/rating match (genres included, since e.g. "war" is a substring of "warrior") to word-boundary regexes and confirmed the false positives were gone without breaking genuine matches ("rated R horror" still yields R).

Not covered by this pass: real Chromecast/AirPlay hardware (the cast button loads the real Google Cast Sender SDK and calls the real Cast Framework API, and didn't error in the browser, but there was no physical Chromecast/Apple TV on hand to confirm a receiver actually starts playback), and GPU-backed hardware encoders.

Fourth pass: mid-show commercial breaks

Generated a 150-second test video with a burned-in timecode overlay (drawtext=text='%{pts\:hms}') specifically so seek correctness could be verified visually rather than assumed, then set a channel to break every 1 minute against it plus a solid-yellow 3-second bumper (easy to detect by color even after H.264 compression).

  • Confirmed via buildTimelineWindow directly that a 150s program splits into 60s + 60s + 30s segments with a bumper between each, and that the segments carry the correct sourceOffsetMs (0, 60000, 120000).
  • Captured the real live /stream/:id HTTP output while 9 seconds into the second segment and confirmed the burned-in timecode read exactly 00:01:09.00060000ms (segment offset)
    • 9000ms (resume offset) to the millisecond.
  • Captured a longer live-stream window spanning an actual break, scanned every decoded frame (not a resampled subset) for the bumper's solid-yellow color, found a clean 671-frame yellow block, and inspected the frames immediately before and after it: the show played up to 00:01:59.800 (the end of its 120-second-mark segment), cut to the bumper, and resumed at 00:02:01.200 — i.e., picked back up essentially exactly at the 120-second mark of the source file, confirming no content was skipped or replayed across the break.

A significant pre-existing bug this pass caught and fixed: while chasing why a break seemed to happen at the "wrong" time, upcomingItems/the XMLTV path and itemAtTime/live playback turned out to be capable of disagreeing about the schedule. Both independently call buildTimelineWindow, but from different starting timestamps (now vs. now - 6h), and the walk used its own starting timestamp as an implicit "the pool cycle resets here" anchor. Two walks starting from different points land on the same phase only when the channel's full loop length happens to evenly divide the gap between those starting points — true by coincidence in every earlier test's short, round-number cycle lengths, but false for this test's 156-second cycle (60+3+60+3+30), which visibly desynced /now from /upcoming. Fixed by always anchoring the walk to a fixed reference (local midnight, or a daypart's own start minute) regardless of which timestamp the caller passes in, and discarding whatever the walk computes before that caller's actual window rather than never computing it — verified by rechecking /now and /upcoming and confirming their first entries now always agree. This was a real defect (the guide could show a different program than what was actually airing) hiding behind every prior test's coincidentally round cycle lengths, not something introduced by mid-show breaks — breaks just happened to be the first test with a cycle length that exposed it.

Install ChannelHub on Unraid in a few clicks.

Find ChannelHub in Community Apps on your Unraid server, review the template, and click Install. Unraid handles the Docker app or plugin setup from the published template.

Open the Apps tab on your Unraid server Search Community Apps for ChannelHub Review the template variables and paths Click Install

Download Statistics

66
Total Downloads

Related apps

Explore more like this

Explore all

Details

Repository
allornothing/channel-hub:latest
Last Updated2026-09-06
First Seen2026-09-07

Runtime arguments

Web UI
http://[IP]:[PORT:8085]/
Network
bridge
Shell
sh
Privileged
false

Template configuration

WebUI PortPorttcp

Web dashboard and stream/EPG/M3U port

Target
8085
Default
8085
Value
8085
App Config/DBPathrw

Where the SQLite database, encryption key, and recordings are stored.

Target
/config
Default
/mnt/user/appdata/channel-hub
Value
/mnt/user/appdata/channel-hub
MediaPathro

Mount your media library (used for local-folder channels and bumpers).

Target
/media
Default
/mnt/user/media
Value
/mnt/user/media
PUBLIC_URLVariable

Public base URL used in generated M3U/HDHomeRun links, e.g. http://192.168.1.50:8085. Leave blank to auto-detect from the request.

Hardware EncoderVariable

nvenc, vaapi, qsv, or leave blank for software encoding (libx264).

Target
HW_ENCODER
Admin UsernameVariable

Set together with Admin Password to require login for the dashboard and API. Leave both blank to run without a login (fine on a trusted LAN).

Target
ADMIN_USER
Admin PasswordVariable

Paired with Admin Username above.

Target
ADMIN_PASSWORD
Stream TokenVariable

Optional shared secret required on stream/HLS URLs; auto-embedded into generated M3U and HDHomeRun lineup links so configured players keep working.

Target
STREAM_TOKEN
Encryption KeyVariable

32-byte base64 key for encrypting stored Plex/Jellyfin/Emby tokens at rest. Leave blank to auto-generate and persist one under /config.

Target
ENCRYPTION_KEY
Disable SSDPVariable

Set to 'true' to disable the HDHomeRun/SSDP auto-discovery responder (e.g. if it conflicts with another tuner emulator on the network).

Target
DISABLE_SSDP