From 9dc39cb63375760889d26ff8f9c4c1cd638b1e76 Mon Sep 17 00:00:00 2001 From: dean1850 Date: Thu, 30 Jul 2026 05:35:48 +0000 Subject: [PATCH 1/3] Publish to ghcr.io and simplify setup to compose + .env Installing jellylook required cloning the repo and building the image locally. A GitHub Actions workflow now builds and publishes a multi-arch image to ghcr.io/dean1850/jellylook, so setup is two downloaded files and `docker compose up -d`. - Add .github/workflows/docker-publish.yml. It builds an amd64 image, boots it and checks /health, the UI, /api/status and /api/settings, asserts the container reports itself healthy and that startup fails fast on missing keys, and only then publishes linux/amd64 + linux/arm64. Tags: latest on main, semver on v* tags, short sha, and the branch name. - docker-compose.yml now pulls the published image; the local build path moves to docker-compose.build.yml for development. - Dockerfile gains OCI labels, tzdata so TZ actually applies to log timestamps, a HEALTHCHECK, and DATA_DIR. - Pin requirements so a given tag is reproducible. - Add .dockerignore. - Strip every comment and the hashed-out Tautulli block from .env.example; all three history sources are now listed uncommented and HISTORY_SOURCE alone selects which are required. The explanations move to README tables covering every variable, its default and where to get each key, alongside a no-clone quick start and update instructions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RsEad8i4hYbwXXwAorPb6t --- .dockerignore | 19 +++ .env.example | 35 +----- .github/workflows/docker-publish.yml | 146 +++++++++++++++++++++++ Dockerfile | 22 ++++ README.md | 168 ++++++++++++++++++--------- docker-compose.build.yml | 11 ++ docker-compose.yml | 2 +- requirements.txt | 10 +- 8 files changed, 325 insertions(+), 88 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-publish.yml create mode 100644 docker-compose.build.yml 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..7bb5d9a 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ One AI call. Sixty recommendations. Zero subscriptions. [![License: MIT](https://img.shields.io/badge/License-MIT-00A4DC.svg)](LICENSE) [![Python 3.12](https://img.shields.io/badge/Python-3.12-AA5CC3.svg)](https://www.python.org/) -[![Docker](https://img.shields.io/badge/Docker-single%20container-00A4DC.svg)](docker-compose.yml) -[![FastAPI](https://img.shields.io/badge/FastAPI-async-AA5CC3.svg)](https://fastapi.tiangolo.com/) +[![ghcr.io](https://img.shields.io/badge/ghcr.io-dean1850%2Fjellylook-00A4DC.svg)](https://github.com/dean1850/jellylook/pkgs/container/jellylook) +[![Build](https://github.com/dean1850/jellylook/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/dean1850/jellylook/actions/workflows/docker-publish.yml) jellylook main view — poster grid with match percentages, IMDB ratings and Add to Seerr buttons @@ -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`), `1.0.0` / `1.0` (releases), and `sha-abc1234` (an exact commit). + +## 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 (also adjustable in Settings) | +| `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? Your fork's first image lands as a **private** package. Open your fork's Packages tab → jellylook → Package settings → Change visibility → Public, or `docker compose pull` will fail 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 package is private. On this repo it is public; on a fork, make your own package public (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/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 From 7c4baf382e11ae5470014480fa005976994365b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 05:39:36 +0000 Subject: [PATCH 2/3] Correct two README claims found while testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RECS_PER_SCAN in .env is overridden by the app_settings row that init_db() seeds, so it only applies before the database exists — say so rather than presenting it as the live batch size. Also stop listing semver tags as available before any release is tagged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RsEad8i4hYbwXXwAorPb6t --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7bb5d9a..f1d2fff 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Your SQLite database lives in `./data` next to the compose file and survives upd image: ghcr.io/dean1850/jellylook:1.0.0 ``` -Available tags: `latest` (newest build of `main`), `1.0.0` / `1.0` (releases), and `sha-abc1234` (an exact commit). +Available tags: `latest` (newest build of `main`), `sha-abc1234` (an exact commit), and — once a release is tagged — `1.0.0` / `1.0`. ## Configuration @@ -118,7 +118,7 @@ After editing `.env`, apply it with `docker compose up -d --force-recreate`. | `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 (also adjustable in Settings) | +| `RECS_PER_SCAN` | `60` | Titles requested per scan. Only used until the database exists — from the first run on, the **Settings** panel value wins, so change the batch size there | | `PER_PAGE` | `20` | Cards per page | | `LOG_LEVEL` | `INFO` | Container log verbosity — `DEBUG`, `INFO`, `WARNING`, `ERROR` | @@ -200,7 +200,7 @@ docker compose -f docker-compose.build.yml up -d --build 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`. -> Forking this repo? Your fork's first image lands as a **private** package. Open your fork's Packages tab → jellylook → Package settings → Change visibility → Public, or `docker compose pull` will fail with `denied` for everyone but you. +> 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 @@ -214,7 +214,7 @@ The match % is the model's own similarity estimate — directionally useful, not ## Troubleshooting -- **`docker compose pull` says `denied` / `unauthorized`** — the package is private. On this repo it is public; on a fork, make your own package public (see [Building from source](#building-from-source)). +- **`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. From adbaf35006d7dd07d1f91572bd8e1c0004f20c16 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 05:46:43 +0000 Subject: [PATCH 3/3] Make RECS_PER_SCAN in .env actually control batch size init_db() seeded recs_per_scan=60 into app_settings, and _recs_per_scan() reads that table before falling back to .env, so RECS_PER_SCAN could never take effect on any install past its first run. recs_per_scan is no longer seeded. A missing row now means "use .env", and a row is written only when the user saves one in the Settings panel, so the panel still wins when it has been used deliberately. Saving the panel at the .env value clears the row instead of storing it, so routinely saving settings cannot silently pin the batch size and make a later .env edit look ignored. GET /api/settings returns the effective value rather than raw storage, so the panel shows the number a scan would really use instead of a hard-coded 60. Existing databases carry the seeded row, so a one-time migration drops it. A new schema_meta table records that it ran, so a value the user chooses later is never deleted on a subsequent start. Verified with an end-to-end suite driving the real app against stub upstreams: RECS_PER_SCAN=13 yields a 13-title batch, a saved override of 20 wins, saving 13 again returns control to .env, and a simulated pre-migration database picks up .env while keeping a later user choice across restarts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RsEad8i4hYbwXXwAorPb6t --- README.md | 2 +- app/db.py | 70 +++++++++++++++++++++++++++++++++++++++++++++- app/main.py | 20 +++++++++---- app/recommender.py | 6 ++-- 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f1d2fff..7538dac 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ After editing `.env`, apply it with `docker compose up -d --force-recreate`. | `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. Only used until the database exists — from the first run on, the **Settings** panel value wins, so change the batch size there | +| `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` | 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