Initial commit
This commit is contained in:
BIN
backend/app/routers/__pycache__/requests.cpython-312.pyc
Normal file
BIN
backend/app/routers/__pycache__/requests.cpython-312.pyc
Normal file
Binary file not shown.
367
backend/app/routers/admin.py
Normal file
367
backend/app/routers/admin.py
Normal file
@@ -0,0 +1,367 @@
|
||||
from typing import Any, Dict, List
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
|
||||
|
||||
from ..auth import require_admin
|
||||
from ..config import settings as env_settings
|
||||
from ..db import (
|
||||
delete_setting,
|
||||
get_all_users,
|
||||
get_request_cache_overview,
|
||||
get_settings_overrides,
|
||||
get_user_by_username,
|
||||
set_setting,
|
||||
set_user_blocked,
|
||||
set_user_password,
|
||||
set_user_role,
|
||||
run_integrity_check,
|
||||
vacuum_db,
|
||||
clear_requests_cache,
|
||||
clear_history,
|
||||
cleanup_history,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..clients.sonarr import SonarrClient
|
||||
from ..clients.radarr import RadarrClient
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..services.jellyfin_sync import sync_jellyfin_users
|
||||
import logging
|
||||
from ..logging_config import configure_logging
|
||||
from ..routers import requests as requests_router
|
||||
from ..routers.branding import save_branding_image
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
"jellyseerr_api_key",
|
||||
"jellyfin_api_key",
|
||||
"sonarr_api_key",
|
||||
"radarr_api_key",
|
||||
"prowlarr_api_key",
|
||||
"qbittorrent_password",
|
||||
}
|
||||
|
||||
SETTING_KEYS: List[str] = [
|
||||
"jellyseerr_base_url",
|
||||
"jellyseerr_api_key",
|
||||
"jellyfin_base_url",
|
||||
"jellyfin_api_key",
|
||||
"jellyfin_public_url",
|
||||
"jellyfin_sync_to_arr",
|
||||
"artwork_cache_mode",
|
||||
"sonarr_base_url",
|
||||
"sonarr_api_key",
|
||||
"sonarr_quality_profile_id",
|
||||
"sonarr_root_folder",
|
||||
"radarr_base_url",
|
||||
"radarr_api_key",
|
||||
"radarr_quality_profile_id",
|
||||
"radarr_root_folder",
|
||||
"prowlarr_base_url",
|
||||
"prowlarr_api_key",
|
||||
"qbittorrent_base_url",
|
||||
"qbittorrent_username",
|
||||
"qbittorrent_password",
|
||||
"log_level",
|
||||
"log_file",
|
||||
"requests_sync_ttl_minutes",
|
||||
"requests_poll_interval_seconds",
|
||||
"requests_delta_sync_interval_minutes",
|
||||
"requests_full_sync_time",
|
||||
"requests_cleanup_time",
|
||||
"requests_cleanup_days",
|
||||
"requests_data_source",
|
||||
]
|
||||
|
||||
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
|
||||
if not isinstance(folders, list):
|
||||
return []
|
||||
results = []
|
||||
for folder in folders:
|
||||
if not isinstance(folder, dict):
|
||||
continue
|
||||
folder_id = folder.get("id")
|
||||
path = folder.get("path")
|
||||
if folder_id is None or path is None:
|
||||
continue
|
||||
results.append({"id": folder_id, "path": path, "label": path})
|
||||
return results
|
||||
|
||||
|
||||
def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]:
|
||||
if not isinstance(profiles, list):
|
||||
return []
|
||||
results = []
|
||||
for profile in profiles:
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
profile_id = profile.get("id")
|
||||
name = profile.get("name")
|
||||
if profile_id is None or name is None:
|
||||
continue
|
||||
results.append({"id": profile_id, "name": name, "label": name})
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def list_settings() -> Dict[str, Any]:
|
||||
overrides = get_settings_overrides()
|
||||
results = []
|
||||
for key in SETTING_KEYS:
|
||||
override_present = key in overrides
|
||||
value = overrides.get(key) if override_present else getattr(env_settings, key)
|
||||
is_set = value is not None and str(value).strip() != ""
|
||||
sensitive = key in SENSITIVE_KEYS
|
||||
results.append(
|
||||
{
|
||||
"key": key,
|
||||
"value": None if sensitive else value,
|
||||
"isSet": is_set,
|
||||
"source": "db" if override_present else ("env" if is_set else "unset"),
|
||||
"sensitive": sensitive,
|
||||
}
|
||||
)
|
||||
return {"settings": results}
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
updates = 0
|
||||
touched_logging = False
|
||||
for key, value in payload.items():
|
||||
if key not in SETTING_KEYS:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and value.strip() == "":
|
||||
delete_setting(key)
|
||||
updates += 1
|
||||
continue
|
||||
set_setting(key, str(value))
|
||||
updates += 1
|
||||
if key in {"log_level", "log_file"}:
|
||||
touched_logging = True
|
||||
if touched_logging:
|
||||
runtime = get_runtime_settings()
|
||||
configure_logging(runtime.log_level, runtime.log_file)
|
||||
return {"status": "ok", "updated": updates}
|
||||
|
||||
|
||||
@router.get("/sonarr/options")
|
||||
async def sonarr_options() -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=400, detail="Sonarr not configured")
|
||||
root_folders = await client.get_root_folders()
|
||||
profiles = await client.get_quality_profiles()
|
||||
return {
|
||||
"rootFolders": _normalize_root_folders(root_folders),
|
||||
"qualityProfiles": _normalize_quality_profiles(profiles),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/radarr/options")
|
||||
async def radarr_options() -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=400, detail="Radarr not configured")
|
||||
root_folders = await client.get_root_folders()
|
||||
profiles = await client.get_quality_profiles()
|
||||
return {
|
||||
"rootFolders": _normalize_root_folders(root_folders),
|
||||
"qualityProfiles": _normalize_quality_profiles(profiles),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/jellyfin/users")
|
||||
async def jellyfin_users() -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=400, detail="Jellyfin not configured")
|
||||
users = await client.get_users()
|
||||
if not isinstance(users, list):
|
||||
return {"users": []}
|
||||
results = []
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"id": user.get("Id"),
|
||||
"name": user.get("Name"),
|
||||
"hasPassword": user.get("HasPassword"),
|
||||
"lastLoginDate": user.get("LastLoginDate"),
|
||||
}
|
||||
)
|
||||
return {"users": results}
|
||||
|
||||
|
||||
@router.post("/jellyfin/users/sync")
|
||||
async def jellyfin_users_sync() -> Dict[str, Any]:
|
||||
imported = await sync_jellyfin_users()
|
||||
return {"status": "ok", "imported": imported}
|
||||
|
||||
|
||||
@router.post("/requests/sync")
|
||||
async def requests_sync() -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
|
||||
state = await requests_router.start_requests_sync(
|
||||
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
|
||||
)
|
||||
logger.info("Admin triggered requests sync: status=%s", state.get("status"))
|
||||
return {"status": "ok", "sync": state}
|
||||
|
||||
|
||||
@router.post("/requests/sync/delta")
|
||||
async def requests_sync_delta() -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
|
||||
state = await requests_router.start_requests_delta_sync(
|
||||
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
|
||||
)
|
||||
logger.info("Admin triggered delta requests sync: status=%s", state.get("status"))
|
||||
return {"status": "ok", "sync": state}
|
||||
|
||||
|
||||
@router.post("/requests/artwork/prefetch")
|
||||
async def requests_artwork_prefetch() -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
state = await requests_router.start_artwork_prefetch(
|
||||
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
|
||||
)
|
||||
logger.info("Admin triggered artwork prefetch: status=%s", state.get("status"))
|
||||
return {"status": "ok", "prefetch": state}
|
||||
|
||||
|
||||
@router.get("/requests/artwork/status")
|
||||
async def requests_artwork_status() -> Dict[str, Any]:
|
||||
return {"status": "ok", "prefetch": requests_router.get_artwork_prefetch_state()}
|
||||
|
||||
|
||||
@router.get("/requests/sync/status")
|
||||
async def requests_sync_status() -> Dict[str, Any]:
|
||||
return {"status": "ok", "sync": requests_router.get_requests_sync_state()}
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def read_logs(lines: int = 200) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
log_file = runtime.log_file
|
||||
if not log_file:
|
||||
raise HTTPException(status_code=400, detail="Log file not configured")
|
||||
if not os.path.isabs(log_file):
|
||||
log_file = os.path.join(os.getcwd(), log_file)
|
||||
if not os.path.exists(log_file):
|
||||
raise HTTPException(status_code=404, detail="Log file not found")
|
||||
lines = max(1, min(lines, 1000))
|
||||
from collections import deque
|
||||
|
||||
with open(log_file, "r", encoding="utf-8", errors="replace") as handle:
|
||||
tail = deque(handle, maxlen=lines)
|
||||
return {"lines": list(tail)}
|
||||
|
||||
|
||||
@router.get("/requests/cache")
|
||||
async def requests_cache(limit: int = 50) -> Dict[str, Any]:
|
||||
return {"rows": get_request_cache_overview(limit)}
|
||||
|
||||
|
||||
@router.post("/branding/logo")
|
||||
async def upload_branding_logo(file: UploadFile = File(...)) -> Dict[str, Any]:
|
||||
return await save_branding_image(file)
|
||||
|
||||
|
||||
@router.post("/maintenance/repair")
|
||||
async def repair_database() -> Dict[str, Any]:
|
||||
result = run_integrity_check()
|
||||
vacuum_db()
|
||||
logger.info("Database repair executed: integrity_check=%s", result)
|
||||
return {"status": "ok", "integrity": result}
|
||||
|
||||
|
||||
@router.post("/maintenance/flush")
|
||||
async def flush_database() -> Dict[str, Any]:
|
||||
cleared = clear_requests_cache()
|
||||
history = clear_history()
|
||||
delete_setting("requests_sync_last_at")
|
||||
logger.warning("Database flush executed: requests_cache=%s history=%s", cleared, history)
|
||||
return {"status": "ok", "requestsCleared": cleared, "historyCleared": history}
|
||||
|
||||
|
||||
@router.post("/maintenance/cleanup")
|
||||
async def cleanup_database(days: int = 90) -> Dict[str, Any]:
|
||||
result = cleanup_history(days)
|
||||
logger.info("Database cleanup executed: days=%s result=%s", days, result)
|
||||
return {"status": "ok", "days": days, "cleared": result}
|
||||
|
||||
|
||||
@router.post("/maintenance/logs/clear")
|
||||
async def clear_logs() -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
log_file = runtime.log_file
|
||||
if not log_file:
|
||||
raise HTTPException(status_code=400, detail="Log file not configured")
|
||||
if not os.path.isabs(log_file):
|
||||
log_file = os.path.join(os.getcwd(), log_file)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
||||
with open(log_file, "w", encoding="utf-8"):
|
||||
pass
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
logger.info("Log file cleared")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users() -> Dict[str, Any]:
|
||||
users = get_all_users()
|
||||
return {"users": users}
|
||||
|
||||
|
||||
@router.post("/users/{username}/block")
|
||||
async def block_user(username: str) -> Dict[str, Any]:
|
||||
set_user_blocked(username, True)
|
||||
return {"status": "ok", "username": username, "blocked": True}
|
||||
|
||||
|
||||
@router.post("/users/{username}/unblock")
|
||||
async def unblock_user(username: str) -> Dict[str, Any]:
|
||||
set_user_blocked(username, False)
|
||||
return {"status": "ok", "username": username, "blocked": False}
|
||||
|
||||
|
||||
@router.post("/users/{username}/role")
|
||||
async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
role = payload.get("role")
|
||||
if role not in {"admin", "user"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
set_user_role(username, role)
|
||||
return {"status": "ok", "username": username, "role": role}
|
||||
|
||||
|
||||
@router.post("/users/{username}/password")
|
||||
async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
new_password = payload.get("password") if isinstance(payload, dict) else None
|
||||
if not isinstance(new_password, str) or len(new_password.strip()) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters.")
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.get("auth_provider") != "local":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Password changes are only available for local users."
|
||||
)
|
||||
set_user_password(username, new_password.strip())
|
||||
return {"status": "ok", "username": username}
|
||||
114
backend/app/routers/auth.py
Normal file
114
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from ..db import (
|
||||
verify_user_password,
|
||||
create_user_if_missing,
|
||||
set_last_login,
|
||||
get_user_by_username,
|
||||
set_user_password,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..security import create_access_token
|
||||
from ..auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
user = verify_user_password(form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
if user.get("is_blocked"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||
token = create_access_token(user["username"], user["role"])
|
||||
set_last_login(user["username"])
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": user["username"], "role": user["role"]},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/jellyfin/login")
|
||||
async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin not configured")
|
||||
try:
|
||||
response = await client.authenticate_by_name(form_data.username, form_data.password)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if not isinstance(response, dict) or not response.get("User"):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
||||
create_user_if_missing(form_data.username, "jellyfin-user", role="user", auth_provider="jellyfin")
|
||||
user = get_user_by_username(form_data.username)
|
||||
if user and user.get("is_blocked"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||
try:
|
||||
users = await client.get_users()
|
||||
if isinstance(users, list):
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
name = user.get("Name")
|
||||
if isinstance(name, str) and name:
|
||||
create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin")
|
||||
except Exception:
|
||||
pass
|
||||
token = create_access_token(form_data.username, "user")
|
||||
set_last_login(form_data.username)
|
||||
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
|
||||
|
||||
|
||||
@router.post("/jellyseerr/login")
|
||||
async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyseerr not configured")
|
||||
payload = {"email": form_data.username, "password": form_data.password}
|
||||
try:
|
||||
response = await client.post("/api/v1/auth/login", payload=payload)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if not isinstance(response, dict):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
|
||||
create_user_if_missing(form_data.username, "jellyseerr-user", role="user", auth_provider="jellyseerr")
|
||||
user = get_user_by_username(form_data.username)
|
||||
if user and user.get("is_blocked"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||
token = create_access_token(form_data.username, "user")
|
||||
set_last_login(form_data.username)
|
||||
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/password")
|
||||
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||
if current_user.get("auth_provider") != "local":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password changes are only available for local users.",
|
||||
)
|
||||
current_password = payload.get("current_password") if isinstance(payload, dict) else None
|
||||
new_password = payload.get("new_password") if isinstance(payload, dict) else None
|
||||
if not isinstance(current_password, str) or not isinstance(new_password, str):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
if len(new_password.strip()) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
|
||||
)
|
||||
user = verify_user_password(current_user["username"], current_password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
|
||||
set_user_password(current_user["username"], new_password.strip())
|
||||
return {"status": "ok"}
|
||||
64
backend/app/routers/branding.py
Normal file
64
backend/app/routers/branding.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import os
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image
|
||||
|
||||
router = APIRouter(prefix="/branding", tags=["branding"])
|
||||
|
||||
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
|
||||
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
|
||||
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
|
||||
|
||||
|
||||
def _ensure_branding_dir() -> None:
|
||||
os.makedirs(_BRANDING_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _resize_image(image: Image.Image, max_size: int = 300) -> Image.Image:
|
||||
image = image.convert("RGBA")
|
||||
image.thumbnail((max_size, max_size))
|
||||
return image
|
||||
|
||||
|
||||
@router.get("/logo.png")
|
||||
async def branding_logo() -> FileResponse:
|
||||
if not os.path.exists(_LOGO_PATH):
|
||||
raise HTTPException(status_code=404, detail="Logo not found")
|
||||
headers = {"Cache-Control": "public, max-age=300"}
|
||||
return FileResponse(_LOGO_PATH, media_type="image/png", headers=headers)
|
||||
|
||||
|
||||
@router.get("/favicon.ico")
|
||||
async def branding_favicon() -> FileResponse:
|
||||
if not os.path.exists(_FAVICON_PATH):
|
||||
raise HTTPException(status_code=404, detail="Favicon not found")
|
||||
headers = {"Cache-Control": "public, max-age=300"}
|
||||
return FileResponse(_FAVICON_PATH, media_type="image/x-icon", headers=headers)
|
||||
|
||||
|
||||
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="Please upload an image file.")
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
|
||||
try:
|
||||
image = Image.open(BytesIO(content))
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=400, detail="Image file could not be read.") from exc
|
||||
|
||||
_ensure_branding_dir()
|
||||
image = _resize_image(image, 300)
|
||||
image.save(_LOGO_PATH, format="PNG")
|
||||
|
||||
favicon = image.copy()
|
||||
favicon.thumbnail((64, 64))
|
||||
try:
|
||||
favicon.save(_FAVICON_PATH, format="ICO", sizes=[(32, 32), (64, 64)])
|
||||
except OSError:
|
||||
favicon.save(_FAVICON_PATH, format="ICO")
|
||||
|
||||
return {"status": "ok", "width": image.width, "height": image.height}
|
||||
82
backend/app/routers/images.py
Normal file
82
backend/app/routers/images.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import os
|
||||
import re
|
||||
import mimetypes
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
import httpx
|
||||
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/images", tags=["images"])
|
||||
|
||||
_TMDB_BASE = "https://image.tmdb.org/t/p"
|
||||
_ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
|
||||
|
||||
|
||||
def _safe_filename(path: str) -> str:
|
||||
trimmed = path.strip("/")
|
||||
trimmed = trimmed.replace("/", "_")
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
|
||||
return safe or "image"
|
||||
|
||||
|
||||
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
|
||||
if not path or "://" in path or ".." in path:
|
||||
return False
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
if size not in _ALLOWED_SIZES:
|
||||
return False
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
|
||||
if cache_mode != "cache":
|
||||
return False
|
||||
|
||||
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
file_path = os.path.join(cache_dir, _safe_filename(path))
|
||||
if os.path.exists(file_path):
|
||||
return True
|
||||
|
||||
url = f"{_TMDB_BASE}/{size}{path}"
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
content = response.content
|
||||
with open(file_path, "wb") as handle:
|
||||
handle.write(content)
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/tmdb")
|
||||
async def tmdb_image(path: str, size: str = "w342"):
|
||||
if not path or "://" in path or ".." in path:
|
||||
raise HTTPException(status_code=400, detail="Invalid image path")
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
if size not in _ALLOWED_SIZES:
|
||||
raise HTTPException(status_code=400, detail="Invalid size")
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
|
||||
url = f"{_TMDB_BASE}/{size}{path}"
|
||||
if cache_mode != "cache":
|
||||
return RedirectResponse(url=url)
|
||||
|
||||
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
file_path = os.path.join(cache_dir, _safe_filename(path))
|
||||
headers = {"Cache-Control": "public, max-age=86400"}
|
||||
if os.path.exists(file_path):
|
||||
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
||||
return FileResponse(file_path, media_type=media_type, headers=headers)
|
||||
|
||||
try:
|
||||
await cache_tmdb_image(path, size)
|
||||
if os.path.exists(file_path):
|
||||
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
||||
return FileResponse(file_path, media_type=media_type, headers=headers)
|
||||
raise HTTPException(status_code=502, detail="Image cache failed")
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Image fetch failed: {exc}") from exc
|
||||
1539
backend/app/routers/requests.py
Normal file
1539
backend/app/routers/requests.py
Normal file
File diff suppressed because it is too large
Load Diff
95
backend/app/routers/status.py
Normal file
95
backend/app/routers/status.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from typing import Any, Dict
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..clients.sonarr import SonarrClient
|
||||
from ..clients.radarr import RadarrClient
|
||||
from ..clients.prowlarr import ProwlarrClient
|
||||
from ..clients.qbittorrent import QBittorrentClient
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
|
||||
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
||||
if not configured:
|
||||
return {"name": name, "status": "not_configured"}
|
||||
try:
|
||||
result = await func()
|
||||
return {"name": name, "status": "up", "detail": result}
|
||||
except httpx.HTTPError as exc:
|
||||
return {"name": name, "status": "down", "message": str(exc)}
|
||||
except Exception as exc:
|
||||
return {"name": name, "status": "down", "message": str(exc)}
|
||||
|
||||
|
||||
@router.get("/services")
|
||||
async def services_status() -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||
qbittorrent = QBittorrentClient(
|
||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||
)
|
||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
|
||||
services = []
|
||||
services.append(
|
||||
await _check(
|
||||
"Jellyseerr",
|
||||
jellyseerr.configured(),
|
||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
||||
)
|
||||
)
|
||||
services.append(
|
||||
await _check(
|
||||
"Sonarr",
|
||||
sonarr.configured(),
|
||||
sonarr.get_system_status,
|
||||
)
|
||||
)
|
||||
services.append(
|
||||
await _check(
|
||||
"Radarr",
|
||||
radarr.configured(),
|
||||
radarr.get_system_status,
|
||||
)
|
||||
)
|
||||
prowlarr_status = await _check(
|
||||
"Prowlarr",
|
||||
prowlarr.configured(),
|
||||
prowlarr.get_health,
|
||||
)
|
||||
if prowlarr_status.get("status") == "up":
|
||||
health = prowlarr_status.get("detail")
|
||||
if isinstance(health, list) and health:
|
||||
prowlarr_status["status"] = "degraded"
|
||||
prowlarr_status["message"] = "Health warnings"
|
||||
services.append(prowlarr_status)
|
||||
services.append(
|
||||
await _check(
|
||||
"qBittorrent",
|
||||
qbittorrent.configured(),
|
||||
qbittorrent.get_app_version,
|
||||
)
|
||||
)
|
||||
services.append(
|
||||
await _check(
|
||||
"Jellyfin",
|
||||
jellyfin.configured(),
|
||||
jellyfin.get_system_info,
|
||||
)
|
||||
)
|
||||
|
||||
overall = "up"
|
||||
if any(s.get("status") == "down" for s in services):
|
||||
overall = "down"
|
||||
elif any(s.get("status") in {"degraded", "not_configured"} for s in services):
|
||||
overall = "degraded"
|
||||
|
||||
return {"overall": overall, "services": services}
|
||||
Reference in New Issue
Block a user