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
This commit is contained in:
45
.env.example
Normal file
45
.env.example
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# TVHeadend server configuration
|
||||||
|
TVH_HOST=192.168.1.100
|
||||||
|
TVH_PORT=9981
|
||||||
|
TVH_USER=admin
|
||||||
|
TVH_PASS=your_tvheadend_password
|
||||||
|
|
||||||
|
# TVHeadend SSH user (for SCP file transfer, defaults to TVH_USER)
|
||||||
|
TVH_SSH_USER=admin
|
||||||
|
# SSH port (defaults to 22)
|
||||||
|
TVH_SSH_PORT=22
|
||||||
|
|
||||||
|
# Path to SSH private key on the Docker host (for volume mount)
|
||||||
|
SSH_KEY_HOST_PATH=./id_rsa
|
||||||
|
|
||||||
|
# Path to Jellyfin media library on the Docker host
|
||||||
|
JELLYFIN_MEDIA_PATH=/mnt/jellyfin/tv
|
||||||
|
|
||||||
|
# Application settings
|
||||||
|
POLL_INTERVAL=300
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Dry run mode - set to true to test without transferring/deleting
|
||||||
|
DRY_RUN=false
|
||||||
|
|
||||||
|
# TVDB configuration (optional - for episode lookup)
|
||||||
|
# Get your API key from https://thetvdb.com/api-information
|
||||||
|
TVDB_API_KEY=
|
||||||
|
TVDB_PIN=
|
||||||
|
TVDB_ENABLED=true
|
||||||
|
TVDB_CACHE_TTL=86400
|
||||||
|
# Optional JSON file mapping TVHeadend show names to TVDB names
|
||||||
|
# TVDB_SHOW_MAPPING=/app/data/show_mapping.json
|
||||||
|
|
||||||
|
# TVMaze configuration (optional - fallback for episode lookup)
|
||||||
|
# No API key required - public API
|
||||||
|
TVMAZE_ENABLED=true
|
||||||
|
TVMAZE_CACHE_TTL=86400
|
||||||
|
|
||||||
|
# Jellyfin API configuration (optional - for library scan triggers)
|
||||||
|
# Get your API key from Jellyfin Admin Dashboard > API Keys
|
||||||
|
JELLYFIN_URL=http://localhost:8096
|
||||||
|
JELLYFIN_API_KEY=
|
||||||
|
# Optional: scan only a specific library (get ID from get_libraries())
|
||||||
|
# JELLYFIN_LIBRARY_ID=
|
||||||
|
JELLYFIN_ENABLED=true
|
||||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Application data
|
||||||
|
data/
|
||||||
|
|
||||||
|
# SSH keys (never commit these!)
|
||||||
|
*.pem
|
||||||
|
id_rsa
|
||||||
|
id_ed25519
|
||||||
42
CLAUDE.md
Normal file
42
CLAUDE.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Build & Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install for development
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
python -m tvh2jellyfin.main
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run single test
|
||||||
|
pytest tests/test_file.py::test_function
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
ruff check src/
|
||||||
|
|
||||||
|
# Format
|
||||||
|
ruff format src/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This application transfers TV recordings from a remote TVHeadend server to a local Jellyfin media library.
|
||||||
|
|
||||||
|
**Data Flow:**
|
||||||
|
1. `tvheadend.py` - Queries TVHeadend JSON API for completed recordings
|
||||||
|
2. `transfer.py` - Downloads files via SCP from TVHeadend server to local destination
|
||||||
|
3. `organizer.py` - Generates destination paths organized by show name
|
||||||
|
4. `state.py` - Tracks transferred recordings (JSON file) to avoid duplicates
|
||||||
|
5. `main.py` - Orchestrates the sync loop with scheduling
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
- SCP pulls files FROM TVHeadend (source is remote, destination is local Docker volume)
|
||||||
|
- TVHeadend API used for both discovery (grid_finished) and cleanup (remove after transfer)
|
||||||
|
- Dry run mode skips SSH connection and all mutations
|
||||||
|
- State persisted as JSON for Docker volume compatibility
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies for paramiko
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
openssh-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY pyproject.toml .
|
||||||
|
|
||||||
|
# Install the application
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
# Create data directory for state persistence
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Create .ssh directory for keys (owned by runtime user 1000:1000)
|
||||||
|
RUN mkdir -p /app/.ssh && chmod 700 /app/.ssh && chown 1000:1000 /app/.ssh
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV STATE_FILE=/app/data/state.json
|
||||||
|
ENV SSH_KEY_PATH=/app/.ssh/id_rsa
|
||||||
|
|
||||||
|
CMD ["python", "-m", "tvh2jellyfin.main"]
|
||||||
211
README.md
Normal file
211
README.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# 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)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` with your configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
```bash
|
||||||
|
chmod 600 ~/.ssh/tvh2jellyfin
|
||||||
|
```
|
||||||
|
|
||||||
|
Test SSH connection manually:
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/tvh2jellyfin user@tvheadend-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### TVHeadend API Issues
|
||||||
|
|
||||||
|
Test API access:
|
||||||
|
```bash
|
||||||
|
curl -u admin:password http://tvheadend:9981/api/serverinfo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Issues
|
||||||
|
|
||||||
|
Ensure the Jellyfin media directory is writable:
|
||||||
|
```bash
|
||||||
|
touch /mnt/jellyfin/tv/.test && rm /mnt/jellyfin/tv/.test
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Transfer History
|
||||||
|
|
||||||
|
The state file (`data/state.json`) contains all transfer records.
|
||||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
tvh2jellyfin:
|
||||||
|
user: "1000:1000"
|
||||||
|
build: .
|
||||||
|
container_name: tvh2jellyfin
|
||||||
|
environment:
|
||||||
|
# TVHeadend connection (API)
|
||||||
|
- TVH_HOST=${TVH_HOST:-192.168.1.100}
|
||||||
|
- TVH_PORT=${TVH_PORT:-9981}
|
||||||
|
- TVH_USER=${TVH_USER}
|
||||||
|
- TVH_PASS=${TVH_PASS}
|
||||||
|
# TVHeadend SSH access (for file transfer)
|
||||||
|
- TVH_SSH_USER=${TVH_SSH_USER:-${TVH_USER}}
|
||||||
|
- SSH_KEY_PATH=/app/.ssh/id_rsa
|
||||||
|
# Local destination path (inside container)
|
||||||
|
- DEST_PATH=/media/tv
|
||||||
|
# Application settings
|
||||||
|
- POLL_INTERVAL=${POLL_INTERVAL:-300}
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
- DRY_RUN=${DRY_RUN:-false}
|
||||||
|
volumes:
|
||||||
|
# State persistence
|
||||||
|
- ./data:/app/data
|
||||||
|
# SSH key for SCP from TVHeadend (host path, read-only)
|
||||||
|
- ${SSH_KEY_HOST_PATH:-~/.ssh/id_rsa}:/app/.ssh/id_rsa:ro
|
||||||
|
# Jellyfin media library (destination)
|
||||||
|
- ${JELLYFIN_MEDIA_PATH:-/mnt/jellyfin/tv}:/media/tv
|
||||||
|
restart: unless-stopped
|
||||||
44
pyproject.toml
Normal file
44
pyproject.toml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tvh2jellyfin"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Transfer recordings from TVHeadend to Jellyfin"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [
|
||||||
|
{name = "Dan"}
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"requests>=2.31.0",
|
||||||
|
"paramiko>=3.4.0",
|
||||||
|
"schedule>=1.2.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
tvh2jellyfin = "tvh2jellyfin.main:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py310"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "N", "W"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
requests>=2.31.0
|
||||||
|
paramiko>=3.4.0
|
||||||
|
schedule>=1.2.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
3
src/tvh2jellyfin/__init__.py
Normal file
3
src/tvh2jellyfin/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""TVH2Jellyfin - Transfer recordings from TVHeadend to Jellyfin."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
127
src/tvh2jellyfin/config.py
Normal file
127
src/tvh2jellyfin/config.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""Configuration management for TVH2Jellyfin."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from .jellyfin import JellyfinConfig
|
||||||
|
from .tvdb import TVDBConfig
|
||||||
|
from .tvmaze import TVMazeConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TVHeadendConfig:
|
||||||
|
"""TVHeadend server configuration for API and SSH access."""
|
||||||
|
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
ssh_user: str
|
||||||
|
ssh_key_path: str
|
||||||
|
ssh_port: int = 22
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self) -> str:
|
||||||
|
return f"http://{self.host}:{self.port}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
"""Application configuration."""
|
||||||
|
|
||||||
|
tvheadend: TVHeadendConfig
|
||||||
|
dest_path: str
|
||||||
|
poll_interval: int
|
||||||
|
state_file: str
|
||||||
|
log_level: str
|
||||||
|
dry_run: bool
|
||||||
|
tvdb: TVDBConfig | None
|
||||||
|
tvmaze: TVMazeConfig
|
||||||
|
jellyfin: JellyfinConfig | None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "Config":
|
||||||
|
"""Load configuration from environment variables."""
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
tvh_host = os.environ.get("TVH_HOST", "localhost")
|
||||||
|
|
||||||
|
tvheadend = TVHeadendConfig(
|
||||||
|
host=tvh_host,
|
||||||
|
port=int(os.environ.get("TVH_PORT", "9981")),
|
||||||
|
username=os.environ.get("TVH_USER", ""),
|
||||||
|
password=os.environ.get("TVH_PASS", ""),
|
||||||
|
ssh_user=os.environ.get("TVH_SSH_USER", os.environ.get("TVH_USER", "")),
|
||||||
|
ssh_key_path=os.environ.get("SSH_KEY_PATH", str(Path.home() / ".ssh" / "id_rsa")),
|
||||||
|
ssh_port=int(os.environ.get("TVH_SSH_PORT", "22")),
|
||||||
|
)
|
||||||
|
|
||||||
|
# TVDB configuration (optional)
|
||||||
|
tvdb_api_key = os.environ.get("TVDB_API_KEY", "")
|
||||||
|
tvdb: TVDBConfig | None = None
|
||||||
|
if tvdb_api_key:
|
||||||
|
tvdb = TVDBConfig(
|
||||||
|
api_key=tvdb_api_key,
|
||||||
|
pin=os.environ.get("TVDB_PIN", ""),
|
||||||
|
cache_ttl=int(os.environ.get("TVDB_CACHE_TTL", "86400")),
|
||||||
|
show_mapping_file=os.environ.get("TVDB_SHOW_MAPPING"),
|
||||||
|
enabled=os.environ.get("TVDB_ENABLED", "true").lower()
|
||||||
|
in ("true", "1", "yes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# TVmaze configuration (always available as fallback)
|
||||||
|
tvmaze = TVMazeConfig(
|
||||||
|
cache_ttl=int(os.environ.get("TVMAZE_CACHE_TTL", "86400")),
|
||||||
|
enabled=os.environ.get("TVMAZE_ENABLED", "true").lower()
|
||||||
|
in ("true", "1", "yes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Jellyfin configuration (optional)
|
||||||
|
jellyfin_url = os.environ.get("JELLYFIN_URL", "")
|
||||||
|
jellyfin_api_key = os.environ.get("JELLYFIN_API_KEY", "")
|
||||||
|
jellyfin: JellyfinConfig | None = None
|
||||||
|
if jellyfin_url and jellyfin_api_key:
|
||||||
|
jellyfin = JellyfinConfig(
|
||||||
|
url=jellyfin_url,
|
||||||
|
api_key=jellyfin_api_key,
|
||||||
|
library_id=os.environ.get("JELLYFIN_LIBRARY_ID") or None,
|
||||||
|
enabled=os.environ.get("JELLYFIN_ENABLED", "true").lower()
|
||||||
|
in ("true", "1", "yes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
tvheadend=tvheadend,
|
||||||
|
dest_path=os.environ.get("DEST_PATH", "/media/tv"),
|
||||||
|
poll_interval=int(os.environ.get("POLL_INTERVAL", "300")),
|
||||||
|
state_file=os.environ.get("STATE_FILE", "/app/data/state.json"),
|
||||||
|
log_level=os.environ.get("LOG_LEVEL", "INFO"),
|
||||||
|
dry_run=os.environ.get("DRY_RUN", "").lower() in ("true", "1", "yes"),
|
||||||
|
tvdb=tvdb,
|
||||||
|
tvmaze=tvmaze,
|
||||||
|
jellyfin=jellyfin,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self) -> list[str]:
|
||||||
|
"""Validate configuration and return list of errors."""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if not self.tvheadend.host:
|
||||||
|
errors.append("TVH_HOST is required")
|
||||||
|
if not self.tvheadend.username:
|
||||||
|
errors.append("TVH_USER is required")
|
||||||
|
if not self.tvheadend.password:
|
||||||
|
errors.append("TVH_PASS is required")
|
||||||
|
|
||||||
|
# SSH and destination validation only needed when not in dry run mode
|
||||||
|
if not self.dry_run:
|
||||||
|
if not self.tvheadend.ssh_user:
|
||||||
|
errors.append("TVH_SSH_USER is required")
|
||||||
|
if not Path(self.tvheadend.ssh_key_path).exists():
|
||||||
|
errors.append(f"SSH key not found at {self.tvheadend.ssh_key_path}")
|
||||||
|
if not self.dest_path:
|
||||||
|
errors.append("DEST_PATH is required")
|
||||||
|
|
||||||
|
return errors
|
||||||
99
src/tvh2jellyfin/episode_resolver.py
Normal file
99
src/tvh2jellyfin/episode_resolver.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Episode resolver that orchestrates lookups across multiple sources."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .tvdb import EpisodeInfo, TVDBClient, TVDBConfig
|
||||||
|
from .tvmaze import TVMazeClient, TVMazeConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeResolver:
|
||||||
|
"""
|
||||||
|
Resolves episode information using multiple sources.
|
||||||
|
|
||||||
|
Tries sources in order:
|
||||||
|
1. TheTVDB (primary)
|
||||||
|
2. TVmaze (fallback)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
tvdb_client: TVDBClient | None = None,
|
||||||
|
tvmaze_client: TVMazeClient | None = None,
|
||||||
|
):
|
||||||
|
self.tvdb_client = tvdb_client
|
||||||
|
self.tvmaze_client = tvmaze_client
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
tvdb_config: TVDBConfig | None = None,
|
||||||
|
tvmaze_config: TVMazeConfig | None = None,
|
||||||
|
) -> "EpisodeResolver":
|
||||||
|
"""
|
||||||
|
Create an EpisodeResolver with configured clients.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tvdb_config: TVDB configuration (optional)
|
||||||
|
tvmaze_config: TVmaze configuration (optional, defaults to enabled)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured EpisodeResolver instance
|
||||||
|
"""
|
||||||
|
tvdb_client = None
|
||||||
|
tvmaze_client = None
|
||||||
|
|
||||||
|
if tvdb_config and tvdb_config.enabled and tvdb_config.api_key:
|
||||||
|
tvdb_client = TVDBClient(tvdb_config)
|
||||||
|
logger.info("TVDB client initialized")
|
||||||
|
|
||||||
|
# TVmaze is always available as fallback (no API key required)
|
||||||
|
if tvmaze_config is None:
|
||||||
|
tvmaze_config = TVMazeConfig()
|
||||||
|
|
||||||
|
if tvmaze_config.enabled:
|
||||||
|
tvmaze_client = TVMazeClient(tvmaze_config)
|
||||||
|
logger.info("TVmaze client initialized (fallback)")
|
||||||
|
|
||||||
|
return cls(tvdb_client=tvdb_client, tvmaze_client=tvmaze_client)
|
||||||
|
|
||||||
|
def lookup(
|
||||||
|
self,
|
||||||
|
show_name: str,
|
||||||
|
broadcast_timestamp: int,
|
||||||
|
) -> tuple[EpisodeInfo | None, str]:
|
||||||
|
"""
|
||||||
|
Look up episode information from available sources.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
show_name: Show name from TVHeadend recording
|
||||||
|
broadcast_timestamp: Unix timestamp of broadcast start
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (EpisodeInfo or None, source name)
|
||||||
|
Source name is one of: "tvdb", "tvmaze", "none"
|
||||||
|
"""
|
||||||
|
# Try TVDB first
|
||||||
|
if self.tvdb_client:
|
||||||
|
try:
|
||||||
|
episode = self.tvdb_client.lookup_episode(show_name, broadcast_timestamp)
|
||||||
|
if episode:
|
||||||
|
return episode, "tvdb"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"TVDB lookup error: {e}")
|
||||||
|
|
||||||
|
# Fall back to TVmaze
|
||||||
|
if self.tvmaze_client:
|
||||||
|
try:
|
||||||
|
episode = self.tvmaze_client.lookup_episode(show_name, broadcast_timestamp)
|
||||||
|
if episode:
|
||||||
|
return episode, "tvmaze"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"TVmaze lookup error: {e}")
|
||||||
|
|
||||||
|
return None, "none"
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Check if at least one lookup source is available."""
|
||||||
|
return self.tvdb_client is not None or self.tvmaze_client is not None
|
||||||
105
src/tvh2jellyfin/jellyfin.py
Normal file
105
src/tvh2jellyfin/jellyfin.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Jellyfin API client for triggering library scans."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JellyfinConfig:
|
||||||
|
"""Jellyfin server configuration."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
api_key: str
|
||||||
|
library_id: str | None = None # Optional: scan specific library only
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinClient:
|
||||||
|
"""Client for Jellyfin API to trigger library scans."""
|
||||||
|
|
||||||
|
def __init__(self, config: JellyfinConfig):
|
||||||
|
self.config = config
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
"X-MediaBrowser-Token": config.api_key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
})
|
||||||
|
# Normalize URL (remove trailing slash)
|
||||||
|
self.base_url = config.url.rstrip("/")
|
||||||
|
|
||||||
|
def _api_post(self, endpoint: str) -> bool:
|
||||||
|
"""Make POST request to Jellyfin API."""
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.post(url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return True
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Jellyfin API call failed: {endpoint} - {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _api_get(self, endpoint: str) -> dict | None:
|
||||||
|
"""Make GET request to Jellyfin API."""
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.get(url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Jellyfin API call failed: {endpoint} - {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_connection(self) -> bool:
|
||||||
|
"""Test connection to Jellyfin server."""
|
||||||
|
try:
|
||||||
|
data = self._api_get("/System/Info")
|
||||||
|
if data:
|
||||||
|
server_name = data.get("ServerName", "Unknown")
|
||||||
|
version = data.get("Version", "Unknown")
|
||||||
|
logger.info(f"Connected to Jellyfin: {server_name} v{version}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to connect to Jellyfin: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def trigger_library_scan(self) -> bool:
|
||||||
|
"""
|
||||||
|
Trigger a library scan in Jellyfin.
|
||||||
|
|
||||||
|
If a specific library_id is configured, only that library is scanned.
|
||||||
|
Otherwise, triggers a full library refresh.
|
||||||
|
"""
|
||||||
|
if self.config.library_id:
|
||||||
|
# Scan specific library
|
||||||
|
endpoint = f"/Items/{self.config.library_id}/Refresh"
|
||||||
|
logger.info(f"Triggering Jellyfin library scan for library: {self.config.library_id}")
|
||||||
|
else:
|
||||||
|
# Full library refresh
|
||||||
|
endpoint = "/Library/Refresh"
|
||||||
|
logger.info("Triggering full Jellyfin library scan")
|
||||||
|
|
||||||
|
success = self._api_post(endpoint)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info("Jellyfin library scan triggered successfully")
|
||||||
|
else:
|
||||||
|
logger.warning("Failed to trigger Jellyfin library scan")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
def get_libraries(self) -> list[dict]:
|
||||||
|
"""Get list of media libraries (useful for finding library IDs)."""
|
||||||
|
data = self._api_get("/Library/VirtualFolders")
|
||||||
|
if data:
|
||||||
|
return [
|
||||||
|
{"name": lib.get("Name"), "id": lib.get("ItemId")}
|
||||||
|
for lib in data
|
||||||
|
]
|
||||||
|
return []
|
||||||
311
src/tvh2jellyfin/main.py
Normal file
311
src/tvh2jellyfin/main.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
"""Main application entry point for TVH2Jellyfin."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import schedule
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .episode_resolver import EpisodeResolver
|
||||||
|
from .jellyfin import JellyfinClient
|
||||||
|
from .organizer import generate_destination_path_with_episode
|
||||||
|
from .state import StateManager, TransferRecord
|
||||||
|
from .transfer import TransferError, TransferService
|
||||||
|
from .tvheadend import Recording, TVHeadendClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Global flag for graceful shutdown
|
||||||
|
shutdown_requested = False
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level: str) -> None:
|
||||||
|
"""Configure logging for the application."""
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, level.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def signal_handler(signum: int, frame) -> None:
|
||||||
|
"""Handle shutdown signals gracefully."""
|
||||||
|
global shutdown_requested
|
||||||
|
logger.info(f"Received signal {signum}, initiating shutdown...")
|
||||||
|
shutdown_requested = True
|
||||||
|
|
||||||
|
|
||||||
|
def process_recording(
|
||||||
|
recording: Recording,
|
||||||
|
tvh_client: TVHeadendClient,
|
||||||
|
transfer_service: TransferService | None,
|
||||||
|
state_manager: StateManager,
|
||||||
|
dest_base_path: str,
|
||||||
|
episode_resolver: EpisodeResolver | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Process a single recording: transfer, verify, and cleanup.
|
||||||
|
|
||||||
|
Returns True if successful, False otherwise.
|
||||||
|
"""
|
||||||
|
# Look up episode information
|
||||||
|
episode_info = None
|
||||||
|
source_name = "none"
|
||||||
|
if episode_resolver:
|
||||||
|
episode_info, source_name = episode_resolver.lookup(
|
||||||
|
recording.title, recording.start
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate destination path using episode info if available
|
||||||
|
dest_dir, dest_filename = generate_destination_path_with_episode(
|
||||||
|
recording, dest_base_path, episode_info
|
||||||
|
)
|
||||||
|
full_dest_path = f"{dest_dir}/{dest_filename}"
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info(f"[DRY RUN] Would process: {recording.display_name}")
|
||||||
|
logger.info(f"[DRY RUN] Source: {recording.filename}")
|
||||||
|
logger.info(f"[DRY RUN] Destination: {full_dest_path}")
|
||||||
|
if episode_info:
|
||||||
|
logger.info(
|
||||||
|
f"[DRY RUN] Episode: {episode_info.series_name} "
|
||||||
|
f"{episode_info.formatted_episode} (via {source_name})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("[DRY RUN] Episode: Not found (using fallback naming)")
|
||||||
|
logger.info(f"[DRY RUN] Size: {recording.filesize / 1024 / 1024:.1f} MB")
|
||||||
|
logger.info("[DRY RUN] Would delete from TVHeadend after transfer")
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info(f"Processing: {recording.display_name}")
|
||||||
|
|
||||||
|
if transfer_service is None:
|
||||||
|
logger.error("Transfer service not initialized")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Transfer the file
|
||||||
|
transfer_service.transfer_file(
|
||||||
|
source_path=recording.filename,
|
||||||
|
dest_dir=dest_dir,
|
||||||
|
dest_filename=dest_filename,
|
||||||
|
expected_size=recording.filesize,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the transfer
|
||||||
|
if not transfer_service.verify_transfer(full_dest_path, recording.filesize):
|
||||||
|
logger.error(f"Transfer verification failed for {recording.display_name}")
|
||||||
|
transfer_service.delete_local_file(full_dest_path)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Remove from TVHeadend
|
||||||
|
if not tvh_client.remove_recording(recording.uuid):
|
||||||
|
logger.warning(
|
||||||
|
f"Transfer succeeded but failed to remove from TVHeadend: {recording.uuid}"
|
||||||
|
)
|
||||||
|
# Continue anyway - the file is transferred
|
||||||
|
|
||||||
|
# Record successful transfer
|
||||||
|
record = TransferRecord.create(
|
||||||
|
uuid=recording.uuid,
|
||||||
|
title=recording.display_name,
|
||||||
|
source_path=recording.filename,
|
||||||
|
dest_path=full_dest_path,
|
||||||
|
filesize=recording.filesize,
|
||||||
|
)
|
||||||
|
state_manager.mark_transferred(record)
|
||||||
|
|
||||||
|
logger.info(f"Successfully processed: {recording.display_name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except TransferError as e:
|
||||||
|
logger.error(f"Transfer failed for {recording.display_name}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run_sync(
|
||||||
|
tvh_client: TVHeadendClient,
|
||||||
|
transfer_service: TransferService | None,
|
||||||
|
state_manager: StateManager,
|
||||||
|
dest_base_path: str,
|
||||||
|
episode_resolver: EpisodeResolver | None = None,
|
||||||
|
jellyfin_client: JellyfinClient | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Run a single sync cycle."""
|
||||||
|
prefix = "[DRY RUN] " if dry_run else ""
|
||||||
|
logger.info(f"{prefix}Starting sync cycle...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get finished recordings from TVHeadend
|
||||||
|
recordings = tvh_client.get_finished_recordings()
|
||||||
|
|
||||||
|
if not recordings:
|
||||||
|
logger.info(f"{prefix}No completed recordings found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Filter out already transferred (skip in dry run to show all)
|
||||||
|
if dry_run:
|
||||||
|
new_recordings = recordings
|
||||||
|
already_transferred = [r for r in recordings if state_manager.is_transferred(r.uuid)]
|
||||||
|
if already_transferred:
|
||||||
|
logger.info(f"{prefix}Already transferred ({len(already_transferred)}):")
|
||||||
|
for r in already_transferred:
|
||||||
|
logger.info(f"{prefix} - {r.display_name}")
|
||||||
|
new_recordings = [r for r in recordings if not state_manager.is_transferred(r.uuid)]
|
||||||
|
else:
|
||||||
|
new_recordings = [r for r in recordings if not state_manager.is_transferred(r.uuid)]
|
||||||
|
|
||||||
|
if not new_recordings:
|
||||||
|
logger.info(f"{prefix}No new recordings to transfer")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"{prefix}Found {len(new_recordings)} new recording(s) to transfer")
|
||||||
|
|
||||||
|
# Process each recording
|
||||||
|
success_count = 0
|
||||||
|
for recording in new_recordings:
|
||||||
|
if shutdown_requested:
|
||||||
|
logger.info("Shutdown requested, stopping sync")
|
||||||
|
break
|
||||||
|
|
||||||
|
if process_recording(
|
||||||
|
recording,
|
||||||
|
tvh_client,
|
||||||
|
transfer_service,
|
||||||
|
state_manager,
|
||||||
|
dest_base_path,
|
||||||
|
episode_resolver=episode_resolver,
|
||||||
|
dry_run=dry_run,
|
||||||
|
):
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
logger.info(f"{prefix}Sync cycle complete: {success_count}/{len(new_recordings)} processed")
|
||||||
|
|
||||||
|
# Trigger Jellyfin library scan if any transfers succeeded
|
||||||
|
if success_count > 0 and jellyfin_client and not dry_run:
|
||||||
|
jellyfin_client.trigger_library_scan()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Sync cycle failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
|
global shutdown_requested
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
config = Config.from_env()
|
||||||
|
setup_logging(config.log_level)
|
||||||
|
|
||||||
|
if config.dry_run:
|
||||||
|
logger.info("TVH2Jellyfin starting in DRY RUN mode...")
|
||||||
|
logger.info("No files will be transferred, renamed, or deleted")
|
||||||
|
else:
|
||||||
|
logger.info("TVH2Jellyfin starting...")
|
||||||
|
|
||||||
|
# Validate configuration
|
||||||
|
errors = config.validate()
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
logger.error(f"Configuration error: {error}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Initialize components
|
||||||
|
tvh_client = TVHeadendClient(config.tvheadend)
|
||||||
|
transfer_service: TransferService | None = None
|
||||||
|
if not config.dry_run:
|
||||||
|
transfer_service = TransferService(config.tvheadend, config.dest_path)
|
||||||
|
state_manager = StateManager(config.state_file)
|
||||||
|
|
||||||
|
# Initialize episode resolver for TVDB/TVmaze lookups
|
||||||
|
episode_resolver = EpisodeResolver.create(
|
||||||
|
tvdb_config=config.tvdb,
|
||||||
|
tvmaze_config=config.tvmaze,
|
||||||
|
)
|
||||||
|
if episode_resolver.is_available():
|
||||||
|
if config.tvdb and config.tvdb.enabled:
|
||||||
|
logger.info("Episode lookup enabled: TVDB (primary) + TVmaze (fallback)")
|
||||||
|
else:
|
||||||
|
logger.info("Episode lookup enabled: TVmaze only")
|
||||||
|
else:
|
||||||
|
logger.warning("Episode lookup disabled: no sources configured")
|
||||||
|
|
||||||
|
# Initialize Jellyfin client for library scan triggers
|
||||||
|
jellyfin_client: JellyfinClient | None = None
|
||||||
|
if config.jellyfin and config.jellyfin.enabled:
|
||||||
|
jellyfin_client = JellyfinClient(config.jellyfin)
|
||||||
|
logger.info("Jellyfin integration enabled: library scan will trigger after transfers")
|
||||||
|
else:
|
||||||
|
logger.info("Jellyfin integration disabled")
|
||||||
|
|
||||||
|
# Test connections
|
||||||
|
logger.info("Testing connections...")
|
||||||
|
|
||||||
|
if not tvh_client.test_connection():
|
||||||
|
logger.error("Failed to connect to TVHeadend")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not config.dry_run:
|
||||||
|
if transfer_service:
|
||||||
|
if not transfer_service.test_connection():
|
||||||
|
logger.error("Failed to connect to TVHeadend via SSH")
|
||||||
|
sys.exit(1)
|
||||||
|
if not transfer_service.test_destination():
|
||||||
|
logger.error("Destination path not accessible")
|
||||||
|
sys.exit(1)
|
||||||
|
if jellyfin_client:
|
||||||
|
if not jellyfin_client.test_connection():
|
||||||
|
logger.error("Failed to connect to Jellyfin")
|
||||||
|
sys.exit(1)
|
||||||
|
logger.info("All connections verified successfully")
|
||||||
|
else:
|
||||||
|
logger.info("TVHeadend API verified (skipping SSH/destination/Jellyfin in dry run mode)")
|
||||||
|
|
||||||
|
# Set up signal handlers
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
|
# Create sync function with dependencies
|
||||||
|
def sync_job():
|
||||||
|
run_sync(
|
||||||
|
tvh_client,
|
||||||
|
transfer_service,
|
||||||
|
state_manager,
|
||||||
|
config.dest_path,
|
||||||
|
episode_resolver=episode_resolver,
|
||||||
|
jellyfin_client=jellyfin_client,
|
||||||
|
dry_run=config.dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Schedule regular syncs
|
||||||
|
schedule.every(config.poll_interval).seconds.do(sync_job)
|
||||||
|
|
||||||
|
# Run initial sync
|
||||||
|
logger.info("Running initial sync...")
|
||||||
|
sync_job()
|
||||||
|
|
||||||
|
# In dry run mode, exit after single run
|
||||||
|
if config.dry_run:
|
||||||
|
logger.info("Dry run complete")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Main loop
|
||||||
|
logger.info(f"Scheduling syncs every {config.poll_interval} seconds")
|
||||||
|
|
||||||
|
while not shutdown_requested:
|
||||||
|
schedule.run_pending()
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
logger.info("Shutting down...")
|
||||||
|
if transfer_service:
|
||||||
|
transfer_service.close()
|
||||||
|
logger.info("TVH2Jellyfin stopped")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
139
src/tvh2jellyfin/organizer.py
Normal file
139
src/tvh2jellyfin/organizer.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""File organization and naming logic for Jellyfin destination."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from .tvheadend import Recording
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .tvdb import EpisodeInfo
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(name: str) -> str:
|
||||||
|
"""
|
||||||
|
Sanitize a string to be safe for use as a filename.
|
||||||
|
|
||||||
|
- Removes or replaces characters that are invalid in filenames
|
||||||
|
- Normalizes unicode characters
|
||||||
|
- Replaces multiple spaces/underscores with single ones
|
||||||
|
"""
|
||||||
|
# Normalize unicode characters
|
||||||
|
name = unicodedata.normalize("NFKD", name)
|
||||||
|
|
||||||
|
# Replace characters that are problematic in filenames
|
||||||
|
# Keep alphanumeric, spaces, hyphens, underscores, and periods
|
||||||
|
name = re.sub(r'[<>:"/\\|?*]', "", name)
|
||||||
|
|
||||||
|
# Replace multiple spaces or underscores with single space
|
||||||
|
name = re.sub(r"[\s_]+", " ", name)
|
||||||
|
|
||||||
|
# Strip leading/trailing whitespace and periods
|
||||||
|
name = name.strip(" .")
|
||||||
|
|
||||||
|
# Ensure we have something left
|
||||||
|
if not name:
|
||||||
|
name = "Unknown"
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_directory_name(name: str) -> str:
|
||||||
|
"""
|
||||||
|
Sanitize a string to be safe for use as a directory name.
|
||||||
|
|
||||||
|
Similar to sanitize_filename but also handles trailing dots/spaces
|
||||||
|
which can cause issues on some filesystems.
|
||||||
|
"""
|
||||||
|
name = sanitize_filename(name)
|
||||||
|
|
||||||
|
# Remove trailing dots and spaces (problematic on Windows/NTFS)
|
||||||
|
name = name.rstrip(". ")
|
||||||
|
|
||||||
|
return name if name else "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_destination_path(
|
||||||
|
recording: Recording,
|
||||||
|
base_path: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Generate the destination directory and filename for a recording.
|
||||||
|
|
||||||
|
Organizes files by show name:
|
||||||
|
{base_path}/{show_name}/{episode_title}.{ext}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple of (directory_path, filename)
|
||||||
|
"""
|
||||||
|
# Get the show name (title) for the directory
|
||||||
|
show_name = sanitize_directory_name(recording.title)
|
||||||
|
|
||||||
|
# Build the episode filename
|
||||||
|
source_path = Path(recording.filename)
|
||||||
|
extension = source_path.suffix.lower() or ".ts"
|
||||||
|
|
||||||
|
# Create a descriptive filename
|
||||||
|
if recording.subtitle:
|
||||||
|
# Has episode info: "Show Name - Episode Title.ts"
|
||||||
|
episode_name = f"{recording.title} - {recording.subtitle}"
|
||||||
|
else:
|
||||||
|
# No episode info, include timestamp to make unique
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
timestamp = datetime.fromtimestamp(recording.start).strftime("%Y-%m-%d %H%M")
|
||||||
|
episode_name = f"{recording.title} - {timestamp}"
|
||||||
|
|
||||||
|
filename = sanitize_filename(episode_name) + extension
|
||||||
|
|
||||||
|
# Build full destination directory
|
||||||
|
dest_dir = str(Path(base_path) / show_name)
|
||||||
|
|
||||||
|
return dest_dir, filename
|
||||||
|
|
||||||
|
|
||||||
|
def get_full_destination_path(recording: Recording, base_path: str) -> str:
|
||||||
|
"""Get the complete destination path including filename."""
|
||||||
|
dest_dir, filename = generate_destination_path(recording, base_path)
|
||||||
|
return str(Path(dest_dir) / filename)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_destination_path_with_episode(
|
||||||
|
recording: Recording,
|
||||||
|
base_path: str,
|
||||||
|
episode_info: EpisodeInfo | None = None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Generate the destination directory and filename for a recording.
|
||||||
|
|
||||||
|
Uses episode information from TVDB/TVmaze when available for proper
|
||||||
|
Jellyfin naming format: {show_name}/{show_name} - SxxExx.{ext}
|
||||||
|
|
||||||
|
Falls back to the original naming logic when episode info is not available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recording: The recording to process
|
||||||
|
base_path: Base destination path
|
||||||
|
episode_info: Episode information from TVDB/TVmaze lookup (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple of (directory_path, filename)
|
||||||
|
"""
|
||||||
|
# Get file extension from source
|
||||||
|
source_path = Path(recording.filename)
|
||||||
|
extension = source_path.suffix.lower() or ".ts"
|
||||||
|
|
||||||
|
if episode_info:
|
||||||
|
# Use TVDB/TVmaze canonical name and episode info
|
||||||
|
show_name = sanitize_directory_name(episode_info.series_name)
|
||||||
|
episode_name = f"{episode_info.series_name} - {episode_info.formatted_episode}"
|
||||||
|
filename = sanitize_filename(episode_name) + extension
|
||||||
|
season_folder = f"Season {episode_info.season:02d}"
|
||||||
|
dest_dir = str(Path(base_path) / show_name / season_folder)
|
||||||
|
return dest_dir, filename
|
||||||
|
|
||||||
|
# Fall back to original logic when no episode info available
|
||||||
|
return generate_destination_path(recording, base_path)
|
||||||
114
src/tvh2jellyfin/state.py
Normal file
114
src/tvh2jellyfin/state.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""State management for tracking transferred recordings."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TransferRecord:
|
||||||
|
"""Record of a transferred file."""
|
||||||
|
|
||||||
|
uuid: str
|
||||||
|
title: str
|
||||||
|
source_path: str
|
||||||
|
dest_path: str
|
||||||
|
filesize: int
|
||||||
|
transferred_at: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
uuid: str,
|
||||||
|
title: str,
|
||||||
|
source_path: str,
|
||||||
|
dest_path: str,
|
||||||
|
filesize: int,
|
||||||
|
) -> "TransferRecord":
|
||||||
|
"""Create a new transfer record with current timestamp."""
|
||||||
|
return cls(
|
||||||
|
uuid=uuid,
|
||||||
|
title=title,
|
||||||
|
source_path=source_path,
|
||||||
|
dest_path=dest_path,
|
||||||
|
filesize=filesize,
|
||||||
|
transferred_at=datetime.now().isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StateManager:
|
||||||
|
"""Manages persistent state of transferred recordings."""
|
||||||
|
|
||||||
|
def __init__(self, state_file: str):
|
||||||
|
self.state_file = Path(state_file)
|
||||||
|
self._state: dict = {"transferred": {}, "version": 1}
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
"""Load state from disk."""
|
||||||
|
if self.state_file.exists():
|
||||||
|
try:
|
||||||
|
with open(self.state_file) as f:
|
||||||
|
self._state = json.load(f)
|
||||||
|
logger.info(f"Loaded state with {len(self._state.get('transferred', {}))} records")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to parse state file: {e}")
|
||||||
|
self._state = {"transferred": {}, "version": 1}
|
||||||
|
else:
|
||||||
|
logger.info("No existing state file, starting fresh")
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
"""Save state to disk."""
|
||||||
|
# Ensure parent directory exists
|
||||||
|
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.state_file, "w") as f:
|
||||||
|
json.dump(self._state, f, indent=2)
|
||||||
|
logger.debug("State saved")
|
||||||
|
except IOError as e:
|
||||||
|
logger.error(f"Failed to save state: {e}")
|
||||||
|
|
||||||
|
def is_transferred(self, uuid: str) -> bool:
|
||||||
|
"""Check if a recording has already been transferred."""
|
||||||
|
return uuid in self._state.get("transferred", {})
|
||||||
|
|
||||||
|
def mark_transferred(self, record: TransferRecord) -> None:
|
||||||
|
"""Mark a recording as successfully transferred."""
|
||||||
|
if "transferred" not in self._state:
|
||||||
|
self._state["transferred"] = {}
|
||||||
|
|
||||||
|
self._state["transferred"][record.uuid] = asdict(record)
|
||||||
|
self._save()
|
||||||
|
logger.info(f"Marked as transferred: {record.title} ({record.uuid})")
|
||||||
|
|
||||||
|
def get_transfer_record(self, uuid: str) -> TransferRecord | None:
|
||||||
|
"""Get the transfer record for a specific UUID."""
|
||||||
|
data = self._state.get("transferred", {}).get(uuid)
|
||||||
|
if data:
|
||||||
|
return TransferRecord(**data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_all_transfers(self) -> list[TransferRecord]:
|
||||||
|
"""Get all transfer records."""
|
||||||
|
records = []
|
||||||
|
for data in self._state.get("transferred", {}).values():
|
||||||
|
records.append(TransferRecord(**data))
|
||||||
|
return records
|
||||||
|
|
||||||
|
def get_transfer_count(self) -> int:
|
||||||
|
"""Get the number of transferred recordings."""
|
||||||
|
return len(self._state.get("transferred", {}))
|
||||||
|
|
||||||
|
def remove_transfer(self, uuid: str) -> bool:
|
||||||
|
"""Remove a transfer record (e.g., if cleanup needed)."""
|
||||||
|
if uuid in self._state.get("transferred", {}):
|
||||||
|
del self._state["transferred"][uuid]
|
||||||
|
self._save()
|
||||||
|
logger.info(f"Removed transfer record: {uuid}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
211
src/tvh2jellyfin/transfer.py
Normal file
211
src/tvh2jellyfin/transfer.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""Transfer service - SCP from TVHeadend to local destination."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
from .config import TVHeadendConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TransferError(Exception):
|
||||||
|
"""Raised when a file transfer fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TransferService:
|
||||||
|
"""Service for transferring files from TVHeadend via SCP to local destination."""
|
||||||
|
|
||||||
|
def __init__(self, tvh_config: TVHeadendConfig, dest_path: str):
|
||||||
|
self.tvh_config = tvh_config
|
||||||
|
self.dest_path = Path(dest_path)
|
||||||
|
self.client: paramiko.SSHClient | None = None
|
||||||
|
self.sftp: paramiko.SFTPClient | None = None
|
||||||
|
|
||||||
|
def connect(self) -> None:
|
||||||
|
"""Establish SSH connection to TVHeadend server."""
|
||||||
|
self.client = paramiko.SSHClient()
|
||||||
|
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
|
||||||
|
try:
|
||||||
|
key_path = Path(self.tvh_config.ssh_key_path).expanduser()
|
||||||
|
private_key = paramiko.RSAKey.from_private_key_file(str(key_path))
|
||||||
|
|
||||||
|
self.client.connect(
|
||||||
|
hostname=self.tvh_config.host,
|
||||||
|
port=self.tvh_config.ssh_port,
|
||||||
|
username=self.tvh_config.ssh_user,
|
||||||
|
pkey=private_key,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
self.sftp = self.client.open_sftp()
|
||||||
|
logger.info(f"Connected to TVHeadend at {self.tvh_config.host}")
|
||||||
|
except paramiko.AuthenticationException as e:
|
||||||
|
logger.error(f"SSH authentication failed: {e}")
|
||||||
|
raise TransferError(f"SSH authentication failed: {e}")
|
||||||
|
except paramiko.SSHException as e:
|
||||||
|
logger.error(f"SSH connection failed: {e}")
|
||||||
|
raise TransferError(f"SSH connection failed: {e}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(f"SSH key not found: {self.tvh_config.ssh_key_path}")
|
||||||
|
raise TransferError(f"SSH key not found: {self.tvh_config.ssh_key_path}")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close SSH connection."""
|
||||||
|
if self.sftp:
|
||||||
|
self.sftp.close()
|
||||||
|
self.sftp = None
|
||||||
|
if self.client:
|
||||||
|
self.client.close()
|
||||||
|
self.client = None
|
||||||
|
logger.debug("SSH connection closed")
|
||||||
|
|
||||||
|
def ensure_connected(self) -> None:
|
||||||
|
"""Ensure we have an active connection."""
|
||||||
|
if not self.sftp:
|
||||||
|
self.connect()
|
||||||
|
|
||||||
|
def _ensure_local_directory(self, directory: Path) -> None:
|
||||||
|
"""Create local directory and any missing parents."""
|
||||||
|
try:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.debug(f"Ensured local directory exists: {directory}")
|
||||||
|
except OSError as e:
|
||||||
|
raise TransferError(f"Failed to create local directory {directory}: {e}")
|
||||||
|
|
||||||
|
def transfer_file(
|
||||||
|
self,
|
||||||
|
source_path: str,
|
||||||
|
dest_dir: str,
|
||||||
|
dest_filename: str,
|
||||||
|
expected_size: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Transfer a file from TVHeadend to local destination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: Full path to source file on TVHeadend server
|
||||||
|
dest_dir: Local destination directory
|
||||||
|
dest_filename: Filename for destination
|
||||||
|
expected_size: Expected file size for verification
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full path to transferred file on local filesystem
|
||||||
|
"""
|
||||||
|
self.ensure_connected()
|
||||||
|
|
||||||
|
if not self.sftp:
|
||||||
|
raise TransferError("Not connected")
|
||||||
|
|
||||||
|
# Ensure local destination directory exists
|
||||||
|
local_dest_dir = Path(dest_dir)
|
||||||
|
self._ensure_local_directory(local_dest_dir)
|
||||||
|
|
||||||
|
local_dest_path = local_dest_dir / dest_filename
|
||||||
|
|
||||||
|
logger.info(f"Transferring: {self.tvh_config.host}:{source_path} -> {local_dest_path}")
|
||||||
|
|
||||||
|
# Get remote file size if not provided
|
||||||
|
if expected_size is None:
|
||||||
|
try:
|
||||||
|
file_stat = self.sftp.stat(source_path)
|
||||||
|
expected_size = file_stat.st_size
|
||||||
|
except IOError as e:
|
||||||
|
raise TransferError(f"Cannot access remote file: {e}")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Download file from TVHeadend to local destination
|
||||||
|
self.sftp.get(source_path, str(local_dest_path))
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
speed_mbps = (expected_size / 1024 / 1024) / elapsed if elapsed > 0 else 0
|
||||||
|
logger.info(
|
||||||
|
f"Transfer complete: {expected_size / 1024 / 1024:.1f} MB "
|
||||||
|
f"in {elapsed:.1f}s ({speed_mbps:.1f} MB/s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
except IOError as e:
|
||||||
|
raise TransferError(f"Transfer failed: {e}")
|
||||||
|
|
||||||
|
return str(local_dest_path)
|
||||||
|
|
||||||
|
def verify_transfer(self, local_path: str, expected_size: int) -> bool:
|
||||||
|
"""
|
||||||
|
Verify a transferred file exists locally and has correct size.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
local_path: Path to file on local filesystem
|
||||||
|
expected_size: Expected file size in bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if verification passes
|
||||||
|
"""
|
||||||
|
path = Path(local_path)
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
logger.error(f"Transferred file not found: {local_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
actual_size = path.stat().st_size
|
||||||
|
|
||||||
|
if actual_size != expected_size:
|
||||||
|
logger.error(
|
||||||
|
f"Size mismatch for {local_path}: expected {expected_size}, got {actual_size}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"Verified transfer: {local_path} ({actual_size} bytes)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_local_file(self, local_path: str) -> bool:
|
||||||
|
"""Delete a local file (for cleanup on failed verification)."""
|
||||||
|
path = Path(local_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
logger.info(f"Deleted local file: {local_path}")
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"Failed to delete local file {local_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_connection(self) -> bool:
|
||||||
|
"""Test SSH connection to TVHeadend server."""
|
||||||
|
try:
|
||||||
|
self.connect()
|
||||||
|
logger.info(f"Successfully connected to TVHeadend via SSH")
|
||||||
|
return True
|
||||||
|
except TransferError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def test_destination(self) -> bool:
|
||||||
|
"""Test that local destination path is accessible and writable."""
|
||||||
|
try:
|
||||||
|
if not self.dest_path.exists():
|
||||||
|
logger.warning(f"Destination path does not exist, will create: {self.dest_path}")
|
||||||
|
|
||||||
|
# Try to create and write to a test file
|
||||||
|
self._ensure_local_directory(self.dest_path)
|
||||||
|
test_file = self.dest_path / ".tvh2jellyfin_test"
|
||||||
|
try:
|
||||||
|
test_file.touch()
|
||||||
|
test_file.unlink()
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"Destination path is not writable: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"Destination path verified: {self.dest_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to verify destination: {e}")
|
||||||
|
return False
|
||||||
284
src/tvh2jellyfin/tvdb.py
Normal file
284
src/tvh2jellyfin/tvdb.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"""TheTVDB API client for episode information lookup."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EpisodeInfo:
|
||||||
|
"""Episode information from TVDB lookup."""
|
||||||
|
|
||||||
|
series_id: int
|
||||||
|
series_name: str
|
||||||
|
season: int
|
||||||
|
episode: int
|
||||||
|
episode_name: str
|
||||||
|
air_date: str # YYYY-MM-DD format
|
||||||
|
|
||||||
|
@property
|
||||||
|
def formatted_episode(self) -> str:
|
||||||
|
"""Return episode in Jellyfin format: SxxExx."""
|
||||||
|
return f"S{self.season:02d}E{self.episode:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TVDBConfig:
|
||||||
|
"""TheTVDB API configuration."""
|
||||||
|
|
||||||
|
api_key: str
|
||||||
|
pin: str = ""
|
||||||
|
cache_ttl: int = 86400 # 24 hours default
|
||||||
|
show_mapping_file: str | None = None
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class TVDBClient:
|
||||||
|
"""Client for TheTVDB API v4."""
|
||||||
|
|
||||||
|
BASE_URL = "https://api4.thetvdb.com/v4"
|
||||||
|
|
||||||
|
def __init__(self, config: TVDBConfig):
|
||||||
|
self.config = config
|
||||||
|
self.session = requests.Session()
|
||||||
|
self._token: str | None = None
|
||||||
|
self._token_expires: float = 0
|
||||||
|
# Cache: name -> (series_id, canonical_name, timestamp)
|
||||||
|
self._series_cache: dict[str, tuple[int, str, float]] = {}
|
||||||
|
# Cache: cache_key -> (EpisodeInfo | None, timestamp)
|
||||||
|
self._episode_cache: dict[str, tuple[EpisodeInfo | None, float]] = {}
|
||||||
|
# Show name mapping: tvh_name -> tvdb_name
|
||||||
|
self._show_mapping: dict[str, str] = {}
|
||||||
|
self._load_show_mapping()
|
||||||
|
|
||||||
|
def _load_show_mapping(self) -> None:
|
||||||
|
"""Load show name mapping from JSON file if configured."""
|
||||||
|
if not self.config.show_mapping_file:
|
||||||
|
return
|
||||||
|
|
||||||
|
mapping_path = Path(self.config.show_mapping_file)
|
||||||
|
if not mapping_path.exists():
|
||||||
|
logger.warning(f"Show mapping file not found: {mapping_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(mapping_path) as f:
|
||||||
|
self._show_mapping = json.load(f)
|
||||||
|
logger.info(f"Loaded {len(self._show_mapping)} show name mappings")
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
logger.error(f"Failed to load show mapping file: {e}")
|
||||||
|
|
||||||
|
def _authenticate(self) -> None:
|
||||||
|
"""Get JWT token from TVDB API."""
|
||||||
|
url = f"{self.BASE_URL}/login"
|
||||||
|
payload = {"apikey": self.config.api_key}
|
||||||
|
if self.config.pin:
|
||||||
|
payload["pin"] = self.config.pin
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.post(url, json=payload, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
self._token = data["data"]["token"]
|
||||||
|
# Token is valid for 30 days, but refresh more often for safety
|
||||||
|
self._token_expires = time.time() + 86400 # 24 hours
|
||||||
|
logger.debug("TVDB authentication successful")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"TVDB authentication failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _ensure_authenticated(self) -> None:
|
||||||
|
"""Ensure we have a valid token."""
|
||||||
|
if self._token is None or time.time() >= self._token_expires:
|
||||||
|
self._authenticate()
|
||||||
|
|
||||||
|
def _api_get(self, endpoint: str, params: dict | None = None) -> dict:
|
||||||
|
"""Make authenticated GET request to TVDB API."""
|
||||||
|
self._ensure_authenticated()
|
||||||
|
|
||||||
|
url = f"{self.BASE_URL}{endpoint}"
|
||||||
|
headers = {"Authorization": f"Bearer {self._token}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.get(url, headers=headers, params=params, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"TVDB API call failed: {endpoint} - {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _is_cache_valid(self, timestamp: float) -> bool:
|
||||||
|
"""Check if a cached entry is still valid."""
|
||||||
|
return time.time() - timestamp < self.config.cache_ttl
|
||||||
|
|
||||||
|
def search_series(self, name: str) -> tuple[int, str] | None:
|
||||||
|
"""
|
||||||
|
Search for a series by name, return (series_id, canonical_name).
|
||||||
|
|
||||||
|
Uses cache to minimize API calls.
|
||||||
|
"""
|
||||||
|
# Check show mapping first
|
||||||
|
mapped_name = self._show_mapping.get(name, name)
|
||||||
|
|
||||||
|
# Check cache
|
||||||
|
cache_key = mapped_name.lower()
|
||||||
|
if cache_key in self._series_cache:
|
||||||
|
series_id, canonical_name, timestamp = self._series_cache[cache_key]
|
||||||
|
if self._is_cache_valid(timestamp):
|
||||||
|
return series_id, canonical_name
|
||||||
|
|
||||||
|
# Search via API
|
||||||
|
try:
|
||||||
|
data = self._api_get("/search", {"query": mapped_name, "type": "series"})
|
||||||
|
results = data.get("data", [])
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
logger.warning(f"No series found for: {name}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Use first result (best match)
|
||||||
|
series = results[0]
|
||||||
|
series_id = series["tvdb_id"]
|
||||||
|
canonical_name = series.get("name", name)
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
self._series_cache[cache_key] = (series_id, canonical_name, time.time())
|
||||||
|
logger.debug(f"Found series: {canonical_name} (ID: {series_id})")
|
||||||
|
|
||||||
|
return series_id, canonical_name
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_episodes_by_airdate(
|
||||||
|
self, series_id: int, air_date: str
|
||||||
|
) -> list[EpisodeInfo]:
|
||||||
|
"""
|
||||||
|
Get episodes for a series that aired on a specific date.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
series_id: TVDB series ID
|
||||||
|
air_date: Date in YYYY-MM-DD format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching episodes (usually 1, but could be multiple)
|
||||||
|
"""
|
||||||
|
# Check cache
|
||||||
|
cache_key = f"{series_id}:{air_date}"
|
||||||
|
if cache_key in self._episode_cache:
|
||||||
|
cached_info, timestamp = self._episode_cache[cache_key]
|
||||||
|
if self._is_cache_valid(timestamp):
|
||||||
|
return [cached_info] if cached_info else []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get all episodes for the series
|
||||||
|
# TVDB v4 doesn't have direct airdate filter, so we need to paginate
|
||||||
|
episodes: list[EpisodeInfo] = []
|
||||||
|
page = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = self._api_get(
|
||||||
|
f"/series/{series_id}/episodes/default",
|
||||||
|
{"page": page}
|
||||||
|
)
|
||||||
|
|
||||||
|
for ep in data.get("data", {}).get("episodes", []):
|
||||||
|
ep_aired = ep.get("aired", "")
|
||||||
|
if ep_aired == air_date:
|
||||||
|
info = EpisodeInfo(
|
||||||
|
series_id=series_id,
|
||||||
|
series_name=data.get("data", {}).get("name", "Unknown"),
|
||||||
|
season=ep.get("seasonNumber", 0),
|
||||||
|
episode=ep.get("number", 0),
|
||||||
|
episode_name=ep.get("name", ""),
|
||||||
|
air_date=air_date,
|
||||||
|
)
|
||||||
|
episodes.append(info)
|
||||||
|
|
||||||
|
# Check for more pages
|
||||||
|
links = data.get("links", {})
|
||||||
|
if links.get("next"):
|
||||||
|
page += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Cache the first result (or None if no matches)
|
||||||
|
first_ep = episodes[0] if episodes else None
|
||||||
|
self._episode_cache[cache_key] = (first_ep, time.time())
|
||||||
|
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def lookup_episode(
|
||||||
|
self,
|
||||||
|
show_name: str,
|
||||||
|
broadcast_timestamp: int,
|
||||||
|
) -> EpisodeInfo | None:
|
||||||
|
"""
|
||||||
|
Main entry point: look up episode info by show name and broadcast time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
show_name: Show name from TVHeadend recording
|
||||||
|
broadcast_timestamp: Unix timestamp of broadcast start
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EpisodeInfo if found, None otherwise
|
||||||
|
"""
|
||||||
|
# Convert timestamp to air date
|
||||||
|
air_date = datetime.fromtimestamp(broadcast_timestamp).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Check episode cache first (using show name as part of key)
|
||||||
|
cache_key = f"{show_name.lower()}:{air_date}"
|
||||||
|
if cache_key in self._episode_cache:
|
||||||
|
cached_info, timestamp = self._episode_cache[cache_key]
|
||||||
|
if self._is_cache_valid(timestamp):
|
||||||
|
return cached_info
|
||||||
|
|
||||||
|
# Search for series
|
||||||
|
series_result = self.search_series(show_name)
|
||||||
|
if not series_result:
|
||||||
|
# Cache negative result
|
||||||
|
self._episode_cache[cache_key] = (None, time.time())
|
||||||
|
return None
|
||||||
|
|
||||||
|
series_id, canonical_name = series_result
|
||||||
|
|
||||||
|
# Find episodes for the air date
|
||||||
|
episodes = self.get_episodes_by_airdate(series_id, air_date)
|
||||||
|
|
||||||
|
if not episodes:
|
||||||
|
logger.debug(f"No episode found for {show_name} on {air_date}")
|
||||||
|
# Cache negative result
|
||||||
|
self._episode_cache[cache_key] = (None, time.time())
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return first match
|
||||||
|
episode = episodes[0]
|
||||||
|
|
||||||
|
# Update episode with canonical series name
|
||||||
|
episode = EpisodeInfo(
|
||||||
|
series_id=episode.series_id,
|
||||||
|
series_name=canonical_name,
|
||||||
|
season=episode.season,
|
||||||
|
episode=episode.episode,
|
||||||
|
episode_name=episode.episode_name,
|
||||||
|
air_date=episode.air_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache by show name too
|
||||||
|
self._episode_cache[cache_key] = (episode, time.time())
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"TVDB: {show_name} ({air_date}) -> {canonical_name} {episode.formatted_episode}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return episode
|
||||||
139
src/tvh2jellyfin/tvheadend.py
Normal file
139
src/tvh2jellyfin/tvheadend.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""TVHeadend API client for fetching and managing recordings."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.auth import HTTPDigestAuth
|
||||||
|
|
||||||
|
from .config import TVHeadendConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Recording:
|
||||||
|
"""Represents a TVHeadend recording."""
|
||||||
|
|
||||||
|
uuid: str
|
||||||
|
title: str
|
||||||
|
subtitle: str
|
||||||
|
filename: str
|
||||||
|
filesize: int
|
||||||
|
channel: str
|
||||||
|
start: int
|
||||||
|
stop: int
|
||||||
|
status: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
"""Human-readable name for the recording."""
|
||||||
|
if self.subtitle:
|
||||||
|
return f"{self.title} - {self.subtitle}"
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_response(cls, data: dict[str, Any]) -> "Recording":
|
||||||
|
"""Create a Recording from TVHeadend API response data."""
|
||||||
|
return cls(
|
||||||
|
uuid=data.get("uuid", ""),
|
||||||
|
title=data.get("disp_title", data.get("title", "Unknown")),
|
||||||
|
subtitle=data.get("disp_subtitle", data.get("subtitle", "")),
|
||||||
|
filename=data.get("filename", ""),
|
||||||
|
filesize=data.get("filesize", 0),
|
||||||
|
channel=data.get("channelname", ""),
|
||||||
|
start=data.get("start", 0),
|
||||||
|
stop=data.get("stop", 0),
|
||||||
|
status=data.get("status", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TVHeadendClient:
|
||||||
|
"""Client for interacting with TVHeadend JSON API."""
|
||||||
|
|
||||||
|
def __init__(self, config: TVHeadendConfig):
|
||||||
|
self.config = config
|
||||||
|
self._create_session()
|
||||||
|
|
||||||
|
def _create_session(self) -> None:
|
||||||
|
"""Create a new session with fresh authentication."""
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.auth = HTTPDigestAuth(self.config.username, self.config.password)
|
||||||
|
|
||||||
|
def _api_call(self, endpoint: str, params: dict | None = None) -> dict[str, Any]:
|
||||||
|
"""Make an API call to TVHeadend."""
|
||||||
|
url = f"{self.config.base_url}/api/{endpoint}"
|
||||||
|
try:
|
||||||
|
response = self.session.get(url, params=params, timeout=30)
|
||||||
|
if response.status_code == 401:
|
||||||
|
# Digest auth nonce may have expired, refresh session and retry
|
||||||
|
logger.debug("Got 401, refreshing session and retrying")
|
||||||
|
self._create_session()
|
||||||
|
response = self.session.get(url, params=params, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"API call failed: {endpoint} - {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _api_post(self, endpoint: str, data: dict | None = None) -> dict[str, Any]:
|
||||||
|
"""Make a POST API call to TVHeadend."""
|
||||||
|
url = f"{self.config.base_url}/api/{endpoint}"
|
||||||
|
try:
|
||||||
|
response = self.session.post(url, data=data, timeout=30)
|
||||||
|
if response.status_code == 401:
|
||||||
|
# Digest auth nonce may have expired, refresh session and retry
|
||||||
|
logger.debug("Got 401, refreshing session and retrying")
|
||||||
|
self._create_session()
|
||||||
|
response = self.session.post(url, data=data, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"API POST call failed: {endpoint} - {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_finished_recordings(self) -> list[Recording]:
|
||||||
|
"""Fetch all finished recordings from TVHeadend."""
|
||||||
|
params = {
|
||||||
|
"limit": 1000, # Get all recordings
|
||||||
|
}
|
||||||
|
data = self._api_call("dvr/entry/grid_finished", params)
|
||||||
|
|
||||||
|
recordings = []
|
||||||
|
for entry in data.get("entries", []):
|
||||||
|
if entry.get("status") == "Completed OK":
|
||||||
|
recordings.append(Recording.from_api_response(entry))
|
||||||
|
|
||||||
|
logger.info(f"Found {len(recordings)} completed recordings")
|
||||||
|
return recordings
|
||||||
|
|
||||||
|
def get_recording_details(self, uuid: str) -> Recording | None:
|
||||||
|
"""Get details for a specific recording."""
|
||||||
|
params = {"uuid": uuid}
|
||||||
|
data = self._api_call("dvr/entry/grid", params)
|
||||||
|
|
||||||
|
entries = data.get("entries", [])
|
||||||
|
if entries:
|
||||||
|
return Recording.from_api_response(entries[0])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def remove_recording(self, uuid: str) -> bool:
|
||||||
|
"""Remove a recording from TVHeadend (deletes file and entry)."""
|
||||||
|
try:
|
||||||
|
self._api_post("dvr/entry/remove", {"uuid": uuid})
|
||||||
|
logger.info(f"Removed recording from TVHeadend: {uuid}")
|
||||||
|
return True
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
logger.error(f"Failed to remove recording: {uuid}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_connection(self) -> bool:
|
||||||
|
"""Test connection to TVHeadend server."""
|
||||||
|
try:
|
||||||
|
self._api_call("serverinfo")
|
||||||
|
logger.info(f"Successfully connected to TVHeadend at {self.config.base_url}")
|
||||||
|
return True
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Failed to connect to TVHeadend: {e}")
|
||||||
|
return False
|
||||||
181
src/tvh2jellyfin/tvmaze.py
Normal file
181
src/tvh2jellyfin/tvmaze.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""TVmaze API client for episode information lookup (fallback)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .tvdb import EpisodeInfo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TVMazeConfig:
|
||||||
|
"""TVmaze API configuration."""
|
||||||
|
|
||||||
|
cache_ttl: int = 86400 # 24 hours default
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class TVMazeClient:
|
||||||
|
"""Client for TVmaze API (no authentication required)."""
|
||||||
|
|
||||||
|
BASE_URL = "https://api.tvmaze.com"
|
||||||
|
|
||||||
|
def __init__(self, config: TVMazeConfig | None = None):
|
||||||
|
self.config = config or TVMazeConfig()
|
||||||
|
self.session = requests.Session()
|
||||||
|
# Cache: name -> (show_id, show_name, timestamp)
|
||||||
|
self._series_cache: dict[str, tuple[int, str, float]] = {}
|
||||||
|
# Cache: cache_key -> (EpisodeInfo | None, timestamp)
|
||||||
|
self._episode_cache: dict[str, tuple[EpisodeInfo | None, float]] = {}
|
||||||
|
|
||||||
|
def _is_cache_valid(self, timestamp: float) -> bool:
|
||||||
|
"""Check if a cached entry is still valid."""
|
||||||
|
return time.time() - timestamp < self.config.cache_ttl
|
||||||
|
|
||||||
|
def search_series(self, name: str) -> tuple[int, str] | None:
|
||||||
|
"""
|
||||||
|
Search for a series by name, return (show_id, show_name).
|
||||||
|
|
||||||
|
Uses cache to minimize API calls.
|
||||||
|
"""
|
||||||
|
# Check cache
|
||||||
|
cache_key = name.lower()
|
||||||
|
if cache_key in self._series_cache:
|
||||||
|
show_id, show_name, timestamp = self._series_cache[cache_key]
|
||||||
|
if self._is_cache_valid(timestamp):
|
||||||
|
return show_id, show_name
|
||||||
|
|
||||||
|
# Search via API
|
||||||
|
try:
|
||||||
|
url = f"{self.BASE_URL}/singlesearch/shows"
|
||||||
|
response = self.session.get(url, params={"q": name}, timeout=30)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
logger.debug(f"TVmaze: No series found for: {name}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
show_id = data["id"]
|
||||||
|
show_name = data.get("name", name)
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
self._series_cache[cache_key] = (show_id, show_name, time.time())
|
||||||
|
logger.debug(f"TVmaze: Found series: {show_name} (ID: {show_id})")
|
||||||
|
|
||||||
|
return show_id, show_name
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"TVmaze search failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_episode_by_date(
|
||||||
|
self, show_id: int, air_date: str, show_name: str
|
||||||
|
) -> EpisodeInfo | None:
|
||||||
|
"""
|
||||||
|
Get episode for a show that aired on a specific date.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
show_id: TVmaze show ID
|
||||||
|
air_date: Date in YYYY-MM-DD format
|
||||||
|
show_name: Show name (for building EpisodeInfo)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EpisodeInfo if found, None otherwise
|
||||||
|
"""
|
||||||
|
# Check cache
|
||||||
|
cache_key = f"{show_id}:{air_date}"
|
||||||
|
if cache_key in self._episode_cache:
|
||||||
|
cached_info, timestamp = self._episode_cache[cache_key]
|
||||||
|
if self._is_cache_valid(timestamp):
|
||||||
|
return cached_info
|
||||||
|
|
||||||
|
try:
|
||||||
|
# TVmaze has a direct endpoint for episodes by date
|
||||||
|
url = f"{self.BASE_URL}/shows/{show_id}/episodesbydate"
|
||||||
|
response = self.session.get(url, params={"date": air_date}, timeout=30)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
logger.debug(f"TVmaze: No episode found for show {show_id} on {air_date}")
|
||||||
|
self._episode_cache[cache_key] = (None, time.time())
|
||||||
|
return None
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
episodes = response.json()
|
||||||
|
|
||||||
|
if not episodes:
|
||||||
|
self._episode_cache[cache_key] = (None, time.time())
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Use first episode if multiple on same day
|
||||||
|
ep = episodes[0]
|
||||||
|
info = EpisodeInfo(
|
||||||
|
series_id=show_id,
|
||||||
|
series_name=show_name,
|
||||||
|
season=ep.get("season", 0),
|
||||||
|
episode=ep.get("number", 0),
|
||||||
|
episode_name=ep.get("name", ""),
|
||||||
|
air_date=air_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._episode_cache[cache_key] = (info, time.time())
|
||||||
|
return info
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"TVmaze episode lookup failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def lookup_episode(
|
||||||
|
self,
|
||||||
|
show_name: str,
|
||||||
|
broadcast_timestamp: int,
|
||||||
|
) -> EpisodeInfo | None:
|
||||||
|
"""
|
||||||
|
Main entry point: look up episode info by show name and broadcast time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
show_name: Show name from TVHeadend recording
|
||||||
|
broadcast_timestamp: Unix timestamp of broadcast start
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EpisodeInfo if found, None otherwise
|
||||||
|
"""
|
||||||
|
# Convert timestamp to air date
|
||||||
|
air_date = datetime.fromtimestamp(broadcast_timestamp).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Check episode cache first (using show name as part of key)
|
||||||
|
cache_key = f"tvmaze:{show_name.lower()}:{air_date}"
|
||||||
|
if cache_key in self._episode_cache:
|
||||||
|
cached_info, timestamp = self._episode_cache[cache_key]
|
||||||
|
if self._is_cache_valid(timestamp):
|
||||||
|
return cached_info
|
||||||
|
|
||||||
|
# Search for series
|
||||||
|
series_result = self.search_series(show_name)
|
||||||
|
if not series_result:
|
||||||
|
self._episode_cache[cache_key] = (None, time.time())
|
||||||
|
return None
|
||||||
|
|
||||||
|
show_id, canonical_name = series_result
|
||||||
|
|
||||||
|
# Find episode for the air date
|
||||||
|
episode = self.get_episode_by_date(show_id, air_date, canonical_name)
|
||||||
|
|
||||||
|
if not episode:
|
||||||
|
self._episode_cache[cache_key] = (None, time.time())
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Cache by show name too
|
||||||
|
self._episode_cache[cache_key] = (episode, time.time())
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"TVmaze: {show_name} ({air_date}) -> {canonical_name} {episode.formatted_episode}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return episode
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
117
tests/test_episode_resolver.py
Normal file
117
tests/test_episode_resolver.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""Tests for episode resolver."""
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tvh2jellyfin.episode_resolver import EpisodeResolver
|
||||||
|
from tvh2jellyfin.tvdb import EpisodeInfo
|
||||||
|
|
||||||
|
|
||||||
|
class TestEpisodeResolver:
|
||||||
|
"""Tests for EpisodeResolver."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_episode_info(self):
|
||||||
|
return EpisodeInfo(
|
||||||
|
series_id=456,
|
||||||
|
series_name="Test Show",
|
||||||
|
season=1,
|
||||||
|
episode=5,
|
||||||
|
episode_name="Pilot",
|
||||||
|
air_date="2024-01-15",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_lookup_tvdb_success(self, mock_episode_info):
|
||||||
|
"""Test successful lookup from TVDB."""
|
||||||
|
mock_tvdb = Mock()
|
||||||
|
mock_tvdb.lookup_episode.return_value = mock_episode_info
|
||||||
|
|
||||||
|
resolver = EpisodeResolver(tvdb_client=mock_tvdb, tvmaze_client=None)
|
||||||
|
result, source = resolver.lookup("Test Show", 1705312800)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.season == 1
|
||||||
|
assert result.episode == 5
|
||||||
|
assert source == "tvdb"
|
||||||
|
|
||||||
|
def test_lookup_tvmaze_fallback(self, mock_episode_info):
|
||||||
|
"""Test fallback to TVmaze when TVDB fails."""
|
||||||
|
mock_tvdb = Mock()
|
||||||
|
mock_tvdb.lookup_episode.return_value = None
|
||||||
|
|
||||||
|
mock_tvmaze = Mock()
|
||||||
|
mock_tvmaze.lookup_episode.return_value = mock_episode_info
|
||||||
|
|
||||||
|
resolver = EpisodeResolver(tvdb_client=mock_tvdb, tvmaze_client=mock_tvmaze)
|
||||||
|
result, source = resolver.lookup("Test Show", 1705312800)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert source == "tvmaze"
|
||||||
|
|
||||||
|
def test_lookup_tvdb_exception_fallback(self, mock_episode_info):
|
||||||
|
"""Test fallback to TVmaze when TVDB raises exception."""
|
||||||
|
mock_tvdb = Mock()
|
||||||
|
mock_tvdb.lookup_episode.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
mock_tvmaze = Mock()
|
||||||
|
mock_tvmaze.lookup_episode.return_value = mock_episode_info
|
||||||
|
|
||||||
|
resolver = EpisodeResolver(tvdb_client=mock_tvdb, tvmaze_client=mock_tvmaze)
|
||||||
|
result, source = resolver.lookup("Test Show", 1705312800)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert source == "tvmaze"
|
||||||
|
|
||||||
|
def test_lookup_both_fail(self):
|
||||||
|
"""Test when both sources fail."""
|
||||||
|
mock_tvdb = Mock()
|
||||||
|
mock_tvdb.lookup_episode.return_value = None
|
||||||
|
|
||||||
|
mock_tvmaze = Mock()
|
||||||
|
mock_tvmaze.lookup_episode.return_value = None
|
||||||
|
|
||||||
|
resolver = EpisodeResolver(tvdb_client=mock_tvdb, tvmaze_client=mock_tvmaze)
|
||||||
|
result, source = resolver.lookup("Unknown Show", 1705312800)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert source == "none"
|
||||||
|
|
||||||
|
def test_lookup_no_clients(self):
|
||||||
|
"""Test when no clients are available."""
|
||||||
|
resolver = EpisodeResolver(tvdb_client=None, tvmaze_client=None)
|
||||||
|
result, source = resolver.lookup("Test Show", 1705312800)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert source == "none"
|
||||||
|
|
||||||
|
def test_lookup_tvmaze_only(self, mock_episode_info):
|
||||||
|
"""Test with only TVmaze client."""
|
||||||
|
mock_tvmaze = Mock()
|
||||||
|
mock_tvmaze.lookup_episode.return_value = mock_episode_info
|
||||||
|
|
||||||
|
resolver = EpisodeResolver(tvdb_client=None, tvmaze_client=mock_tvmaze)
|
||||||
|
result, source = resolver.lookup("Test Show", 1705312800)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert source == "tvmaze"
|
||||||
|
|
||||||
|
def test_is_available_with_tvdb(self):
|
||||||
|
"""Test is_available with TVDB client."""
|
||||||
|
resolver = EpisodeResolver(tvdb_client=Mock(), tvmaze_client=None)
|
||||||
|
assert resolver.is_available() is True
|
||||||
|
|
||||||
|
def test_is_available_with_tvmaze(self):
|
||||||
|
"""Test is_available with TVmaze client."""
|
||||||
|
resolver = EpisodeResolver(tvdb_client=None, tvmaze_client=Mock())
|
||||||
|
assert resolver.is_available() is True
|
||||||
|
|
||||||
|
def test_is_available_with_both(self):
|
||||||
|
"""Test is_available with both clients."""
|
||||||
|
resolver = EpisodeResolver(tvdb_client=Mock(), tvmaze_client=Mock())
|
||||||
|
assert resolver.is_available() is True
|
||||||
|
|
||||||
|
def test_is_available_with_none(self):
|
||||||
|
"""Test is_available with no clients."""
|
||||||
|
resolver = EpisodeResolver(tvdb_client=None, tvmaze_client=None)
|
||||||
|
assert resolver.is_available() is False
|
||||||
150
tests/test_jellyfin.py
Normal file
150
tests/test_jellyfin.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""Tests for Jellyfin client."""
|
||||||
|
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from tvh2jellyfin.jellyfin import JellyfinClient, JellyfinConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestJellyfinConfig:
|
||||||
|
"""Tests for JellyfinConfig dataclass."""
|
||||||
|
|
||||||
|
def test_config_defaults(self):
|
||||||
|
"""Test default values."""
|
||||||
|
config = JellyfinConfig(
|
||||||
|
url="http://localhost:8096",
|
||||||
|
api_key="test_key",
|
||||||
|
)
|
||||||
|
assert config.library_id is None
|
||||||
|
assert config.enabled is True
|
||||||
|
|
||||||
|
def test_config_with_library_id(self):
|
||||||
|
"""Test config with specific library ID."""
|
||||||
|
config = JellyfinConfig(
|
||||||
|
url="http://localhost:8096",
|
||||||
|
api_key="test_key",
|
||||||
|
library_id="abc123",
|
||||||
|
)
|
||||||
|
assert config.library_id == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
class TestJellyfinClient:
|
||||||
|
"""Tests for Jellyfin API client."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config(self):
|
||||||
|
return JellyfinConfig(
|
||||||
|
url="http://localhost:8096",
|
||||||
|
api_key="test_api_key",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_with_library(self):
|
||||||
|
return JellyfinConfig(
|
||||||
|
url="http://localhost:8096",
|
||||||
|
api_key="test_api_key",
|
||||||
|
library_id="lib123",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, config):
|
||||||
|
return JellyfinClient(config)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_with_library(self, config_with_library):
|
||||||
|
return JellyfinClient(config_with_library)
|
||||||
|
|
||||||
|
def test_url_normalization(self):
|
||||||
|
"""Test that trailing slashes are removed from URL."""
|
||||||
|
config = JellyfinConfig(
|
||||||
|
url="http://localhost:8096/",
|
||||||
|
api_key="test_key",
|
||||||
|
)
|
||||||
|
client = JellyfinClient(config)
|
||||||
|
assert client.base_url == "http://localhost:8096"
|
||||||
|
|
||||||
|
def test_headers_set(self, client):
|
||||||
|
"""Test that auth headers are set correctly."""
|
||||||
|
assert client.session.headers["X-MediaBrowser-Token"] == "test_api_key"
|
||||||
|
assert client.session.headers["Content-Type"] == "application/json"
|
||||||
|
|
||||||
|
@patch("requests.Session.get")
|
||||||
|
def test_test_connection_success(self, mock_get, client):
|
||||||
|
"""Test successful connection test."""
|
||||||
|
mock_get.return_value.json.return_value = {
|
||||||
|
"ServerName": "Test Server",
|
||||||
|
"Version": "10.8.0",
|
||||||
|
}
|
||||||
|
mock_get.return_value.raise_for_status = Mock()
|
||||||
|
|
||||||
|
result = client.test_connection()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
assert "/System/Info" in mock_get.call_args[0][0]
|
||||||
|
|
||||||
|
@patch("requests.Session.get")
|
||||||
|
def test_test_connection_failure(self, mock_get, client):
|
||||||
|
"""Test connection test failure."""
|
||||||
|
mock_get.side_effect = requests.exceptions.ConnectionError("Connection refused")
|
||||||
|
|
||||||
|
result = client.test_connection()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@patch("requests.Session.post")
|
||||||
|
def test_trigger_library_scan_full(self, mock_post, client):
|
||||||
|
"""Test triggering full library scan."""
|
||||||
|
mock_post.return_value.raise_for_status = Mock()
|
||||||
|
|
||||||
|
result = client.trigger_library_scan()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
assert "/Library/Refresh" in mock_post.call_args[0][0]
|
||||||
|
|
||||||
|
@patch("requests.Session.post")
|
||||||
|
def test_trigger_library_scan_specific_library(self, mock_post, client_with_library):
|
||||||
|
"""Test triggering scan for specific library."""
|
||||||
|
mock_post.return_value.raise_for_status = Mock()
|
||||||
|
|
||||||
|
result = client_with_library.trigger_library_scan()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
assert "/Items/lib123/Refresh" in mock_post.call_args[0][0]
|
||||||
|
|
||||||
|
@patch("requests.Session.post")
|
||||||
|
def test_trigger_library_scan_failure(self, mock_post, client):
|
||||||
|
"""Test library scan trigger failure."""
|
||||||
|
mock_post.side_effect = requests.exceptions.RequestException("API error")
|
||||||
|
|
||||||
|
result = client.trigger_library_scan()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@patch("requests.Session.get")
|
||||||
|
def test_get_libraries_success(self, mock_get, client):
|
||||||
|
"""Test getting list of libraries."""
|
||||||
|
mock_get.return_value.json.return_value = [
|
||||||
|
{"Name": "TV Shows", "ItemId": "tv123"},
|
||||||
|
{"Name": "Movies", "ItemId": "mov456"},
|
||||||
|
]
|
||||||
|
mock_get.return_value.raise_for_status = Mock()
|
||||||
|
|
||||||
|
result = client.get_libraries()
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0] == {"name": "TV Shows", "id": "tv123"}
|
||||||
|
assert result[1] == {"name": "Movies", "id": "mov456"}
|
||||||
|
|
||||||
|
@patch("requests.Session.get")
|
||||||
|
def test_get_libraries_failure(self, mock_get, client):
|
||||||
|
"""Test getting libraries when API fails."""
|
||||||
|
mock_get.side_effect = requests.exceptions.RequestException("API error")
|
||||||
|
|
||||||
|
result = client.get_libraries()
|
||||||
|
|
||||||
|
assert result == []
|
||||||
216
tests/test_organizer.py
Normal file
216
tests/test_organizer.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""Tests for organizer module."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tvh2jellyfin.organizer import (
|
||||||
|
generate_destination_path,
|
||||||
|
generate_destination_path_with_episode,
|
||||||
|
sanitize_directory_name,
|
||||||
|
sanitize_filename,
|
||||||
|
)
|
||||||
|
from tvh2jellyfin.tvdb import EpisodeInfo
|
||||||
|
from tvh2jellyfin.tvheadend import Recording
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitizeFilename:
|
||||||
|
"""Tests for filename sanitization."""
|
||||||
|
|
||||||
|
def test_removes_invalid_characters(self):
|
||||||
|
assert sanitize_filename('Test: "Show"') == "Test Show"
|
||||||
|
# Multiple spaces get normalized to single space
|
||||||
|
assert sanitize_filename("Show <1> | Part 2") == "Show 1 Part 2"
|
||||||
|
|
||||||
|
def test_normalizes_spaces(self):
|
||||||
|
assert sanitize_filename("Test Show") == "Test Show"
|
||||||
|
assert sanitize_filename("Test_Show") == "Test Show"
|
||||||
|
|
||||||
|
def test_strips_whitespace(self):
|
||||||
|
assert sanitize_filename(" Test Show ") == "Test Show"
|
||||||
|
|
||||||
|
def test_empty_returns_unknown(self):
|
||||||
|
assert sanitize_filename("") == "Unknown"
|
||||||
|
assert sanitize_filename(" ") == "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitizeDirectoryName:
|
||||||
|
"""Tests for directory name sanitization."""
|
||||||
|
|
||||||
|
def test_removes_trailing_dots(self):
|
||||||
|
assert sanitize_directory_name("Test Show.") == "Test Show"
|
||||||
|
assert sanitize_directory_name("Test Show...") == "Test Show"
|
||||||
|
|
||||||
|
def test_handles_empty(self):
|
||||||
|
assert sanitize_directory_name("") == "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateDestinationPath:
|
||||||
|
"""Tests for destination path generation."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def recording_with_subtitle(self):
|
||||||
|
return Recording(
|
||||||
|
uuid="123",
|
||||||
|
title="Test Show",
|
||||||
|
subtitle="Episode Title",
|
||||||
|
filename="/recordings/test.ts",
|
||||||
|
filesize=1000000,
|
||||||
|
channel="BBC One",
|
||||||
|
start=1705312800,
|
||||||
|
stop=1705316400,
|
||||||
|
status="Completed OK",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def recording_without_subtitle(self):
|
||||||
|
return Recording(
|
||||||
|
uuid="456",
|
||||||
|
title="Another Show",
|
||||||
|
subtitle="",
|
||||||
|
filename="/recordings/another.ts",
|
||||||
|
filesize=2000000,
|
||||||
|
channel="ITV",
|
||||||
|
start=1705312800,
|
||||||
|
stop=1705316400,
|
||||||
|
status="Completed OK",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_with_subtitle(self, recording_with_subtitle):
|
||||||
|
dest_dir, filename = generate_destination_path(
|
||||||
|
recording_with_subtitle, "/media/tv"
|
||||||
|
)
|
||||||
|
assert dest_dir == "/media/tv/Test Show"
|
||||||
|
assert filename == "Test Show - Episode Title.ts"
|
||||||
|
|
||||||
|
def test_without_subtitle(self, recording_without_subtitle):
|
||||||
|
dest_dir, filename = generate_destination_path(
|
||||||
|
recording_without_subtitle, "/media/tv"
|
||||||
|
)
|
||||||
|
assert dest_dir == "/media/tv/Another Show"
|
||||||
|
# Should include timestamp
|
||||||
|
assert filename.startswith("Another Show - ")
|
||||||
|
assert filename.endswith(".ts")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateDestinationPathWithEpisode:
|
||||||
|
"""Tests for destination path generation with episode info."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def recording(self):
|
||||||
|
return Recording(
|
||||||
|
uuid="123",
|
||||||
|
title="Coronation Street",
|
||||||
|
subtitle="",
|
||||||
|
filename="/recordings/Coronation-Street/Coronation Street2026-01-06.ts",
|
||||||
|
filesize=1000000,
|
||||||
|
channel="ITV",
|
||||||
|
start=1767744000, # 2026-01-06
|
||||||
|
stop=1767747600,
|
||||||
|
status="Completed OK",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def episode_info(self):
|
||||||
|
return EpisodeInfo(
|
||||||
|
series_id=456,
|
||||||
|
series_name="Coronation Street",
|
||||||
|
season=67,
|
||||||
|
episode=4,
|
||||||
|
episode_name="Episode 4",
|
||||||
|
air_date="2026-01-06",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_with_episode_info(self, recording, episode_info):
|
||||||
|
"""Test Jellyfin naming format with episode info."""
|
||||||
|
dest_dir, filename = generate_destination_path_with_episode(
|
||||||
|
recording, "/media/tv", episode_info
|
||||||
|
)
|
||||||
|
assert dest_dir == "/media/tv/Coronation Street/Season 67"
|
||||||
|
assert filename == "Coronation Street - S67E04.ts"
|
||||||
|
|
||||||
|
def test_without_episode_info(self, recording):
|
||||||
|
"""Test fallback when episode info not available."""
|
||||||
|
dest_dir, filename = generate_destination_path_with_episode(
|
||||||
|
recording, "/media/tv", None
|
||||||
|
)
|
||||||
|
assert dest_dir == "/media/tv/Coronation Street"
|
||||||
|
# Should use fallback logic (timestamp)
|
||||||
|
assert filename.startswith("Coronation Street - ")
|
||||||
|
assert filename.endswith(".ts")
|
||||||
|
|
||||||
|
def test_uses_canonical_series_name(self, recording):
|
||||||
|
"""Test that canonical series name from TVDB is used."""
|
||||||
|
episode_info = EpisodeInfo(
|
||||||
|
series_id=456,
|
||||||
|
series_name="Doctor Who (2005)", # TVDB canonical name
|
||||||
|
season=14,
|
||||||
|
episode=1,
|
||||||
|
episode_name="The Church on Ruby Road",
|
||||||
|
air_date="2023-12-25",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recording might have different name
|
||||||
|
recording_with_diff_name = Recording(
|
||||||
|
uuid="789",
|
||||||
|
title="Doctor Who", # TVHeadend name
|
||||||
|
subtitle="",
|
||||||
|
filename="/recordings/doctor-who.ts",
|
||||||
|
filesize=3000000,
|
||||||
|
channel="BBC One",
|
||||||
|
start=1703512800,
|
||||||
|
stop=1703516400,
|
||||||
|
status="Completed OK",
|
||||||
|
)
|
||||||
|
|
||||||
|
dest_dir, filename = generate_destination_path_with_episode(
|
||||||
|
recording_with_diff_name, "/media/tv", episode_info
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should use TVDB canonical name with season folder
|
||||||
|
assert dest_dir == "/media/tv/Doctor Who (2005)/Season 14"
|
||||||
|
assert filename == "Doctor Who (2005) - S14E01.ts"
|
||||||
|
|
||||||
|
def test_large_episode_numbers(self, recording):
|
||||||
|
"""Test with season/episode numbers > 99."""
|
||||||
|
episode_info = EpisodeInfo(
|
||||||
|
series_id=456,
|
||||||
|
series_name="Long Running Show",
|
||||||
|
season=67,
|
||||||
|
episode=125,
|
||||||
|
episode_name="Episode",
|
||||||
|
air_date="2026-01-06",
|
||||||
|
)
|
||||||
|
|
||||||
|
dest_dir, filename = generate_destination_path_with_episode(
|
||||||
|
recording, "/media/tv", episode_info
|
||||||
|
)
|
||||||
|
|
||||||
|
assert filename == "Long Running Show - S67E125.ts"
|
||||||
|
|
||||||
|
def test_preserves_file_extension(self, recording):
|
||||||
|
"""Test that file extension is preserved."""
|
||||||
|
recording_mkv = Recording(
|
||||||
|
uuid="123",
|
||||||
|
title="Test Show",
|
||||||
|
subtitle="",
|
||||||
|
filename="/recordings/test.mkv",
|
||||||
|
filesize=1000000,
|
||||||
|
channel="BBC",
|
||||||
|
start=1705312800,
|
||||||
|
stop=1705316400,
|
||||||
|
status="Completed OK",
|
||||||
|
)
|
||||||
|
|
||||||
|
episode_info = EpisodeInfo(
|
||||||
|
series_id=456,
|
||||||
|
series_name="Test Show",
|
||||||
|
season=1,
|
||||||
|
episode=1,
|
||||||
|
episode_name="Pilot",
|
||||||
|
air_date="2024-01-15",
|
||||||
|
)
|
||||||
|
|
||||||
|
_, filename = generate_destination_path_with_episode(
|
||||||
|
recording_mkv, "/media/tv", episode_info
|
||||||
|
)
|
||||||
|
|
||||||
|
assert filename.endswith(".mkv")
|
||||||
190
tests/test_tvdb.py
Normal file
190
tests/test_tvdb.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""Tests for TheTVDB client."""
|
||||||
|
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tvh2jellyfin.tvdb import EpisodeInfo, TVDBClient, TVDBConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestEpisodeInfo:
|
||||||
|
"""Tests for EpisodeInfo dataclass."""
|
||||||
|
|
||||||
|
def test_formatted_episode_single_digit(self):
|
||||||
|
"""Test SxxExx formatting with single digit numbers."""
|
||||||
|
info = EpisodeInfo(
|
||||||
|
series_id=123,
|
||||||
|
series_name="Test Show",
|
||||||
|
season=1,
|
||||||
|
episode=5,
|
||||||
|
episode_name="Pilot",
|
||||||
|
air_date="2024-01-15",
|
||||||
|
)
|
||||||
|
assert info.formatted_episode == "S01E05"
|
||||||
|
|
||||||
|
def test_formatted_episode_double_digit(self):
|
||||||
|
"""Test SxxExx formatting with double digit numbers."""
|
||||||
|
info = EpisodeInfo(
|
||||||
|
series_id=123,
|
||||||
|
series_name="Test Show",
|
||||||
|
season=12,
|
||||||
|
episode=25,
|
||||||
|
episode_name="Episode",
|
||||||
|
air_date="2024-01-15",
|
||||||
|
)
|
||||||
|
assert info.formatted_episode == "S12E25"
|
||||||
|
|
||||||
|
def test_formatted_episode_large_numbers(self):
|
||||||
|
"""Test formatting with season/episode > 99."""
|
||||||
|
info = EpisodeInfo(
|
||||||
|
series_id=123,
|
||||||
|
series_name="Long Running Show",
|
||||||
|
season=67,
|
||||||
|
episode=125,
|
||||||
|
episode_name="Episode",
|
||||||
|
air_date="2024-01-15",
|
||||||
|
)
|
||||||
|
assert info.formatted_episode == "S67E125"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTVDBClient:
|
||||||
|
"""Tests for TVDB API client."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config(self):
|
||||||
|
return TVDBConfig(
|
||||||
|
api_key="test_key",
|
||||||
|
pin="",
|
||||||
|
cache_ttl=3600,
|
||||||
|
show_mapping_file=None,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, config):
|
||||||
|
with patch.object(TVDBClient, "_authenticate"):
|
||||||
|
return TVDBClient(config)
|
||||||
|
|
||||||
|
def test_cache_valid(self, client):
|
||||||
|
"""Test cache validity check."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Recent timestamp should be valid
|
||||||
|
assert client._is_cache_valid(time.time() - 100)
|
||||||
|
|
||||||
|
# Old timestamp should be invalid
|
||||||
|
assert not client._is_cache_valid(time.time() - 7200)
|
||||||
|
|
||||||
|
@patch("requests.Session.post")
|
||||||
|
def test_authenticate(self, mock_post, config):
|
||||||
|
"""Test authentication flow."""
|
||||||
|
mock_post.return_value.json.return_value = {"data": {"token": "test_token"}}
|
||||||
|
mock_post.return_value.raise_for_status = Mock()
|
||||||
|
|
||||||
|
client = TVDBClient(config)
|
||||||
|
client._authenticate()
|
||||||
|
|
||||||
|
assert client._token == "test_token"
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
|
||||||
|
@patch("requests.Session.get")
|
||||||
|
@patch("requests.Session.post")
|
||||||
|
def test_search_series_success(self, mock_post, mock_get, config):
|
||||||
|
"""Test successful series search."""
|
||||||
|
mock_post.return_value.json.return_value = {"data": {"token": "test_token"}}
|
||||||
|
mock_post.return_value.raise_for_status = Mock()
|
||||||
|
|
||||||
|
mock_get.return_value.json.return_value = {
|
||||||
|
"data": [{"tvdb_id": 456, "name": "The Test Show"}]
|
||||||
|
}
|
||||||
|
mock_get.return_value.raise_for_status = Mock()
|
||||||
|
|
||||||
|
client = TVDBClient(config)
|
||||||
|
result = client.search_series("Test Show")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result[0] == 456
|
||||||
|
assert result[1] == "The Test Show"
|
||||||
|
|
||||||
|
@patch("requests.Session.get")
|
||||||
|
@patch("requests.Session.post")
|
||||||
|
def test_search_series_not_found(self, mock_post, mock_get, config):
|
||||||
|
"""Test series search with no results."""
|
||||||
|
mock_post.return_value.json.return_value = {"data": {"token": "test_token"}}
|
||||||
|
mock_post.return_value.raise_for_status = Mock()
|
||||||
|
|
||||||
|
mock_get.return_value.json.return_value = {"data": []}
|
||||||
|
mock_get.return_value.raise_for_status = Mock()
|
||||||
|
|
||||||
|
client = TVDBClient(config)
|
||||||
|
result = client.search_series("Nonexistent Show")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("tvh2jellyfin.tvdb.requests.Session")
|
||||||
|
def test_lookup_episode_success(self, mock_session_class, config):
|
||||||
|
"""Test successful episode lookup."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
mock_session = Mock()
|
||||||
|
mock_session_class.return_value = mock_session
|
||||||
|
|
||||||
|
# Use a timestamp and derive the expected air date from it
|
||||||
|
timestamp = 1767744000
|
||||||
|
expected_air_date = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Mock authentication
|
||||||
|
mock_session.post.return_value.json.return_value = {"data": {"token": "test_token"}}
|
||||||
|
mock_session.post.return_value.raise_for_status = Mock()
|
||||||
|
|
||||||
|
# Mock search and episode responses
|
||||||
|
mock_session.get.return_value.raise_for_status = Mock()
|
||||||
|
mock_session.get.return_value.json.side_effect = [
|
||||||
|
{"data": [{"tvdb_id": 456, "name": "Coronation Street"}]},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"name": "Coronation Street",
|
||||||
|
"episodes": [
|
||||||
|
{
|
||||||
|
"seasonNumber": 67,
|
||||||
|
"number": 4,
|
||||||
|
"name": "Episode 4",
|
||||||
|
"aired": expected_air_date,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"links": {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
client = TVDBClient(config)
|
||||||
|
result = client.lookup_episode("Coronation Street", timestamp)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.season == 67
|
||||||
|
assert result.episode == 4
|
||||||
|
assert result.formatted_episode == "S67E04"
|
||||||
|
|
||||||
|
def test_show_mapping_file(self, tmp_path, config):
|
||||||
|
"""Test show name mapping from file."""
|
||||||
|
mapping_file = tmp_path / "mapping.json"
|
||||||
|
mapping_file.write_text('{"Doctor Who": "Doctor Who (2005)"}')
|
||||||
|
|
||||||
|
config.show_mapping_file = str(mapping_file)
|
||||||
|
|
||||||
|
with patch.object(TVDBClient, "_authenticate"):
|
||||||
|
client = TVDBClient(config)
|
||||||
|
|
||||||
|
assert client._show_mapping.get("Doctor Who") == "Doctor Who (2005)"
|
||||||
|
|
||||||
|
def test_caching_prevents_duplicate_calls(self, client):
|
||||||
|
"""Test that cache prevents duplicate API calls."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Manually populate cache
|
||||||
|
client._series_cache["test show"] = (123, "Test Show", time.time())
|
||||||
|
|
||||||
|
# Search should use cache, not make API call
|
||||||
|
result = client.search_series("test show")
|
||||||
|
|
||||||
|
assert result == (123, "Test Show")
|
||||||
121
tests/test_tvmaze.py
Normal file
121
tests/test_tvmaze.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Tests for TVmaze client."""
|
||||||
|
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tvh2jellyfin.tvmaze import TVMazeClient, TVMazeConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestTVMazeClient:
|
||||||
|
"""Tests for TVmaze API client."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config(self):
|
||||||
|
return TVMazeConfig(cache_ttl=3600, enabled=True)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, config):
|
||||||
|
return TVMazeClient(config)
|
||||||
|
|
||||||
|
@patch("requests.Session.get")
|
||||||
|
def test_search_series_success(self, mock_get, client):
|
||||||
|
"""Test successful series search."""
|
||||||
|
mock_get.return_value.status_code = 200
|
||||||
|
mock_get.return_value.json.return_value = {
|
||||||
|
"id": 789,
|
||||||
|
"name": "Coronation Street",
|
||||||
|
}
|
||||||
|
mock_get.return_value.raise_for_status = Mock()
|
||||||
|
|
||||||
|
result = client.search_series("Coronation Street")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result[0] == 789
|
||||||
|
assert result[1] == "Coronation Street"
|
||||||
|
|
||||||
|
@patch("requests.Session.get")
|
||||||
|
def test_search_series_not_found(self, mock_get, client):
|
||||||
|
"""Test series search with 404 response."""
|
||||||
|
mock_get.return_value.status_code = 404
|
||||||
|
|
||||||
|
result = client.search_series("Nonexistent Show")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("requests.Session.get")
|
||||||
|
def test_get_episode_by_date_success(self, mock_get, client):
|
||||||
|
"""Test successful episode lookup by date."""
|
||||||
|
mock_get.return_value.status_code = 200
|
||||||
|
mock_get.return_value.json.return_value = [
|
||||||
|
{"season": 67, "number": 4, "name": "Episode 4"}
|
||||||
|
]
|
||||||
|
mock_get.return_value.raise_for_status = Mock()
|
||||||
|
|
||||||
|
result = client.get_episode_by_date(789, "2026-01-06", "Coronation Street")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.season == 67
|
||||||
|
assert result.episode == 4
|
||||||
|
assert result.series_name == "Coronation Street"
|
||||||
|
|
||||||
|
@patch("requests.Session.get")
|
||||||
|
def test_get_episode_by_date_not_found(self, mock_get, client):
|
||||||
|
"""Test episode lookup with no results."""
|
||||||
|
mock_get.return_value.status_code = 404
|
||||||
|
|
||||||
|
result = client.get_episode_by_date(789, "2026-01-01", "Coronation Street")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("requests.Session.get")
|
||||||
|
def test_lookup_episode_full_flow(self, mock_get, client):
|
||||||
|
"""Test full episode lookup flow."""
|
||||||
|
# Set up responses for search and episode lookup
|
||||||
|
mock_get.return_value.status_code = 200
|
||||||
|
mock_get.return_value.json.side_effect = [
|
||||||
|
{"id": 789, "name": "Coronation Street"}, # Search response
|
||||||
|
[{"season": 67, "number": 4, "name": "Episode 4"}], # Episode response
|
||||||
|
]
|
||||||
|
mock_get.return_value.raise_for_status = Mock()
|
||||||
|
|
||||||
|
# Timestamp for 2026-01-06
|
||||||
|
result = client.lookup_episode("Coronation Street", 1767744000)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.series_name == "Coronation Street"
|
||||||
|
assert result.formatted_episode == "S67E04"
|
||||||
|
|
||||||
|
def test_caching_series(self, client):
|
||||||
|
"""Test that series cache prevents duplicate calls."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Manually populate cache
|
||||||
|
client._series_cache["coronation street"] = (789, "Coronation Street", time.time())
|
||||||
|
|
||||||
|
# Should use cache
|
||||||
|
result = client.search_series("coronation street")
|
||||||
|
|
||||||
|
assert result == (789, "Coronation Street")
|
||||||
|
|
||||||
|
def test_caching_episodes(self, client):
|
||||||
|
"""Test that episode cache prevents duplicate calls."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from tvh2jellyfin.tvdb import EpisodeInfo
|
||||||
|
|
||||||
|
cached_episode = EpisodeInfo(
|
||||||
|
series_id=789,
|
||||||
|
series_name="Coronation Street",
|
||||||
|
season=67,
|
||||||
|
episode=4,
|
||||||
|
episode_name="Episode 4",
|
||||||
|
air_date="2026-01-06",
|
||||||
|
)
|
||||||
|
client._episode_cache["789:2026-01-06"] = (cached_episode, time.time())
|
||||||
|
|
||||||
|
# Should use cache
|
||||||
|
result = client.get_episode_by_date(789, "2026-01-06", "Coronation Street")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.season == 67
|
||||||
Reference in New Issue
Block a user