Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ docker run -d \
| `CALENDAR_DAYS_BEFORE` | Days back for calendar | No (default: 0) |
| `CALENDAR_ONLY` | Only send calendar data (true/false) | No (default: false) |

### Terminus / BYOS (Self-Hosted)

If you run a self-hosted [Terminus](https://github.com/usetrmnl/byos_hanami)
instance, the collector can serve data via HTTP instead of pushing to webhooks.
See the [Terminus Setup Guide](docs/terminus.md) for details.

### Python Script (No Docker)

Run the collector directly with Python if you prefer not to use Docker:
Expand Down
12 changes: 11 additions & 1 deletion collector/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ ENV CALENDAR_DAYS_BEFORE="0"
ENV CALENDAR_ONLY=""
ENV INTERVAL="0"
ENV TZ="UTC"
ENV SERVE_PORT=""
EXPOSE 8080

# Entry point script
COPY --chmod=755 <<'EOF' /app/entrypoint.sh
Expand All @@ -51,7 +53,11 @@ set -e
# Check if config file exists
if [[ -f "/app/config.yaml" ]]; then
echo "Starting with config file..."
exec python /app/trmnl_collector.py --config /app/config.yaml
ARGS="--config /app/config.yaml"
if [[ -n "$SERVE_PORT" ]]; then
ARGS="$ARGS --serve --port $SERVE_PORT"
fi
exec python /app/trmnl_collector.py $ARGS
fi

# Fall back to environment variables (single instance mode)
Expand Down Expand Up @@ -95,6 +101,10 @@ if [[ -n "$TZ" ]]; then
ARGS="$ARGS -z $TZ"
fi

if [[ -n "$SERVE_PORT" ]]; then
ARGS="$ARGS --serve --port $SERVE_PORT"
fi

# Run the Python collector
exec python /app/trmnl_collector.py $ARGS
EOF
Expand Down
9 changes: 8 additions & 1 deletion collector/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ defaults:
calendar_days: 7 # Days forward for calendar
calendar_days_before: 0 # Days back for calendar (historical)

# Optional: HTTP server for Terminus/BYOS Extensions
# When enabled, the collector serves data via HTTP so Terminus can poll for it.
# serve:
# enabled: true
# port: 8080
# host: 0.0.0.0

# Instance definitions
# Each instance sends to its own TRMNL webhook
instances:
Expand All @@ -24,7 +31,7 @@ instances:
- name: sonarr
url: http://sonarr:8989
api_key: your-sonarr-api-key
webhook: https://usetrmnl.com/api/custom_plugins/your-sonarr-webhook-id
webhook: https://usetrmnl.com/api/custom_plugins/your-sonarr-webhook-id # Optional when using serve mode
# type: sonarr # Optional - auto-detected from URL

# ============================================
Expand Down
5 changes: 5 additions & 0 deletions collector/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ services:
# Timezone (can also be set in config.yaml)
- TZ=America/New_York
network_mode: host
# --- Terminus/BYOS: uncomment to enable HTTP serve mode ---
# ports:
# - "8080:8080"
# environment:
# - SERVE_PORT=8080

# ============================================
# ALTERNATIVE: Environment Variable Mode
Expand Down
81 changes: 81 additions & 0 deletions collector/trmnl_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler

import requests
import yaml
Expand Down Expand Up @@ -604,6 +606,7 @@ def collect(self) -> Dict[str, Any]:
'app_name': display_name,
'app_type': app_type,
'last_updated': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'last_updated_local': datetime.now(ZoneInfo(self.timezone)).strftime('%Y-%m-%d %H:%M') if self.timezone else datetime.now().strftime('%Y-%m-%d %H:%M'),
'timezone': tz_abbrev,
'calendar': calendar,
}
Expand All @@ -621,6 +624,7 @@ def collect(self) -> Dict[str, Any]:
'app_name': display_name,
'app_type': app_type,
'last_updated': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'last_updated_local': datetime.now(ZoneInfo(self.timezone)).strftime('%Y-%m-%d %H:%M') if self.timezone else datetime.now().strftime('%Y-%m-%d %H:%M'),
'timezone': tz_abbrev,
'health': health,
'queue': queue,
Expand Down Expand Up @@ -664,6 +668,65 @@ def send(self, payload: Dict[str, Any]) -> bool:
return False


# --- HTTP serve mode for Terminus/BYOS ---

_serve_data: Dict[str, Dict[str, Any]] = {}
_serve_lock = threading.Lock()


def store_payload(name: str, payload: Dict[str, Any]):
"""Cache latest payload for HTTP serving."""
data = payload.get('merge_variables', payload)
with _serve_lock:
_serve_data[name] = data


class DataHandler(BaseHTTPRequestHandler):
"""HTTP handler that serves cached Servarr data as JSON."""

def do_GET(self):
path = self.path.rstrip('/')

if path == '' or path == '/':
with _serve_lock:
instances = {
name: f'/data/{name}' for name in _serve_data
}
self._json_response(200, {'instances': instances})
return

if path.startswith('/data/'):
name = path[6:]
with _serve_lock:
data = _serve_data.get(name)
if data is None:
self._json_response(404, {'error': f'Instance "{name}" not found'})
return
self._json_response(200, data)
return

self.send_response(404)
self.end_headers()

def _json_response(self, status: int, body: Any):
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(body).encode())

def log_message(self, format, *args):
logger.debug(f"HTTP: {args[0]}")


def start_server(host: str, port: int):
"""Start HTTP server in a daemon thread."""
server = HTTPServer((host, port), DataHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
logger.info(f"HTTP server listening on {host}:{port}")
return server


def load_config(config_path: str) -> Dict[str, Any]:
"""Load configuration from YAML file."""
with open(config_path, 'r') as f:
Expand Down Expand Up @@ -721,6 +784,7 @@ def run_collection(collectors: List[ServarrCollector]) -> bool:
for collector in collectors:
try:
payload = collector.collect()
store_payload(collector.name, payload)
if collector.send(payload):
succeeded.append(collector.name)
else:
Expand Down Expand Up @@ -780,6 +844,9 @@ def main():
parser.add_argument('-i', '--interval', type=int, default=0, help='Collection interval in seconds (0 = run once)')
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
parser.add_argument('--dry-run', action='store_true', help='Print JSON, don\'t send to webhook')
parser.add_argument('--serve', action='store_true', help='Start HTTP server for Terminus/BYOS')
parser.add_argument('--port', type=int, default=8080, help='HTTP server port (default: 8080)')
parser.add_argument('--host', default='0.0.0.0', help='HTTP server bind address (default: 0.0.0.0)')
parser.add_argument('--version', action='version', version=f'%(prog)s {VERSION}')

args = parser.parse_args()
Expand Down Expand Up @@ -821,6 +888,20 @@ def signal_handler(signum, frame):
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)

# Start HTTP server if requested
serve = args.serve
if args.config:
serve_config = config.get('serve', {})
serve = serve or serve_config.get('enabled', False)
serve_port = serve_config.get('port', args.port)
serve_host = serve_config.get('host', args.host)
else:
serve_port = args.port
serve_host = args.host

if serve:
start_server(serve_host, serve_port)

# Run collection
if interval > 0:
logger.info(f"Running continuously with {interval}s interval (Ctrl+C to stop)")
Expand Down
97 changes: 97 additions & 0 deletions docs/terminus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Terminus / BYOS Setup

Guide for using the Servarr collector with a self-hosted
[Terminus (BYOS)](https://github.com/usetrmnl/byos_hanami) instance.

## Architecture

Instead of pushing data to TRMNL cloud webhooks, the collector runs a
lightweight HTTP server. A Terminus Extension polls this endpoint for
JSON data and renders the same Liquid templates server-side.

```
Servarr APIs → Collector ←── GET JSON ── Terminus Extension → render → device
```

## 1. Configure the Collector

In your `config.yaml`, enable serve mode and remove or omit the `webhook` field:

```yaml
interval: 900
timezone: America/New_York

serve:
enabled: true
port: 8080

instances:
- name: sonarr
url: http://sonarr:8989
api_key: your-api-key
# webhook is not needed in serve mode
```

## 2. Run with Docker Compose

```yaml
services:
trmnl-collector:
image: ghcr.io/pythcon/trmnl-servarr-collector:latest
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./config.yaml:/app/config.yaml:ro
environment:
- TZ=America/New_York
- SERVE_PORT=8080
```

Or set `SERVE_PORT=8080` to enable serve mode without a config file.

## 3. Verify the Endpoint

```bash
curl http://localhost:8080/
# Returns: {"instances": {"sonarr": "/data/sonarr"}}

curl http://localhost:8080/data/sonarr
# Returns: {"app_name": "Sonarr", "queue": {...}, ...}
```

> **Note:** The first request after startup may return 404 until the
> first collection cycle completes (up to `interval` seconds).

## 4. Create a Terminus Extension

1. Open your Terminus dashboard
2. Go to **Extensions → New Extension**
3. Configure:
- **Name:** Servarr - Sonarr (or your app name)
- **URI:** `http://trmnl-collector:8080/data/sonarr`
- **Kind:** Poll
- **Schedule:** 15 minutes (match your collector interval)
- **Template:** paste the contents of `src/full.liquid`
- **Model:** select your device model
4. Save and add the generated screen to your device playlist

Repeat for each Servarr instance (radarr, lidarr, etc.), using the
appropriate `/data/<name>` path.

## Template Notes

The existing Liquid templates work on Terminus with these caveats:

- **Settings:** All `trmnl.plugin_settings` values have `| default:` fallbacks,
so defaults activate automatically (Dashboard mode, all sections shown, 3 items each).
- **Timestamps:** `trmnl.user.utc_offset` is not available on Terminus. The collector
includes a `last_updated_local` field with a pre-formatted local timestamp as a
workaround. To use it in the template title bar, replace:
```liquid
{{ last_updated | date: "%s" | plus: trmnl.user.utc_offset | date: "%Y-%m-%d %H:%M" }}
```
with:
```liquid
{{ last_updated_local }}
```