Compare commits

...

1 commit

Author SHA1 Message Date
Claude
7635471412
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
2026-10-11 13:28:58 -04:00
10 changed files with 1819 additions and 0 deletions

View 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

View 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"]

View 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`.

View 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"

File diff suppressed because it is too large Load diff

View 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

View 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"]

View 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

View 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
View 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")))