- Python 96.9%
- HTML 2.1%
- CSS 0.5%
- Just 0.2%
- Dockerfile 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
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
|
||
| .forgejo/workflows | ||
| deploy | ||
| docs | ||
| src/xique | ||
| tests | ||
| .containerignore | ||
| .gitignore | ||
| .gitleaks.toml | ||
| .pre-commit-config.yaml | ||
| .python-version | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| container-entrypoint.sh | ||
| Containerfile | ||
| justfile | ||
| LICENSE | ||
| mkdocs.yml | ||
| noxfile.py | ||
| pylock.toml | ||
| pyproject.toml | ||
| README.md | ||
| ruff.toml | ||
| uv.lock | ||
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-servecontainingxique-serve.json(copied fromdeploy/; adjust the${TS_CERT_DOMAIN}proxy target if you change ports). ~/docker/xique/xique.envwith at leastXIQUE_SECRET_KEY(required), optionallyXIQUE_TMDB_TOKEN, andXIQUE_IMPORT_DIR=/data/imports-tempso uploads stream into the shared volume that both web and worker mount.- The shared
~/docker/tailscale-systemd.envpinningTAILSCALE_VERSIONfor the sidecar image.
Bring-up
- 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/ - Copy the serve config where the sidecar mounts it:
cp deploy/xique-serve.json ~/docker/xique/ts-serve/ systemctl --user daemon-reloadsystemctl --user start xique-pod- First boot only: the sidecar prints a Tailscale login URL — extract it
with
journalctl --user -u xique-tailscaleand 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.