From 86ec770ba45ee5b7b9b3b7432c273d3979636e18 Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Fri, 17 Jul 2026 13:09:34 -0400 Subject: [PATCH] Initial commit: Huntarr v1 - Premium Adult Request Dashboard --- .gitignore | 8 + Dockerfile | 29 + main.py | 747 ++++++++++++++++++++++++++ requirements.txt | 4 + templates/layout.html | 1190 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1978 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 templates/layout.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66c2df0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*$py.class +venv/ +env/ +.env +config.json +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e8074ea --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/main.py b/main.py new file mode 100644 index 0000000..dae368d --- /dev/null +++ b/main.py @@ -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)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b3015a4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.100.0 +uvicorn>=0.22.0 +requests>=2.31.0 +jinja2>=3.1.2 diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..95593ab --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,1190 @@ + + + + + + Huntarr{% if search_query %} · {{ search_query }}{% endif %} + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ +
+ + + + + + + + {% if active_tab == 'scenes' %} + + {% endif %} + + +
+ + + {% if active_tab == 'scenes' and missing_only %}{% endif %} +
+ + + + +
+
+ + +
+ + + + + + + + {% if active_tab == 'scenes' %} + + {% endif %} + + + +
+
+ + + +{% if active_tab in ['performers', 'studios', 'tags'] %} + +{% endif %} + + + +{% if active_tab == 'scenes' %} + + + + + +
+ + +
+
+
+ +
+ + +
+ Filters + +
+ + + + +
+ + {% if search_query %}{% endif %} + + +
+ + +
+ + + +
+ + +
+ Quick Tags +
+ {% for t in ["Anal","Blowjob","POV","Creampie","MILF","VR","Teen","Threesome","Lesbian","Outdoor","Interracial","Amateur"] %} + {{ t }} + {% endfor %} +
+
+ + +
+ Studios +
+ {% for s in ["Brazzers","Vixen","Blacked","Deeper","Naughty America","Mofos","Evil Angel","Reality Kings"] %} + {{ s }} + {% endfor %} +
+
+ + +
+
+
+{% endif %} + + + +
+ + + {% if not items %} +
+
+ + + +
+
+

No {{ active_tab }} found

+

+ {% if not stashdb_status.configured %}Configure your StashDB API key in settings. + {% else %}Try a different search or adjust filters.{% endif %} +

+
+ {% if not stashdb_status.configured %} + + {% endif %} +
+ + + + {% elif active_tab == 'scenes' %} +
+ {% for scene in items %} + {% set img = scene.images[0].url if scene.images else '' %} +
+ + +
+ {% if img %} + {{ scene.title or '' }} + {% endif %} +
+ +
+ + + {% if scene.library_status == 'Active/In Library' %} + + + + + {% elif scene.library_status == 'Tracking/Searching' %} + Tracking + {% endif %} + +
+ +
+
+ + +
+

+ {{ scene.title or '(Untitled)' }} +

+
+ {{ scene.date or '–' }} + {% if scene.studio %} + {{ scene.studio.name }} + {% endif %} +
+ + +
+ {% if scene.library_status == 'Active/In Library' %} + + + In Library + + {% elif scene.library_status == 'Tracking/Searching' %} + + + Tracking + + {% else %} + + {% endif %} +
+
+
+ {% endfor %} +
+ + + + {% elif active_tab == 'performers' %} +
+ {% for p in items %} + {% set img = p.images[0].url if p.images else '' %} + + + +
+ {% if img %} + {{ p.name }} + {% endif %} +
+ {{ p.name[0]|upper }} +
+
+ +

+ {{ p.name }} +

+ {% if p.gender %} + {{ p.gender }} + {% endif %} +
+ {% endfor %} +
+ + + + {% elif active_tab == 'studios' %} +
+ {% for studio in items %} + {% set img = studio.images[0].url if studio.images else '' %} + +
+ {% if img %} + {{ studio.name }} + {% endif %} +
+ + + +
+
+
+

{{ studio.name }}

+
+
+ {% endfor %} +
+ + + + {% elif active_tab == 'tags' %} +
+ +
+ {% set letters = ['#','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'] %} + {% for letter in letters %} + + {{ letter }} + + {% endfor %} +
+ + +
+ {% set ns = namespace(current_letter='') %} + {% for tag in items %} + {% set first = tag.name[0]|upper if tag.name else '#' %} + {% set bucket = first if first in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' else '#' %} + {% if bucket != ns.current_letter %} + {% set ns.current_letter = bucket %} + {{ bucket }} + {% endif %} + + {{ tag.name }} + + {% endfor %} +
+
+ {% endif %} + + + + {% if total_pages > 1 %} +
+ {% set sp = [1, page-3]|max %} + {% set ep = [total_pages, page+3]|min %} + + {% if page > 1 %} + + + + {% endif %} + + {% if sp > 1 %} + 1 + {% if sp > 2 %}{% endif %} + {% endif %} + + {% for p in range(sp, ep+1) %} + {{ p }} + {% endfor %} + + {% if ep < total_pages %} + {% if ep < total_pages - 1 %}{% endif %} + {{ total_pages }} + {% endif %} + + {% if page < total_pages %} + + + + {% endif %} +
+ {% endif %} +
+ + + + + + + + + + + + + + + + +
+ + + + + + +