Dan Head a8b247a83f Initial commit: TVH2Jellyfin application
A Docker-based Python application that transfers completed recordings
from TVHeadend to Jellyfin media storage via SCP.

Features:
- Monitors TVHeadend for completed recordings via JSON API
- Transfers files via SCP with SSH key authentication
- Organizes files by show name
- Deletes recordings from TVHeadend after successful transfer
- Tracks transferred files to avoid duplicates
- Dry run mode for testing without making changes
2026-04-03 09:31:04 +01:00

TVH2Jellyfin

Transfer completed recordings from TVHeadend to Jellyfin media storage.

Features

  • Monitors TVHeadend for completed recordings via JSON API
  • Pulls files from TVHeadend server via SCP
  • Writes to local Jellyfin media directory (Docker volume)
  • Organizes files by show name: {dest_path}/{show_name}/{episode}.ts
  • Deletes recordings from TVHeadend after successful transfer
  • Tracks transferred files to avoid duplicates
  • Runs as a Docker container
  • Dry run mode for testing without making changes

Prerequisites

  1. SSH key access from the Docker host to the TVHeadend server
  2. TVHeadend API access (username/password)
  3. Docker and Docker Compose

Setup

1. Generate SSH Key (if needed)

ssh-keygen -t rsa -b 4096 -f ~/.ssh/tvh2jellyfin
ssh-copy-id -i ~/.ssh/tvh2jellyfin.pub user@tvheadend-server

2. Configure Environment

Copy the example environment file and edit with your settings:

cp .env.example .env

Edit .env with your configuration:

# TVHeadend server
TVH_HOST=192.168.1.100
TVH_PORT=9981
TVH_USER=admin
TVH_PASS=your_password

# SSH user on TVHeadend server (defaults to TVH_USER)
TVH_SSH_USER=admin

# SSH key path on Docker host
SSH_KEY_PATH=~/.ssh/tvh2jellyfin

# Jellyfin media library path on Docker host
JELLYFIN_MEDIA_PATH=/mnt/jellyfin/tv

# Poll interval in seconds (default: 300 = 5 minutes)
POLL_INTERVAL=300

3. Build and Run

docker compose up -d

4. View Logs

docker compose logs -f

Configuration Options

Variable Description Default
TVH_HOST TVHeadend server hostname/IP localhost
TVH_PORT TVHeadend HTTP port 9981
TVH_USER TVHeadend API username (required)
TVH_PASS TVHeadend API password (required)
TVH_SSH_USER SSH username for TVHeadend server TVH_USER
SSH_KEY_PATH Path to SSH private key on Docker host ~/.ssh/id_rsa
JELLYFIN_MEDIA_PATH Jellyfin media path on Docker host /mnt/jellyfin/tv
POLL_INTERVAL Seconds between checks 300
LOG_LEVEL Logging level INFO
DRY_RUN Test mode (no transfers/deletions) false

How It Works

┌─────────────────────┐                    ┌─────────────────────┐
│   TVHeadend Server  │                    │   Docker Host       │
│                     │                    │                     │
│  ┌───────────────┐  │   HTTP API         │  ┌───────────────┐  │
│  │  TVHeadend    │◄─┼────────────────────┼──│ tvh2jellyfin  │  │
│  │  API (:9981)  │  │                    │  │  container    │  │
│  └───────────────┘  │                    │  └───────┬───────┘  │
│                     │                    │          │          │
│  ┌───────────────┐  │   SCP (SSH)        │          ▼          │
│  │  Recordings   │──┼────────────────────┼──►┌─────────────┐   │
│  │  /recordings  │  │                    │   │ /media/tv   │   │
│  └───────────────┘  │                    │   │ (Jellyfin)  │   │
│                     │                    │   └─────────────┘   │
└─────────────────────┘                    └─────────────────────┘
  1. Container queries TVHeadend API for completed recordings
  2. Downloads files via SCP from TVHeadend server
  3. Writes to local Jellyfin media volume
  4. Removes recording from TVHeadend via API

File Organization

Recordings are organized by show name:

/mnt/jellyfin/tv/
├── Show Name/
│   ├── Show Name - Episode Title.ts
│   └── Show Name - 2024-01-15 2000.ts  (if no episode title)
└── Another Show/
    └── Another Show - Episode.ts

Dry Run Mode

Test what would happen without actually transferring or deleting anything:

DRY_RUN=true docker compose up

In dry run mode:

  • Connects to TVHeadend API and lists completed recordings
  • Shows what files would be transferred and where
  • Shows which recordings have already been transferred
  • Does NOT connect via SSH
  • Does NOT transfer any files
  • Does NOT delete recordings from TVHeadend
  • Does NOT update the state file
  • Exits after a single check (no polling loop)

Example output:

[DRY RUN] Starting sync cycle...
[DRY RUN] Found 2 new recording(s) to transfer
[DRY RUN] Would process: The News - Evening Edition
[DRY RUN]   Source: /recordings/The News - Evening Edition.ts
[DRY RUN]   Destination: /media/tv/The News/The News - Evening Edition.ts
[DRY RUN]   Size: 1250.5 MB
[DRY RUN]   Would delete from TVHeadend after transfer

Development

Install Dependencies

python -m venv venv
source venv/bin/activate
pip install -e ".[dev]"

Run Tests

pytest

Run Locally

export TVH_HOST=...
export TVH_USER=...
export TVH_PASS=...
export TVH_SSH_USER=...
export DEST_PATH=/path/to/destination
python -m tvh2jellyfin.main

Troubleshooting

SSH Connection Issues

Ensure the SSH key has correct permissions:

chmod 600 ~/.ssh/tvh2jellyfin

Test SSH connection manually:

ssh -i ~/.ssh/tvh2jellyfin user@tvheadend-server

TVHeadend API Issues

Test API access:

curl -u admin:password http://tvheadend:9981/api/serverinfo

Permission Issues

Ensure the Jellyfin media directory is writable:

touch /mnt/jellyfin/tv/.test && rm /mnt/jellyfin/tv/.test

View Transfer History

The state file (data/state.json) contains all transfer records.

Description
No description provided
Readme 55 KiB
Languages
Python 99.1%
Dockerfile 0.9%