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: 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`), `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