Back up the server pieces: Smithflix Hub and AI playlists script [skip ci]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TdkZRTCV92L7gbQX2Z5Ay
This commit is contained in:
parent
7964697665
commit
7635471412
10 changed files with 1819 additions and 0 deletions
60
server/ai-playlists/.env.example
Normal file
60
server/ai-playlists/.env.example
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# --- Required ---
|
||||||
|
# Use the internal address, not your public domain.
|
||||||
|
JELLYFIN_URL=http://host.docker.internal:8096
|
||||||
|
# Jellyfin Dashboard -> API Keys -> +
|
||||||
|
JELLYFIN_API_KEY=paste-your-key-here
|
||||||
|
|
||||||
|
# --- Ollama ---
|
||||||
|
OLLAMA_URL=http://host.docker.internal:11434
|
||||||
|
OLLAMA_MODEL=llama3.1:8b
|
||||||
|
# Context window; 16k fits ~150 candidates comfortably. Lower if you're short on RAM/VRAM.
|
||||||
|
NUM_CTX=16384
|
||||||
|
|
||||||
|
# --- Playlist behaviour ---
|
||||||
|
PICKS=12
|
||||||
|
HISTORY_SIZE=25
|
||||||
|
CANDIDATE_POOL=150
|
||||||
|
MIN_HISTORY=3
|
||||||
|
PLAYLIST_PREFIX=Because you watched
|
||||||
|
# Only these users (comma-separated names). Blank = all enabled users.
|
||||||
|
USERS=
|
||||||
|
# Skip these users.
|
||||||
|
EXCLUDE_USERS=
|
||||||
|
|
||||||
|
# --- Extra AI rows ---
|
||||||
|
# Seasonal row (Halloween Picks in October, Holiday Picks in December, Date Night Picks Feb 1-14)
|
||||||
|
ENABLE_SEASONAL=true
|
||||||
|
# Movies in the library nobody has watched yet
|
||||||
|
ENABLE_HIDDEN_GEMS=true
|
||||||
|
# One shared "Family Movie Night" list added to every family member
|
||||||
|
ENABLE_FAMILY=true
|
||||||
|
# Who counts as family (comma-separated names). Blank = everyone with enough history.
|
||||||
|
FAMILY_USERS=
|
||||||
|
# Highest rating allowed in Family Movie Night (G, PG, PG-13, R ... or NONE for no limit)
|
||||||
|
FAMILY_MAX_RATING=PG-13
|
||||||
|
|
||||||
|
# --- Schedule ---
|
||||||
|
RUN_AT=03:30
|
||||||
|
RUN_ON_START=true
|
||||||
|
# true = run once and exit (handy for testing or host cron)
|
||||||
|
RUN_ONCE=false
|
||||||
|
# true = log the picks but don't create/delete playlists
|
||||||
|
DRY_RUN=true
|
||||||
|
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# --- TV channels (for the live TV guide) ---
|
||||||
|
# Sorts every movie and show into a network-style channel (Nickelodeon, Disney Channel,
|
||||||
|
# Primetime, ...) by tagging it in Jellyfin as smithflix-ch-<channel>. ErsatzTV builds one
|
||||||
|
# channel per tag. Shows go by their real network; the AI places movies and the rest.
|
||||||
|
# Try it with DRY_RUN=true first and read data/channels_report.txt.
|
||||||
|
ENABLE_CHANNELS=false
|
||||||
|
# Optional: your own channel list (copy the format from DEFAULT_CHANNELS in the script)
|
||||||
|
# CHANNELS_FILE=/data/channels.json
|
||||||
|
|
||||||
|
# --- Not on Smithflix yet (AI picks of titles you don't have; shown in the apps with a Request button) ---
|
||||||
|
# Uses Seerr to look titles up. Same key as the Smithflix Hub (Seerr -> Settings -> General -> API Key).
|
||||||
|
SEERR_URL=https://requests.smithflix.us
|
||||||
|
SEERR_API_KEY=paste-your-seerr-api-key-here
|
||||||
|
ENABLE_OUTSIDE=true
|
||||||
|
OUTSIDE_PICKS=15
|
||||||
13
server/ai-playlists/Dockerfile
Normal file
13
server/ai-playlists/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& pip install --no-cache-dir requests
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY jellyfin_ai_playlists.py .
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
VOLUME ["/data"]
|
||||||
|
CMD ["python", "/app/jellyfin_ai_playlists.py"]
|
||||||
55
server/ai-playlists/README.md
Normal file
55
server/ai-playlists/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Jellyfin AI Playlists
|
||||||
|
|
||||||
|
Each night every user gets up to four AI-picked playlists from your own library:
|
||||||
|
**Because you watched …**, a **seasonal** row (Halloween / Holiday / Date Night Picks),
|
||||||
|
**Hidden Gems** (movies nobody has watched yet) and a shared **Family Movie Night** list.
|
||||||
|
Turn the extra rows on or off in `.env` (`ENABLE_SEASONAL`, `ENABLE_HIDDEN_GEMS`, `ENABLE_FAMILY`, `FAMILY_USERS`).
|
||||||
|
|
||||||
|
Builds a private **"Because you watched …"** playlist for every Jellyfin user each night.
|
||||||
|
A local Ollama model picks movies **from your own library** that the user hasn't watched yet,
|
||||||
|
so every suggestion is playable, and the playlist shows up in every Jellyfin app (TV, phone, web).
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
1. Reads each user's recently watched movies and favorites from the Jellyfin API.
|
||||||
|
2. Pre-filters unwatched movies by genre match and rating, down to about 150 candidates, so the prompt stays small.
|
||||||
|
3. Asks Ollama to pick the best 12 and name the "anchor" movie that explains them.
|
||||||
|
4. Replaces that user's previous AI playlist with the new one, for example *Because you watched Heat*.
|
||||||
|
|
||||||
|
If Ollama is down or returns nonsense, the user's existing playlist is left alone.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
```bash
|
||||||
|
cd jellyfin-ai-playlists
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # set JELLYFIN_API_KEY (Dashboard -> API Keys)
|
||||||
|
ollama pull llama3.1:8b # or: docker exec -it ollama ollama pull llama3.1:8b
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
`DRY_RUN=true` is set in the example, so the first run only **logs** the picks.
|
||||||
|
Check that they look sensible, then set `DRY_RUN=false` and run `docker compose up -d`.
|
||||||
|
|
||||||
|
## Networking notes
|
||||||
|
- If Jellyfin and Ollama run on the same machine, `host.docker.internal` works as shown.
|
||||||
|
If they're on different machines, use their LAN IPs, for example `http://192.168.1.50:11434`.
|
||||||
|
- If Ollama runs in Docker on the same host, it must publish port 11434, or you can join this
|
||||||
|
container to Ollama's Docker network and use `http://ollama:11434`.
|
||||||
|
- Don't expose Ollama publicly; it has no authentication.
|
||||||
|
|
||||||
|
## Useful settings
|
||||||
|
| Variable | Default | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| `PICKS` | 12 | Movies per playlist |
|
||||||
|
| `CANDIDATE_POOL` | 150 | Unwatched titles sent to the model. Lower it for small models or less RAM. |
|
||||||
|
| `USERS` / `EXCLUDE_USERS` | all | Limit which accounts get playlists |
|
||||||
|
| `RUN_AT` | 03:30 | Daily run time, in the container's `TZ` |
|
||||||
|
| `RUN_ONCE` | false | Run once and exit, if you'd rather use host cron |
|
||||||
|
|
||||||
|
Run on demand: `docker compose run --rm -e RUN_ONCE=true jellyfin-ai-playlists`
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
- Movies only for now. TV series would need episode-level history.
|
||||||
|
- Playlists can't show the model's reasons on screen; they're printed in the logs instead.
|
||||||
|
- The script tracks the playlists it created in `./data/state.json`. If you delete that file, the next
|
||||||
|
run removes any of a user's playlists whose name starts with `PLAYLIST_PREFIX`.
|
||||||
14
server/ai-playlists/docker-compose.yml
Normal file
14
server/ai-playlists/docker-compose.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
services:
|
||||||
|
jellyfin-ai-playlists:
|
||||||
|
build: .
|
||||||
|
container_name: jellyfin-ai-playlists
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- TZ=America/New_York
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
# Lets the container reach Jellyfin/Ollama running on the same host
|
||||||
|
# at http://host.docker.internal:<port>
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
1144
server/ai-playlists/jellyfin_ai_playlists.py
Normal file
1144
server/ai-playlists/jellyfin_ai_playlists.py
Normal file
File diff suppressed because it is too large
Load diff
11
server/smithflix-hub/.env.example
Normal file
11
server/smithflix-hub/.env.example
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Jellyfin on the media server (used only to check who is signed in)
|
||||||
|
JELLYFIN_URL=http://192.168.86.121:8096
|
||||||
|
|
||||||
|
# Seerr: address and API key (Seerr -> Settings -> General -> API Key).
|
||||||
|
# The key stays on this server; the apps never see it.
|
||||||
|
SEERR_URL=https://requests.smithflix.us
|
||||||
|
SEERR_API_KEY=paste-your-seerr-api-key-here
|
||||||
|
|
||||||
|
# Web app: downloaded from GitHub and kept up to date automatically
|
||||||
|
WEB_AUTO_UPDATE=true
|
||||||
|
WEB_UPDATE_HOURS=1
|
||||||
9
server/smithflix-hub/Dockerfile
Normal file
9
server/smithflix-hub/Dockerfile
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
RUN pip install --no-cache-dir flask==3.1.* requests==2.* gunicorn==23.*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY hub.py .
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 8097
|
||||||
|
# one worker (so the web-app updater runs once), several threads for concurrent requests
|
||||||
|
CMD ["gunicorn", "--workers", "1", "--threads", "8", "--bind", "0.0.0.0:8097", "--access-logfile", "-", "hub:app"]
|
||||||
17
server/smithflix-hub/README.md
Normal file
17
server/smithflix-hub/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Smithflix Hub
|
||||||
|
|
||||||
|
The small service behind the Smithflix apps. It runs on the PowerEdge and is reached at
|
||||||
|
https://app.smithflix.us.
|
||||||
|
|
||||||
|
- Requests: search for titles and request them through Seerr. Each request is sent as the
|
||||||
|
signed-in person, so Seerr's own permissions and auto-approve rules apply.
|
||||||
|
- "Not on Smithflix yet": serves the AI's nightly picks of titles you don't have yet
|
||||||
|
(written by the AI playlist script to /opt/jellyfin-ai-playlist/data/suggestions.json).
|
||||||
|
- Web app: serves the Smithflix web app and installs new versions from GitHub automatically.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
cp .env.example .env # then paste your Seerr API key
|
||||||
|
docker compose up -d --build
|
||||||
|
docker logs -f smithflix-hub
|
||||||
|
|
||||||
|
Health check: http://192.168.86.20:8097/api/health
|
||||||
14
server/smithflix-hub/docker-compose.yml
Normal file
14
server/smithflix-hub/docker-compose.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
services:
|
||||||
|
smithflix-hub:
|
||||||
|
build: .
|
||||||
|
container_name: smithflix-hub
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- TZ=America/New_York
|
||||||
|
ports:
|
||||||
|
- "8097:8097"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
# the nightly AI script writes the "Not on Smithflix yet" picks here
|
||||||
|
- /opt/jellyfin-ai-playlist/data:/suggestions:ro
|
||||||
482
server/smithflix-hub/hub.py
Normal file
482
server/smithflix-hub/hub.py
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Smithflix Hub
|
||||||
|
-------------
|
||||||
|
The small service behind the Smithflix apps (TV and web):
|
||||||
|
|
||||||
|
* /api/... Requests (via Seerr) and AI suggestions for titles that aren't on Smithflix yet.
|
||||||
|
Every call must carry the person's Jellyfin sign-in token; the hub checks it with
|
||||||
|
Jellyfin, then talks to Seerr *as that person*, so Seerr's own permissions and
|
||||||
|
auto-approve rules apply. The Seerr admin key never leaves this service.
|
||||||
|
* / The Smithflix web app (a branded Jellyfin web build), kept up to date automatically
|
||||||
|
from the public smithflix-releases repo.
|
||||||
|
|
||||||
|
All settings come from environment variables; see .env.example.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from functools import wraps
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from flask import Flask, abort, g, jsonify, request, send_from_directory
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Config
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "").rstrip("/")
|
||||||
|
SEERR_URL = os.environ.get("SEERR_URL", "").rstrip("/")
|
||||||
|
SEERR_API_KEY = os.environ.get("SEERR_API_KEY", "")
|
||||||
|
SUGGESTIONS_FILE = os.environ.get("SUGGESTIONS_FILE", "/suggestions/suggestions.json")
|
||||||
|
WEB_DIR = os.environ.get("WEB_DIR", "/data/web")
|
||||||
|
WEB_RELEASE_REPO = os.environ.get("WEB_RELEASE_REPO", "TySmittie52/smithflix-releases")
|
||||||
|
WEB_RELEASE_TAG = os.environ.get("WEB_RELEASE_TAG", "web")
|
||||||
|
WEB_UPDATE_HOURS = float(os.environ.get("WEB_UPDATE_HOURS", "1"))
|
||||||
|
TMDB_IMG = "https://image.tmdb.org/t/p"
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S")
|
||||||
|
log = logging.getLogger("hub")
|
||||||
|
|
||||||
|
app = Flask(__name__, static_folder=None)
|
||||||
|
http = requests.Session()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Small TTL cache
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class TTLCache:
|
||||||
|
def __init__(self, ttl):
|
||||||
|
self.ttl, self.data, self.lock = ttl, {}, threading.Lock()
|
||||||
|
|
||||||
|
def get(self, key):
|
||||||
|
with self.lock:
|
||||||
|
hit = self.data.get(key)
|
||||||
|
if hit and hit[0] > time.time():
|
||||||
|
return hit[1]
|
||||||
|
self.data.pop(key, None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, key, value):
|
||||||
|
with self.lock:
|
||||||
|
if len(self.data) > 5000:
|
||||||
|
self.data.clear()
|
||||||
|
self.data[key] = (time.time() + self.ttl, value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
token_cache = TTLCache(300) # Jellyfin token -> user
|
||||||
|
seerr_user_cache = TTLCache(3600) # Jellyfin user id -> Seerr user id
|
||||||
|
media_cache = TTLCache(600) # (type, tmdbId) -> details
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Auth: the caller's Jellyfin token
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
TOKEN_RE = re.compile(r'Token="([^"]+)"', re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def caller_token():
|
||||||
|
for header in ("Authorization", "X-Emby-Authorization"):
|
||||||
|
m = TOKEN_RE.search(request.headers.get(header, ""))
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return (request.headers.get("X-Emby-Token") or request.headers.get("X-MediaBrowser-Token")
|
||||||
|
or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def jellyfin_user(token):
|
||||||
|
cached = token_cache.get(token)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
r = http.get(f"{JELLYFIN_URL}/Users/Me", timeout=15,
|
||||||
|
headers={"Authorization": f'MediaBrowser Token="{token}"'})
|
||||||
|
if r.status_code != 200:
|
||||||
|
return None
|
||||||
|
u = r.json()
|
||||||
|
user = {"id": u["Id"], "name": u.get("Name", ""),
|
||||||
|
"admin": bool((u.get("Policy") or {}).get("IsAdministrator"))}
|
||||||
|
return token_cache.set(token, user)
|
||||||
|
|
||||||
|
|
||||||
|
def signed_in(fn):
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*a, **kw):
|
||||||
|
token = caller_token()
|
||||||
|
if not token:
|
||||||
|
return jsonify(error="Sign in to Smithflix first."), 401
|
||||||
|
try:
|
||||||
|
user = jellyfin_user(token)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
log.warning("Jellyfin unreachable: %s", e)
|
||||||
|
return jsonify(error="Smithflix can't reach the media server right now."), 503
|
||||||
|
if not user:
|
||||||
|
return jsonify(error="Your Smithflix sign-in has expired. Sign in again."), 401
|
||||||
|
g.user = user
|
||||||
|
return fn(*a, **kw)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def cors(resp):
|
||||||
|
if request.path.startswith("/api/"):
|
||||||
|
resp.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin", "*")
|
||||||
|
resp.headers["Access-Control-Allow-Headers"] = "Authorization, X-Emby-Authorization, X-Emby-Token, Content-Type"
|
||||||
|
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||||
|
resp.headers["Vary"] = "Origin"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/<path:_>", methods=["OPTIONS"])
|
||||||
|
def preflight(_):
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Seerr
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class SeerrError(Exception):
|
||||||
|
def __init__(self, message, status=502):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
def seerr(method, path, seerr_user=None, **kw):
|
||||||
|
if not SEERR_URL or not SEERR_API_KEY:
|
||||||
|
raise SeerrError("Requests aren't set up yet (Seerr is not configured).", 503)
|
||||||
|
headers = {"X-Api-Key": SEERR_API_KEY, "Accept": "application/json"}
|
||||||
|
if seerr_user:
|
||||||
|
headers["X-Api-User"] = str(seerr_user)
|
||||||
|
try:
|
||||||
|
r = http.request(method, f"{SEERR_URL}/api/v1{path}", headers=headers, timeout=30, **kw)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
log.warning("Seerr unreachable: %s", e)
|
||||||
|
raise SeerrError("Smithflix can't reach the requests server right now.", 503)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def norm_id(i):
|
||||||
|
return (i or "").replace("-", "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def seerr_user_id(jf_user):
|
||||||
|
"""The Seerr account linked to this Jellyfin account (imported into Seerr if it isn't yet)."""
|
||||||
|
jid = norm_id(jf_user["id"])
|
||||||
|
cached = seerr_user_cache.get(jid)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
r = seerr("GET", f"/user/jellyfin/{jid}")
|
||||||
|
if r.status_code == 200:
|
||||||
|
return seerr_user_cache.set(jid, r.json()["id"])
|
||||||
|
|
||||||
|
def scan():
|
||||||
|
skip = 0
|
||||||
|
while True:
|
||||||
|
page = seerr("GET", "/user", params={"take": 100, "skip": skip})
|
||||||
|
if page.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = page.json()
|
||||||
|
for u in data.get("results", []):
|
||||||
|
if norm_id(u.get("jellyfinUserId")) == jid:
|
||||||
|
return u["id"]
|
||||||
|
skip += 100
|
||||||
|
if skip >= (data.get("pageInfo") or {}).get("results", 0):
|
||||||
|
return None
|
||||||
|
|
||||||
|
found = scan()
|
||||||
|
if not found:
|
||||||
|
log.info("Linking %s to Seerr (importing their Jellyfin account)", jf_user["name"])
|
||||||
|
seerr("POST", "/user/import-from-jellyfin", json={"jellyfinUserIds": [jf_user["id"]]})
|
||||||
|
found = scan()
|
||||||
|
if not found:
|
||||||
|
raise SeerrError("Your account isn't linked to requests yet. Sign in once at "
|
||||||
|
"requests.smithflix.us, then try again.", 409)
|
||||||
|
return seerr_user_cache.set(jid, found)
|
||||||
|
|
||||||
|
|
||||||
|
STATUS = {2: "requested", 3: "processing", 4: "partial", 5: "available", 6: "blocked"}
|
||||||
|
|
||||||
|
|
||||||
|
def media_status(media_info):
|
||||||
|
if not media_info:
|
||||||
|
return "none"
|
||||||
|
return STATUS.get(media_info.get("status"), "none")
|
||||||
|
|
||||||
|
|
||||||
|
def img(path, size):
|
||||||
|
return f"{TMDB_IMG}/{size}{path}" if path else None
|
||||||
|
|
||||||
|
|
||||||
|
def compact(r, media_type=None):
|
||||||
|
"""Seerr/TMDB result -> what the apps need."""
|
||||||
|
mt = media_type or r.get("mediaType")
|
||||||
|
date = r.get("releaseDate") or r.get("firstAirDate") or ""
|
||||||
|
return {
|
||||||
|
"tmdbId": r.get("id"),
|
||||||
|
"type": "tv" if mt == "tv" else "movie",
|
||||||
|
"title": r.get("title") or r.get("name") or "",
|
||||||
|
"year": int(date[:4]) if date[:4].isdigit() else None,
|
||||||
|
"overview": r.get("overview") or "",
|
||||||
|
"poster": img(r.get("posterPath"), "w342"),
|
||||||
|
"backdrop": img(r.get("backdropPath"), "w780"),
|
||||||
|
"rating": r.get("voteAverage"),
|
||||||
|
"status": media_status(r.get("mediaInfo")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def details(media_type, tmdb_id, seerr_user=None):
|
||||||
|
key = (media_type, int(tmdb_id))
|
||||||
|
hit = media_cache.get(key)
|
||||||
|
if hit:
|
||||||
|
return hit
|
||||||
|
r = seerr("GET", f"/{'tv' if media_type == 'tv' else 'movie'}/{int(tmdb_id)}", seerr_user)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return None
|
||||||
|
return media_cache.set(key, compact(r.json(), media_type))
|
||||||
|
|
||||||
|
|
||||||
|
def seerr_errors(fn):
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*a, **kw):
|
||||||
|
try:
|
||||||
|
return fn(*a, **kw)
|
||||||
|
except SeerrError as e:
|
||||||
|
return jsonify(error=str(e)), e.status
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# API
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return jsonify(ok=True, seerr=bool(SEERR_URL and SEERR_API_KEY),
|
||||||
|
web=os.path.exists(os.path.join(WEB_DIR, "index.html")))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/me")
|
||||||
|
@signed_in
|
||||||
|
def me():
|
||||||
|
linked = None
|
||||||
|
try:
|
||||||
|
linked = bool(seerr_user_id(g.user))
|
||||||
|
except SeerrError:
|
||||||
|
linked = False
|
||||||
|
return jsonify(name=g.user["name"], requestsLinked=linked)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/search")
|
||||||
|
@signed_in
|
||||||
|
@seerr_errors
|
||||||
|
def search():
|
||||||
|
q = (request.args.get("q") or "").strip()
|
||||||
|
if len(q) < 2:
|
||||||
|
return jsonify(results=[])
|
||||||
|
uid = seerr_user_id(g.user)
|
||||||
|
# Seerr needs spaces as %20 (not "+") or it rejects the search
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
r = seerr("GET", f"/search?query={quote(q, safe='')}&page={page}", uid)
|
||||||
|
if r.status_code != 200:
|
||||||
|
log.warning("Seerr search failed (%s): %s", r.status_code, r.text[:200])
|
||||||
|
raise SeerrError("Search isn't working right now.", 502)
|
||||||
|
results = [compact(x) for x in r.json().get("results", []) if x.get("mediaType") in ("movie", "tv")]
|
||||||
|
return jsonify(results=results)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/request")
|
||||||
|
@signed_in
|
||||||
|
@seerr_errors
|
||||||
|
def make_request():
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
media_type = "tv" if body.get("type") == "tv" else "movie"
|
||||||
|
try:
|
||||||
|
tmdb_id = int(body.get("tmdbId"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify(error="Missing title."), 400
|
||||||
|
uid = seerr_user_id(g.user)
|
||||||
|
payload = {"mediaType": media_type, "mediaId": tmdb_id}
|
||||||
|
if media_type == "tv":
|
||||||
|
payload["seasons"] = "all"
|
||||||
|
r = seerr("POST", "/request", uid, json=payload)
|
||||||
|
media_cache.data.pop((media_type, tmdb_id), None)
|
||||||
|
if r.status_code in (200, 201):
|
||||||
|
req = r.json()
|
||||||
|
approved = req.get("status") == 2
|
||||||
|
log.info("%s requested %s %s (%s)", g.user["name"], media_type, tmdb_id,
|
||||||
|
"approved" if approved else "waiting for approval")
|
||||||
|
return jsonify(status="approved" if approved else "pending",
|
||||||
|
message="Requested! It'll show up on Smithflix once it's downloaded."
|
||||||
|
if approved else "Requested! It's waiting for approval.")
|
||||||
|
try:
|
||||||
|
msg = r.json().get("message") or r.json().get("error") or ""
|
||||||
|
except ValueError:
|
||||||
|
msg = ""
|
||||||
|
if r.status_code == 409 or "already" in msg.lower():
|
||||||
|
return jsonify(status="exists", message="That one has already been requested."), 200
|
||||||
|
if r.status_code == 403:
|
||||||
|
return jsonify(error="Your account isn't allowed to request this. Ask Ty."), 403
|
||||||
|
if r.status_code == 429 or "quota" in msg.lower():
|
||||||
|
return jsonify(error="You've hit your request limit for now."), 429
|
||||||
|
log.warning("Seerr refused request (%s): %s", r.status_code, msg)
|
||||||
|
return jsonify(error=msg or "The request didn't go through."), 502
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/requests")
|
||||||
|
@signed_in
|
||||||
|
@seerr_errors
|
||||||
|
def my_requests():
|
||||||
|
uid = seerr_user_id(g.user)
|
||||||
|
r = seerr("GET", f"/user/{uid}/requests", uid, params={"take": 30})
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise SeerrError("Couldn't load your requests.", 502)
|
||||||
|
out = []
|
||||||
|
for req in r.json().get("results", []):
|
||||||
|
media = req.get("media") or {}
|
||||||
|
mt = media.get("mediaType") or req.get("type")
|
||||||
|
d = details(mt, media.get("tmdbId"), uid) if media.get("tmdbId") else None
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
state = {1: "pending", 2: "approved", 3: "declined", 4: "failed", 5: "completed"}.get(req.get("status"), "pending")
|
||||||
|
out.append(dict(d, request=state, requestedAt=req.get("createdAt")))
|
||||||
|
return jsonify(results=out)
|
||||||
|
|
||||||
|
|
||||||
|
def load_suggestions():
|
||||||
|
try:
|
||||||
|
with open(SUGGESTIONS_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/suggestions")
|
||||||
|
@signed_in
|
||||||
|
def suggestions():
|
||||||
|
data = load_suggestions()
|
||||||
|
entry = (data.get("users") or {}).get(norm_id(g.user["id"])) or {}
|
||||||
|
items = entry.get("items", [])
|
||||||
|
# Refresh each title's status (something may have been requested or downloaded since last night)
|
||||||
|
try:
|
||||||
|
uid = seerr_user_id(g.user)
|
||||||
|
fresh = []
|
||||||
|
for it in items:
|
||||||
|
d = details(it["type"], it["tmdbId"], uid)
|
||||||
|
if d and d["status"] in ("available", "partial", "blocked"):
|
||||||
|
continue
|
||||||
|
fresh.append(dict(it, status=d["status"] if d else it.get("status", "none")))
|
||||||
|
items = fresh
|
||||||
|
except SeerrError:
|
||||||
|
pass
|
||||||
|
return jsonify(generated=data.get("generated"), results=items)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Web app (static), auto-updated from GitHub releases
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
PLACEHOLDER = """<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width">
|
||||||
|
<title>Smithflix</title><body style="margin:0;display:grid;place-items:center;height:100vh;background:#090C14;
|
||||||
|
color:#E6ECF7;font:16px system-ui"><div style="text-align:center"><h1 style="font-weight:700">smithflix</h1>
|
||||||
|
<p>The web app is being installed. Check back in a few minutes.</p></div></body>"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
@app.get("/<path:path>")
|
||||||
|
def web(path="index.html"):
|
||||||
|
if path.startswith("api/"):
|
||||||
|
abort(404)
|
||||||
|
full = os.path.join(WEB_DIR, path)
|
||||||
|
if os.path.isdir(full):
|
||||||
|
path = os.path.join(path, "index.html")
|
||||||
|
full = os.path.join(WEB_DIR, path)
|
||||||
|
if not os.path.exists(os.path.join(WEB_DIR, "index.html")):
|
||||||
|
return PLACEHOLDER
|
||||||
|
if not os.path.isfile(full):
|
||||||
|
# The web app routes with #/... so every real page is "/" plus actual files.
|
||||||
|
# Anything else is a scanner guessing (e.g. /wp-login.php): answer "not found".
|
||||||
|
abort(404)
|
||||||
|
resp = send_from_directory(WEB_DIR, path)
|
||||||
|
if path.endswith(("index.html", "config.json", "serviceworker.js")):
|
||||||
|
resp.headers["Cache-Control"] = "no-cache"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def update_web_once():
|
||||||
|
meta_path = os.path.join(WEB_DIR, ".release.json")
|
||||||
|
try:
|
||||||
|
rel = http.get(f"https://api.github.com/repos/{WEB_RELEASE_REPO}/releases/tags/{WEB_RELEASE_TAG}",
|
||||||
|
timeout=30, headers={"Accept": "application/vnd.github+json"})
|
||||||
|
if rel.status_code != 200:
|
||||||
|
log.info("Web app: no release '%s' yet (%s)", WEB_RELEASE_TAG, rel.status_code)
|
||||||
|
return
|
||||||
|
asset = next((a for a in rel.json().get("assets", []) if a["name"].endswith(".tar.gz")), None)
|
||||||
|
if not asset:
|
||||||
|
return
|
||||||
|
stamp = {"id": asset["id"], "updated": asset["updated_at"]}
|
||||||
|
try:
|
||||||
|
with open(meta_path) as f:
|
||||||
|
if json.load(f) == stamp:
|
||||||
|
return
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
log.info("Web app: installing %s (%s)", asset["name"], asset["updated_at"])
|
||||||
|
parent = os.path.dirname(WEB_DIR.rstrip("/")) or "."
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
tmp = tempfile.mkdtemp(dir=parent)
|
||||||
|
with http.get(asset["browser_download_url"], stream=True, timeout=300) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
tgz = os.path.join(tmp, "web.tar.gz")
|
||||||
|
with open(tgz, "wb") as f:
|
||||||
|
shutil.copyfileobj(r.raw, f)
|
||||||
|
out = os.path.join(tmp, "web")
|
||||||
|
with tarfile.open(tgz) as t:
|
||||||
|
t.extractall(out, filter="data")
|
||||||
|
os.remove(tgz)
|
||||||
|
if not os.path.exists(os.path.join(out, "index.html")):
|
||||||
|
log.warning("Web app: download has no index.html, keeping the current version")
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
return
|
||||||
|
with open(os.path.join(out, ".release.json"), "w") as f:
|
||||||
|
json.dump(stamp, f)
|
||||||
|
old = WEB_DIR.rstrip("/") + ".old"
|
||||||
|
shutil.rmtree(old, ignore_errors=True)
|
||||||
|
if os.path.exists(WEB_DIR):
|
||||||
|
os.rename(WEB_DIR, old)
|
||||||
|
os.rename(out, WEB_DIR)
|
||||||
|
shutil.rmtree(old, ignore_errors=True)
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
log.info("Web app: updated")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Web app update failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def web_updater():
|
||||||
|
while True:
|
||||||
|
update_web_once()
|
||||||
|
time.sleep(max(0.1, WEB_UPDATE_HOURS) * 3600)
|
||||||
|
|
||||||
|
|
||||||
|
if os.environ.get("WEB_AUTO_UPDATE", "true").lower() in ("1", "true", "yes"):
|
||||||
|
threading.Thread(target=web_updater, daemon=True).start()
|
||||||
|
|
||||||
|
if not JELLYFIN_URL:
|
||||||
|
log.error("JELLYFIN_URL is not set")
|
||||||
|
if not (SEERR_URL and SEERR_API_KEY):
|
||||||
|
log.warning("SEERR_URL / SEERR_API_KEY not set: requests are turned off")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8097")))
|
||||||
Loading…
Reference in a new issue