All apps · 0 apps
ChannelHub
Docker app from allornothing's Repository
Overview
Readme
View on GitHubChannel-Hub (self-hosted)
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 (
Settingspage); 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.m3u8segmented 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 - Titlestyle local filenames) air in order per series, so a channel plays a show like appointment TV instead of shuffling episodes - Searchable catalog —
/api/catalogfull-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/:channelNumberplays 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_PASSWORDgate the dashboard/API, and an optionalSTREAM_TOKENgates/streamand/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 thecronpackage, updatinglast_scanned_at; still scannable manually anytime - Health dashboard —
/healthpage: 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
drawtextcountdown) 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
- 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 editunraid/channel-hub.xmlto match.) - 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 - Set the Media path to your existing media share (read-only is sufficient).
- Set App Config/DB to a persistent
appdatapath, e.g./mnt/user/appdata/channel-hub. - 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).
- Start the container and open the WebUI on port
8085.
Using it
- 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. - 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".
- 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.
- TV Guide → see the live channel-by-time grid.
- Watch → play any channel live in the browser (arrow keys or Prev/Next to surf channels).
- Links → M3U, XMLTV, HDHomeRun discovery/lineup, and HLS URLs to paste into your player.
- 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/:idand/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-tokenendpoint 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 assecret.keynext 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
ffprobeand 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/:idwas captured withcurland the bytes verified withffprobeas real H.264/ AAC video/hls/:id/playlist.m3u8was 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
/nowand/epg.xmlreported /api/config/export→/api/config/importround-tripped without data loss/discover.json,/lineup.json,/lineup_status.json,/device.xmlall 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.m3uand/epg.xmlconfirmed 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/:idconfirmed to 401 without?token=, 200 with the correct one; the generated M3U embeds the token automaticallyseries_orderverified against three real S01E01–E03 clips (with deliberately shuffled filesystem order) — playback and/nowboth aired them E1→E2→E3→wrap, confirmed directly against the scheduler function as well as over HTTPpad_to_slotverified 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/catalogthrewUnknown named parameter 'limit'on its count query —limit/offsetwere bound into a query that never referenced them; fixed by using separate bound-parameter objects for the search query and the count query.itemAtTimecould 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
fragLoadErrorunderSTREAM_TOKEN: ffmpeg'splaylist.m3u8lists bare segment filenames, so the browser's follow-up segment requests carried no token and hit- 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 confirmedlast_scanned_atupdated on its own without any manual scan call. - "Returning Soon" filler: first proved the ffmpeg
drawtextcountdown 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/:idHTTP 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
activeViewersgoes 0 → 1 while a stream is open and back to 0 after it closes, by curling/stream/:idin 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.orgdownload 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/numberthe UPDATE statement never references) straight to.run().node:sqliterejects 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 60against 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-tentirely and stopping the recording with a real Node wall-clocksetTimeoutinstead — 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 yieldsR).
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
buildTimelineWindowdirectly that a 150s program splits into 60s + 60s + 30s segments with a bumper between each, and that the segments carry the correctsourceOffsetMs(0, 60000, 120000). - Captured the real live
/stream/:idHTTP output while 9 seconds into the second segment and confirmed the burned-in timecode read exactly 00:01:09.000 —60000ms(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.
Categories
Download Statistics
Related apps
Explore more like this
Explore allDetails
allornothing/channel-hub:latestRuntime arguments
- Web UI
http://[IP]:[PORT:8085]/- Network
bridge- Shell
sh- Privileged
- false
Template configuration
Web dashboard and stream/EPG/M3U port
- Target
- 8085
- Default
- 8085
- Value
- 8085
Where the SQLite database, encryption key, and recordings are stored.
- Target
- /config
- Default
- /mnt/user/appdata/channel-hub
- Value
- /mnt/user/appdata/channel-hub
Mount your media library (used for local-folder channels and bumpers).
- Target
- /media
- Default
- /mnt/user/media
- Value
- /mnt/user/media
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.
nvenc, vaapi, qsv, or leave blank for software encoding (libx264).
- Target
- HW_ENCODER
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
Paired with Admin Username above.
- Target
- ADMIN_PASSWORD
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
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
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