UNPKG

fetchtv

Version:

A Node.js CLI tool to manage Fetch TV recordings.

496 lines (347 loc) 22.6 kB
# 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.