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, Form from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel import base64 import hmac import hashlib # 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_root_folder: 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 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_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))