# fetchtv

[![NPM Version](https://img.shields.io/npm/v/fetchtv)](https://www.npmjs.com/package/fetchtv)
[![Docker Pulls](https://img.shields.io/docker/pulls/furey/fetchtv)](https://hub.docker.com/r/furey/fetchtv)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](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

> [!IMPORTANT]<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.
