fetchtv
May 27, 2026 · View on GitHub
A Node.js CLI tool to download Fetch TV PVR recordings over the local network.
No Fetch credentials or cloud account required.
Based on lingfish/fetchtv-cli (Python) which is based on jinxo13/FetchTV-Helpers (also Python).
Contents
- Demo
- Quick Start
- Installation
- Usage
- Template Variables
- Examples
- Programmatic API
- Tests
- GitHub Workflows
- Disclaimer
- Support
Demo
https://gist.github.com/user-attachments/assets/61dfab62-a715-4cc3-a4d1-93ee0db43827
Quick Start
Installation
- NPX (Easiest)
- Node.js from Source
- Docker from Source
Installation: NPX
The easiest way to install fetchtv is via NPX.
First, ensure Node.js is running:
node --version # Ideally >= v22.x but fetchtv is >= v18.x compatible
Then, run fetchtv via NPX:
npx fetchtv # Run the tool
npx fetchtv@latest # (optional) "@latest" ensures package is up-to-date
npx -y fetchtv@latest # (optional) "-y" flag skips any prompts
npx fetchtv info
npx fetchtv shows
npx fetchtv recordings
# etc…
If you encounter permissions errors with
npxtry runningnpx clear-npx-cacheprior to runningnpx -y fetchtv(this clears the cache and re-downloads the package).
Installation: Node.js from Source
Note
Node.js from source requires Node.js installed and running on your system (suggestion: use Volta).
-
Clone the
fetchtvrepository:git clone https://github.com/furey/fetchtv.git -
Navigate to the cloned repository directory:
cd /path/to/fetchtv -
Ensure Node.js is running:
node --version # Ideally >= v22.x but fetchtv is >= v18.x compatible -
Install Node.js dependencies:
npm ci -
Run
fetchtv:node fetchtv.js node fetchtv.js info node fetchtv.js recordings node fetchtv.js shows # etc…
Optional: Link fetchtv Tool
You may optionally link the fetchtv tool to your system path for easier access:
npm link
This will create a symlink to the fetchtv command in your global node_modules directory, allowing you to run it from anywhere in your terminal:
fetchtv
fetchtv info
fetchtv shows
fetchtv recordings
# etc…
To uninstall the linked tool, run:
npm unlink
Installation: Docker from Docker Hub
Pre-built multi-arch (linux/amd64, linux/arm64) images live at furey/fetchtv — no clone or build required:
docker run --rm --network host furey/fetchtv info
docker run --rm --network host furey/fetchtv recordings
See the Docker Hub overview for a focused quick-start.
Installation: Docker from Source
Note
Docker from source requires Docker installed and running on your system.
-
Clone the
fetchtvrepository:git clone https://github.com/furey/fetchtv.git -
Navigate to the cloned repository directory:
cd /path/to/fetchtv -
Ensure Docker is running:
docker --version # Ideally >= v27.x -
Build the Docker image:
docker build -t fetchtv . -
Run the container:
docker run -t --rm fetchtv docker run -t --rm fetchtv info docker run -t --rm fetchtv shows docker run -t --rm fetchtv recordings # etc…
UPnP/SSDP Discovery Issues
UPnP/SSDP discovery can be unreliable in Docker containers.
To work around this, it's recommended to specify your Fetch TV server's IP address directly with the --ip (and optionally --port) option when running the container. For example:
docker run -t --rm fetchtv
docker run -t --rm fetchtv info --ip=192.168.86.71
docker run -t --rm fetchtv shows --ip=192.168.86.71
docker run -t --rm fetchtv recordings --ip=192.168.86.71
# etc…
Usage
If you installed via NPX, you can run it from anywhere:
npx fetchtv <COMMAND> [OPTIONS]
If you installed from Node.js source, you can run it from the cloned repo directory:
cd /path/to/fetchtv
node fetchtv.js <COMMAND> [OPTIONS]
If you linked the tool after installing from source, you can run it from anywhere:
fetchtv <COMMAND> [OPTIONS]
| Command/Option | Alias | Type | Description |
|---|---|---|---|
info | command | Returns Fetch TV server details | |
recordings | command | List episode recordings | |
shows | command | List show titles and not the episodes within | |
--ip | string | Specify the IP Address of the Fetch TV server | |
--port | number | Specify the port of the Fetch TV server (default: 49152) | |
--show | -s | array | Filter recordings to show titles containing the specified text (repeatable) |
--exclude | -e | array | Filter recordings to show titles NOT containing the specified text (repeatable) |
--title | -t | array | Filter recordings to episode titles containing the specified text (repeatable) |
--is-recording | boolean | Filter recordings to only those that are currently recording | |
--save | string | Save recordings to the specified path | |
--template | string | Template for save path/filename structure (uses --save as base path) | |
--for-plex | boolean | Uses Plex-compatible template for saving recordings (overrides --template) | |
--overwrite | -o | boolean | Overwrite existing files when saving |
--json | -j | boolean | Output show/recording/save results in JSON |
--debug | -d | boolean | Enable verbose logging for debugging |
--help | -h | boolean | Show help message |
Template Variables
Important
When using --template, the template string must be enclosed in single quotes (') to prevent shell expansion. For example:
fetchtv recordings --save=./downloads --template='${show_title}/${recording_title}.${ext}'
When using --template, the following variables are available:
| Variable | Description | Example |
|---|---|---|
${show_title} | Title of the show | Australian Survivor |
${recording_title} | Title of the recording/episode | S10 E2 - Episode 2 of Season 10 - Tue 18 Feb |
${season_number} | Season number (if available) | 10 |
${season_number_padded} | Season number with leading zero | 10 |
${episode_number} | Episode number (if available) | 2 |
${episode_number_padded} | Episode number with leading zero | 02 |
${ext} | File extension (ts, mp4, etc) | ts |
Plex-Compatible Template
The --for-plex option uses a predefined template optimized for Plex media server:
`${show_title}/Season ${season_number}/${show_title} - S${season_number}E${episode_number_padded}.${ext}`
Example Templates
Save recordings with show folder:
${show_title}/${recording_title}.${ext}
Save recordings with show folder and SXXEXX episode naming:
${show_title}/S${season_number_padded}E${episode_number_padded}.${ext}
Save recordings with show and season folders:
${show_title}/Season ${season_number}/${recording_title}.${ext}
Examples
Note
The following examples assume you have a Fetch TV server on your local network and you've linked the tool to your system path.
Search for Fetch TV servers:
fetchtv
Display Fetch box details (uses auto-discovery):
fetchtv info
List recorded show titles:
fetchtv shows --ip=192.168.86.71
List recordings:
fetchtv recordings --ip=192.168.86.71
List recordings and output as JSON:
fetchtv recordings --ip=192.168.86.71 --json
Save new recordings to ./downloads (creates directory if needed):
fetchtv recordings --ip=192.168.86.71 --save=./downloads
Save new recordings but exclude show titles containing News:
fetchtv recordings --ip=192.168.86.71 --exclude=News --save=./downloads
Save new episodes for the show MasterChef:
fetchtv recordings --ip=192.168.86.71 --show=MasterChef --save=./downloads
Save & overwrite specific MasterChef episodes containing S04E12 or S04E13:
fetchtv recordings --ip=192.168.86.71 --show=MasterChef --title=S04E12 --title=S04E13 --save=./downloads --overwrite
List only items currently being recorded:
fetchtv recordings --ip=192.168.86.71 --is-recording
Save only items currently being recorded:
fetchtv recordings --ip=192.168.86.71 --is-recording --save=./in-progress
Save recordings using a custom path template:
fetchtv recordings --ip=192.168.86.71 --save=./downloads --template='${show_title}/${recording_title}.${ext}'
Save recordings in Plex-compatible path format:
fetchtv recordings --ip=192.168.86.71 --save=./media --for-plex
Programmatic API
In addition to the CLI, fetchtv.js can be imported as an ES module by other Node projects that want to drive a Fetch TV box programmatically (e.g. a long-running watcher that mirrors new recordings into a media library).
import {
discoverFetchServers,
discoverFetch,
getFetchRecordings,
downloadFile
} from 'fetchtv'
// Enumerate every Fetch TV box on the LAN
const servers = await discoverFetchServers()
// Or grab a single box by IP (returns the full UPnP location object
// needed by the other helpers below)
const location = await discoverFetch({ ip: '192.168.1.50', port: 49152 })
// List recordings (optionally filtered)
const shows = await getFetchRecordings({
location,
filters: {
folderFilter: [], // include only shows whose title contains any of these (lowercased)
excludeFilter: [], // exclude shows whose title contains any of these (lowercased)
titleFilter: [], // include only items whose title contains any of these (lowercased)
showsOnly: false, // true → return just the show folders, no items
isRecordingFilter: false // true → return only items still being recorded
}
})
// Download a single item
await downloadFile({
item: shows[0].items[0],
filePath: '/tmp/example.ts',
progressBar: null,
overwrite: false
})
| Export | Signature | Returns |
|---|---|---|
discoverFetchServers | ({ timeoutMs = 3000 } = {}) => Promise<Server[]> | Every Fetch TV device found on the LAN via SSDP. Empty array if none. |
discoverFetch | ({ ip, port }) => Promise<Location | null> | First Fetch TV device matching the given ip/port (or first found via SSDP if ip is omitted), with the full _rawDeviceXml payload needed by getFetchRecordings and friends. |
getFetchRecordings | ({ location, filters }) => Promise<Show[]> | Show folders and their items. See example above for filters shape. |
downloadFile | ({ item, filePath, progressBar, overwrite }) => Promise<{ success, filePath, error?, warning? }> | Streams a recording to disk (supports resume). |
isCurrentlyRecording | (item) => Promise<boolean> | Whether an item is still being recorded (vs. a complete file). |
formatItem | (item) => string | Human-readable description (title, size, duration). |
createValidFilename | (name) => string | Filesystem-safe version of a string (strips/replaces problematic characters). |
processPathTemplate | ({ template, placeholders }) => string | Substitutes {season}, {season_padded}, {season_unpadded} etc. in a path template. |
parseXml | (xmlString) => object | null | Parses an XML string (SOAP envelopes, MediaServer.xml, etc.) with the same fast-xml-parser options the CLI uses. Returns null on empty/invalid input. |
parseLocations | (urls) => Promise<Location[]> | Fetches and parses an array of UPnP MediaServer.xml URLs into Location objects. Useful if you have device URLs from somewhere other than SSDP. |
getApiService | (location) => Promise<{ cd_ctr, cd_service } | null> | Extracts the ContentDirectory control URL + service URN from a Location. Required to use the lower-level browse helpers below. |
findDirectories | ({ apiService, objectId }) => Promise<Container[]> | Lists child containers (folders) under a UPnP ObjectID — e.g. enumerate "Recordings" by passing the root's object id. |
findItems | ({ apiService, objectId, showTitle }) => Promise<Item[]> | Lists items (recordings) under a UPnP container, with season/episode numbers parsed from titles and the file extension inferred from protocolInfo. |
browseRequest | ({ apiService, objectId }) => Promise<object | null> | Raw SOAP Browse, returns the parsed DIDL-Lite payload. Use this if you need attributes neither findItems nor findDirectories surface. |
saveRecordings | ({ recordings, savePath, template, overwrite }) => Promise<Result[]> | Higher-level batch save: takes the output of getFetchRecordings, writes each item to disk with resume + lock-file handling, and updates the saved-files DB. |
loadSavedFiles | (savePath) => Promise<Record<string, string>> | Reads fetchtv.json (id → title map of already-saved items) from savePath. Returns {} if missing or unreadable. |
addSavedFile | ({ savePath, savedFilesDb, item }) => Promise<void> | Marks an item as saved by updating savedFilesDb in memory and persisting it back to fetchtv.json. |
tsToSeconds | (timestamp) => number | Parses a HH:MM:SS / MM:SS / SS string into seconds; returns 0 for non-string or malformed input. |
processFilter | (arr) => string[] | Normalizes an array of filter strings: splits on commas, trims, lowercases, drops empties. Used to build the filters argument for getFetchRecordings. |
sortRecordingsByTitle | (recordings) => Recording[] | Sorts shows alphabetically, ignoring a leading "The " prefix. Returns a deep clone — does not mutate the input. |
isCurrentlyRecording / downloadFile recognise two distinct "still recording" sentinels in the UPnP directory listing: the 4398046510080-byte marker, and any non-positive size (typically -1, used by Fetch TV when a recording has started but its final size isn't known yet). Both cause downloadFile to refuse the download — partial bytes from an in-progress recording would otherwise be written out as a truncated file.
Deletion is intentionally not exposed: Fetch TV firmware advertises the standard UPnP DestroyObject action in its ContentDirectory SCPD but its request handler rejects it (Unknown Service Action), and HTTP DELETE on the item URL returns 501. The Fetch box's real control plane for deletion lives in Fetch's cloud APIs (auth + WebSocket to messages.fetchtv.com.au) and is out of scope for this LAN-only library.
The module is safe to import — running the CLI requires invoking fetchtv.js directly as a script.
Tests
A feature-level test suite covers every command and code path that doesn't require talking to a real Fetch TV box:
npm install
npm test
Tests use Node's built-in node:test runner (no external framework) and nock to intercept HTTP. The CLI-level tests in test/commands.test.js stand up a local http.createServer and spawn node fetchtv.js --ip 127.0.0.1 --port <random> against it.
| File | What it covers |
|---|---|
helpers.test.js | Pure helpers: filename sanitization, timestamp parsing, filter normalization, "The"-prefix-aware sort, XML node navigation, item projection |
xml.test.js | parseXml against a Browse-shaped fixture with >1000 entity references (regression catch for fast-xml-parser entity-expansion cap changes) |
didl.test.js | DIDL-Lite item/container parsing: S/E number extraction, extension inference from protocolInfo, size/duration coercion |
discovery.test.js | discoverFetch via explicit --ip, including the non-Fetch and unreachable cases |
filters.test.js | --show / --exclude / --title filter behaviour end-to-end through getFetchRecordings |
recording-detection.test.js | isCurrentlyRecording size sentinels and HEAD/GET fallback paths |
templates.test.js | processPathTemplate: standard placeholders, Plex template, missing-placeholder throw, traversal sanitization |
save.test.js | loadSavedFiles / addSavedFile, isLockFileStale, end-to-end save flow with a mocked download |
commands.test.js | Spawned CLI: info / recordings / shows, prefix-matched commands, --show / --exclude / --title, --is-recording, --json, --for-plex |
Tests run locally only — there's no CI gate.
GitHub Workflows
Three workflows automate publication and presentation. Two run on release; one keeps the Docker Hub overview in sync.
| Workflow | File | Trigger | Effect |
|---|---|---|---|
| Publish to NPM | publish-npm.yml | release: created | Publishes fetchtv on NPM |
| Publish to Docker Hub | publish-docker.yml | release: created | Publishes furey/fetchtv on Docker Hub |
| Sync Docker Hub Description | dockerhub-description.yml | push to main touching DOCKER_README.md (or manual) | Pushes DOCKER_README.md to the furey/fetchtv Hub overview |
Publish to NPM
Checks out the repo, sets up Node.js 22, runs npm ci, then npm publish against the public NPM registry using the NPM_TOKEN secret.
Publish to Docker Hub
Builds multi-arch images (linux/amd64, linux/arm64) via Buildx + QEMU, authenticates with DOCKERHUB_USERNAME / DOCKERHUB_TOKEN, and pushes tags derived from the release's semver ({{version}}, {{major}}.{{minor}}, latest) to furey/fetchtv.
Sync Docker Hub Description
Pushes DOCKER_README.md to the furey/fetchtv Docker Hub overview using peter-evans/dockerhub-description@v4. Uses the same DOCKERHUB_USERNAME / DOCKERHUB_TOKEN credentials as the image-publish workflow. Triggers on any push to main that touches the README or the workflow file, and can be run manually via workflow_dispatch.
Disclaimer
This project:
- Is licensed under the GNU GPLv3 License.
- Is not affiliated with or endorsed by Fetch TV.
- Is a derivative work based on
lingfish/fetchtv-cli. - 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.
Support
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.