Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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