The kokopelli successor, named for the joy magpie (喜鹊, "shee-chweh") — the bird of good tidings that bridges the Silver River.
  • Python 96.9%
  • HTML 2.1%
  • CSS 0.5%
  • Just 0.2%
  • Dockerfile 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Marvin8 d8a37e6729
All checks were successful
/ pr-review (push) Has been skipped
/ gitleaks (push) Successful in 18s
/ pr-review (release) Has been skipped
/ gitleaks (release) Successful in 1m8s
/ checks (push) Successful in 7m28s
/ publish-container (push) Has been skipped
/ deploy-docs (push) Has been skipped
/ checks (release) Successful in 7m56s
/ container (push) Successful in 3m7s
/ container (release) Successful in 3m43s
/ publish-container (release) Successful in 3m48s
/ deploy-docs (release) Successful in 2m11s
bump: version 0.1.1 → 0.2.0
2026-10-17 18:46:45 +10:00
.forgejo/workflows 👷 ci: deploy docs on release 2026-10-17 12:40:42 +10:00
deploy 🐛 fix: point the tailscale sidecar at the mounted serve config 2026-10-17 15:48:13 +10:00
docs 🚸 Add version, source, and docs footer to every page 2026-10-17 17:43:01 +10:00
src/xique 📢 Audit-log invite minting and redemption 2026-10-17 18:23:20 +10:00
tests 📢 Audit-log invite minting and redemption 2026-10-17 18:23:20 +10:00
.containerignore 🔧 chore: add Containerfile, container entrypoint, and .containerignore 2026-10-17 07:06:43 +10:00
.gitignore ✨ feat: two-sink logging from XIQUE_LOG_LEVEL and XIQUE_LOG_FILE 2026-10-15 17:43:34 +10:00
.gitleaks.toml 🐛 ci: fix duplicate workflow keys, add gitleaks and deptry config 2026-10-08 16:46:39 +10:00
.pre-commit-config.yaml 📌 chore: bump fastapi and ty 2026-10-17 07:11:55 +10:00
.python-version ✨ feat: project scaffold, tooling, and settings 2026-10-08 14:39:35 +10:00
AGENTS.md 📝 docs: document the xique name and pronunciation; sync AGENTS.md with the toolchain 2026-10-08 17:02:58 +10:00
CHANGELOG.md docs: update changelog for 0.2.0 2026-10-17 18:46:41 +10:00
container-entrypoint.sh 🐛 fix: honor an operator-set XIQUE_PORT inside the container 2026-10-17 08:55:56 +10:00
Containerfile 🐛 fix: web restart throttling, shared import dir env, and rootless user mapping 2026-10-17 07:35:13 +10:00
justfile 👷 ci: add bump-my-version and the release justfile 2026-10-17 11:43:26 +10:00
LICENSE Initial commit 2026-10-08 02:34:28 +00:00
mkdocs.yml 📝 docs: wire the material version selector to mike 2026-10-17 13:21:26 +10:00
noxfile.py 👷 ci: add python 3.15 to the nox test matrix 2026-10-10 12:35:34 +10:00
pylock.toml 📝 docs: add the operator documentation site 2026-10-17 12:40:42 +10:00
pyproject.toml bump: version 0.1.1 → 0.2.0 2026-10-17 18:46:45 +10:00
README.md 📢 Audit-log invite minting and redemption 2026-10-17 18:23:20 +10:00
ruff.toml ✨ feat: project scaffold, tooling, and settings 2026-10-08 14:39:35 +10:00
uv.lock bump: version 0.1.1 → 0.2.0 2026-10-17 18:46:45 +10:00

Xique

Self-hosted "what should I watch next?" — recommendations for movies and TV.

Xique is the kokopelli successor. It owns its recommendation engine, keeps an append-only local watch ledger, and reads live watch state from Jellyfin (read-only). The web UI is server-rendered with near-zero JavaScript.

Status: under construction — Phase 1 (core) is in progress.

Name

Xique (喜鹊) is Mandarin for the magpie — literally "joy magpie" (喜 xǐ, "shee", dipping tone; 鹊 què, "chweh", falling tone). An English speaker can say SHEE-chweh.

The bird was chosen for three reasons. In Chinese folklore the magpie is the bird of good news: a magpie calling at the gate heralds a guest or glad tidings (灵鹊报喜, "the spirit magpie announces joy") — an apt emblem for a feed that heralds what to watch next. In the legend of the Magpie Bridge (鹊桥), every magpie in the world flies up on the seventh night of the seventh moon to bridge the Silver River — the Milky Way — so the cowherd and the weaver girl can meet: many birds, one bridge, which is the shape of a multi-user service built from many separate watch histories. And magpies collect shiny things, as this one collects a catalogue of films and shows.

The name was chosen in October 2026 from a shortlist of Yuelao (月老), Siming (司命), Tuibei (推背), Jiesheng (结绳), and Qianxing (牵星); xique was free on PyPI at the time, as were the runners-up.

Stack

  • Python 3.14+
  • FastAPI + uvicorn, Jinja2 templates
  • SQLite via aiosqlite (WAL); the worker owns migrations
  • httpx2 for outbound HTTP
  • argon2 password hashing, server-side sessions

Layout

src/xique/
  config.py    # pydantic-settings, XIQUE_* environment variables
  db/          # aiosqlite connections, schema migrations, queries
  services/    # business logic (auth, Jellyfin, TMDB, engine, imports)
  web/         # FastAPI app factory, routes, Jinja2 templates, static/
  worker.py    # worker entry point + job dispatch loop
tests/

Three console scripts share one image: xique-web (serves the app), xique-worker (runs migrations and the job loop), and xique (operator commands).

Development

uv sync --all-groups  # create the venv and install dependencies
uv run nox            # run every check CI runs
uv run tryke test     # run the test suite
uv run ruff format .  # format
uv run ruff check .   # lint
uv run ty check .     # type-check

CLI

The xique console script hosts operator commands:

xique import-trakt /path/to/export.zip [--user NAME]

import-trakt validates a Trakt account export zip and queues a trakt_import job for the worker. Without --user the instance must hold exactly one user; otherwise pass the target username. The zip file is never deleted, and the import's status appears in /settings once the worker picks the job up.

Web

The web UI is server-rendered with zero JavaScript: plain HTML forms, POST/Redirect/GET, CSRF-protected POSTs, and server-side sessions.

  • /movies, /shows — paginated core recommendations, 20 per page
  • /discover — genre-affinity discovery lists plus wildcard picks
  • /item/{id} — details for one item: TMDB tagline, overview, genres, status, cast, a link to TMDB, the recommendation's seeds, and rate/watched/hide forms
  • /library/unrated — watched items that still carry no rating
  • /settings — Jellyfin connection, Trakt import, and sync status; administrators also mint single-use invite links here and see the links still outstanding

New invitees pick a username and password at /invite/{code} and are signed straight in, landing on /settings to connect Jellyfin. An expired or already used invite explains itself instead of showing an error page. Every page carries a footer naming the running version, with links to the source repository and the documentation for that version.

Actions are form POSTs: /actions/rate (with optional watched and hide checkboxes), /actions/hide, /actions/seen, and /actions/refresh, which enqueues a recommendation recompute for the worker. The theme is a CSS custom property pair in /static/style.css; by default it follows the system prefers-color-scheme, and the theme toggle sets a light/dark cookie via POST /theme that overrides it.

Configuration

Every setting is read from the environment with the XIQUE_ prefix:

Variable Default Purpose
XIQUE_DATABASE_PATH /data/db/xique.db sqlite database file
XIQUE_SECRET_KEY required signs sessions and CSRF tokens
XIQUE_TMDB_TOKEN unset TMDB read access token
XIQUE_HOST 127.0.0.1 web listen address
XIQUE_PORT 8000 web listen port
XIQUE_SESSION_COOKIE_NAME xique_session session cookie name
XIQUE_SESSION_TTL_DAYS 30 session lifetime, in days
XIQUE_POLL_INTERVAL_SECONDS 5.0 seconds the worker sleeps between cycles
XIQUE_IMPORT_DIR <tempdir>/xique-imports directory uploaded Trakt export zips stream into
XIQUE_LOG_LEVEL INFO minimum level for the log file (DEBUG–CRITICAL, case-insensitive)
XIQUE_LOG_FILE unset log file path; unset logs errors to stdout only
XIQUE_COOKIE_SECURE true add the Secure flag to session and CSRF cookies — set false only for plain-HTTP deployments without TLS
XIQUE_TRUSTED_PROXIES unset comma-separated proxy addresses whose X-Forwarded-For is honored for login throttling — set only when the app sits directly behind those proxies
XIQUE_LOGIN_ATTEMPT_LIMIT 5 failed logins per username+address pair before throttling
XIQUE_LOGIN_WINDOW_SECONDS 900 window, in seconds, over which failed logins are counted
XIQUE_SOURCE_URL https://forge.marvin8.zone/marvin8/xique source repository linked from the page footer
XIQUE_DOCS_BASE_URL https://xique.marvin8.zone base URL of the versioned docs; the footer links {docs_base_url}/{version}/

Running it

The container image is published to registry.marvin8.zone/marvin8/xique on release tags, and the deploy/ directory holds the rootless-podman quadlets: one pod (xique.pod) carrying the web process, the worker, and a Tailscale sidecar that serves the app to your tailnet with automatic HTTPS.

Prerequisites on the host:

  • Rootless podman with systemd quadlet support; units installed under ~/.config/containers/systemd/.
  • Host directories: ~/docker/xique/data/db (the sqlite database — the only backed-up content), ~/docker/xique/ts-state (Tailscale node state), and ~/docker/xique/ts-serve containing xique-serve.json (copied from deploy/; adjust the ${TS_CERT_DOMAIN} proxy target if you change ports).
  • ~/docker/xique/xique.env with at least XIQUE_SECRET_KEY (required), optionally XIQUE_TMDB_TOKEN, and XIQUE_IMPORT_DIR=/data/imports-temp so uploads stream into the shared volume that both web and worker mount.
  • The shared ~/docker/tailscale-systemd.env pinning TAILSCALE_VERSION for the sidecar image.

Bring-up

  1. Copy the five quadlet units into the systemd directory:
    cp deploy/xique.pod deploy/xique-web.container deploy/xique-worker.container \
       deploy/xique-tailscale.container deploy/xique-imports.volume \
       ~/.config/containers/systemd/
    
  2. Copy the serve config where the sidecar mounts it: cp deploy/xique-serve.json ~/docker/xique/ts-serve/
  3. systemctl --user daemon-reload
  4. systemctl --user start xique-pod
  5. First boot only: the sidecar prints a Tailscale login URL — extract it with journalctl --user -u xique-tailscale and open it in a browser to approve the node.

The app is then reachable at https://xique.<your-tailnet>.ts.net; TLS comes from Tailscale and funnel stays disabled, so nothing is exposed beyond the tailnet. All containers in the pod start simultaneously; on a first-ever boot the web process finds no schema, refuses to serve, and keeps restarting until the worker has migrated the database — this settles in seconds. (The CI smoke proves the same first-boot in serial order: worker migrates first, then web serves.)

Upgrades use the :latest tag:

podman pull registry.marvin8.zone/marvin8/xique:latest
systemctl --user restart xique-pod

Release tags are available for rollbacks. Every container carries an explicit memory cap in its unit (web/worker 512m, Tailscale 256m).

Backups: the database is a plain file at ~/docker/xique/data/db/xique.db; uploaded Trakt archives live on a separate named volume and are transient. Full backup/restore guidance (including WAL checkpoint behaviour) is coming in the operator documentation (#16).

Documentation

The operator documentation — installation, upgrades, backup and restore — is built with mkdocs and published at xique.marvin8.zone on every release. To preview it locally:

uv run mkdocs serve

Logging

Both processes log errors and above to stdout. Set XIQUE_LOG_FILE to also write a rotating log file (5 MB per file, 3 backups) at the level chosen with XIQUE_LOG_LEVEL — down to DEBUG for job lifecycle detail. Job failures are logged with the same token-free messages stored in the jobs table, so a missing XIQUE_TMDB_TOKEN or a failed Trakt import is visible in the worker output instead of only in the database. Account lifecycle events are audited: invite minting (admin, expiry, note — never the code itself) and invite redemption (account created, session issued) both produce log records.

Releases

Versions are major.minor.patch and tags are the bare version (0.1.0, no v prefix). Cutting a release is the operator's step:

just release minor   # or major / patch — the level is required, there is no default

The recipe assembles a CHANGELOG.md entry from the commits since the last tag, then pauses so the entry can be edited by hand. Pressing Enter commits the changelog, runs the version bump (which also refreshes uv.lock and pylock.toml, and tags the release), and pushes the branch and the tag. Ctrl+C aborts safely at any point before that push.

Licence

AGPL-3.0-or-later — see LICENSE.