tvtimes

A modern, self-hosted TV schedule (EPG) for your home lab — colourful set-top-box-style guide, passkeys-first accounts, one Docker Compose stack.

Website Docker Hub Release Docker pulls Platform License

--- ## What it is Create an account, connect your TV sources, and land on a "tonight" overview — what's on now, films coming up, the week's best-rated — backed by a fast, virtualised guide grid enriched with channel logos, release years, genres and a cinematic TMDB "hero" panel. Everything runs on your own hardware. | | | |---|---| | **Sources** | M3U / M3U8 playlists · Xtream Codes · Stalker portals · **HDHomeRun** (native discovery, or the connector agent for tuners on another network). Drag to reorder them — the guide lists channels source-by-source in that order. | | **Tonight** | the landing view: a card per channel that's on air now (with what's next), films starting in the next few hours, and the highest TMDB-rated films across the coming week — each card opens the programme panel | | **Guide** | virtualised grid + live "now" line on desktop, single-channel agenda on phones; genre colours, date nav, group filter, channel search, full keyboard nav, and ▲/▼ page buttons a Fire TV remote can reach; programme labels stay pinned as you scroll through a long film; installable PWA with an offline shell and an in-app "new version" prompt | | **Time** | one account timezone (captured from your browser at signup) with a per-source override and a per-channel offset you nudge from a programme's info panel — line a US-West feed up with an East-coast EPG without touching its sibling | | **Enrichment** | your own TMDB API key powers backdrops, logos, cast, ratings and synopses for film programmes. Channel logos come from the playlist, else the iptv-org database, else the SiliconDust guide for HDHomeRun — shown on a neutral plate so dark and light marks both read | | **Export** | hand your whole line-up to another player — one merged, de-duplicated **M3U playlist** + **XMLTV guide** (times already timezone-corrected per channel) behind a rotatable token. Drop the two URLs into Jellyfin, Plex, Emby, TiviMate or Threadfin. The settings panel shows when the feeds were last fetched and which players report watch state back. The routes are documented as OpenAPI: [tvtimes Export API](https://issinoho.github.io/tvtimes/api/); see [how the pieces fit](docs/architecture.md) for the whole path from provider to player. Own an HDHomeRun? [Start here](https://issinoho.github.io/tvtimes/hdhomerun/) | | **Play externally** | press **Play** on a programme and the channel opens straight in a player — [tvdinner](https://github.com/issinoho/tvdinner) on desktop, the system app chooser on Android — or **Download .m3u** for any other player, or **Copy stream URL**. Works on a Fire TV Stick too: open tvtimes in Silk and Play hands the channel straight to MX Player Pro (or whichever player you have installed). The one-channel `.m3u` carries that channel's EPG too. No in-browser player | | **Search & reminders** | search titles across every channel for the next two weeks; add an airing or a whole title to your watchlist and get an email ~15 min before it starts | | **Notifications** | source-health alerts (a source broke, went stale, recovered) and watchlist reminders go out by email, and — alongside it — to any number of [Apprise](https://github.com/caronc/apprise) push targets (Gotify, ntfy, Discord, Telegram, Pushover, …) you add in Settings, each toggleable per event. Four push-only **activity notifications** (reminder set, title watched, channel played, watchlist entry removed) are separate account-wide opt-ins that fan out to every enabled target | | **Auth** | WebAuthn passkeys first (with a clear message when the RP-ID/origin is misconfigured), Argon2id password + HIBP check as a fallback, TOTP 2FA, 60-day rotating refresh sessions with replay detection, a device/session list (one row per login), audit log | ## Run it You need Docker with the Compose plugin. Nothing to build — it pulls a published multi-arch image (`linux/amd64` + `linux/arm64`). Make a directory, drop in the two files below, then `docker compose up -d`. **`.env`** — the only line you must set is `TVTIMES_PUBLIC_ORIGIN`: ```sh TVTIMES_PUBLIC_ORIGIN=http://localhost:8888 # the exact URL you'll open TVTIMES_WEBAUTHN_RP_ID=localhost # the origin's domain; keep as localhost for a bare-IP setup POSTGRES_PASSWORD=change-me TVTIMES_IMAGE=issinoho1969/tvtimes:1 # recommended: every non-breaking update, see Releases # TVTIMES_EMAIL_PROVIDER=smtp # optional; default logs the verification link ```
docker-compose.yml — copy verbatim ```yaml name: tvtimes x-app: &app image: ${TVTIMES_IMAGE:-issinoho1969/tvtimes:1} restart: unless-stopped env_file: - .env # everything in .env reaches the container; the values below still win environment: TVTIMES_ENV: prod TVTIMES_DATABASE_URL: postgresql+asyncpg://tvtimes:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/tvtimes TVTIMES_REDIS_URL: redis://redis:6379/0 TVTIMES_RATELIMIT_STORAGE_URI: redis://redis:6379/1 TVTIMES_PUBLIC_ORIGIN: ${TVTIMES_PUBLIC_ORIGIN:?set TVTIMES_PUBLIC_ORIGIN in .env} TVTIMES_WEBAUTHN_RP_ID: ${TVTIMES_WEBAUTHN_RP_ID:-localhost} TVTIMES_WEBAUTHN_RP_NAME: ${TVTIMES_WEBAUTHN_RP_NAME:-tvtimes} TVTIMES_EMAIL_PROVIDER: ${TVTIMES_EMAIL_PROVIDER:-console} TVTIMES_EMAIL_FROM: ${TVTIMES_EMAIL_FROM:-tvtimes } TVTIMES_SMTP_HOST: ${TVTIMES_SMTP_HOST:-} TVTIMES_SMTP_PORT: ${TVTIMES_SMTP_PORT:-587} TVTIMES_SMTP_USERNAME: ${TVTIMES_SMTP_USERNAME:-} TVTIMES_SMTP_PASSWORD: ${TVTIMES_SMTP_PASSWORD:-} TVTIMES_RESEND_API_KEY: ${TVTIMES_RESEND_API_KEY:-} volumes: - secrets:/data depends_on: db: { condition: service_healthy } redis: { condition: service_healthy } services: tvtimes: <<: *app command: ["web"] ports: - "${TVTIMES_HTTP_PORT:-8888}:8000" healthcheck: test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/api/healthz', timeout=3).status==200 else 1)"] interval: 15s timeout: 5s retries: 5 start_period: 30s worker: <<: *app command: ["worker"] depends_on: db: { condition: service_healthy } redis: { condition: service_healthy } tvtimes: { condition: service_started } db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: tvtimes POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} POSTGRES_DB: tvtimes volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U tvtimes"] interval: 10s timeout: 3s retries: 10 redis: image: redis:7-alpine restart: unless-stopped volumes: - redisdata:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 3s retries: 10 volumes: pgdata: redisdata: secrets: ```
Prefer to fetch them? ```sh curl -O https://raw.githubusercontent.com/issinoho/tvtimes/main/docker-compose.yml curl -o .env https://raw.githubusercontent.com/issinoho/tvtimes/main/.env.example ``` Then: ```sh docker compose up -d ``` Open the origin you configured (default `http://localhost:8888`) and create the first account. With the default `TVTIMES_EMAIL_PROVIDER=console`, the verification link is printed to the log: ```sh docker compose logs tvtimes | grep -E "email.console|email.undelivered_body" ``` The link expires after two hours. If it lapses, sign in with the new account's password and tvtimes sends a fresh one. One container serves the API **and** the web app on port 8888; a second runs the background worker. Postgres, Redis and the auto-generated secrets live in named volumes, so accounts and sessions survive `docker compose pull && docker compose up -d`. ### Essential configuration | Variable | Notes | |---|---| | `TVTIMES_PUBLIC_ORIGIN` | The exact URL the browser uses — scheme, host, port, no trailing slash. Passkeys are bound to it. `http://192.168.x.x:8888` is fine on a trusted LAN; use `https://…` behind a reverse proxy. | | `TVTIMES_WEBAUTHN_RP_ID` | The registrable domain of that origin (no scheme/port); `localhost` for a bare-IP setup. **Changing it later invalidates every passkey.** | | `TVTIMES_HTTP_PORT` | Host port Docker publishes the app on (default `8888`). Only Compose reads it; tvtimes builds its links from `TVTIMES_PUBLIC_ORIGIN`. Without a reverse proxy the two name the same port, so change them together; behind a proxy the origin has no port and this is just where the proxy connects. | | `POSTGRES_PASSWORD` | Used by the db container and the app connection string. | | `TVTIMES_EMAIL_PROVIDER` | `console` (log only) · `smtp` (+ `TVTIMES_SMTP_*`) · `resend` (+ `TVTIMES_RESEND_API_KEY`). | Full list with comments: [`.env.example`](.env.example). Secrets (`TVTIMES_JWT_PRIVATE_KEY_PEM`, `TVTIMES_ENCRYPTION_KEY`) are generated into the `secrets` volume on first run — **back that volume up**; if it's lost, stored credentials and sessions can't be decrypted. ### Behind a reverse proxy (HTTPS) Passkeys and `Secure` cookies need HTTPS (or `localhost`). Terminate TLS at your proxy and forward everything to the container. In `.env`: ```sh TVTIMES_PUBLIC_ORIGIN=https://tv.example.com TVTIMES_WEBAUTHN_RP_ID=tv.example.com ``` Caddy: ``` tv.example.com { reverse_proxy 127.0.0.1:8888 } ``` Set `TVTIMES_TRUSTED_PROXIES` to your proxy's address so the rate limiter and audit log see the real client IP — `X-Forwarded-For` is ignored from anywhere else. A same-host proxy is `127.0.0.1`; the compose network is `172.16.0.0/12`. Security response headers (CSP, HSTS on HTTPS, …) are sent by the app. ### HDHomeRun Add a source → **HDHomeRun**: - **Server on the tuner's network, not in Docker's bridge:** leave the address blank to auto-discover. - **tvtimes in Docker (usual case):** the bridge network can't receive discovery broadcasts — enter the tuner's LAN address, e.g. `http://192.168.1.50`. - **Tuner on a different network from the server:** run [`issinoho1969/tvtimes-connector`](connector/README.md) there and pair it from **Settings → Connectors**. SiliconDust guide data (`api.hdhomerun.com`) is adopted automatically when the tuner reports a `DeviceAuth`. ### Backups & upgrades ```sh # upgrade docker compose pull && docker compose up -d --force-recreate # migrations run on start # database backup docker compose exec db pg_dump -U tvtimes tvtimes | gzip > tvtimes-$(date +%F).sql.gz ``` `--force-recreate` makes Compose swap containers onto the freshly pulled image (plain `up -d` sometimes reports "Running" and skips it). Open tabs pick up the new web app via the "new version available — Reload" prompt. What that `pull` actually fetches is the image tag you pinned — so pin `:1` and an upgrade only ever brings you backward-compatible changes, stopping at the first release that would need you to do something. See [Releases](#releases). Volumes: `tvtimes_pgdata` (accounts, sources, guide), `tvtimes_secrets` (signing + encryption keys — **back up**), `tvtimes_redisdata` (queue/cache, safe to lose). More detail: [`docs/homelab.md`](docs/homelab.md) · ops reference: [`docs/deploy.md`](docs/deploy.md). ## Images | Image | Purpose | |---|---| | `issinoho1969/tvtimes` · `ghcr.io/issinoho/tvtimes` | all-in-one: API + worker + web app | | `issinoho1969/tvtimes-connector` · `ghcr.io/issinoho/tvtimes-connector` | optional LAN agent for off-network HDHomeRun tuners | ## Architecture ``` backend/ FastAPI · SQLAlchemy 2.0 (async) · Alembic · arq worker · Postgres 16 · Redis 7 frontend/ React 18 · Vite · TypeScript · TanStack Query · @tanstack/react-virtual · PWA connector/ Standalone Python agent (outbound HTTPS only) for off-network tuners docker/ Container entrypoint (secret bootstrap · migrations · api|worker dispatch) docs/ homelab · deploy · brand ``` The API serves the built SPA from the **same origin** (`/api/*` to the backend, everything else to the app with a client-routing fallback), so there is no CORS and the refresh cookie is first-party. ## Local development ```sh docker compose -f docker-compose.dev.yml up --build ``` - API + interactive docs: - SPA dev server (HMR): Backend only (SQLite, no worker): ```sh cd backend && uv sync && uv run uvicorn app.main:app --reload ``` Working conventions and the non-obvious invariants are in [`CLAUDE.md`](CLAUDE.md). ### Tests & checks ```sh cd backend && uv run ruff check . && uv run ruff format --check . && uv run mypy . && uv run pytest -q cd frontend && npm run lint && npm run typecheck && npm test && npm run build ``` ## Releases | Git tag | Publishes | |---|---| | `v*` | `issinoho1969/tvtimes` + GHCR mirror, multi-arch | | `connector-v*` | connector image + the wheel/sdist on the GitHub Release | Versions are semantic from 1.0.0 on, so an image tag is a statement about how far you want to be carried. Set it as `TVTIMES_IMAGE` in `.env`: | Image tag | Carries you to | |---|---| | `issinoho1969/tvtimes:1` | **recommended** — every backward-compatible release, stopping at the first that needs your attention | | `issinoho1969/tvtimes:1.1` | that line only: fixes, no new features | | `issinoho1969/tvtimes:1.1.0` | one exact build, forever | | `issinoho1969/tvtimes:latest` | whatever is newest, breaking changes included | Note the image tag has no leading `v`, unlike the git tag it's built from. A major release won't reach `:1` on its own — that's the point of it — so it is announced in its git tag's message and in the release notes. ## Contributing Bug reports, ideas and PRs welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md) and the [`Code of Conduct`](CODE_OF_CONDUCT.md). Security issues go through [`SECURITY.md`](SECURITY.md), not a public issue. ## Licence [MIT](LICENSE). Source parsing and EPG logic is adapted from the [`tvdinner`](https://github.com/issinoho/tvdinner) CLI (MIT). Brand mark built from openly-licensed base glyphs — see [`docs/brand.md`](docs/brand.md).