DISCORD is the fastest way to find out exactly what is happening with this project. Tons of updates happening from community input.
masqueradarr : Guides : Explanations : Documentation
masqueradarr is a self-hosted IPTV aggregator. It pulls channel playlists (M3U) and guide data (EPG/XMLTV) from a range of online IPTV services, normalizes them into one catalog, and serves them back as a single, unified, standards-compliant playlist + guide — behind one trusted identity that your media apps and IPTV clients can talk to.
It is the direct successor to TVApp2, which is now
deprecated. masqueradarr is not a fork or a patch — it is a ground-up re-architecture of the same
idea, carrying the project into the *arr self-hosted media family (Sonarr, Radarr, …) it's named for.
| Swag Pack | Location to full swag pack |
|---|---|
| 📦 Icon Pack | 🔗 masqueradarr icon pack |
| 📦 Font Pack | 🔗 masqueradarr font pack |
| 📦 Emblem-Sets | 🔗 masqueradarr emblem-sets |
| 📦 Label-Sets | 🔗 masqueradarr label-sets |
Note
View more screenshots of the current system including the updated branding and layout.
TVApp2 was a single, self-contained Docker container whose job was simple and effective: on a
schedule, scrape a handful of IPTV providers (TheTvApp, TVPass, MoveOnJoy), download and
regenerate flat .m3u and .xml files, and serve them over a small web interface so that
Jellyfin, Plex, or Emby could ingest them. It was a Node.js app on an Alpine Linux base,
supervised by s6-overlay, configured almost entirely through environment variables, with
HDHomeRun emulation and HD/SD quality toggles. It did one thing well — keep static playlists fresh.
That model had ceilings. Streams were static URLs baked into files, so anything behind a login, a device check, an expiring token, or a rotating mirror couldn't be served. There was no database, no real UI beyond file links, no per-user access control, no live observability of who was watching what, and no transcoding for clients that couldn't play the upstream format. Every new provider meant bespoke scraping glued into the core.
masqueradarr keeps the original promise — aggregate scattered IPTV sources into one playlist + guide — and rebuilds everything underneath it to lift those ceilings:
- Static files → a live, resolve-on-demand engine. Instead of writing dead URLs to disk, masqueradarr resolves each stream at play time through an HLS proxy. That's what makes authenticated and rotating sources possible — e.g. an in-app, server-streamed Chromium captures a real login session, and per-play signed URLs are minted on demand.
- Flat config → MongoDB + a real management SPA. State lives in MongoDB; the front end is a Vue 3 single-page app with full screens for Dashboard, Active Streams, History / Metrics, Playlists, EPG Sources, Channel Mapping, Users, and Settings.
- Bespoke scrapers → a source-agnostic adapter framework. Adding a provider is one adapter file plus one registry line; the generic core (sync, catalog, telemetry) never branches per source.
- File server → an API + a live video proxy. An in-app player and M3U / XMLTV export for external
clients, with playback served by a rebuilt, durable Rust proxy engine (
masq-proxy) that resolves each stream on demand — HLS today; the few remux-dependent paths (e.g. HDHomeRun TS→HLS) are still pending. - Env-var toggles → users, roles & per-user access. Real authentication (scrypt), session vs. stream tokens, and per-user tokenized playlist access.
- Blind scheduling → live observability. WebSocket-pushed viewer/bandwidth/buffering telemetry, live system-performance stats, and MongoDB-backed application logs.
| TVApp2 (deprecated) | masqueradarr | |
|---|---|---|
| Role | Static M3U/XMLTV regenerator | Live IPTV aggregator + delivery platform |
| Streams | Static URLs written to files | Resolve-on-demand via HLS proxy |
| State | Flat files, no DB | MongoDB (Mongoose 8) |
| Frontend | Links to generated files | Vue 3 + Vite management SPA |
| Backend | Node.js scripts | Express 4 API (ESM, TypeScript) |
| Sources | Hard-coded scrapers (TheTvApp, TVPass, MoveOnJoy) | Pluggable adapters + URL / HDHomeRun / file imports |
| Guide data | One bundled XMLTV grabber | Gracenote, EPG-PW, Jesmann, Custom XMLTV + self-EPG |
| Auth | None | scrypt users, roles, per-user access lists |
| Auth'd sources | Not possible | Supported (streamed-login session capture) |
| External clients | Pass-through only | M3U / XMLTV export + live Rust HLS / raw-TS proxy |
| Video engine | None (static URLs) | Remux-free Rust data-plane proxy (resolve-on-demand; retry / failover / raw-TS) |
| Observability | Logs | Live WS telemetry, history/metrics, system stats, app logs |
| Backup | None | Full-system gzip backup / restore + scheduled backups |
| Base image | Alpine + s6-overlay | Debian bookworm (glibc) + tini |
| Config | Environment variables | DB-backed settings + minimal .env bootstrap |
masqueradarr is two independently-built, independently-versioned npm packages that the Docker image stitches together — not a workspace, and they never import across the boundary:
/(root) — the Vue 3 + Vite SPA (the management front end;hls.js,vue-router,mitt).server/— the Express 4 + Mongoose 8 API (ESM, TypeScriptstrict), which serves the built SPA, the/api/*REST surface, and four WebSockets (login-stream, stream-stats, logs-stream, system-stats).
At a high level, the SPA and IPTV clients talk to the Express control plane, which owns MongoDB and
drives a Rust data-plane sidecar (masq-proxy) for stream bytes:
Key subsystems:
- Sources adapter framework — a source-agnostic core (sync → normalize → dedupe → resolve) with ~17 per-provider adapters, plus proxy-only sources — direct (passes user-imported stream URLs straight through) and hdhomerun (imports a local tuner's channel lineup; playback is dormant pending remux support in the video engine) — that back bring-your-own playlists.
- Channel model — a pristine synced reference (
sourcechannels) projected into an editable, UI-facing store (playlistchannels); user edits survive re-syncs. - EPG + scheduler — multiple guide ingesters behind one shared sync path — Gracenote,
EPG-PW, Jesmann, and user-supplied Custom XMLTV (file upload or re-fetchable remote URL,
streamed so multi-GB national guides parse with bounded memory) — plus the self-built guides that
built-in carry. All driven by a
croner-backed runtime scheduler over a persistedcronjobscollection. - Composition + export — composes Global, per-user, and custom
.m3uplaylists with matching XMLTV guide siblings for downstream clients. - Video proxy engine — live. A remux-free Rust data-plane sidecar (
masq-proxy) resolves each stream on demand and serves it over a durable HLS / raw-TS pipe (retry, mirror failover, read-ahead buffering). Node stays the control plane (auth, resolve, token gate, telemetry authority); Rust moves the bytes. See Video Proxy Engine. - Backup & maintenance — full-system gzip backup / restore, scheduled backups, and Mongo index-rebuild / workspace-reset maintenance actions.
The rename and re-architecture are effectively complete: the codebase, brand, runtime, and Docker
images are all masqueradarr. The compose stack pulls the standard app image
iflip721/masqueradarr (built from docker/app.Dockerfile); the all-in-one variant is built from
docker/aio.Dockerfile. If you're coming from TVApp2: there is no in-place upgrade path —
masqueradarr is a new application with a new data model (MongoDB instead of flat files), so stand it up
fresh and re-add your sources through the UI.
masqueradarr is the successor to TVApp2 by TheBinaryNinja, and inherits its core aggregation framework (ported from the sibling project). TVApp2 remains available, archived, and deprecated — all new development happens here.
Aggregation & delivery
- Pulls M3U playlists and EPG / XMLTV guide data from multiple IPTV providers and normalizes them into one catalog.
- Resolve-on-demand catalog — each stream is resolved at play time (no dead URLs on disk), which is what makes authenticated, token-gated, and rotating-mirror sources possible. The bytes are served by the Rust video proxy engine (see Video Proxy Engine below).
- Delivery surfaces — an in-app slide-out player and M3U / XMLTV export for external clients (TiviMate / VLC / Emby / Jellyfin / Plex).
- Composition + export — builds Global, per-user, and custom
.m3uplaylists, each with a matching XMLTV guide sibling advertised viax-tvg-url.
masqueradarr ships as Docker images. There are two deployment shapes.
-
Copy the env template and fill it in:
cp .env.example .env
At minimum set
MONGO_ROOT_USER/MONGO_ROOT_PASS, yourDOMAIN, and the host volume paths (COMPOSE_PATH,BACKUPS_PATH,MONGO_DATA_PATH) — each host dir must be writable by uid 1000 (the container'snodeuser). To publish on a host port other than3000, setMASQUERADARR_PORT(and updateDOMAINto match). -
Bring it up:
docker compose up -d
-
Open
http://localhost:3000(or yourDOMAIN; the host port reflectsMASQUERADARR_PORT). The app self-provisions itsconfig.jsonfrom the.envon every boot — there is no host config file to manage.
A second image bundles app + MongoDB + config bootstrap into one container, so the whole stack runs
from a single docker run with no external database — ideal for a quick trial or a small home server. One
/data volume persists the database, exports, config, and credentials. It's published under the
iflip721/masqueradarr name (see Migration status above). (On amd64, the bundled MongoDB 7.0
requires a CPU with AVX; on hosts without it, use the compose stack.)
To publish on a different host port, change the left side of the -p mapping — e.g. -p 8080:3000
(the container always serves on 3000 internally; MASQUERADARR_PORT only applies to the compose stack).
On first launch there are no users — the app reports needsSetup and the SPA walks you through
creating the first admin account. After that:
- Add a playlist — the Add Playlist modal offers every built-in source plus custom playlists (clone / file / URL / HDHomeRun).
- For an authenticated source (dulo), capture a login session from Settings (a server-streamed Chromium signs you in; only tokens are stored).
- Sync now to populate channels, then optionally add EPG Sources and link guide data on the Channel Mapping screen.
- Create Users with per-user access lists — each gets a personal tokenized
.m3u+ XMLTV guide URL for their IPTV client.
All runtime settings live in MongoDB and are editable on the Settings screen (domain, DNS
nameservers, video configuration, backups, …). The .env only bootstraps infrastructure on first
boot:
| Variable | Purpose |
|---|---|
MASQUERADARR_PORT |
Host port mapped to masqueradarr (default 3000). |
MONGO_ROOT_USER / MONGO_ROOT_PASS |
MongoDB root credentials; also assemble the app's mongoUri. |
DOMAIN |
Public base URL written into composed playlist / guide links. |
DISPLAY_NAME |
App display name. |
TZ |
Container timezone (used by the scheduler). |
COMPOSE_PATH |
Host dir for composed .m3u + XMLTV exports (uid-1000 writable). |
BACKUPS_PATH |
Host dir for scheduled backups. |
MONGO_DATA_PATH |
Host dir for persistent MongoDB data. |
MONGO_HOST_PORT |
Host port mapped to mongod (default 27017). |
MONGO_URI / MONGO_HOST |
Optional — point the app at an external / Atlas MongoDB instead of the compose mongo service. |
DNS_LOG_LEVEL |
Outbound-DNS trace verbosity (1–3); seeds the setting on first boot. |
MASQ_EDGE |
Optional (default off). 1 inverts the topology so the Rust proxy owns the public port and Node runs behind it on a loopback internal port — same public port, reversible. Enable for scale / high concurrency. See Public edge mode. |
App-settings vars are seeded with
$setOnInsert— they apply on the first provision only. Change them in the Settings UI afterward; a redeploy won't clobber UI changes.
Important
This sample enviornment variable is also included in the release notes and the main branch repository: .env.example
Ensure you update COMPOSE_PATH BACKUPS_PATH MONGO_DATA_PATH with the appropriate folders for your system.
For the best experience, create each folder path assigned to COMPOSE_PATH BACKUPS_PATH MONGO_DATA_PATH before composing the docker stack.
mkdir compose && chown -R 1000:1000 ./compose && chmod -R 777 ./compose
mkdir backups && chown -R 1000:1000 ./backups && chmod -R 777 ./backups
mkdir mongo && chown -R 999:999 ./mongo && chmod -R 777 ./mongoThe repo is two independently-built npm packages (not a workspace):
# Frontend (repo root) — Vite dev server on :5173, proxies /api → http://localhost:3000
npm install && npm run dev
# Backend (server/) — tsx watch on :3000 (needs a reachable MongoDB)
cd server && npm install && npm run devThere is no test runner and no linter — correctness is verified by npm run build (type-check) in
each package and by running the app.
- A source-agnostic adapter framework: adding a provider is one adapter file plus one registry line; the generic core (sync → normalize → dedupe → proxy) never branches per source.
| Source | Mechanism |
|---|---|
| Distro TV | makeFastSource · jsrdn tv_v5 catalog (Android-TV UA) · geo-qualified channel IDs · per-play double-underscore VAST macro expansion via resolveStream · distro.tv Origin/Referer-gated CDN · separate guide from epg/query.php |
| FreeLiveSports | makeFastSource · Unreel/PowR sports catalog · direct-HLS masters bearing Unreel VAST macros ([DEVICE_ID]/[CB]/[REF]/[UA]/…) · per-play macro expansion via resolveStream |
| LG Channels | makeFastSource · Public mirror via schedulelist (catalog + XMLTV guide in one call) · direct-HLS masters bearing [DEVICE_ID]/[UA]/[NONCE]/… VAST macros · per-play macro expansion via resolveStream |
| (Local Now) | Sentinel-resolve adapter · localnow://<id>?slug=<slug> stored at sync · resolves to a fresh signed CDN master per play · market-scoped channel set imported via local/import.ts · US-only (geo-gated) |
| Plex | (planned) |
| DaddyLive | HTML catalog scraped from a runtime-selected rotating mirror (mirrorDirectory.ts) · watch.php?id=<N> entry sentinel · 3-hop, Referer-gated scrape per play to a fresh signed master · dynamic SSRF allow-set · self-EPG via schedule scrape + Gracenote crosswalk |
| Pluto TV | makeFastSource · sentinel+resolve · /v2/guide/channels catalog yields channel IDs only · stateful per-region boot session (boot.pluto.tv) · per-play JWT-stitched HLS master from the stitcher CDN |
| STIRR | makeFastSource · sentinel+resolve · videos/list catalog yields video IDs + provider-EPG pointers · per-play resolve via POST /playable · bundled provider guide |
| Samsung TV+ | makeFastSource · Public mirror (i.mjh.nz) · no auth · jmp2.uk short-link redirect followed per play to a rotating CDN master · dynamic SSRF allow-set learned at play time |
| TCL TV+ | makeFastSource · sentinel+resolve · livetab → programlist catalog via the ideonow.com gateway · per-play HLS master minted by a format-stream-url POST |
| Roku Channel | makeFastSource · sentinel+resolve · /api/v2/epg catalog yields channel IDs and metadata only · stateful Cloudflare-sensitive anonymous session · per-play JWT-signed HLS master from Roku's OSM CDN |
| Tubi TV | window.__data scrape of /live for channel catalog · per-channel EPG + short-lived JWT-signed HLS manifest from /oz/epg/programming · manifest minted per request (sentinel+resolve) · self-EPG written via afterSync |
| Vidaa Free TV | makeFastSource · direct-HLS (identity resolveStream) · client-config bootstrap (BOURL + tenant) · geo-qualified channel IDs · ad-DI macros stripped at catalog time |
| Vizio WatchFree+ | makeFastSource · direct-HLS (identity resolveStream) · public anonymous catalog from watchfreeplus-epg-prod.smartcasttv.com · ad-DI macro placeholders substituted with privacy-neutral values at normalize time |
| Whale TV+ | makeFastSource · macro-expansion · keyless auth bootstrap (apiToken → short-lived bearer) · Ottera/SSAI ad macros ([did]/[session_id]/[cachebuster]/…) expanded per play via resolveStream |
| Xumo Play | makeFastSource · sentinel+resolve · Valencia catalog yields channel IDs only · 3-hop per-play resolve (broadcast → asset → HLS source → macro-fill) |
- Clone — hand-pick channels from any synced source into a curated playlist; the channels are independent copies (so edits don't disturb the originals) but streams still route through the real adapter.
- Import — pull in any remote M3U URL (re-syncable), upload a static
.m3ufile, or expose a local HDHomeRun tuner (its channel lineup is imported; playback — the TS→HLS remux — is dormant pending remux support in the video engine). - Every custom playlist rides the same per-user, token-gated
.m3u+ XMLTV export machinery as the built-in sources.
- Local Now (localnow.com) is a US-based FAST (Free Ad-Supported Streaming TV) service that delivers a market-specific lineup of live channels — local broadcast stations, regional news, and national FAST networks — curated by geographic television market. Masqueradarr integrates it as a fully managed, re-syncable custom playlist type with a bundled EPG guide.
Note
US-only. Local Now is geo-gated to US IP addresses. Attempting to add a Local Now playlist from a non-US IP will return a clear error. No VPN workaround is built in; route the server through a US network to use this feature.
When adding a Local Now playlist you choose a city/market — the geographic unit Local Now uses to select a channel lineup. Masqueradarr exposes two ways to pick one:
| Method | How it works |
|---|---|
| City search | Type at least 2 characters in the city field. A typeahead calls GET /api/import/local/cities?q= which queries Local Now's City/Search API and returns matching cities with their market identity. |
| Auto-detect | Click "Use my detected market." The server reads the geo-detected market from Local Now's own homepage response and pre-fills the closest market to the server's public IP. Falls back to New York City if the homepage carries no geo signal. |
Every choice resolves to two stored identifiers:
| Field | What it is | Example |
|---|---|---|
DMA (marketDma) |
A numeric Designated Market Area code — the Nielsen/TV-industry identifier for a regional television market. | 501 (New York) |
Market (marketSlug) |
A comma-joined list of Local Now market slugs — a primary city slug plus any associated PBS station slugs for that market. | nyNewYorkCity,pbs-wnet,pbs-wedh,pbs-wliw,pbs-wnjt |
Both are stored on the playlist row and are used together as the query parameters for every upstream catalog/guide fetch. They are set once at creation and never modified afterward (to change market, delete the playlist and create a new one).
When you add a Local Now playlist, Masqueradarr:
- Creates a custom playlist row (
source: 'local',endpoint: 'custom') with the market's DMA and slug stored directly on the playlist. - Immediately syncs the market's channel lineup from Local Now's combined catalog + guide endpoint. Channels appear in the playlist the moment creation completes.
- Prunes subscription-locked channels — any channel with
subscription_access.unlocked === falseis excluded; only freely available content is imported. - Deduplicates the raw channel list by channel ID before writing.
Each imported channel carries:
| Field | Value |
|---|---|
| Name | The channel's display name from Local Now |
| Group | Local News for local broadcast stations (identified by W/K call signs, My City genre, hyperlocal/epg-local-now slugs), otherwise the first IAB genre tag, otherwise Local |
| Channel number | The broadcast channel number when provided by Local Now |
| Logo | The channel's logo URL from Local Now |
| Stream entry | An opaque localnow://<id>?slug=<slug> sentinel — streams are resolved on demand per play via Local Now's DSP backend, not stored as static URLs |
Each Local Now playlist automatically creates and owns a playlist-bound EPG source (visible in the EPG Sources screen, labeled <Market> — Local Now). Key properties:
| Property | Value |
|---|---|
| Source | local |
| Binding | Playlist-bound (playlistBinding: true) — manual sync and schedule controls are hidden in the EPG Sources UI; the playlist's hourly cronjob drives all refreshes |
| Guide data | Inline program[] from the market's catalog fetch — no separate EPG call needed |
| Guide window | ~5 programs per channel per sync (continuous coverage maintained by the hourly schedule) |
| Program fields | Title, start/end times, description, content rating, season/episode numbers (parsed from Local Now's composite title format), IAB category |
| Channel linking | Channels are self-linked automatically at sync time — no manual Channel Mapping required. Each channel's tvg_id and epg fields are set to point at this EPG source on first sync, so the guide renders immediately |
The EPG source ID matches the playlist ID — they are a matched pair scoped to the same market. Deleting the playlist cascade-deletes the EPG source, all guide channels, all programs, and the hourly cronjob together.
You can add as many Local Now playlists as you want, one per city/market. Each is fully independent:
| Aspect | Behavior |
|---|---|
| Playlist row | A separate playlists document per market, with its own id, marketDma, marketSlug, and marketLabel |
| Channel storage | Each market's channels are stored under their own playlist ID — there is no sharing or collision between markets |
| EPG source | Each market gets its own EpgSource (id = playlist id), its own epgchannels, and its own programs collection scope |
| Schedule | Each market gets its own independent hourly Cronjob |
| Naming | Masqueradarr disambiguates playlist IDs automatically — if you add "New York" twice the second gets a numeric suffix (newYork2) |
| Deletion | Deleting one market's playlist removes only that market's channels, EPG, and schedule — the others are unaffected |
Example: adding New York (DMA 501) and Los Angeles (DMA 803) gives you two separate playlists (newYork and losAngeles), two separate EPG sources, and two independent hourly schedules. Each can be assigned to different users via the standard playlist access controls.
- Ingests guide data from Gracenote, EPG-PW, Jesmann (guided picker), and any Custom XMLTV source — an uploaded file or a re-fetchable remote URL, streamed so multi-GB national guides parse with bounded memory — plus the self-built guides that playlist-bound carry.
- Channel Mapping links channels to guide data with composite match-scoring and many-to-one EPG linking; the link survives re-syncs.
Management SPA (Vue 3)
- Full screens for Dashboard, Active Streams, History / Metrics, Playlists, EPG Sources, Channel Mapping, Users, and Settings.
- An editable channel store where user edits survive re-syncs, and shared progress modals for the long-running sync / compose operations.
Users & access control
- scrypt authentication, admin / user roles, and per-user access lists (allowed playlists / custom playlists).
- A session-token vs. stream-token split, and per-user tokenized M3U access (token-free download, token-gated stream).
Observability
- WebSocket-pushed viewer / bandwidth / buffering telemetry — the Rust proxy measures the true byte edge and reports it over the telemetry seam, so Active Streams and History / Metrics show real, live sessions.
- Live system-performance push (CPU / memory) on the Dashboard.
- Persisted view-session history + per-user metrics, and MongoDB-backed application logs (13
categories, 14-day TTL) with a live log drawer — including a dedicated
proxycategory fed by the Rust engine's full resolve→fetch→rewrite→serve lineage.
The video engine is a rebuilt Rust proxy. The old always-on ffmpeg transcode engine (with the B-Roll slate, ffprobe monitoring, and GPU hardware acceleration) was replaced by a remux-free Rust data-plane sidecar — see Video Proxy Engine for the full picture. The one part still pending is remux / transcode (e.g. HDHomeRun TS→HLS).
Scheduling
- A
croner-backed runtime scheduler over a persistedcronjobscollection: playlist re-sync, EPG re-sync, M3U / XMLTV recompose, and scheduled backups.
Backup & restore
- One-click full-system backup — a gzipped snapshot of every collection, downloaded or written to a configured backup directory on a schedule.
- Restore from an uploaded backup or a saved file; the restore re-orchestrates the dependent subsystems (boot init, DNS, scheduler) in place.
- Maintenance actions from Settings: rebuild MongoDB indexes, or reset the workspace (wipe content, keep users / settings).
All adapters implement the SourceAdapter contract (server/src/sources/types.ts) and are registered in server/src/sources/registry.ts. The generic core (buildSource) never branches per source — every per-source difference is encapsulated in the adapter object. Each adapter's resolveStream/proxy are live — the Rust data-plane engine calls them per stream through the resolve seam (see Video Proxy Engine).
| Adapter | Label | Auth | Resolve Strategy | Self-EPG | Gracenote XWalk |
|---|---|---|---|---|---|
direct |
Imported | — | Identity (passthrough) | — | — |
hdhomerun |
HDHomeRun | — | Catalog import (playback dormant — needs remux) | — | — |
local |
Local Now | — | Sentinel → rotating CDN | — | — |
dulo |
dulo.tv | session | dulo:// sentinel → playbackUrl |
— | yes |
dlhd |
DaddyLive | — | watch.php → 3-hop scrape |
yes | yes |
tubi |
Tubi.TV | — | tubi:// → Tubi API |
yes (inline) | — |
xumo |
Xumo Play | — | broadcast.json → 3-hop API | yes | — |
stirr |
STIRR | — | /playable → 1-hop POST |
yes | — |
tcl |
TCL TV+ | — | format-stream-url → 1-hop POST |
yes | — |
pluto |
Pluto TV | — | pluto:// → region boot + URL |
yes | yes (wired) |
roku |
The Roku Channel | — | roku:// → session + playId |
yes | — |
samsung |
Samsung TV Plus | — | jmp2.uk redirect | yes | yes (wired) |
lg |
LG Channels | — | {MACRO} fill per play |
yes | — |
whale |
Whale TV+ | — | macro fill per play | yes | — |
distro |
Distro TV | — | __MACRO__ fill per play |
yes | — |
freelivesports |
FreeLiveSports | — | macro fill per play | yes | — |
vizio |
Vizio WatchFree+ | — | Identity (direct HLS master) | yes (airings) | — |
vidaa |
Vidaa Free TV | — | Identity (macros pre-expanded) | yes | yes (wired) |
Scope: the playlist data model — what a playlist is, the built-in vs. custom kinds, how guide data binds to it, and how per-user access is granted. The catalog (
playlistchannels) and the export surface (.m3u+ XMLTV guide sibling) meet here.
A Playlist is a row in the playlists collection — the envelope (name, hosted URL, endpoint mode,
schedule, state). Its channels live separately in playlistchannels, queried by the playlist's
source (or clone) id. A playlist row with no channels is a valid, paused shell.
- Two channel stores back every playlist. A sync writes a pristine, source-of-truth
sourcechannelsreference, then projects it into the editable, UI-facingplaylistchannels. You edit the latter (rename, disable, channel #, EPG link) and your edits survive a re-sync: a sync$sets source-derived fields,$setOnInserts the user-editable ones, and prunes channels that vanished upstream. - Hosted URL + endpoint mode. A
global-endpoint playlist is served through the one consolidated Global M3U endpoint; acustom-endpoint playlist is served at its own path. Theurl("HOSTED AT") always prependssettings.domain, so changing the domain in Settings cascades to every playlist's URL. - State + schedule.
state:falsepauses the endpoint (downstream clients get a 404).interval+autodrive the scheduler; a manual Sync now is always available. - Streaming is resolve-on-demand. A channel's stream URL is derived (
/api/v1/<source>/<enc-entry>), never stored — the Rust video proxy resolves the real upstream at play time. Every channel keeps itsoriginsource, so a cloned or imported channel routes through the right adapter's resolver.
| Kind | source tag |
Created via | Channels |
|---|---|---|---|
| (Default) source playlist | <dynamic> |
Add Playlist → Built-In (provisions a zero-channel shell; populates on first Sync now) | Synced from the adapter; id === source |
| Clone | clone |
Add Playlist → Clone — hand-pick channels from any synced source | Independent COPIES in playlistchannels; origin = the provider source for routing |
| URL import | url |
Add Playlist → URL — fetch a remote .m3u / .m3u8 |
Parsed from the upstream; re-syncable via the stored remoteUrl |
| File upload | file |
Add Playlist → File — upload a static .m3u |
Parsed once from the uploaded file |
| HDHomeRun | hdhomerun |
Add Playlist → HDHomeRun — point at a LAN tuner (deviceUrl) |
Discovered from the device (channel lineup); playback (TS→HLS remux) is dormant pending remux support in the video engine |
Built-in defaults are Global-endpoint by default; the custom kinds are Custom-endpoint and ride the
per-playlist export machinery (their own path + guide sibling). All the type tags (clone/file/url/
hdhomerun) and modes (global/custom) are stored lowercase.
Any playlist's channels can be grouped into a failover group: one parent plus an ordered list of children — silent backups for the same real-world channel, possibly from different providers. Select the channels on the playlist detail screen → Group → pick the parent, drag the children into priority order, save.
- One line exported. The composed M3U (and its guide) contains only the parent; children are
hidden from every export surface but stay visible (badged
parent/child) in the management UI. - EPG identity is inherited. Children mirror the parent's
tvg_id/epglink — set at group save and re-cascaded whenever the parent's EPG link changes (drawer, Mapping screen, bulk edits). Direct EPG edits on a child are rejected (409 failover_child_epg_locked); auto-match skips children. - Play-time failover (establish-time). When the parent's stream fails to establish — resolve failure,
transport failure after retries, or (opt-in) a definitive upstream error — the data plane walks the
children in order via
attempt=1,2,…resolves and serves the first one that answers, under the parent's URL and stream identity. The session then sticks to the winning child (the failover cursor never walks back to the dead parent mid-play); the pin resets a few idle minutes after playback stops. - Cross-provider safe. A child's grant carries its own adapter's headers under its own policy key
(
policySource), so a dlhd parent backed by a pluto child never pollutes dlhd's other streams. - Observability. Active Streams badges a failed-over stream with
failover → <child>; the scheduled channel probe keeps probing hidden children, so a dead backup is visible before failover ever reaches it. - Self-healing. Any prune/delete that removes a group's parent (or its last child) auto-disbands the
group; disbanding is also available in the Group modal — children keep their inherited EPG link but
re-enter the export. Children must stay Active to remain probe-covered and candidate-eligible
(a
Disabledchild is skipped at failover; aDisabledparent hides the whole group from exports).
Knobs: failoverEnabled (default on — configuring a group is the real opt-in) and
failoverOnDefiniteError (default off) in the proxyconfigs subsystem.
Seamless mid-segment splicing is a future enhancement — a parent dying mid-play is caught on the player's
next playlist refetch.
Guide data reaches a playlist through two distinct mechanisms — keep them separate:
- Channel-level guide linking (the everyday case). EPG attaches to a playlist through its channels,
not the playlist row. Each channel carries a 2-factor
(tvg_id, epg)link — set on the Channel Mapping screen (or self-linked by sources that ship their own EPG). At compose time the guide is built from exactly the channels that carry a link, so "which EPG sources feed this playlist" is simply whichever sources its channels are mapped to — many sources can contribute to one playlist's guide. - **Playlist-bound EPG sources (
playlistBinding). Built-in carry their own inline guide. When you sync such a playlist, itsafterSynchook writes the guide and upserts a matching EPG source row flaggedplaylistBinding: true, then self-links the playlist's channels to it. These rows are owned by the playlist's sync — the playlist drives their refresh cadence, so the EPG Sources screen hides their manual-sync + schedule controls. You never add or schedule them by hand.
- Access is a per-user allow-list, split to mirror the endpoint modes:
allowedPlaylists(Global-endpoint playlists) andallowedCustomPlaylists(Custom-endpoint playlists). - You assign membership on the Playlists screen (per playlist — it was moved here off the Users screen), not by editing the user.
- Admins ⇒ every playlist. An admin account's allow-lists are materialized to hold every playlist id (a real invariant, not just a role bypass), and creating a new playlist auto-grants it to all admins — so an admin always sees the full catalog.
- Each user gets a personal, tokenized
.m3u+ XMLTV guide URL for their IPTV client: the download is token-free, but the stream is token-gated to that user's allowed playlists.
Scope: the guide-data subsystem — what an EPG source is, the provider kinds, the one shared sync path, how playlist-bound self-EPG differs, and how guide data is woven into a playlist's
.m3uat compose time. The XMLTV wire format itself is the sibling of the M3U export.
An EPG source is a row in epgsources registering one guide provider. A sync writes two collections:
epgchannels (one row per guide channel) and programs (the airings), both keyed by a composite
<epg>:<tvg_id> id so multiple sources never collide.
- One shared sync path. Every kind goes through
syncEpgSource.ts, whether triggered by a manual Sync now or by a scheduler tick; it maintains the per-sourcesyncSuccessCount/syncFailCountandstatus. - A sync is a per-source replace. The source's old channels/programs are swapped for the fresh pull. The
streaming-XMLTV path replaces up-front, so a mid-stream failure marks the source
errorand the next good sync heals it cleanly (the sharedepgchannels/programscollections are scoped bysource). - Reorder + run-stats. The EPG Sources screen is drag-to-reorder (
order); the guide-generation run-stats (lastXmlAt,xmlGeneratedCount,xmlFailCount) are credited during compose (below).
The source discriminator (stored lowercase):
| Kind | Added via | Notes |
|---|---|---|
| gracenote | Add EPG Source → Gracenote | Provider/lineup grid; provenance fields (headend / lineup / postal / country / …) let the grid URL be rebuilt + re-synced |
| epg-pw | Add EPG Source → EPG-PW | epg.pw per-channel XML |
| jesmann | Add EPG Source → Jesmann (guided picker) | Large national XMLTV guides, streamed so multi-GB files parse with bounded memory |
| xml file | Add EPG Source → Custom (upload) | One-shot uploaded XMLTV document |
| remote url | Add EPG Source → Custom (URL) | Re-fetchable remote XMLTV URL (streamed, gzip-aware) |
| playlist-bound | (automatic) — the playlist's afterSync binding |
Playlist-bound self-EPG (playlistBinding:true); not user-added |
- Standalone sources (gracenote / epg-pw / jesmann / custom XMLTV) sync on demand or on a
cronjobsschedule, independent of any playlist. - Playlist-bound sources (built-in) have no manual sync of their own. They are written by the
playlist's
afterSynchook off the same listing that playlist sync already fetched, and the bound source row is re-asserted (playlistBinding:true) on every playlist sync. To refresh a bound guide you sync its playlist — the EPG Sources screen deliberately hides their sync/schedule controls because the playlist owns the cadence. - Either way, the binding between guide data and a playlist's channels is the channel-level
(tvg_id, epg)link — Channel Mapping for user-added sources, self-linked for channel-adapter built-in sources.
Guide data only reaches a downstream client at compose time, and composition is playlist-scoped: a
guide is written as a sibling of the M3U by composeGuide(), which runs off the same Active channel set
composeM3u() just wrote (the Global union, or one Custom playlist) — so a guide can never drift from its M3U.
Per composed surface:
- Select — keep only Active channels that carry a 2-factor
(tvg_id, epg)link; index them by the composite key<epg>:<tvg_id>. - Channels — resolve each key's
epgchannelsrow (display-name / call-sign / channel-no) and emit one<channel>, de-duped by the baretvg_id(first-wins — two sources can publish the same id and a player can't disambiguate anyway). A channel linked to anepgchannelsrow that isn't synced yet is skipped, never orphaned. - Programmes — pull the
programsfor those keys and emit<programme>s, re-tagged to the baretvg_idso each airing matches its<channel id>. - Merge + advertise — the result merges programme data from every EPG source the playlist's channels
link to into one
<tv>document written next to the.m3u, advertised viax-tvg-url. The guide is token-free and not per-user (a superset of any one user's channels is harmless). - Credit — every contributing source gets
lastXmlAt+xmlGeneratedCount++(orxmlFailCount++on failure).
Scope: how masqueradarr actually serves video. The old always-on ffmpeg engine was replaced by a remux-free Rust data-plane sidecar that resolves each stream on demand and pipes it durably to the player. This section covers the two-plane split, the internal seams, the request path, the durability features, the tunable config, and the opt-in public-edge topology.
Video is split across two processes that ship in the same container:
- Node — the control plane (the brains). Everything stateful and provider-specific stays in TypeScript: per-source auth (dulo's Supabase session + device fingerprint), scraping (dlhd's rotating-mirror, 3-hop Referer-gated resolve), the SSRF allow-set, the stream-token gate, telemetry authority, and config storage.
- Rust — the data plane (the muscle). A small standalone binary,
masq-proxy(theproxy/crate), does the byte work: fetch upstream, follow redirects, rewrite.m3u8manifests, and pipe segments — fast, multi-threaded, near-zero-copy. It is driven per stream by a "grant" from Node; it never re-derives provider logic.
Node spawns and supervises masq-proxy as a child process (auto-restart with backoff). A missing or crashed
sidecar is non-fatal — the app keeps managing playlists / EPG / channels / users and serving M3U / XMLTV
downloads; only live playback pauses until it's back.
Node and Rust talk over one private loopback channel — POST /api/internal/*, guarded by a shared
x-masq-secret (the SPA never calls it; only the Rust engine does). One contract, four jobs:
- resolve (
/api/internal/resolve) — Rust asks Node to resolve a stream; Node runs the adapter logic and returns a per-stream grant (masterUrl,upstreamHeaders,allowHosts, segment relabel, the resolvedproxyConfig, …) that Rust replays for the whole stream. - telemetry (
/api/internal/telemetry) — Rust measures the true byte edge and reports batched viewer / byte / phase / close events; Node stays the telemetry authority (Active Streams WS, History / Metrics, persistedViewSession). - log (
/api/internal/log) — Rust ships level-gated, request-tagged engine logs into the dedicatedproxylog category — the same "View logs" drawer as everything else. - authorize (
/api/internal/authorize) — edge mode only (below): the per-request stream-token check when Rust owns the public socket.
The telemetry + log responses both echo the current log level, so changing verbosity on the Settings screen reaches the sidecar within one flush — no restart.
- A player requests
/api/v1/…(in-app) or/api/ext/v1/…(external clients; the mount the composed M3U always emits), carrying the per-user?token=and the?pl=playlist id. - The stream-token gate runs first: valid token? enabled? (for a non-admin) is this source in the user's allow-list? Denials are plain text so a media player surfaces them.
- On allow, Node relays the request to the sidecar and adds the client identity it can see (IP, UA, username) plus the shared secret.
- Inside Rust, the first request (ENTRY) resolves via the seam to get the grant + master URL; child requests (HOP — variant playlists, segments, keys) reuse the cached policy. Manifests are rewritten so every child URL routes back through the proxy with the token re-embedded; segments are relabelled and piped straight through.
The Rust engine is built to keep a stream alive on flaky upstreams:
- Retry — transient upstream failures (transport errors +
502/503/504) are retried with bounded backoff; definitive4xx/5xxare forwarded verbatim (unlessfailoverOnDefiniteErrorroutes them into the failover walk below). - Mirror failover — a dead resolved master forces a fresh resolve, driving dlhd to re-probe and rotate to a live mirror mid-stream.
- Failover groups — when a channel has configured backups and its stream still won't establish, the
engine walks the ordered children (
attempt=1,2,…against the resolve seam) and serves the first live one under the parent's identity, then sticks to it for the session. See Failover groups. - Stall detection — an idle read timeout (
readTimeoutMs) turns a silent upstream into a clean truncation instead of a hang. - Read-ahead buffer — a bounded in-memory buffer (
bufferSizeKb) smooths jitter and fixes the chunked / no-Content-Length byte undercount that used to fake client-side buffering. - Batched telemetry — events are coalesced and posted off the hot path, so reporting never blocks bytes.
- Raw MPEG-TS — with
outputFormat: 'ts', an external-mount stream is served as one continuousvideo/mp2tstream (segments concatenated, no remux) for players that prefer a flat TS pipe; fMP4 / AES sources auto-fall back to HLS.
The engine's knobs live in the proxyconfigs collection (the videoconfig successor), edited in the UI and
resolved by Node into each grant (Rust never reads MongoDB). Two tiers, doc-level fallback:
_id: 'app'— the (Default) config applied to every playlist. Edited on Settings → Advanced._id: 'app_<playlistId>'— a (Custom) per-playlist override that fully replaces the Default for that playlist. Edited in the playlist drawer (ProxyConfigPanel.vue, auto-saved).
| Knob | Status | Effect |
|---|---|---|
headerOverrides |
live | extra upstream headers, merged over the adapter's (operator wins) |
connectTimeoutMs, maxRedirects |
live | per-config upstream HTTP client (cached in Rust) |
readTimeoutMs, bufferSizeKb |
live | per-stream stall timeout + read-ahead buffer size |
outputFormat (hls | ts) |
live | distribution shape (ts = continuous MPEG-TS, external mount only) |
failoverEnabled |
live | walk a channel's ordered failover children on an establish failure (default on) |
failoverOnDefiniteError |
live | also treat a definitive upstream 4xx/5xx as a failover trigger (default off) |
segmentCacheTtlSec |
reserved | shipped in the grant, not yet enforced |
By default Node is the public front door and Rust is a loopback-only sidecar it relays bytes to.
Setting MASQ_EDGE=1 inverts the topology: Rust binds the public port and serves streams
in-process, while reverse-proxying everything else — the SPA, /api/*, the token-free .m3u / guide
downloads, and all four WebSockets — back to Node on a loopback internal port. The public port and DOMAIN
are unchanged, and it's fully reversible by clearing the flag (no rebuild).
Default — MASQ_EDGE off (Node is the front door):
MASQ_EDGE=1 (Rust is the front door):
What changes under the hood. In edge mode the token gate can't be Express middleware (Rust owns the
socket), so it becomes a per-request check against a small Rust auth cache backed by
POST /api/internal/authorize — revocation lands within a 30-second TTL (vs. strictly per-request in the
default topology). The edge synthesizes client identity server-side (forwarded-or-peer IP, real
User-Agent, gated username) and ignores inbound x-masq-* headers, so a public client can't spoof them.
Rust's loopback :8787 listener (/health + /probe) is unchanged, so the channel-probe scheduler keeps
working. Because Rust is now on the critical path for all traffic, Node restarts it unboundedly.
The core idea: take Node's single-threaded event loop out of the video byte path. In the default topology every streamed byte is handled twice (Rust → Node → client); edge mode removes that hop.
- Many concurrent or high-bitrate viewers (the main reason) — once you're serving dozens of streams (or a few 4K ones), Node's event loop becomes the throughput ceiling and adds jitter to everything, including the dashboards you monitor from. Rust shovels bytes far better without starving the control plane.
- Keep the management UI responsive under streaming load — Node's loop stays free for the SPA, the live WebSockets, and logs no matter how much video is flowing.
- Constrained hardware (Raspberry Pi / small VPS) — halving the per-byte copies lets the same box serve noticeably more streams.
- Lowest-latency, most-durable public path — the durability engine already lives in Rust; edge mode applies its backpressure straight to the client socket instead of through Node's pipe.
- When to leave it off — a personal setup with a handful of viewers gains nothing, and the default (sidecar) topology is the simpler, more battle-tested path, keeps strictly per-request token revocation, and has a smaller blast radius (Rust is critical-path only for streaming, not everything). Edge mode is build- and unit-verified, but a full runtime end-to-end pass on a live stack is still pending — treat it as an opt-in scale / performance topology, not the default.

