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
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
- SSH key access from the Docker host to the TVHeadend server
- TVHeadend API access (username/password)
- 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) │ │
│ │ │ └─────────────┘ │
└─────────────────────┘ └─────────────────────┘
- Container queries TVHeadend API for completed recordings
- Downloads files via SCP from TVHeadend server
- Writes to local Jellyfin media volume
- 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
Languages
Python
99.1%
Dockerfile
0.9%