802 lines
28 KiB
Python
802 lines
28 KiB
Python
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))
|