Sync Fetch TV PVR recordings into Plex.
A self-hosted bridge for Australian Fetch TV DVB-T set-top boxes.
- What Fetcharr is
- What Fetcharr isn't
- Features
- Prerequisites
- Quick Start
- Project Layout
- Scripts
- Environment
- Docker Deployment
- Sync State Machine
fetchtvDependency- Security
- Testing
- Disclaimer
- Support
Fetcharr watches a Fetch TV box on your LAN, downloads new episodes of shows you mark to follow, drops the files into your Plex TV library, pokes Plex to scan, and optionally deletes the recording from the Fetch box once Plex confirms the file.
It's the missing automation layer if your media stack is Fetch TV → Plex.
Dashboard: live SYNC/IDLE deck, tally cards, and latest sync outcomes.
Shows: per-show tracking, destination folder + season template, per-show sync.
Recordings: filters, sortable headers, easily identifiable rows already deleted from the Fetch box, etc.
Syncs: activity-type filters over a capped sync history.
- ❌ Indexer integration: (e.g. Sonarr / Radarr / Prowlarr) Fetcharr only consumes what Fetch has already recorded; it doesn't tell Fetch what to record. Use your Fetch TV box's own EPG to schedule recordings.
- ❌ Authentication: designed for trusted LAN deployments. CSRF, rate-limiting, and a strict CSP are in place, but there's no login. Don't expose it to the internet.
- ❌ Remux / transcode: files land as
.tsfrom the box. Add Tdarr or similar downstream if you need.mkv. - ❌ Notifications: no Discord / ntfy / push integration.
Important
Tested against Fetch TV Mighty 3 + Plex Media Server. Other Fetch hardware/firmware is unverified.
- Zero-config discovery: finds your Fetch TV box (SSDP) and Plex server (GDM) on the LAN, and auto-detects the Plex token from a bind-mounted
Preferences.xml. - First-run wizard: walks Fetch box → storage → Plex → optional Fetch Cloud. Re-openable from Settings; previously-saved values prefill.
- Per-show follow: pick a Fetch show, fuzzy-match it to an existing folder under
media_root, set a season template. Done. - Scheduled + manual sync: cron-configurable polling, plus on-demand global or per-show Sync now.
- In-progress recording protection: refuses to download a half-recorded show. Fetch reports misleading sizes during live record; Fetcharr catches the sentinels (and HEAD-probes stale DLNA metadata) so you never end up with truncated files.
- Resumable + truncation-aware downloads: HTTP Range resume across syncs; if on-disk bytes fall short of what Fetch reported, the row stays
partialand the next sync picks up the remainder. - Plex integration: section-refresh after every sync that downloaded something, plus a Refresh Plex now button.
- Optional delete-from-Fetch: once Plex confirms the file, free up the box. Has to go through Fetch's cloud API (see: rationale below).
- Self-housekeeping: sync history auto-prunes to the latest 500 rows; recording rows age out 30 days after delete-from-Fetch. No manual cleanup.
- TZ-aware UI: container
TZpropagates to the browser; timestamps render in that zone regardless of which device hits the page. - Danger Zone: one-click
NUKE ALL STATEreset back to the welcome wizard. DB only; downloaded media files untouched. - Authless LAN service: SQLite-backed, single Docker container, no external runtime dependencies once configured.
The original plan was UPnP DestroyObject from upstream fetchtv, which turned out to be impossible:
- Fetch firmware advertises
DestroyObjectin its ContentDirectory SCPD but the request handler rejects it withUnknown Service Action. - HTTP
DELETEon item URLs returns 501. - No vendor-specific local services are advertised.
The only working deletion path is Fetch's cloud APIs (HTTPS auth + WebSocket to messages.fetchtv.com.au with the user's activation code + PIN; see pyfetchtv for the reference implementation).
- A Fetch TV Mighty PVR on the same LAN as the host running Fetcharr (SSDP/UPnP discovery uses multicast, so Fetcharr's host must be on the same broadcast domain as the box).
- Node 22+ for local dev, or Docker for the container path.
- Plex Media Server is optional; Fetcharr will run without it; you just won't get the post-sync library refresh.
- Fetch cloud account (activation code + PIN) is optional; required only if you want Fetcharr to delete recordings from the box after they sync, since the LAN-side delete API on Fetch is broken (see: Features).
git clone <repo> fetcharr
cd fetcharr
cp .env.example .env # fill in CSRF_SECRET (openssl rand -hex 32)
npm run setup # ci --ignore-scripts + rebuild natives + audit signatures
npm start # http://localhost:8124; first visit shows the setup wizard
# (prestart auto-creates ./config/ and runs migrations)Note
npm run setup calls npm audit signatures, which honours .npmrc's min-release-age=3. If a dep in the lockfile was published in the last 3 days, the audit step will fail (ETARGET notarget). Either wait for it to age past the threshold or run npm install --ignore-scripts --min-release-age=0 once for the freshly-published dep.
Tip
package.json's volta block pins node@22.19.0 + npm@11.15.0 (matching the Dockerfile's node:22-alpine + npm@11.15.0). Install Volta and it'll auto-switch when you cd into the repo (avoids EBADENGINE from the host npm and ABI mismatches on better-sqlite3).
For dev:
npm run dev # node --watch
npm run migrate:refresh # drop the SQLite DB and re-migrateCopy docker-compose.example.yml to docker-compose.yml and fill in the host paths in a sibling .env:
CONFIG_PATH=/path/to/your/config
DATA_PATH=/path/to/your/media
PLEX_PREFS_PATH=/path/to/Plex/Preferences.xml # optional
CSRF_SECRET=<openssl rand -hex 32>
TZ=Australia/Sydney
PUID=1000
PGID=1000
FETCHARR_PORT=8124Then:
docker compose up -d
docker compose logs -fAfter pulling code changes, rebuild the image and recreate the container in one step:
git pull
docker compose up -d --build fetcharrup -d --build rebuilds the image from the local Dockerfile and recreates the container only if its image actually changed; the bind-mounted /config/state.db is untouched. Any pending migrations run on next boot via docker-entrypoint.sh.
The container entrypoint (docker-entrypoint.sh) runs knex migrate:latest against /config/state.db (the bind-mounted SQLite file) before exec'ing the server, so first boot on a fresh host is a no-op for the operator. See: Docker Deployment.
Important
The example compose uses network_mode: host because SSDP multicast (239.255.255.250:1900) does not traverse Docker's bridge network. Without host networking Auto-discover can't find the Fetch box.
fetcharr/
├── src/
│ ├── server.js # Express app; helmet, CSP, rate limit, CSRF, X-Robots-Tag
│ ├── db.js # Knex instance + simple settings get/set
│ ├── folder-matcher.js # Fuse.js wrapper that scans /media/tv
│ ├── sync.js # Sync engine; discover, browse, match shows, download, persist; exports classifyOutcome / matchShow / buildDestPath for tests
│ ├── scheduler.js # node-cron wiring, reloads on settings change
│ ├── plex.js # Plex section refresh + token detection from Preferences.xml
│ ├── fetch-cloud.js # Fetch cloud WebSocket client for delete-from-box
│ └── web/ # Static UI; Vue 3 SPA (browser ESM) + Tailwind v4 Play CDN (self-hosted), hash-routed across 5 tabs, no build step
├── test/
│ ├── sync.test.js # node --test: matchShow, buildDestPath, classifyOutcome state-transition truth table
│ └── folder-matcher.test.js # node --test: real on-disk fixture under os.tmpdir()
├── migrations/
│ └── 0001_initial.js # Initial schema; additive migrations from here on
├── knexfile.js # Honours DB_PATH env (defaults to ./config/state.db)
├── Dockerfile # node:22-alpine + tini + healthcheck
├── docker-entrypoint.sh # `knex migrate:latest` then `exec node src/server.js`
├── docker-compose.example.yml # Generic compose template; copy to docker-compose.yml
├── .env.example # Local-dev minimal envs (CSRF_SECRET, TZ, PUID/PGID)
├── .npmrc # Supply-chain hardening
└── package.json # fetchtv installed from npm (pinned exactly)
| Script | What it does |
|---|---|
npm run setup |
npm ci --ignore-scripts → npm run rebuild:natives → npm audit signatures |
npm run rebuild:natives |
Explicitly rebuilds the native deps allow-listed in package.json (just better-sqlite3) |
npm run migrate |
mkdir -p config && knex migrate:latest; idempotent, auto-run by start / dev |
npm run migrate:refresh |
rm -f ./config/state.db && mkdir -p config && knex migrate:latest; dev only |
npm start |
node src/server.js (chains npm run migrate via prestart) |
npm run dev |
node --watch src/server.js (chains npm run migrate via predev) |
npm test |
node --test 'test/*.test.js'; Node 22 built-in runner, no extra deps |
The Fetch TV box IP/port and all integration credentials (Plex token, Fetch cloud activation code, etc.) are runtime settings; configure them in the web UI (or the first-run wizard), not via env. The env vars below are deploy/runtime knobs only.
Note
MEDIA_ROOT and PLEX_PREFS_PATH also act as defaults for matching DB-backed settings that can be overridden from the UI at runtime. The fallback chain is settings DB value → env var → hardcoded default. The Storage panel in Settings (and the STORAGE step of the wizard) shows the effective value and provides a TEST PATH button.
| Variable | Notes |
|---|---|
MEDIA_ROOT |
Default for the media_root runtime setting (directory where Fetcharr writes downloads). Defaults to /media/tv. Override at runtime from the Settings UI's Storage panel. |
DB_PATH |
Absolute path to the SQLite state file. Defaults to <repo>/config/state.db; compose sets it to /config/state.db so state lives on the bind mount. |
PORT |
HTTP port inside the container. Defaults to 8124. |
NODE_ENV |
production makes the server refuse to start if CSRF_SECRET is unset or the dev placeholder. Compose sets this. |
TZ |
Container timezone (IANA name, e.g. Australia/Sydney). The Dockerfile installs tzdata so any IANA zone resolves. /api/settings exposes the value as tz; the web UI uses it to render all timestamps in that zone regardless of which browser hits the page. |
PUID/PGID |
Runtime UID/GID (set via compose user:). Defaults to 1000:1000. Set to match the owner of the bind-mounted host paths. |
CSRF_SECRET |
32+ random bytes used to sign the CSRF cookie. openssl rand -hex 32. Required in production. |
Compose-only env (set in .env alongside docker-compose.yml):
| Variable | Notes |
|---|---|
FETCHARR_PORT |
Host port the container binds (under network_mode: host, also flows into PORT inside the container). Defaults to 8124. |
CONFIG_PATH |
Host root for the SQLite state bind mount. The container's /config is ${CONFIG_PATH}/fetcharr. |
DATA_PATH |
Host root for media bind mounts. The container's /media/tv is ${DATA_PATH}/media/tv. |
PLEX_PREFS_PATH |
Optional. Host path to Plex's Preferences.xml, bind-mounted read-only into the container so the "Auto-detect from local Plex" button can fish out PlexOnlineToken. Drop the bind-mount entirely from your compose if Plex isn't on this host; the auto-detect button degrades gracefully, and you can paste the token manually from app.plex.tv. Inside the container, the path defaults to /plex-preferences.xml and is overridable as the plex_prefs_path runtime setting (Settings panel for Plex). |
- Image is built locally from the repo via compose. Nothing is pushed to a registry.
- The Dockerfile inlines
npm ci --ignore-scripts && npm run rebuild:nativesinstead of callingnpm run setup, deliberately skippingnpm audit signaturesat build time. That step re-queries the registry and enforces.npmrc'smin-release-age=3, which would block whenever a brand-new dep (e.g. a just-publishedfetchtvrelease) is in the lockfile. Runnpm run setup(ornpm audit signatures) on the host once the newest dep has aged past the threshold; the lockfile's integrity hashes still verify package contents duringnpm ci. - Uses
network_mode: host(noports:mapping); required for SSDP/UPnP auto-discovery of the Fetch TV box, since multicast239.255.255.250:1900doesn't traverse Docker's bridge network. Side-effect: Fetcharr is not on any Docker bridge network; other containers reach it via the host's LAN IP on${FETCHARR_PORT}rather than by container name. - Runs as
${PUID}:${PGID}(default1000:1000) so bind-mounted files have the right owner. tiniis PID 1 inside the container soSIGTERMpropagates cleanly.- The Docker healthcheck hits
GET /healthzevery 30s.
Volumes:
| Container path | Host path | Purpose |
|---|---|---|
/config |
${CONFIG_PATH}/fetcharr |
SQLite state DB; persisted across container recreate. |
/media/tv |
${DATA_PATH}/media/tv |
Plex TV library (where downloads save to). Container-side path is the MEDIA_ROOT env default; can be overridden at runtime as the media_root setting (only useful if you mount the library at a different container path). |
/plex-preferences.xml (ro) |
${PLEX_PREFS_PATH} |
Optional. Local Plex Preferences.xml (read-only, only used by the "Auto-detect from local Plex" button to find PlexOnlineToken). Drop this volume entry if Plex isn't on the same host. |
The sync engine (src/sync.js) processes each item Fetch returns through this decision tree:
- Already-done short-circuit: if a
recordingsrow exists withstatus='done', skip immediately. - In-progress guard:
await isCurrentlyRecording(item)(fromfetchtv) returnstruewhen the UPnP listing reports size <= 0 (Fetch's-1"size unknown" sentinel) or the MAX_OCTET marker (4398046510080), or when a HEAD probe reveals a content-length matching the marker. On any of those, write the row asskippedwitherror='currently recording'and bail. - Download: build the destination path from the show's
dest_folder+season_template, mkdir, then calldownloadFile. The downloader supports HTTP Range resume; a partial-from-last-time row will pick up where it left off rather than re-downloading. - Classify the outcome via
classifyOutcome({ downloadResult, expectedSize, actualSize, tolerance }); a pure function (exported for tests) that returns{ dbStatus, summaryKey, sizeToStore, markDownloadedAt, error }:result.recorded && shortfall ≤ tolerance→done/downloaded, store actual size.result.recorded && shortfall > tolerance(default 1 MB) →partial/failed, store actual size, error explains the byte gap. Next sync's resume will fix it.result.recorded && actualSize unknown(post-downloadfs.statfailed) →done, trust expected.result.recorded && expectedSize <= 0→done(guard; shouldn't reach this branch in practice).!result.recorded && result.error→failed/failed, sizeToStore stays null (leaves existing row's size alone).!result.recorded && result.warning(e.g. "currently recording" warning from downloadFile) →partial/skipped.
Every branch of classifyOutcome is covered by test/sync.test.js. The wider sync flow (DB writes, downloadFile invocation, mkdir, Plex notify) lives outside classifyOutcome and is currently exercised end-to-end against the real Fetch box rather than via integration tests.
If a sync ran without any result.error but did get one or more truncations, the sync is marked partial overall; only summary failed > 0 triggers a non-ok sync status. Truncations land in summary.failed (not summary.skipped) so they're visible at a glance.
fetchtv is installed as a regular npm dep, pinned exactly (no caret) per .npmrc's save-exact=true. The pinned version is in package.json.
To bump fetchtv:
npm install --save-exact fetchtv@<new-version>The min-release-age=3 hardening rule in .npmrc will refuse versions newer than 3 days. If you need a hot bump (e.g. you just published a fix yourself), pass --min-release-age=0 for that single command. The Docker build path is unaffected (it uses npm ci from the lockfile, which doesn't re-check the rule); only npm audit signatures (called by npm run setup) re-checks and will block until the package ages past the threshold; see: Docker Deployment. Limit --min-release-age=0 to packages you personally just published: the resulting package-lock.json change carries the fresh dep into every subsequent npm ci (Docker image rebuilds included), since lockfile integrity hashes verify content but not recency.
.npmrcsets:ignore-scripts=true: never run lifecycle scripts during install. Native rebuilds are explicit viarebuild:natives.engine-strict=true: fail install ifenginesmismatch.audit-level=high:npm auditexits non-zero only onhigh+.save-exact=true: new deps are pinned exactly.package-lock=true: always writepackage-lock.json.min-release-age=3: refuse packages newer than 3 days (mitigates rapid-fire supply-chain attacks).
npm run setupusesnpm ci(notnpm install) so deps come straight from the lock file.npm run rebuild:nativesis an explicit allow-list; onlybetter-sqlite3rebuilds. Adding a new native dep means adding it here on purpose.npm audit signaturesruns as the last step ofsetupto verify the npm registry signatures of every dep. The Docker build deliberately inlinesnpm ci + rebuild:nativesinstead of callingnpm run setupbecausenpm audit signaturesre-queries the registry and enforcesmin-release-age, which would block builds whenever a freshly-published dep is in the lockfile. Runnpm run setup(ornpm audit signaturesdirectly) on the host once the newest dep ages past the threshold; see: Docker Deployment.package-lock.jsonis committed; integrity hashes verify package contents duringnpm cieven when the audit step is skipped.- HTTP: Helmet with a strict CSP.
script-src 'self' 'unsafe-eval'is required because Vue's in-browser template compiler usesnew Function(); everything else is locked down. To drop'unsafe-eval'we would need a build step (Vite) that pre-compiles templates. - Rate limiting:
express-rate-limiton the on-demand POST endpoints that hit the Fetch box, Fetch cloud, or Plex (/api/sync,/api/fetch-shows,/api/discover-fetch,/api/discover-plex,/api/fetch-cloud-test). - CSRF:
csrf-csrf(double-submit cookie) protects state-changing POSTs. The UI fetches a token fromGET /api/csrf-tokenand sends it as thex-csrf-tokenheader.generateTokenis called withoverwrite=trueso a stale browser cookie from a previousCSRF_SECRETdoesn't trigger a 403 mint.getSessionIdentifieris a constant; authless LAN service, andreq.ipflapped under Docker bridge networking. The front-end clears the cached token and retries once on any 403, so secret rotations / cookie clears recover silently. - Indexing:
X-Robots-Tag: noindex, nofollowis set globally; this is a LAN-only service. - HSTS disabled: Fetcharr serves over plain HTTP on the LAN. Helmet's default
Strict-Transport-Securityheader would tell browsers to refuse HTTP for the host for a year, which is wrong for this deployment. Re-enable HSTS (with an appropriatemaxAge) only when fronted by TLS.
Run the automated suite with:
npm testUses Node 22's built-in test runner (node --test) (no additional test dependencies). Two files under test/:
test/sync.test.js: exhaustive truth-table coverage ofclassifyOutcome(every state-machine transition incl. the real-world MPEG-TS Δ-20-byte case +-1sentinel handling), plus pure-function tests formatchShow(case-insensitive substring matching) andbuildDestPath({season}/{season_padded}/{season_unpadded}substitution, filesystem-safe filename sanitisation, missing-season fallback).test/folder-matcher.test.js: real on-disk fixture underos.tmpdir()exercisinglistShowFolders+matchShowFolderagainst realistic disambiguated folder names (Bluey (2018),LOL - Last One Laughing UK, etc.).
The wider sync flow (DB transitions, downloadFile invocation, Plex notify) is still exercised end-to-end against the live Fetch box rather than via integration tests with mocks. Manual smoke test:
npm run dev(orMEDIA_ROOT=/path/to/media/tv npm run devoutside the container), hithttp://localhost:8124.- First visit (with empty settings): auto-redirects to
#/welcomefor the setup wizard. Walks Fetch TV box → storage (media root + TEST PATH) → Plex → Fetch Cloud (with Terminal ID picker after TEST CONNECTION) → ready. - Re-open the wizard later: SETUP WIZARD panel at the top of Settings →
↻ REOPEN WIZARD. All previously-saved values prefill (stored Plex token / Fetch PIN render as••••• (stored)); step 1 surfaces a RETURN VISIT badge so you know you're editing existing config. - Settings: save / discover; cron field reloads the scheduler on save; Plex Auto-discover / Auto-detect token / Load sections / Refresh now buttons should each succeed when Plex is reachable; Storage panel TEST PATH probes
media_rootfor existence + writability. - Shows: on first visit (no shows yet, Fetch IP configured) auto-runs Refresh Shows to populate the datalist. Add a show (folder-suggest auto-completes from the effective
media_root, falls back to a "new folder" suggestion when no match), toggle enabled, per-show Sync now, delete. - Syncs: Run sync now (global + per-show), watch the row appear and finish; clear individual or all history; chip-filter by activity type (DOWNLOADS / FAILS / DELETES / EMPTY).
- Recordings: rows update live during a sync and re-poll every 60 s otherwise; sizes render in the configured
TZin 12-hour AM/PM. Tombstoned rows (deleted from the Fetch box) appear struck-through + dimmed; filter viaON FETCH/DELETEDchips;WHENincludes1H/24Hfor recent activity. - Danger Zone:
NUKE ALL STATEbutton clears the DB and reloads into the wizard.
The PNGs under docs/img/ are captured from the running app by scripts/capture-screenshots.sh. The script pulls the official Playwright Docker image (no host install required), drives headless Chromium across all four tabs, and writes the screenshots back into docs/img/ with the right ownership.
# fetcharr container must be up + reachable at $FETCHARR_URL (default http://localhost:8124)
./scripts/capture-screenshots.sh
# Or point at a remote instance / pin a different Playwright image:
FETCHARR_URL=http://nas.lan:8124 \
PLAYWRIGHT_IMAGE=mcr.microsoft.com/playwright:v1.50.0-noble \
./scripts/capture-screenshots.shThe captures themselves are configured in scripts/capture-screenshots.mjs (viewport 1280×936, viewport-only clip so every shot has the same aspect ratio, 2× device-scale).
This project:
- Is licensed under the GNU GPLv3 License.
- Is not affiliated with or endorsed by Fetch TV or Plex.
- Is built on top of the
fetchtvnpm package for LAN-side Fetch TV access. - Is written with the assistance of AI and may contain errors.
- Is intended for educational and experimental purposes only.
- Is provided as-is with no warranty—please use at your own risk.
If you've found this project helpful consider supporting my work through:
Buy Me a Coffee | GitHub Sponsorship
Contributions help me continue developing and improving this tool, allowing me to dedicate more time to add new features and ensuring it remains a valuable resource for the community.