Initial commit: Huntarr v1 - Premium Adult Request Dashboard

This commit is contained in:
MiTHRAL 2026-07-17 13:09:34 -04:00
commit 86ec770ba4
5 changed files with 1978 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*$py.class
venv/
env/
.env
config.json
.DS_Store

29
Dockerfile Normal file
View file

@ -0,0 +1,29 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies if any are needed (none for pure python/FastAPI)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application files
COPY . .
# Expose port 8000
EXPOSE 8000
# Set environment variables
ENV HOST=0.0.0.0
ENV PORT=8000
ENV CONFIG_PATH=/config/config.json
# Create configuration directory
RUN mkdir -p /config
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

747
main.py Normal file
View file

@ -0,0 +1,747 @@
import os
import json
import time
import math
import logging
import urllib.parse
from typing import Optional, List, Dict, Any
import requests
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="StashDB Whisparr Dashboard")
# Templates setup
templates = Jinja2Templates(directory="templates")
templates.env.filters["urlencode"] = urllib.parse.quote_plus
# Configuration Path
CONFIG_PATH = os.environ.get("CONFIG_PATH", "config.json")
class Settings:
def __init__(self):
self.stashdb_url = os.environ.get("STASHDB_URL", "https://stashdb.org/graphql")
self.stashdb_api_key = os.environ.get("STASHDB_API_KEY", "")
self.whisparr_url = os.environ.get("WHISPARR_URL", "http://localhost:6969")
self.whisparr_api_key = os.environ.get("WHISPARR_API_KEY", "")
self.jellyfin_url = os.environ.get("JELLYFIN_URL", "http://localhost:8096")
self.whisparr_root_folder = os.environ.get("WHISPARR_ROOT_FOLDER", "")
self.whisparr_quality_profile_id = os.environ.get("WHISPARR_QUALITY_PROFILE_ID", "")
self.load()
def load(self):
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, "r") as f:
data = json.load(f)
self.stashdb_url = data.get("stashdb_url", self.stashdb_url)
self.stashdb_api_key = data.get("stashdb_api_key", self.stashdb_api_key)
self.whisparr_url = data.get("whisparr_url", self.whisparr_url)
self.whisparr_api_key = data.get("whisparr_api_key", self.whisparr_api_key)
self.jellyfin_url = data.get("jellyfin_url", self.jellyfin_url)
self.whisparr_root_folder = data.get("whisparr_root_folder", self.whisparr_root_folder)
self.whisparr_quality_profile_id = data.get("whisparr_quality_profile_id", self.whisparr_quality_profile_id)
logger.info("Configuration successfully loaded from file.")
except Exception as e:
logger.error(f"Failed to load config file: {e}")
def save(self):
try:
dir_name = os.path.dirname(CONFIG_PATH)
if dir_name and not os.path.exists(dir_name):
os.makedirs(dir_name, exist_ok=True)
with open(CONFIG_PATH, "w") as f:
json.dump({
"stashdb_url": self.stashdb_url,
"stashdb_api_key": self.stashdb_api_key,
"whisparr_url": self.whisparr_url,
"whisparr_api_key": self.whisparr_api_key,
"jellyfin_url": self.jellyfin_url,
"whisparr_root_folder": self.whisparr_root_folder,
"whisparr_quality_profile_id": self.whisparr_quality_profile_id
}, f, indent=4)
logger.info("Configuration successfully saved to file.")
except Exception as e:
logger.error(f"Failed to save config file: {e}")
# Instantiate global settings
settings = Settings()
# Settings Update Model
class SettingsUpdate(BaseModel):
stashdb_url: str
stashdb_api_key: str
whisparr_url: str
whisparr_api_key: str
jellyfin_url: str
whisparr_root_folder: Optional[str] = ""
whisparr_quality_profile_id: Optional[str] = ""
# GraphQL / StashDB Client
class StashDBClient:
def __init__(self, url: str, api_key: str):
self.url = url
self.api_key = api_key
@property
def headers(self) -> Dict[str, str]:
h = {"Content-Type": "application/json"}
if self.api_key:
h["ApiKey"] = self.api_key
return h
def execute_query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not self.api_key:
raise ValueError("StashDB API key is not configured.")
payload = {"query": query}
if variables:
payload["variables"] = variables
r = requests.post(self.url, json=payload, headers=self.headers, timeout=15)
r.raise_for_status()
data = r.json()
if "errors" in data:
raise Exception(f"GraphQL Error: {data['errors']}")
return data.get("data", {})
# REST / Whisparr Client
class WhisparrClient:
def __init__(self, url: str, api_key: str):
# Normalize API endpoint
url = url.rstrip("/")
if not url.endswith("/api/v3"):
url += "/api/v3"
self.url = url
self.api_key = api_key
@property
def headers(self) -> Dict[str, str]:
return {
"X-Api-Key": self.api_key,
"Content-Type": "application/json"
}
def get_movies(self) -> List[Dict[str, Any]]:
if not self.api_key:
return []
try:
r = requests.get(f"{self.url}/movie", headers=self.headers, timeout=10)
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"Whisparr get_movies failed: {e}")
raise e
def get_root_folders(self) -> List[Dict[str, Any]]:
if not self.api_key:
return []
r = requests.get(f"{self.url}/rootfolder", headers=self.headers, timeout=10)
r.raise_for_status()
return r.json()
def get_quality_profiles(self) -> List[Dict[str, Any]]:
if not self.api_key:
return []
r = requests.get(f"{self.url}/qualityprofile", headers=self.headers, timeout=10)
r.raise_for_status()
return r.json()
def lookup_scene(self, stash_id: str) -> List[Dict[str, Any]]:
if not self.api_key:
raise ValueError("Whisparr API key is not configured.")
term = f"stash:{stash_id}"
r = requests.get(
f"{self.url}/movie/lookup",
params={"term": term},
headers=self.headers,
timeout=15
)
r.raise_for_status()
return r.json()
def add_movie(self, movie_data: Dict[str, Any], root_folder_path: str, quality_profile_id: int) -> Dict[str, Any]:
if not self.api_key:
raise ValueError("Whisparr API key is not configured.")
payload = dict(movie_data)
payload["monitored"] = True
payload["rootFolderPath"] = root_folder_path
payload["qualityProfileId"] = quality_profile_id
payload["addOptions"] = {
"searchForMovie": True,
"monitor": "movieOnly"
}
r = requests.post(f"{self.url}/movie", json=payload, headers=self.headers, timeout=15)
r.raise_for_status()
return r.json()
# Whisparr Movies Cache to limit API rate spikes
class WhisparrCache:
def __init__(self):
self.movies = []
self.last_updated = 0.0
self.ttl = 15.0 # 15 seconds Cache TTL
def get_movies(self, client: WhisparrClient) -> List[Dict[str, Any]]:
if not client.api_key:
return []
if time.time() - self.last_updated > self.ttl:
try:
self.movies = client.get_movies()
self.last_updated = time.time()
except Exception as e:
logger.warning(f"Failed to refresh Whisparr movies list cache, using stale cache: {e}")
if not self.movies:
raise e
return self.movies
def clear(self):
self.last_updated = 0.0
whisparr_cache = WhisparrCache()
# StashDB GraphQL Query definitions
SCENES_QUERY = """
query QueryScenes($input: SceneQueryInput!) {
queryScenes(input: $input) {
count
scenes {
id
title
details
date
duration
images {
url
}
studio {
id
name
images { url }
}
performers {
performer {
id
name
images { url }
}
as
}
tags {
name
}
urls {
url
site { name }
}
}
}
}
"""
SCENE_DETAIL_QUERY = """
query FindScene($id: ID!) {
findScene(id: $id) {
id
title
details
date
duration
images {
url
}
studio {
id
name
images { url }
}
performers {
performer {
id
name
disambiguation
gender
birthdate { date }
images { url }
}
as
}
tags {
name
}
urls {
url
site { name }
}
}
}"""
PERFORMERS_QUERY = """
query QueryPerformers($input: PerformerQueryInput!) {
queryPerformers(input: $input) {
count
performers {
id
name
disambiguation
gender
birthdate {
date
}
images {
url
}
}
}
}
"""
STUDIOS_QUERY = """
query QueryStudios($input: StudioQueryInput!) {
queryStudios(input: $input) {
count
studios {
id
name
images {
url
}
}
}
}
"""
TAGS_QUERY = """
query QueryTags($input: TagQueryInput!) {
queryTags(input: $input) {
count
tags {
id
name
description
}
}
}
"""
# Helper to cross-reference Whisparr status
def get_scene_library_status(scene_id: str, whisparr_map: Dict[str, Dict[str, Any]]) -> str:
movie = whisparr_map.get(scene_id)
if movie:
if movie.get("hasFile"):
return "Active/In Library"
elif movie.get("monitored"):
return "Tracking/Searching"
return "Absent"
def resolve_scene_filters(stash_client: "StashDBClient", search: str) -> Dict[str, Any]:
"""
Given a free-text search string, resolve the best structured filters to use
against SceneQueryInput. Priority order:
1. Exact tag name match -> tags: { value: [id], modifier: INCLUDES }
2. Performer name match -> performers: { value: [id], modifier: INCLUDES }
3. Studio name match -> studios: { value: [id], modifier: INCLUDES }
4. Fall back to title text search
Returns a dict of input fields to merge into the query variables.
"""
filters: Dict[str, Any] = {}
if not search or not search.strip():
return filters
q = search.strip()
# 1. Try exact tag match
try:
tag_res = stash_client.execute_query(
'query($i:TagQueryInput!){queryTags(input:$i){tags{id name}}}',
{"i": {"name": q, "per_page": 10, "sort": "NAME", "direction": "ASC"}}
)
tags = tag_res.get("queryTags", {}).get("tags", [])
exact = [t for t in tags if t["name"].lower() == q.lower()]
if exact:
logger.info(f"Search '{q}' resolved to tag ID {exact[0]['id']}")
return {"tags": {"value": [exact[0]["id"]], "modifier": "INCLUDES"}}
# Partial tag match (single result)
if len(tags) == 1:
logger.info(f"Search '{q}' resolved to single tag ID {tags[0]['id']}")
return {"tags": {"value": [tags[0]["id"]], "modifier": "INCLUDES"}}
except Exception as e:
logger.warning(f"Tag resolution failed for '{q}': {e}")
# 2. Try performer name match
try:
perf_res = stash_client.execute_query(
'query($i:PerformerQueryInput!){queryPerformers(input:$i){performers{id name}}}',
{"i": {"names": q, "per_page": 5}}
)
perfs = perf_res.get("queryPerformers", {}).get("performers", [])
exact_p = [p for p in perfs if p["name"].lower() == q.lower()]
if exact_p:
logger.info(f"Search '{q}' resolved to performer ID {exact_p[0]['id']}")
return {"performers": {"value": [exact_p[0]["id"]], "modifier": "INCLUDES"}}
except Exception as e:
logger.warning(f"Performer resolution failed for '{q}': {e}")
# 3. Try studio name match
try:
studio_res = stash_client.execute_query(
'query($i:StudioQueryInput!){queryStudios(input:$i){studios{id name}}}',
{"i": {"name": q, "per_page": 5}}
)
studios = studio_res.get("queryStudios", {}).get("studios", [])
exact_s = [s for s in studios if s["name"].lower() == q.lower()]
if exact_s:
logger.info(f"Search '{q}' resolved to studio ID {exact_s[0]['id']}")
return {"studios": {"value": [exact_s[0]["id"]], "modifier": "INCLUDES"}}
except Exception as e:
logger.warning(f"Studio resolution failed for '{q}': {e}")
# 4. Fall back: use title field (more precise than text which searches descriptions)
logger.info(f"Search '{q}' using title fallback")
return {"title": q}
@app.get("/", response_class=HTMLResponse)
def index_route(
request: Request,
tab: str = "scenes",
search: Optional[str] = None,
sort: Optional[str] = None,
missing_only: bool = False,
page: int = 1
):
# Set default sorting based on tab
if not sort:
if tab == "scenes":
sort = "DATE_DESC"
elif tab == "performers":
sort = "NAME_ASC"
elif tab == "studios":
sort = "NAME_ASC"
elif tab == "tags":
sort = "NAME_ASC"
# Parse combined sorting enum (e.g. DATE_DESC)
sort_by = "DATE"
direction = "DESC"
if sort:
parts = sort.rsplit("_", 1)
if len(parts) == 2 and parts[1] in ["ASC", "DESC"]:
sort_by = parts[0]
direction = parts[1]
else:
sort_by = sort
direction = "DESC"
items = []
count = 0
total_pages = 1
per_page = 200 if tab == "tags" else 40
# Status containers
stashdb_status = {"configured": bool(settings.stashdb_api_key), "error": None}
whisparr_status = {"online": False, "error": None}
# Verify clients
stash_client = StashDBClient(settings.stashdb_url, settings.stashdb_api_key)
whisparr_client = WhisparrClient(settings.whisparr_url, settings.whisparr_api_key)
# Cache Whisparr status and mapping
whisparr_map = {}
if settings.whisparr_api_key:
try:
whisparr_movies = whisparr_cache.get_movies(whisparr_client)
whisparr_status["online"] = True
for m in whisparr_movies:
sid = m.get("stashId")
if sid:
whisparr_map[sid] = m
except Exception as e:
whisparr_status["online"] = False
whisparr_status["error"] = str(e)
logger.error(f"Whisparr sync failed: {e}")
# Fetch data based on selected tab
if settings.stashdb_api_key:
try:
if tab == "scenes":
# Resolve smart search filters once (tag/performer/studio ID or title)
scene_search_filters: Dict[str, Any] = {}
if search:
scene_search_filters = resolve_scene_filters(stash_client, search)
def build_scene_input(pg: int, ppg: int) -> Dict[str, Any]:
inp: Dict[str, Any] = {
"page": pg,
"per_page": ppg,
"sort": sort_by,
"direction": direction,
}
inp.update(scene_search_filters)
return inp
if missing_only and whisparr_status["online"]:
filtered_scenes: List[Any] = []
stash_page = 1
max_fetch_attempts = 8
total_count_estimate = 0
while len(filtered_scenes) < page * per_page and stash_page <= max_fetch_attempts:
res = stash_client.execute_query(
SCENES_QUERY, {"input": build_scene_input(stash_page, 80)}
)
data = res.get("queryScenes", {})
scenes = data.get("scenes", [])
total_count_estimate = data.get("count", 0)
if not scenes:
break
for scene in scenes:
status = get_scene_library_status(scene["id"], whisparr_map)
scene["library_status"] = status
if status in ["Absent", "Tracking/Searching"]:
filtered_scenes.append(scene)
stash_page += 1
count = len(filtered_scenes)
start_idx = (page - 1) * per_page
items = filtered_scenes[start_idx:start_idx + per_page]
total_pages = math.ceil(max(count, total_count_estimate * 0.4) / per_page)
if page > total_pages:
total_pages = page
else:
res = stash_client.execute_query(
SCENES_QUERY, {"input": build_scene_input(page, per_page)}
)
data = res.get("queryScenes", {})
items = data.get("scenes", [])
count = data.get("count", 0)
total_pages = max(1, math.ceil(count / per_page))
for scene in items:
scene["library_status"] = get_scene_library_status(scene["id"], whisparr_map)
elif tab == "performers":
variables: Dict[str, Any] = {
"input": {
"page": page,
"per_page": per_page,
"sort": sort_by,
"direction": direction
}
}
if search:
# 'names' does substring match across name + aliases
variables["input"]["names"] = search
res = stash_client.execute_query(PERFORMERS_QUERY, variables)
data = res.get("queryPerformers", {})
items = data.get("performers", [])
count = data.get("count", 0)
total_pages = max(1, math.ceil(count / per_page))
for performer in items:
bd = performer.get("birthdate")
if bd and isinstance(bd, dict):
performer["birthdate"] = bd.get("date")
elif tab == "studios":
variables = {
"input": {
"page": page,
"per_page": per_page,
"sort": sort_by,
"direction": direction
}
}
if search:
# 'name' does substring match on studio name
variables["input"]["name"] = search
res = stash_client.execute_query(STUDIOS_QUERY, variables)
data = res.get("queryStudios", {})
items = data.get("studios", [])
count = data.get("count", 0)
total_pages = max(1, math.ceil(count / per_page))
elif tab == "tags":
variables = {
"input": {
"page": page,
"per_page": per_page,
"sort": sort_by,
"direction": direction
}
}
if search:
# 'name' does substring match on tag name
variables["input"]["name"] = search
res = stash_client.execute_query(TAGS_QUERY, variables)
data = res.get("queryTags", {})
items = data.get("tags", [])
count = data.get("count", 0)
total_pages = max(1, math.ceil(count / per_page))
except Exception as e:
stashdb_status["configured"] = True
stashdb_status["error"] = str(e)
logger.error(f"StashDB GraphQL Query failed: {e}")
# Build context for Jinja2 template
context = {
"request": request,
"active_tab": tab,
"search_query": search,
"sort": sort,
"sort_by": sort_by,
"direction": direction,
"missing_only": missing_only,
"page": page,
"items": items,
"count": count,
"total_pages": total_pages,
"config": {
"stashdb_url": settings.stashdb_url,
"stashdb_api_key": settings.stashdb_api_key,
"whisparr_url": settings.whisparr_url,
"whisparr_api_key": settings.whisparr_api_key,
"jellyfin_url": settings.jellyfin_url,
"whisparr_root_folder": settings.whisparr_root_folder,
"whisparr_quality_profile_id": settings.whisparr_quality_profile_id
},
"stashdb_status": stashdb_status,
"whisparr_status": whisparr_status
}
return templates.TemplateResponse(request=request, name="layout.html", context=context)
@app.post("/api/settings")
def update_settings(update: SettingsUpdate):
settings.stashdb_url = update.stashdb_url
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_quality_profile_id = update.whisparr_quality_profile_id or ""
settings.save()
# Force refresh cache when credentials change
whisparr_cache.clear()
return {"status": "success", "message": "Settings updated successfully."}
@app.get("/api/whisparr/profiles")
def get_whisparr_profiles():
if not settings.whisparr_api_key:
return {"folders": [], "profiles": []}
whisparr_client = WhisparrClient(settings.whisparr_url, settings.whisparr_api_key)
try:
folders = whisparr_client.get_root_folders()
profiles = whisparr_client.get_quality_profiles()
return {
"folders": [{"path": f.get("path"), "freeSpace": f.get("freeSpace", 0)} for f in folders],
"profiles": [{"id": p.get("id"), "name": p.get("name")} for p in profiles]
}
except Exception as e:
logger.error(f"Failed to query rootfolders/qualityprofiles from Whisparr: {e}")
raise HTTPException(status_code=500, detail=f"Whisparr connection error: {e}")
@app.post("/api/get-scene/{stash_id}")
def get_scene(stash_id: str):
if not settings.whisparr_api_key:
raise HTTPException(status_code=400, detail="Whisparr is not configured.")
whisparr_client = WhisparrClient(settings.whisparr_url, settings.whisparr_api_key)
# 1. Lookup the scene via Whisparr v3's metadata lookup using 'stash:{stash_id}'
try:
lookup_results = whisparr_client.lookup_scene(stash_id)
except Exception as e:
logger.error(f"Whisparr scene lookup failed: {e}")
raise HTTPException(status_code=500, detail=f"Scene lookup failed: {e}")
if not lookup_results:
raise HTTPException(status_code=404, detail="Scene metadata not found on Whisparr's metadata server.")
# The lookup returns a list of candidate movie objects. Grab the first match.
movie_to_add = lookup_results[0]
# 2. Determine configuration parameters (Auto-detect if not configured)
# Root Folder
root_folder = settings.whisparr_root_folder
if not root_folder:
try:
folders = whisparr_client.get_root_folders()
if folders:
root_folder = folders[0]["path"]
else:
root_folder = "/media"
except Exception:
root_folder = "/media"
# Quality Profile
quality_profile_id = 1
if settings.whisparr_quality_profile_id:
try:
quality_profile_id = int(settings.whisparr_quality_profile_id)
except ValueError:
pass
else:
try:
profiles = whisparr_client.get_quality_profiles()
if profiles:
quality_profile_id = profiles[0]["id"]
except Exception:
pass
# 3. Add movie to Whisparr's database and trigger search
try:
response = whisparr_client.add_movie(movie_to_add, root_folder, quality_profile_id)
# Clear the movies list cache to reflect new tracking status immediately
whisparr_cache.clear()
return {
"status": "success",
"message": f"Successfully added '{movie_to_add.get('title')}' to Whisparr, search triggered.",
"data": response
}
except Exception as e:
# Check if already exists in Whisparr to display a helpful message
# Whisparr returns 400 Bad Request if already added
logger.error(f"Failed to add movie to Whisparr: {e}")
raise HTTPException(status_code=400, detail=f"Failed to add scene: {e}")
@app.get("/api/scene/{stash_id}")
def get_scene_detail(stash_id: str):
"""Fetch full scene metadata from StashDB for the detail drawer."""
if not settings.stashdb_api_key:
raise HTTPException(status_code=400, detail="StashDB is not configured.")
stash_client = StashDBClient(settings.stashdb_url, settings.stashdb_api_key)
try:
data = stash_client.execute_query(SCENE_DETAIL_QUERY, {"id": stash_id})
scene = data.get("findScene")
if not scene:
raise HTTPException(status_code=404, detail="Scene not found on StashDB.")
# Inject library status
whisparr_map: Dict[str, Any] = {}
if settings.whisparr_api_key:
try:
wc = WhisparrClient(settings.whisparr_url, settings.whisparr_api_key)
whisparr_map = get_whisparr_movie_map(wc)
except Exception:
pass
scene["library_status"] = get_scene_library_status(stash_id, whisparr_map)
return scene
except HTTPException:
raise
except Exception as e:
logger.error(f"Scene detail fetch failed: {e}")
raise HTTPException(status_code=500, detail=str(e))

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
fastapi>=0.100.0
uvicorn>=0.22.0
requests>=2.31.0
jinja2>=3.1.2

1190
templates/layout.html Normal file

File diff suppressed because it is too large Load diff