fetchtv

May 27, 2026 · View on GitHub

NPM Version Docker Pulls License: GPL v3

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

https://gist.github.com/user-attachments/assets/61dfab62-a715-4cc3-a4d1-93ee0db43827

Quick Start

Installation

Installation: NPX

Note


NPX requires Node.js installed and running on your system (suggestion: use Volta).

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 npx try running npx clear-npx-cache prior to running npx -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).

  1. Clone the fetchtv repository:

    git clone https://github.com/furey/fetchtv.git
    
  2. Navigate to the cloned repository directory:

    cd /path/to/fetchtv
    
  3. Ensure Node.js is running:

    node --version # Ideally >= v22.x but fetchtv is >= v18.x compatible
    
  4. Install Node.js dependencies:

    npm ci
    
  5. Run fetchtv:

    node fetchtv.js
    node fetchtv.js info
    node fetchtv.js recordings
    node fetchtv.js shows
    
    # etc…
    

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.

  1. Clone the fetchtv repository:

    git clone https://github.com/furey/fetchtv.git
    
  2. Navigate to the cloned repository directory:

    cd /path/to/fetchtv
    
  3. Ensure Docker is running:

    docker --version # Ideally >= v27.x
    
  4. Build the Docker image:

    docker build -t fetchtv .
    
  5. 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/OptionAliasTypeDescription
infocommandReturns Fetch TV server details
recordingscommandList episode recordings
showscommandList show titles and not the episodes within
--ipstringSpecify the IP Address of the Fetch TV server
--portnumberSpecify the port of the Fetch TV server (default: 49152)
--show-sarrayFilter recordings to show titles containing the specified text (repeatable)
--exclude-earrayFilter recordings to show titles NOT containing the specified text (repeatable)
--title-tarrayFilter recordings to episode titles containing the specified text (repeatable)
--is-recordingbooleanFilter recordings to only those that are currently recording
--savestringSave recordings to the specified path
--templatestringTemplate for save path/filename structure (uses --save as base path)
--for-plexbooleanUses Plex-compatible template for saving recordings (overrides --template)
--overwrite-obooleanOverwrite existing files when saving
--json-jbooleanOutput show/recording/save results in JSON
--debug-dbooleanEnable verbose logging for debugging
--help-hbooleanShow 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:

VariableDescriptionExample
${show_title}Title of the showAustralian Survivor
${recording_title}Title of the recording/episodeS10 E2 - Episode 2 of Season 10 - Tue 18 Feb
${season_number}Season number (if available)10
${season_number_padded}Season number with leading zero10
${episode_number}Episode number (if available)2
${episode_number_padded}Episode number with leading zero02
${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
})
ExportSignatureReturns
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) => stringHuman-readable description (title, size, duration).
createValidFilename(name) => stringFilesystem-safe version of a string (strips/replaces problematic characters).
processPathTemplate({ template, placeholders }) => stringSubstitutes {season}, {season_padded}, {season_unpadded} etc. in a path template.
parseXml(xmlString) => object | nullParses 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) => numberParses 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.

FileWhat it covers
helpers.test.jsPure helpers: filename sanitization, timestamp parsing, filter normalization, "The"-prefix-aware sort, XML node navigation, item projection
xml.test.jsparseXml against a Browse-shaped fixture with >1000 entity references (regression catch for fast-xml-parser entity-expansion cap changes)
didl.test.jsDIDL-Lite item/container parsing: S/E number extraction, extension inference from protocolInfo, size/duration coercion
discovery.test.jsdiscoverFetch 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.jsisCurrentlyRecording size sentinels and HEAD/GET fallback paths
templates.test.jsprocessPathTemplate: standard placeholders, Plex template, missing-placeholder throw, traversal sanitization
save.test.jsloadSavedFiles / addSavedFile, isLockFileStale, end-to-end save flow with a mocked download
commands.test.jsSpawned 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.

WorkflowFileTriggerEffect
Publish to NPMpublish-npm.ymlrelease: createdPublishes fetchtv on NPM
Publish to Docker Hubpublish-docker.ymlrelease: createdPublishes furey/fetchtv on Docker Hub
Sync Docker Hub Descriptiondockerhub-description.ymlpush 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.