Compare commits

...

2 commits

Author SHA1 Message Date
MiTHRAL
a17f3383e3 chore: Add docker-compose.yml for easier deployment 2026-07-17 13:13:44 -04:00
MiTHRAL
52048ad8f7 feat: Lock down Whisparr fields and add Jellyfin Auth 2026-07-17 13:13:05 -04:00
4 changed files with 151 additions and 16 deletions

29
docker-compose.yml Normal file
View file

@ -0,0 +1,29 @@
version: '3.8'
services:
huntarr:
build: .
image: huntarr:latest
container_name: huntarr
restart: unless-stopped
ports:
# Change 8000 on the left to whatever port you want exposed on your host
- "8000:8000"
volumes:
# Mounts the local ./config folder to /config in the container to persist config.json
- ./config:/config
environment:
- TZ=America/New_York
- CONFIG_PATH=/config/config.json
# You can also pass these via environment variables if you prefer not using the JSON
# - STASHDB_URL=https://stashdb.org/graphql
# - STASHDB_API_KEY=your_key_here
# - WHISPARR_URL=http://whisparr:6969
# - WHISPARR_API_KEY=your_key_here
# - JELLYFIN_URL=http://jellyfin:8096
networks:
- mediaserver_default
networks:
mediaserver_default:
external: true

71
main.py
View file

@ -7,10 +7,13 @@ import urllib.parse
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
import requests import requests
from fastapi import FastAPI, Request, HTTPException, Depends from fastapi import FastAPI, Request, HTTPException, Depends, Form
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
import base64
import hmac
import hashlib
# Setup logging # Setup logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -78,12 +81,67 @@ settings = Settings()
class SettingsUpdate(BaseModel): class SettingsUpdate(BaseModel):
stashdb_url: str stashdb_url: str
stashdb_api_key: str stashdb_api_key: str
whisparr_url: str
whisparr_api_key: str
jellyfin_url: str
whisparr_root_folder: Optional[str] = "" whisparr_root_folder: Optional[str] = ""
whisparr_quality_profile_id: Optional[str] = "" whisparr_quality_profile_id: Optional[str] = ""
def sign_data(data: str) -> str:
secret = (settings.whisparr_api_key or "default_secret").encode()
h = hmac.new(secret, data.encode(), hashlib.sha256).digest()
return f"{data}.{base64.urlsafe_b64encode(h).decode()}"
def verify_data(signed_data: str) -> bool:
try:
data, sig = signed_data.rsplit(".", 1)
expected_sig = base64.urlsafe_b64encode(hmac.new((settings.whisparr_api_key or "default_secret").encode(), data.encode(), hashlib.sha256).digest()).decode()
return hmac.compare_digest(sig, expected_sig)
except Exception:
return False
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
path = request.url.path
if path in ["/login", "/logout"] or path.startswith("/static"):
return await call_next(request)
auth_cookie = request.cookies.get("huntarr_auth")
if not auth_cookie or not verify_data(auth_cookie):
if path.startswith("/api"):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
return RedirectResponse("/login", status_code=303)
return await call_next(request)
@app.get("/login", response_class=HTMLResponse)
def login_get(request: Request):
return templates.TemplateResponse(request=request, name="login.html", context={})
@app.post("/login", response_class=HTMLResponse)
def login_post(request: Request, username: str = Form(...), password: str = Form(...)):
jf_url = settings.jellyfin_url.rstrip("/")
try:
res = requests.post(
f"{jf_url}/Users/AuthenticateByName",
json={"Username": username, "Pw": password},
headers={"X-Emby-Authorization": 'MediaBrowser Client="Huntarr", Device="Server", DeviceId="Huntarr", Version="1.0"'},
timeout=5
)
if res.status_code == 200:
data = res.json()
if data.get("AccessToken"):
response = RedirectResponse("/", status_code=303)
response.set_cookie("huntarr_auth", sign_data(username), httponly=True, max_age=86400*30)
return response
except Exception as e:
logger.error(f"Jellyfin auth error: {e}")
return templates.TemplateResponse(request=request, name="login.html", context={"error": "Invalid username or password or Jellyfin unreachable."})
@app.get("/logout")
def logout():
response = RedirectResponse("/login", status_code=303)
response.delete_cookie("huntarr_auth")
return response
# GraphQL / StashDB Client # GraphQL / StashDB Client
class StashDBClient: class StashDBClient:
def __init__(self, url: str, api_key: str): def __init__(self, url: str, api_key: str):
@ -626,9 +684,6 @@ def index_route(
def update_settings(update: SettingsUpdate): def update_settings(update: SettingsUpdate):
settings.stashdb_url = update.stashdb_url settings.stashdb_url = update.stashdb_url
settings.stashdb_api_key = update.stashdb_api_key settings.stashdb_api_key = update.stashdb_api_key
settings.whisparr_url = update.whisparr_url
settings.whisparr_api_key = update.whisparr_api_key
settings.jellyfin_url = update.jellyfin_url
settings.whisparr_root_folder = update.whisparr_root_folder or "" settings.whisparr_root_folder = update.whisparr_root_folder or ""
settings.whisparr_quality_profile_id = update.whisparr_quality_profile_id or "" settings.whisparr_quality_profile_id = update.whisparr_quality_profile_id or ""

View file

@ -718,14 +718,6 @@
<div class="border-t border-slate-900 pt-4 flex flex-col gap-3"> <div class="border-t border-slate-900 pt-4 flex flex-col gap-3">
<span class="text-2xs font-bold text-slate-700 uppercase tracking-widest">Whisparr</span> <span class="text-2xs font-bold text-slate-700 uppercase tracking-widest">Whisparr</span>
<div class="flex flex-col gap-2">
<label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">Base URL</label>
<input type="url" name="whisparr_url" value="{{ config.whisparr_url }}" placeholder="http://localhost:6969" class="w-full px-3 py-2">
</div>
<div class="flex flex-col gap-2">
<label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">API Key</label>
<input type="password" name="whisparr_api_key" value="{{ config.whisparr_api_key }}" placeholder="API Key" class="w-full px-3 py-2">
</div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">Root Folder</label> <label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">Root Folder</label>

59
templates/login.html Normal file
View file

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Huntarr Login</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
html { background: #030507; }
body {
background: #030507;
color: #94a3b8;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
input {
background: #0d1117;
border: 1px solid #1a2035;
color: #e2e8f0;
outline: none;
transition: border-color 0.2s;
}
input:focus {
border-color: #3b82f6;
}
</style>
</head>
<body class="flex items-center justify-center min-h-screen p-4">
<div class="w-full max-w-sm flex flex-col gap-6">
<div class="text-center">
<h1 class="text-2xl font-black tracking-tighter text-slate-100">Huntarr</h1>
<p class="text-xs text-slate-500 mt-1">Sign in with Jellyfin</p>
</div>
{% if error %}
<div class="bg-red-500/10 border border-red-500/20 rounded p-3 text-center">
<p class="text-xs font-semibold text-red-400">{{ error }}</p>
</div>
{% endif %}
<form action="/login" method="POST" class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-2xs font-bold text-slate-600 uppercase tracking-widest">Username</label>
<input type="text" name="username" required autofocus class="px-3 py-2.5 rounded-lg text-sm w-full">
</div>
<div class="flex flex-col gap-1.5">
<label class="text-2xs font-bold text-slate-600 uppercase tracking-widest">Password</label>
<input type="password" name="password" required class="px-3 py-2.5 rounded-lg text-sm w-full">
</div>
<button type="submit" class="mt-2 w-full py-2.5 rounded-lg font-bold text-sm bg-slate-100 hover:bg-white text-slate-950 transition-colors">
Sign In
</button>
</form>
</div>
</body>
</html>