fetchtv
Version:
A Node.js CLI tool to manage Fetch TV recordings.
496 lines (347 loc) • 22.6 kB
Markdown
# fetchtv
[](https://www.npmjs.com/package/fetchtv)
[](https://hub.docker.com/r/furey/fetchtv)
[](https://www.gnu.org/licenses/gpl-3.0)
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`](https://github.com/lingfish/fetchtv-cli) (Python) which is based on [`jinxo13/FetchTV-Helpers`](https://github.com/jinxo13/FetchTV-Helpers) (also Python).
## Contents
- [Demo](#demo)
- [Quick Start](#quick-start)
- [Installation](#installation)
- [Usage](#usage)
- [Template Variables](#template-variables)
- [Examples](#examples)
- [Programmatic API](#programmatic-api)
- [Tests](#tests)
- [GitHub Workflows](#github-workflows)
- [Disclaimer](#disclaimer)
- [Support](#support)
## Demo
<https://gist.github.com/user-attachments/assets/61dfab62-a715-4cc3-a4d1-93ee0db43827>
## Quick Start
- [Install](#installation) `fetchtv`
- [Run](#usage) `fetchtv`
## Installation
- [NPX](#installation-npx) (Easiest)
- [Node.js from Source](#installation-nodejs-from-source)
- [Docker from Source](#installation-docker-from-source)
### Installation: NPX
> [!NOTE]<br>
> NPX requires [Node.js](https://nodejs.org/en/download) installed and running on your system (suggestion: use [Volta](https://volta.sh)).
The easiest way to install `fetchtv` is via NPX.
First, ensure Node.js is running:
```console
node --version # Ideally >= v22.x but fetchtv is >= v18.x compatible
```
Then, run `fetchtv` via NPX:
```console
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]<br>
> Node.js from source requires [Node.js](https://nodejs.org/en/download) installed and running on your system (suggestion: use [Volta](https://volta.sh)).
1. Clone the `fetchtv` repository:<br>
```console
git clone https://github.com/furey/fetchtv.git
```
1. Navigate to the cloned repository directory:<br>
```console
cd /path/to/fetchtv
```
1. Ensure Node.js is running:<br>
```console
node --version # Ideally >= v22.x but fetchtv is >= v18.x compatible
```
1. Install Node.js dependencies:<br>
```console
npm ci
```
1. Run `fetchtv`:<br>
```console
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:
```console
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:
```console
fetchtv
fetchtv info
fetchtv shows
fetchtv recordings
# etc…
```
To uninstall the linked tool, run:
```console
npm unlink
```
### Installation: Docker from Docker Hub
Pre-built multi-arch (`linux/amd64`, `linux/arm64`) images live at [`furey/fetchtv`](https://hub.docker.com/r/furey/fetchtv) — no clone or build required:
```console
docker run --rm --network host furey/fetchtv info
docker run --rm --network host furey/fetchtv recordings
```
See the [Docker Hub overview](https://hub.docker.com/r/furey/fetchtv) for a focused quick-start.
### Installation: Docker from Source
> [!NOTE]<br>
> Docker from source requires [Docker](https://docs.docker.com/get-started/get-docker) installed and running on your system.
1. Clone the `fetchtv` repository:<br>
```console
git clone https://github.com/furey/fetchtv.git
```
1. Navigate to the cloned repository directory:<br>
```console
cd /path/to/fetchtv
```
1. Ensure Docker is running:<br>
```console
docker --version # Ideally >= v27.x
```
1. Build the Docker image:<br>
```console
docker build -t fetchtv .
```
1. Run the container:<br>
```console
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:
```console
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](#installation-npx), you can run it from anywhere:
```console
npx fetchtv <COMMAND> [OPTIONS]
```
If you [installed from Node.js source](#installation-nodejs-from-source), you can run it from the cloned repo directory:
```console
cd /path/to/fetchtv
node fetchtv.js <COMMAND> [OPTIONS]
```
If you [linked the tool](#optional-link-fetchtv-tool) after installing from source, you can run it from anywhere:
```console
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
> []<br>
> When using `--template`, the template string must be enclosed in single quotes (`'`) to prevent shell expansion. For example:<br>
>
> ```console
>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:
```js
`${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]<br>
> The following examples assume you have a Fetch TV server on your local network and you've [linked the tool](#optional-link-fetchtv-tool) to your system path.
Search for Fetch TV servers:
```console
fetchtv
```
Display Fetch box details (uses auto-discovery):
```console
fetchtv info
```
List recorded show titles:
```console
fetchtv shows --ip=192.168.86.71
```
List recordings:
```console
fetchtv recordings --ip=192.168.86.71
```
List recordings and output as JSON:
```console
fetchtv recordings --ip=192.168.86.71 --json
```
Save new recordings to `./downloads` (creates directory if needed):
```console
fetchtv recordings --ip=192.168.86.71 --save=./downloads
```
Save new recordings but exclude show titles containing `News`:
```console
fetchtv recordings --ip=192.168.86.71 --exclude=News --save=./downloads
```
Save new episodes for the show `MasterChef`:
```console
fetchtv recordings --ip=192.168.86.71 --show=MasterChef --save=./downloads
```
Save & overwrite specific `MasterChef` episodes containing `S04E12` or `S04E13`:
```console
fetchtv recordings --ip=192.168.86.71 --show=MasterChef --title=S04E12 --title=S04E13 --save=./downloads --overwrite
```
List only items currently being recorded:
```console
fetchtv recordings --ip=192.168.86.71 --is-recording
```
Save only items currently being recorded:
```console
fetchtv recordings --ip=192.168.86.71 --is-recording --save=./in-progress
```
Save recordings using a custom path template:
```console
fetchtv recordings --ip=192.168.86.71 --save=./downloads --template='${show_title}/${recording_title}.${ext}'
```
Save recordings in Plex-compatible path format:
```console
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).
```js
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. |
`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:
```console
npm install
npm test
```
Tests use Node's built-in `node:test` runner (no external framework) and [`nock`](https://github.com/nock/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`](./.github/workflows/publish-npm.yml) | `release: created` | Publishes [`fetchtv` on NPM](https://www.npmjs.com/package/fetchtv) |
| Publish to Docker Hub | [`publish-docker.yml`](./.github/workflows/publish-docker.yml) | `release: created` | Publishes [`furey/fetchtv` on Docker Hub](https://hub.docker.com/r/furey/fetchtv) |
| Sync Docker Hub Description | [`dockerhub-description.yml`](./.github/workflows/dockerhub-description.yml) | push to `main` touching `DOCKER_README.md` (or manual) | Pushes `DOCKER_README.md` to the [`furey/fetchtv` Hub overview](https://hub.docker.com/r/furey/fetchtv) |
### 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`](./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](./LICENSE.txt).
- Is not affiliated with or endorsed by Fetch TV.
- Is a derivative work based on [`lingfish/fetchtv-cli`](https://github.com/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](https://www.buymeacoffee.com/furey) | [GitHub Sponsorship](https://github.com/sponsors/furey)
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.