diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..2569514
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,19 @@
+.git
+.github
+docs
+data
+.env
+.env.*
+!.env.example
+docker-compose.yml
+docker-compose.build.yml
+README.md
+LICENSE
+__pycache__
+**/__pycache__
+*.py[cod]
+.venv
+venv
+.vscode
+.idea
+.DS_Store
diff --git a/.env.example b/.env.example
index c72b014..57339a5 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,3 @@
-# --- core ---
TZ=Australia/Sydney
JELLYLOOK_PORT=3045
RETENTION_DAYS=60
@@ -6,52 +5,30 @@ RECS_PER_SCAN=60
PER_PAGE=20
LOG_LEVEL=INFO
-# --- watch-history source ---
-# HISTORY_SOURCE picks your media-server stack:
-# jellystat = Jellyfin + Jellystat (default)
-# tautulli = Plex + Tautulli
-# jellyfin = Jellyfin only (no Jellystat)
-HISTORY_SOURCE=jellystat # jellystat | tautulli | jellyfin
+HISTORY_SOURCE=jellystat
-# Jellyfin + Jellystat — hash out (#) this block if you use Plex + Tautulli
JELLYSTAT_URL=http://192.168.1.5:3015
-JELLYSTAT_API_KEY= # Jellystat -> Settings -> API Keys
+JELLYSTAT_API_KEY=
JELLYFIN_URL=http://192.168.1.5:8096
-JELLYFIN_API_KEY= # used for ownership check + id/poster fallback
+JELLYFIN_API_KEY=
-# Plex + Tautulli — un-hash this block (and set HISTORY_SOURCE=tautulli)
-# if you use Plex; hash out the Jellystat/Jellyfin block above instead
-#TAUTULLI_URL=http://192.168.1.5:8181
-#TAUTULLI_API_KEY= # Tautulli -> Settings -> Web Interface -> API key
+TAUTULLI_URL=http://192.168.1.5:8181
+TAUTULLI_API_KEY=
-# --- requests (Overseerr / Jellyseerr) ---
SEERR_URL=http://192.168.1.5:5055
SEERR_API_KEY=
-# --- AI provider switch ---
-# LLM_PROVIDER: anthropic | openai | google | openwebui | ollama
-# Fill in ONLY the key(s) for the provider you use.
LLM_PROVIDER=anthropic
-LLM_MODEL=claude-haiku-4-5 # e.g. gpt-4o-mini · gemini-2.0-flash · qwen3:14b
+LLM_MODEL=claude-haiku-4-5
LLM_TEMPERATURE=0.7
-# anthropic (Claude)
ANTHROPIC_API_KEY=
-
-# openai (or any OpenAI-compatible endpoint)
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
-
-# google (Gemini — key from https://aistudio.google.com/apikey)
GOOGLE_API_KEY=
-
-# openwebui (key from your Open WebUI account settings)
OPENWEBUI_BASE_URL=http://192.168.1.6:8080
OPENWEBUI_API_KEY=
-
-# ollama (no key needed)
OLLAMA_BASE_URL=http://192.168.1.6:11434
-# --- ratings / artwork ---
TMDB_API_KEY=
OMDB_API_KEY=
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000..9ddfb9a
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,146 @@
+name: Publish container image
+
+on:
+ push:
+ branches:
+ - main
+ - 'claude/**'
+ tags:
+ - 'v*.*.*'
+ workflow_dispatch:
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build:
+ name: Smoke test and publish
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v5
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build amd64 image for testing
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: linux/amd64
+ load: true
+ tags: jellylook:ci
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Start the container
+ run: |
+ docker run -d --name jellylook-ci -p 8000:8000 \
+ -e HISTORY_SOURCE=jellystat \
+ -e JELLYSTAT_API_KEY=ci-dummy \
+ -e JELLYFIN_API_KEY=ci-dummy \
+ -e TMDB_API_KEY=ci-dummy \
+ -e OMDB_API_KEY=ci-dummy \
+ -e LLM_PROVIDER=anthropic \
+ -e ANTHROPIC_API_KEY=ci-dummy \
+ -e SEERR_URL=http://127.0.0.1:9 \
+ jellylook:ci
+
+ - name: Wait for the health endpoint
+ run: |
+ for _ in $(seq 1 30); do
+ if curl -fsS http://127.0.0.1:8000/health | grep -q '"status":"ok"'; then
+ echo "health endpoint is up"
+ exit 0
+ fi
+ sleep 2
+ done
+ echo "container never became healthy"
+ exit 1
+
+ - name: Check the UI and API respond
+ run: |
+ curl -fsS -o /dev/null -w 'index.html %{http_code}\n' http://127.0.0.1:8000/
+ curl -fsS -o /dev/null -w 'app.js %{http_code}\n' http://127.0.0.1:8000/app.js
+ curl -fsS http://127.0.0.1:8000/api/status | tee /tmp/status.json
+ grep -q '"provider":"anthropic"' /tmp/status.json
+ curl -fsS http://127.0.0.1:8000/api/settings | grep -q 'default_sort'
+
+ - name: Check the container reports itself healthy
+ run: |
+ for _ in $(seq 1 30); do
+ state=$(docker inspect -f '{{.State.Health.Status}}' jellylook-ci)
+ echo "health: $state"
+ [ "$state" = "healthy" ] && exit 0
+ [ "$state" = "unhealthy" ] && exit 1
+ sleep 5
+ done
+ echo "HEALTHCHECK never reported healthy"
+ exit 1
+
+ - name: Check startup fails fast on missing configuration
+ run: |
+ set +e
+ docker run --rm jellylook:ci > /tmp/badconf.log 2>&1
+ status=$?
+ set -e
+ cat /tmp/badconf.log
+ if [ "$status" -eq 0 ]; then
+ echo "expected a non-zero exit when required keys are missing"
+ exit 1
+ fi
+ grep -q "TMDB_API_KEY required" /tmp/badconf.log
+
+ - name: Container logs
+ if: always()
+ run: docker logs jellylook-ci || true
+
+ - name: Stop the container
+ if: always()
+ run: docker rm -f jellylook-ci || true
+
+ - name: Log in to the container registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Derive image tags
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=raw,value=latest,enable={{is_default_branch}}
+ type=ref,event=branch
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha,format=short
+
+ - name: Build and push multi-arch image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm64
+ push: true
+ provenance: false
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Summary
+ run: |
+ echo "### Published" >> "$GITHUB_STEP_SUMMARY"
+ echo '```' >> "$GITHUB_STEP_SUMMARY"
+ echo "${{ steps.meta.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
+ echo '```' >> "$GITHUB_STEP_SUMMARY"
diff --git a/Dockerfile b/Dockerfile
index 137ddde..f9a4160 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,29 @@
FROM python:3.12-slim
+
+LABEL org.opencontainers.image.title="jellylook" \
+ org.opencontainers.image.description="Self-hosted AI watch-next recommendations for Jellyfin and Plex" \
+ org.opencontainers.image.source="https://github.com/dean1850/jellylook" \
+ org.opencontainers.image.licenses="MIT"
+
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PIP_NO_CACHE_DIR=1 \
+ DATA_DIR=/app/data
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends tzdata \
+ && rm -rf /var/lib/apt/lists/*
+
WORKDIR /app
+
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+
COPY app/ ./app/
+
EXPOSE 8000
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=4)" || exit 1
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/README.md b/README.md
index 09b8661..7538dac 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,8 @@ One AI call. Sixty recommendations. Zero subscriptions.
[](LICENSE)
[](https://www.python.org/)
-[](docker-compose.yml)
-[](https://fastapi.tiangolo.com/)
+[](https://github.com/dean1850/jellylook/pkgs/container/jellylook)
+[](https://github.com/dean1850/jellylook/actions/workflows/docker-publish.yml)
@@ -25,6 +25,7 @@ Dark-only, Jellyfin palette. Sort, filter, 20 cards per page. Results are kept f
## Features
+- **No build step** — a prebuilt multi-arch image is published to `ghcr.io/dean1850/jellylook`. Runs on x86 and on ARM (Raspberry Pi, Synology, QNAP).
- **Pick your AI** — Anthropic (Claude), OpenAI, Google AI (Gemini), Open WebUI, or Ollama. Switch with one line in `.env`; run fully local with Ollama if you like.
- **One AI call per scan** returns the whole batch (default 60). Sorting, filtering, paging and Seerr requests never trigger another call.
- **Match %** and **"Because you watched \"** on every card, so you can see why each title was suggested.
@@ -32,7 +33,7 @@ Dark-only, Jellyfin palette. Sort, filter, 20 cards per page. Results are kept f
- **Add to Seerr** — movies request in one click, TV opens a season picker. Titles you already own show an "In library" chip instead.
- **Jellystat (or Tautulli) is the taste signal**; Jellyfin is used only for the ownership check and as a fallback history source. On a Plex setup, Tautulli covers users, history and the ownership check by itself — no Jellyfin needed.
- SQLite storage, metadata cache (keeps you under OMDb's 1,000/day free limit — today's usage shows in Settings), automatic 60-day purge.
-- Single container: FastAPI + vanilla HTML/CSS/JS. No database server, no build step, no telemetry.
+- Single container: FastAPI + vanilla HTML/CSS/JS. No database server, no telemetry.
## Screenshots
@@ -49,74 +50,132 @@ Dark-only, Jellyfin palette. Sort, filter, 20 cards per page. Results are kept f
- **Overseerr or Jellyseerr** if you want the Add-to-Seerr button (optional — cards still render without it).
- Free API keys for **TMDb** and **OMDb**, plus a key for whichever AI provider you choose (or a local Ollama, which needs none).
-## Installation
+## Quick start
-### 1. Get the code
+No clone, no build — you need two files.
+
+### 1. Create a folder and fetch the files
```bash
-git clone https://github.com//jellylook.git
-cd jellylook
+mkdir jellylook && cd jellylook
+curl -O https://raw.githubusercontent.com/dean1850/jellylook/main/docker-compose.yml
+curl -o .env https://raw.githubusercontent.com/dean1850/jellylook/main/.env.example
```
-### 2. Create your `.env`
+### 2. Fill in `.env`
+
+Open `.env` in an editor. The minimum you must set depends on your media server:
+
+| Your setup | `HISTORY_SOURCE` | Keys you must fill in |
+|---|---|---|
+| Jellyfin + Jellystat | `jellystat` (default) | `JELLYSTAT_API_KEY`, `JELLYFIN_API_KEY` |
+| Plex + Tautulli | `tautulli` | `TAUTULLI_API_KEY` |
+| Jellyfin only | `jellyfin` | `JELLYFIN_API_KEY` |
+
+Everyone also needs `TMDB_API_KEY`, `OMDB_API_KEY` and one AI provider key. `SEERR_API_KEY` is optional. Full list of every setting is in [Configuration](#configuration) below.
+
+Then point the URLs at your servers — `JELLYSTAT_URL`, `JELLYFIN_URL` (or `TAUTULLI_URL`) and `SEERR_URL`. **Use LAN IPs, not `localhost`** — these URLs are dialled from *inside* the container, where `localhost` is the container itself.
+
+Settings you don't use can stay at their placeholder values; jellylook only reads the ones your `HISTORY_SOURCE` and `LLM_PROVIDER` select.
+
+### 3. Start it
```bash
-cp .env.example .env
+docker compose up -d
```
-Open `.env` in an editor and fill in the keys below. Everything else can stay at its default.
+jellylook fails fast on missing configuration and prints exactly which `.env` variables it still needs — if the container exits immediately, run `docker compose logs jellylook` and it will tell you what to fix.
+
+### 4. Open it
+
+Go to `http://:3045`, tick who's watching, and press **Scan**. The first scan takes a minute or so (one AI call plus metadata lookups for ~60 titles); everything after that — sorting, filtering, paging, Seerr requests — is instant and free.
+
+## Updating
+
+```bash
+docker compose pull
+docker compose up -d
+```
+
+Your SQLite database lives in `./data` next to the compose file and survives updates. To pin a specific release instead of tracking `latest`, change the tag in `docker-compose.yml`:
+
+```yaml
+image: ghcr.io/dean1850/jellylook:1.0.0
+```
+
+Available tags: `latest` (newest build of `main`), `sha-abc1234` (an exact commit), and — once a release is tagged — `1.0.0` / `1.0`.
+
+## Configuration
+
+Secrets and startup options live in `.env`. Day-to-day preferences (default users, batch size, TV request mode, default sort/filter) are edited in the app's **Settings** panel and stored in SQLite — those take effect immediately, no restart.
-**Jellyfin + Jellystat** (default — `HISTORY_SOURCE=jellystat`):
+After editing `.env`, apply it with `docker compose up -d --force-recreate`.
-| Key | Where to get it |
-|---|---|
-| `JELLYSTAT_API_KEY` | Jellystat → Settings → API Keys |
-| `JELLYFIN_API_KEY` | Jellyfin → Dashboard → API Keys |
+### Core
-**Plex + Tautulli** (set `HISTORY_SOURCE=tautulli`, un-hash the Tautulli block and hash out (`#`) the Jellystat/Jellyfin lines instead):
+| Variable | Default | What it does |
+|---|---|---|
+| `TZ` | `Australia/Sydney` | Timezone used for log timestamps |
+| `JELLYLOOK_PORT` | `3045` | Host port the UI is served on |
+| `RETENTION_DAYS` | `60` | How long results and cached metadata are kept before purge |
+| `RECS_PER_SCAN` | `60` | Titles requested per scan (5–100). The **Settings** panel overrides it; saving the panel back to this value hands control to `.env` again |
+| `PER_PAGE` | `20` | Cards per page |
+| `LOG_LEVEL` | `INFO` | Container log verbosity — `DEBUG`, `INFO`, `WARNING`, `ERROR` |
-| Key | Where to get it |
-|---|---|
-| `TAUTULLI_API_KEY` | Tautulli → Settings → Web Interface → API key |
+### Watch-history source
+
+`HISTORY_SOURCE` picks your media-server stack. Only the block for the source you choose is used; the rest is ignored.
+
+| Variable | Default | What it does |
+|---|---|---|
+| `HISTORY_SOURCE` | `jellystat` | `jellystat` (Jellyfin + Jellystat), `tautulli` (Plex + Tautulli), or `jellyfin` (Jellyfin alone, no Jellystat) |
+| `JELLYSTAT_URL` | `http://192.168.1.5:3015` | Your Jellystat address |
+| `JELLYSTAT_API_KEY` | — | Jellystat → Settings → API Keys |
+| `JELLYFIN_URL` | `http://192.168.1.5:8096` | Your Jellyfin address |
+| `JELLYFIN_API_KEY` | — | Jellyfin → Dashboard → API Keys. Used for the library ownership check and as an id/poster fallback |
+| `TAUTULLI_URL` | `http://192.168.1.5:8181` | Your Tautulli address |
+| `TAUTULLI_API_KEY` | — | Tautulli → Settings → Web Interface → API key |
-**Both stacks also need:**
+On Plex (`tautulli`), Tautulli supplies users, history *and* the ownership check — no Jellyfin keys are needed and the Jellyfin lines are ignored.
-| Key | Where to get it |
-|---|---|
-| `SEERR_API_KEY` | Overseerr/Jellyseerr → Settings → General |
-| `TMDB_API_KEY` | [themoviedb.org](https://www.themoviedb.org/settings/api) — free v3 key |
-| `OMDB_API_KEY` | [omdbapi.com](https://www.omdbapi.com/apikey.aspx) — free, 1,000 lookups/day |
-| one AI key | see the provider table below |
+### Requests (Overseerr / Jellyseerr)
-Also update `JELLYSTAT_URL` + `JELLYFIN_URL` (or `TAUTULLI_URL`) and `SEERR_URL` to match your network. **Use LAN IPs, not `localhost`** — these URLs must be reachable *from inside the container*.
+| Variable | Default | What it does |
+|---|---|---|
+| `SEERR_URL` | `http://192.168.1.5:5055` | Your Overseerr or Jellyseerr address |
+| `SEERR_API_KEY` | — | Overseerr/Jellyseerr → Settings → General. Optional; without it the Add-to-Seerr button stays disabled |
-### 3. Choose an AI provider
+### AI provider
-Set `LLM_PROVIDER` and `LLM_MODEL`, plus the matching key:
+Set `LLM_PROVIDER` and `LLM_MODEL`, then fill in **only** the keys for that provider — the others can stay blank.
| `LLM_PROVIDER` | Needs | Example `LLM_MODEL` |
|---|---|---|
| `anthropic` | `ANTHROPIC_API_KEY` | `claude-haiku-4-5` |
| `openai` | `OPENAI_API_KEY` (+ optional `OPENAI_BASE_URL`) | `gpt-4o-mini` |
-| `google` | `GOOGLE_API_KEY` | `gemini-2.0-flash` |
+| `google` | `GOOGLE_API_KEY` — from [aistudio.google.com/apikey](https://aistudio.google.com/apikey) | `gemini-2.0-flash` |
| `openwebui` | `OPENWEBUI_API_KEY` + `OPENWEBUI_BASE_URL` | whatever your instance serves |
| `ollama` | `OLLAMA_BASE_URL` only — no key | `qwen3:14b` |
-Open WebUI uses its OpenAI-compatible endpoint (`{OPENWEBUI_BASE_URL}/api/chat/completions`) — create an API key under your Open WebUI account settings.
-
-### 4. Build and start
+| Variable | Default | What it does |
+|---|---|---|
+| `LLM_PROVIDER` | `anthropic` | `anthropic`, `openai`, `google`, `openwebui` or `ollama` |
+| `LLM_MODEL` | `claude-haiku-4-5` | Model name, as your provider spells it |
+| `LLM_TEMPERATURE` | `0.7` | Creativity of the suggestions — lower is safer, higher is more adventurous |
+| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Point at any OpenAI-compatible endpoint (LM Studio, LiteLLM, vLLM…) |
+| `OPENWEBUI_BASE_URL` | `http://192.168.1.6:8080` | Your Open WebUI address; the key comes from your Open WebUI account settings |
+| `OLLAMA_BASE_URL` | `http://192.168.1.6:11434` | Your Ollama address |
-```bash
-docker compose up --build -d
-```
+Open WebUI is called through its OpenAI-compatible endpoint (`{OPENWEBUI_BASE_URL}/api/chat/completions`).
-jellylook fails fast on missing configuration and prints exactly which `.env` variables it still needs — if the container exits immediately, run `docker compose logs jellylook` and it will tell you what to fix.
+### Ratings and artwork
-### 5. Open it
-
-Go to `http://:3045`, tick who's watching, and press **Scan**. The first scan takes a minute or so (one AI call plus metadata lookups for ~60 titles); everything after that — sorting, filtering, paging, Seerr requests — is instant and free.
+| Variable | Default | What it does |
+|---|---|---|
+| `TMDB_API_KEY` | — | Free v3 key from [themoviedb.org](https://www.themoviedb.org/settings/api). Supplies ids, posters, backdrops and scores |
+| `OMDB_API_KEY` | — | Free key from [omdbapi.com](https://www.omdbapi.com/apikey.aspx) — 1,000 lookups/day. Supplies IMDB ratings |
-### Verifying the install (optional)
+## Verifying the install
With a filled `.env`:
@@ -126,21 +185,22 @@ docker compose run --rm jellylook python -m app.selftest
This enriches a known title through TMDb + OMDb, proves the cache works, and asks your active AI provider for 5 sample recommendations.
-## Configuration reference
+## Building from source
-Secrets live in `.env`. Day-to-day preferences (default users, batch size, TV request mode, default sort/filter) are edited in the app's **Settings** panel and stored in SQLite — no restart needed for those.
+Contributors and anyone who'd rather not pull a prebuilt image can build locally:
-| `.env` variable | Default | What it does |
-|---|---|---|
-| `JELLYLOOK_PORT` | `3045` | Host port the UI is served on |
-| `HISTORY_SOURCE` | `jellystat` | `jellystat` (Jellyfin), `tautulli` (Plex) or `jellyfin` |
-| `RECS_PER_SCAN` | `60` | Titles requested per scan |
-| `PER_PAGE` | `20` | Cards per page |
-| `RETENTION_DAYS` | `60` | How long results and cache are kept before purge |
-| `LLM_TEMPERATURE` | `0.7` | Creativity of the AI suggestions |
-| `LOG_LEVEL` | `INFO` | Container log verbosity |
+```bash
+git clone https://github.com/dean1850/jellylook.git
+cd jellylook
+cp .env.example .env # then fill it in
+docker compose -f docker-compose.build.yml up -d --build
+```
+
+`docker-compose.build.yml` is identical to the published one except it builds from the Dockerfile instead of pulling from ghcr.io.
+
+Every push to `main` runs [the workflow](.github/workflows/docker-publish.yml), which boots the freshly built image and checks its health endpoint, UI and API before publishing `linux/amd64` and `linux/arm64` images. Tagging a release (`git tag v1.2.3 && git push --tags`) publishes `1.2.3` and `1.2` alongside `latest`.
-Restart the container after changing `.env`: `docker compose up -d --force-recreate`.
+> Forking this repo? The published package inherits your fork's visibility, so a public fork publishes a publicly pullable image with no extra steps. If a fork's package does come out private, open Packages → jellylook → Package settings → Change visibility → Public, or `docker compose pull` fails with `denied` for everyone but you.
## How a scan works
@@ -154,10 +214,12 @@ The match % is the model's own similarity estimate — directionally useful, not
## Troubleshooting
+- **`docker compose pull` says `denied` / `unauthorized`** — the image you're pulling is a private package. `ghcr.io/dean1850/jellylook` is public and needs no login; on a fork, check your own package's visibility (see [Building from source](#building-from-source)).
- **Container exits at startup** — jellylook fails fast on missing config and prints exactly which `.env` variables it needs. Check `docker compose logs jellylook`.
- **No users / scan fails immediately** — the history source (Jellystat, Tautulli or Jellyfin) is unreachable or the API key is wrong. The URLs must be reachable *from inside the container* (use LAN IPs, not `localhost`).
- **Add to Seerr disabled** — Seerr didn't answer; cards still work and the button returns when Seerr does.
- **OMDb limit** — the free key allows 1,000 lookups/day. The cache makes re-scans nearly free; Settings shows today's count.
+- **`exec format error`** — you're on hardware the image wasn't built for. The published image covers `linux/amd64` and `linux/arm64`; 32-bit ARM (older Raspberry Pi OS) needs a local build.
## Security
diff --git a/app/db.py b/app/db.py
index 8fc9dba..9b6301a 100644
--- a/app/db.py
+++ b/app/db.py
@@ -43,6 +43,10 @@
day TEXT PRIMARY KEY, count INTEGER NOT NULL
);
+CREATE TABLE IF NOT EXISTS schema_meta (
+ key TEXT PRIMARY KEY, value TEXT
+);
+
CREATE INDEX IF NOT EXISTS idx_rec_created ON recommendations(created_at);
CREATE INDEX IF NOT EXISTS idx_rec_scan ON recommendations(scan_id);
CREATE INDEX IF NOT EXISTS idx_scan_created ON scans(created_at);
@@ -51,12 +55,35 @@
DEFAULT_SETTINGS = {
"default_user_ids": "",
- "recs_per_scan": "60",
"tv_request_mode": "ask", # ask | all | first
"default_sort": "match", # match | imdb | year
"default_filter": "all", # all | movie | tv
}
+# Settings whose default comes from .env rather than from DEFAULT_SETTINGS.
+# They are deliberately NOT seeded: a missing row means "use the .env value",
+# and a row exists only once the user saves one in the Settings panel.
+ENV_BACKED_SETTINGS = {"recs_per_scan"}
+
+ALLOWED_SETTINGS = set(DEFAULT_SETTINGS) | ENV_BACKED_SETTINGS
+
+# recs_per_scan used to be seeded at this value, which made RECS_PER_SCAN in
+# .env dead config. Dropping that row once lets .env through again.
+_LEGACY_SEEDED_RECS_PER_SCAN = "60"
+
+RECS_PER_SCAN_MIN = 5
+RECS_PER_SCAN_MAX = 100
+
+
+def clamp_recs_per_scan(value: object) -> int:
+ """Coerce a batch size into the supported range.
+
+ Raises ValueError/TypeError for anything non-numeric so callers can decide
+ between rejecting the input and falling back to a default.
+ """
+ return max(RECS_PER_SCAN_MIN, min(RECS_PER_SCAN_MAX, int(value)))
+
+
SORT_SQL = {
"match": "match_score DESC NULLS LAST, imdb_rating DESC NULLS LAST",
"imdb": "imdb_rating DESC NULLS LAST, match_score DESC NULLS LAST",
@@ -99,6 +126,29 @@ def init_db() -> None:
conn.execute(
"INSERT OR IGNORE INTO app_settings(key, value) VALUES (?, ?)", (k, v)
)
+ _migrate_unseed_recs_per_scan(conn)
+
+
+def _migrate_unseed_recs_per_scan(conn: sqlite3.Connection) -> None:
+ """Drop the auto-seeded recs_per_scan row so RECS_PER_SCAN applies again.
+
+ Runs exactly once (guarded by schema_meta), and only removes the value the
+ old code seeded — a value the user chose themselves is left alone unless it
+ happens to equal that seed, in which case .env takes over.
+ """
+ marker = "unseed_recs_per_scan"
+ done = conn.execute(
+ "SELECT 1 FROM schema_meta WHERE key = ?", (marker,)
+ ).fetchone()
+ if done:
+ return
+ conn.execute(
+ "DELETE FROM app_settings WHERE key = 'recs_per_scan' AND value = ?",
+ (_LEGACY_SEEDED_RECS_PER_SCAN,),
+ )
+ conn.execute(
+ "INSERT INTO schema_meta(key, value) VALUES (?, ?)", (marker, _utcnow())
+ )
def purge_old(retention_days: int) -> dict:
@@ -118,11 +168,29 @@ def purge_old(retention_days: int) -> dict:
# --- settings ----------------------------------------------------------------
def settings_get_all() -> dict:
+ """Stored settings only — an env-backed key is absent until it is saved."""
with _conn() as conn:
rows = conn.execute("SELECT key, value FROM app_settings").fetchall()
return {row["key"]: row["value"] for row in rows}
+def settings_effective(recs_per_scan: int) -> dict:
+ """Stored settings with .env filling in the env-backed keys.
+
+ This is what the UI shows, so the Settings panel displays the value a scan
+ would actually use rather than a hard-coded placeholder.
+ """
+ values = settings_get_all()
+ values.setdefault("recs_per_scan", str(clamp_recs_per_scan(recs_per_scan)))
+ return values
+
+
+def settings_clear(key: str) -> None:
+ """Remove a stored override so its .env default applies again."""
+ with _conn() as conn:
+ conn.execute("DELETE FROM app_settings WHERE key = ?", (key,))
+
+
def settings_set(key: str, value: str) -> None:
with _conn() as conn:
conn.execute(
diff --git a/app/main.py b/app/main.py
index 823e265..4862ffc 100644
--- a/app/main.py
+++ b/app/main.py
@@ -98,7 +98,7 @@ async def users():
@app.get("/api/settings")
async def settings_get():
- return db.settings_get_all()
+ return db.settings_effective(get_settings().recs_per_scan)
_SETTING_CHOICES = {
@@ -117,7 +117,7 @@ def _validate_setting(key: str, value: str) -> str:
return value
if key == "recs_per_scan":
try:
- return str(max(5, min(100, int(value))))
+ return str(db.clamp_recs_per_scan(value))
except (ValueError, TypeError):
raise HTTPException(400, "recs_per_scan must be a number")
return value # default_user_ids: free-form id list
@@ -125,15 +125,23 @@ def _validate_setting(key: str, value: str) -> str:
@app.post("/api/settings")
async def settings_post(payload: dict):
- allowed = set(db.DEFAULT_SETTINGS)
# Validate everything first so a bad value can't half-apply the payload.
validated = {
str(key): _validate_setting(str(key), str(value))
- for key, value in payload.items() if key in allowed
+ for key, value in payload.items() if key in db.ALLOWED_SETTINGS
+ }
+ env_defaults = {
+ "recs_per_scan": str(db.clamp_recs_per_scan(get_settings().recs_per_scan)),
}
for key, value in validated.items():
- db.settings_set(key, value)
- return db.settings_get_all()
+ # Saving an env-backed setting at its .env value hands control back to
+ # .env, so routinely saving the panel can't silently pin the value and
+ # make a later .env edit look ignored.
+ if key in db.ENV_BACKED_SETTINGS and value == env_defaults.get(key):
+ db.settings_clear(key)
+ else:
+ db.settings_set(key, value)
+ return db.settings_effective(get_settings().recs_per_scan)
# --- scan -----------------------------------------------------------------------
diff --git a/app/recommender.py b/app/recommender.py
index 9d28e0a..9582b96 100644
--- a/app/recommender.py
+++ b/app/recommender.py
@@ -172,9 +172,9 @@ def _dedupe(suggestions: list[Suggestion],
def _recs_per_scan(env_default: int) -> int:
- """Runtime pref (Settings menu) wins over the .env default."""
+ """A saved Settings-panel value wins; otherwise RECS_PER_SCAN from .env."""
try:
- value = int(db.settings_get_all().get("recs_per_scan", env_default))
- return max(5, min(100, value))
+ return db.clamp_recs_per_scan(
+ db.settings_get_all().get("recs_per_scan", env_default))
except (ValueError, TypeError):
return env_default
diff --git a/docker-compose.build.yml b/docker-compose.build.yml
new file mode 100644
index 0000000..0b6362e
--- /dev/null
+++ b/docker-compose.build.yml
@@ -0,0 +1,11 @@
+services:
+ jellylook:
+ build: .
+ image: jellylook:local
+ container_name: jellylook
+ env_file: .env
+ ports:
+ - "${JELLYLOOK_PORT:-3045}:8000"
+ volumes:
+ - ./data:/app/data
+ restart: unless-stopped
diff --git a/docker-compose.yml b/docker-compose.yml
index 2774342..34d9493 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,6 @@
services:
jellylook:
- build: .
+ image: ghcr.io/dean1850/jellylook:latest
container_name: jellylook
env_file: .env
ports:
diff --git a/requirements.txt b/requirements.txt
index afd77ea..2c6d82c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
-fastapi
-uvicorn[standard]
-httpx
-pydantic
-pydantic-settings
+fastapi==0.141.1
+uvicorn[standard]==0.52.0
+httpx==0.28.1
+pydantic==2.13.4
+pydantic-settings==2.14.2