Initial commit: Huntarr v1 - Premium Adult Request Dashboard
This commit is contained in:
commit
86ec770ba4
5 changed files with 1978 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
venv/
|
||||
env/
|
||||
.env
|
||||
config.json
|
||||
.DS_Store
|
||||
29
Dockerfile
Normal file
29
Dockerfile
Normal 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
747
main.py
Normal 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
4
requirements.txt
Normal 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
1190
templates/layout.html
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue