Homelabtoolkit v1
This commit is contained in:
@@ -15,15 +15,25 @@ logging.basicConfig(
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("embytoolkit")
|
||||
logger = logging.getLogger("homelabtoolkit")
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFont, ImageFilter, ImageColor, ImageOps, UnidentifiedImageError
|
||||
|
||||
from services import db as db_service
|
||||
from services import favorites as favorites_service
|
||||
from services import music_covers as music_service
|
||||
from services import music_library as library_service
|
||||
from services import navidrome as navidrome_service
|
||||
from services import settings as settings_service
|
||||
from services.favorites import FavoritesError
|
||||
from services.music_covers import ProcessOptions
|
||||
from services.navidrome import NavidromeError
|
||||
from services.recommendations import DEFAULT_TARGET_SIZE
|
||||
|
||||
Image.MAX_IMAGE_PIXELS = None # Emby backdrops can exceed PIL's default bomb threshold; source is trusted
|
||||
|
||||
EMBY_URL = os.environ.get("EMBY_URL", "http://10.0.0.2:8096")
|
||||
@@ -290,8 +300,21 @@ async def get_cached_emby_source_image(
|
||||
# --- Emby API helpers ---
|
||||
|
||||
|
||||
def apply_settings(values: dict) -> None:
|
||||
"""Push effective settings into the live module globals the app reads."""
|
||||
global EMBY_URL, EMBY_API_KEY
|
||||
EMBY_URL = values["emby_url"]
|
||||
EMBY_API_KEY = values["emby_api_key"]
|
||||
navidrome_service.NAVIDROME_URL = values["navidrome_url"]
|
||||
navidrome_service.NAVIDROME_USER = values["navidrome_user"]
|
||||
navidrome_service.NAVIDROME_PASSWORD = values["navidrome_password"]
|
||||
music_service.MUSIC_ROOT = Path(values["music_root"])
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
apply_settings(settings_service.load())
|
||||
db_service.init_db()
|
||||
get_http_client()
|
||||
try:
|
||||
yield
|
||||
@@ -302,9 +325,14 @@ async def lifespan(app: FastAPI):
|
||||
http_client = None
|
||||
|
||||
|
||||
app = FastAPI(title="EmbyToolkit", lifespan=lifespan)
|
||||
app = FastAPI(title="HomelabToolkit", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# The React SPA is built to frontend/dist. When present we serve its assets and
|
||||
# fall back to index.html for client-side routes (see the catch-all near the end).
|
||||
FRONTEND_DIST = Path("frontend/dist")
|
||||
if (FRONTEND_DIST / "assets").exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets")
|
||||
|
||||
def get_http_client() -> httpx.AsyncClient:
|
||||
global http_client
|
||||
@@ -1983,25 +2011,6 @@ async def render_collection_art_preview(options: dict) -> tuple[str, bytes]:
|
||||
|
||||
# --- API Routes ---
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
return templates.TemplateResponse(request, "index.html")
|
||||
|
||||
|
||||
@app.get("/collections", response_class=HTMLResponse)
|
||||
async def collections_page(request: Request):
|
||||
return templates.TemplateResponse(request, "collections.html")
|
||||
|
||||
|
||||
@app.get("/airing", response_class=HTMLResponse)
|
||||
async def airing_page(request: Request):
|
||||
return templates.TemplateResponse(request, "airing.html")
|
||||
|
||||
|
||||
@app.get("/bulk-assign", response_class=HTMLResponse)
|
||||
async def bulk_assign_page(request: Request):
|
||||
return templates.TemplateResponse(request, "bulk_assign.html")
|
||||
|
||||
|
||||
@app.get("/api/bulk-assign/series")
|
||||
async def get_bulk_assign_series(
|
||||
@@ -2209,7 +2218,7 @@ async def bulk_apply_new_season_banner(request: Request):
|
||||
skipped_missing_assets: list[str] = []
|
||||
failed: list[dict] = []
|
||||
|
||||
logger.info("=== Bulk Apply Selected: %d %s item(s) ===", len(item_ids), emby_type)
|
||||
logger.info("=== Bulk Apply Selected: %d item(s) ===", len(item_ids))
|
||||
for i, item_id in enumerate(item_ids):
|
||||
item = items_by_id.get(item_id)
|
||||
if item is None:
|
||||
@@ -2892,11 +2901,495 @@ async def bulk_apply_category(request: Request):
|
||||
@app.get("/api/config")
|
||||
async def get_config():
|
||||
return {
|
||||
"emby_url": EMBY_URL,
|
||||
"connected": bool(EMBY_API_KEY),
|
||||
"app_name": "HomelabToolkit",
|
||||
"emby": {
|
||||
"url": EMBY_URL,
|
||||
"connected": bool(EMBY_API_KEY),
|
||||
},
|
||||
"navidrome": {
|
||||
"url": navidrome_service.NAVIDROME_URL,
|
||||
"configured": navidrome_service.is_configured(),
|
||||
},
|
||||
"music": {
|
||||
"root": str(music_service.MUSIC_ROOT),
|
||||
"available": music_service.MUSIC_ROOT.exists(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Dashboard overview ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/api/dashboard")
|
||||
async def get_dashboard():
|
||||
"""Aggregate library counts for the homepage. Resilient: any failing source
|
||||
degrades to null/0 rather than failing the whole response."""
|
||||
client = get_http_client()
|
||||
|
||||
async def emby_count(item_types: str) -> int:
|
||||
data = await emby_get("/Items", {
|
||||
"IncludeItemTypes": item_types,
|
||||
"Recursive": "true",
|
||||
"Limit": "1",
|
||||
"ImageTypeLimit": "0",
|
||||
})
|
||||
return int(data.get("TotalRecordCount", 0))
|
||||
|
||||
async def latest_added() -> str | None:
|
||||
data = await emby_get("/Items", {
|
||||
"IncludeItemTypes": "Movie,Episode",
|
||||
"Recursive": "true",
|
||||
"SortBy": "DateCreated",
|
||||
"SortOrder": "Descending",
|
||||
"Limit": "1",
|
||||
"Fields": "DateCreated",
|
||||
})
|
||||
items = data.get("Items") or []
|
||||
return items[0].get("DateCreated") if items else None
|
||||
|
||||
async def user_count() -> int:
|
||||
data = await emby_get("/Users")
|
||||
return len(data) if isinstance(data, list) else 0
|
||||
|
||||
async def favorites_count() -> int:
|
||||
return len(await favorites_service.list_favorites_users(emby_client_adapter))
|
||||
|
||||
async def navidrome_stats() -> dict:
|
||||
status = await navidrome_service.ping(client)
|
||||
if not status.get("connected"):
|
||||
return {"connected": False, "configured": status.get("configured", False)}
|
||||
stats = await navidrome_service.get_stats(client)
|
||||
return {"connected": True, "configured": True, **stats}
|
||||
|
||||
(
|
||||
movies, series, episodes, collections, users, favorites, last_added, navidrome,
|
||||
) = await asyncio.gather(
|
||||
emby_count("Movie"),
|
||||
emby_count("Series"),
|
||||
emby_count("Episode"),
|
||||
emby_count("BoxSet"),
|
||||
user_count(),
|
||||
favorites_count(),
|
||||
latest_added(),
|
||||
navidrome_stats(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
def safe(value, default=0):
|
||||
return default if isinstance(value, Exception) else value
|
||||
|
||||
return {
|
||||
"emby": {
|
||||
"connected": bool(EMBY_API_KEY),
|
||||
"url": EMBY_URL,
|
||||
"movies": safe(movies),
|
||||
"series": safe(series),
|
||||
"episodes": safe(episodes),
|
||||
"collections": safe(collections),
|
||||
"users": safe(users),
|
||||
"favorites_collections": safe(favorites),
|
||||
"last_added": safe(last_added, None),
|
||||
},
|
||||
"navidrome": navidrome if not isinstance(navidrome, Exception) else {"connected": False},
|
||||
"music": {
|
||||
"available": music_service.MUSIC_ROOT.exists(),
|
||||
"root": str(music_service.MUSIC_ROOT),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/emby/refresh-libraries")
|
||||
async def emby_refresh_libraries():
|
||||
"""Trigger a scan of all Emby libraries."""
|
||||
response = await emby_request(
|
||||
"POST", "/Library/Refresh", headers={"X-Emby-Token": EMBY_API_KEY}
|
||||
)
|
||||
if response.status_code not in (200, 204):
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Emby library refresh failed ({response.status_code}).",
|
||||
)
|
||||
return {"status": "started"}
|
||||
|
||||
|
||||
@app.post("/api/navidrome/scan")
|
||||
async def navidrome_scan(full: bool = Query(False)):
|
||||
"""Trigger a Navidrome library scan."""
|
||||
try:
|
||||
result = await navidrome_service.start_scan(get_http_client(), full=full)
|
||||
except NavidromeError as exc:
|
||||
raise _handle_navidrome_error(exc) from exc
|
||||
return {"status": "started", **result}
|
||||
|
||||
|
||||
@app.get("/api/emby/user-activity")
|
||||
async def emby_user_activity():
|
||||
"""Per-user last login + last activity, enriched with the most recent
|
||||
session's IP/device. ``/Users`` carries login timestamps; ``/Sessions``
|
||||
carries the remote endpoint (IP)."""
|
||||
users, sessions = await asyncio.gather(
|
||||
emby_get("/Users"),
|
||||
emby_get("/Sessions"),
|
||||
return_exceptions=True,
|
||||
)
|
||||
if isinstance(users, Exception):
|
||||
raise HTTPException(status_code=502, detail="Could not load Emby users.")
|
||||
if isinstance(sessions, Exception) or not isinstance(sessions, list):
|
||||
sessions = []
|
||||
|
||||
def clean_ip(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
value = value.strip()
|
||||
if value.startswith("::ffff:"):
|
||||
value = value[len("::ffff:"):]
|
||||
return value or None
|
||||
|
||||
def platform_of(client: str | None, device: str | None) -> str:
|
||||
text = f"{client or ''} {device or ''}".lower()
|
||||
if "android" in text:
|
||||
return "android"
|
||||
if any(k in text for k in ("ios", "iphone", "ipad", "apple tv", "tvos")):
|
||||
return "ios"
|
||||
if any(k in text for k in ("web", "browser", "chrome", "firefox", "safari", "edge")):
|
||||
return "web"
|
||||
return "other"
|
||||
|
||||
latest_session: dict[str, dict] = {}
|
||||
devices: dict[str, str] = {} # device id -> platform
|
||||
for session in sessions:
|
||||
client = session.get("Client")
|
||||
device = session.get("DeviceName")
|
||||
device_id = session.get("DeviceId") or f"{device}::{client}"
|
||||
if device_id:
|
||||
devices[device_id] = platform_of(client, device)
|
||||
user_id = session.get("UserId")
|
||||
if not user_id:
|
||||
continue
|
||||
last = session.get("LastActivityDate") or ""
|
||||
existing = latest_session.get(user_id)
|
||||
if existing is None or last > existing["last"]:
|
||||
latest_session[user_id] = {
|
||||
"last": last,
|
||||
"ip": clean_ip(session.get("RemoteEndPoint")),
|
||||
"device": device,
|
||||
"client": client,
|
||||
}
|
||||
|
||||
rows = []
|
||||
for user in users if isinstance(users, list) else []:
|
||||
user_id = user.get("Id")
|
||||
session = latest_session.get(user_id, {})
|
||||
rows.append({
|
||||
"id": user_id,
|
||||
"name": user.get("Name", ""),
|
||||
"last_login": user.get("LastLoginDate"),
|
||||
"last_activity": user.get("LastActivityDate"),
|
||||
"ip": session.get("ip"),
|
||||
"device": session.get("device"),
|
||||
"client": session.get("client"),
|
||||
})
|
||||
|
||||
rows.sort(key=lambda r: (r["last_activity"] or r["last_login"] or ""), reverse=True)
|
||||
|
||||
platform_counts = {"android": 0, "ios": 0, "web": 0, "other": 0}
|
||||
for platform in devices.values():
|
||||
platform_counts[platform] += 1
|
||||
device_count = len(devices)
|
||||
|
||||
def pct(value: int) -> int:
|
||||
return round(value / device_count * 100) if device_count else 0
|
||||
|
||||
summary = {
|
||||
"user_count": len(rows),
|
||||
"device_count": device_count,
|
||||
"platforms": platform_counts,
|
||||
"platform_pct": {key: pct(value) for key, value in platform_counts.items()},
|
||||
}
|
||||
return {"users": rows, "summary": summary}
|
||||
|
||||
|
||||
# ── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/api/settings")
|
||||
async def get_settings():
|
||||
return settings_service.load()
|
||||
|
||||
|
||||
@app.post("/api/settings")
|
||||
async def update_settings(request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(status_code=400, detail="Settings payload must be an object.")
|
||||
values = settings_service.save(body)
|
||||
apply_settings(values)
|
||||
navidrome_status = await navidrome_service.ping(get_http_client())
|
||||
return {
|
||||
"settings": values,
|
||||
"navidrome": navidrome_status,
|
||||
"music_available": music_service.MUSIC_ROOT.exists(),
|
||||
}
|
||||
|
||||
|
||||
# ── Navidrome (Subsonic API) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _handle_navidrome_error(exc: NavidromeError) -> HTTPException:
|
||||
return HTTPException(status_code=exc.status, detail=exc.message)
|
||||
|
||||
|
||||
@app.get("/api/navidrome/status")
|
||||
async def navidrome_status():
|
||||
return await navidrome_service.ping(get_http_client())
|
||||
|
||||
|
||||
@app.get("/api/navidrome/artists")
|
||||
async def navidrome_artists():
|
||||
try:
|
||||
return {"items": await navidrome_service.get_artists(get_http_client())}
|
||||
except NavidromeError as exc:
|
||||
raise _handle_navidrome_error(exc) from exc
|
||||
|
||||
|
||||
@app.get("/api/navidrome/albums")
|
||||
async def navidrome_albums(
|
||||
q: str = Query(""),
|
||||
type: str = Query("alphabeticalByName"),
|
||||
size: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
client = get_http_client()
|
||||
try:
|
||||
if q.strip():
|
||||
items = await navidrome_service.search_albums(client, q.strip(), count=size)
|
||||
else:
|
||||
items = await navidrome_service.get_albums(client, list_type=type, size=size, offset=offset)
|
||||
return {"items": items, "offset": offset, "size": size, "has_more": len(items) >= size}
|
||||
except NavidromeError as exc:
|
||||
raise _handle_navidrome_error(exc) from exc
|
||||
|
||||
|
||||
_navidrome_formats_cache: dict = {"data": None, "expires": 0.0}
|
||||
NAVIDROME_FORMATS_TTL = 1800
|
||||
|
||||
|
||||
@app.get("/api/navidrome/formats")
|
||||
async def navidrome_formats(refresh: bool = Query(False)):
|
||||
now = time.time()
|
||||
cached = _navidrome_formats_cache
|
||||
if not refresh and cached["data"] is not None and cached["expires"] > now:
|
||||
return cached["data"]
|
||||
try:
|
||||
data = await navidrome_service.get_format_breakdown(get_http_client())
|
||||
except NavidromeError as exc:
|
||||
raise _handle_navidrome_error(exc) from exc
|
||||
_navidrome_formats_cache.update(data=data, expires=now + NAVIDROME_FORMATS_TTL)
|
||||
return data
|
||||
|
||||
|
||||
@app.get("/api/navidrome/album/{album_id}")
|
||||
async def navidrome_album(album_id: str):
|
||||
try:
|
||||
return await navidrome_service.get_album(get_http_client(), album_id)
|
||||
except NavidromeError as exc:
|
||||
raise _handle_navidrome_error(exc) from exc
|
||||
|
||||
|
||||
@app.get("/api/navidrome/cover/{cover_id}")
|
||||
async def navidrome_cover(cover_id: str, size: int = Query(0, ge=0, le=1500)):
|
||||
try:
|
||||
image_bytes, content_type = await navidrome_service.get_cover_art(
|
||||
get_http_client(), cover_id, size or None
|
||||
)
|
||||
except NavidromeError as exc:
|
||||
raise _handle_navidrome_error(exc) from exc
|
||||
return Response(content=image_bytes, media_type=content_type, headers={"Cache-Control": "public, max-age=86400"})
|
||||
|
||||
|
||||
# ── Music library maintenance (music-covers) ─────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/api/music/scan")
|
||||
async def music_scan():
|
||||
return await asyncio.to_thread(music_service.scan_library)
|
||||
|
||||
|
||||
# ── Music Collection Completeness ────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.post("/api/music-collection/scan")
|
||||
async def music_collection_scan():
|
||||
return library_service.start_scan_job()
|
||||
|
||||
|
||||
@app.post("/api/music-collection/refresh-metadata")
|
||||
async def music_collection_refresh_metadata():
|
||||
return library_service.start_metadata_job()
|
||||
|
||||
|
||||
@app.get("/api/music-collection/status")
|
||||
async def music_collection_status():
|
||||
return await asyncio.to_thread(library_service.get_status)
|
||||
|
||||
|
||||
@app.get("/api/music-collection/overview")
|
||||
async def music_collection_overview():
|
||||
return await asyncio.to_thread(library_service.get_overview)
|
||||
|
||||
|
||||
@app.get("/api/music-collection/artists")
|
||||
async def music_collection_artists(q: str = Query("")):
|
||||
return {"artists": await asyncio.to_thread(library_service.get_artists_completeness, q)}
|
||||
|
||||
|
||||
@app.get("/api/music-collection/artist/{artist_id}/albums")
|
||||
async def music_collection_artist_albums(artist_id: int):
|
||||
return await asyncio.to_thread(library_service.get_artist_albums, artist_id)
|
||||
|
||||
|
||||
@app.post("/api/music-collection/album/{completeness_id}/decision")
|
||||
async def music_collection_decision(completeness_id: int, request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
action = (body or {}).get("action", "")
|
||||
try:
|
||||
return await asyncio.to_thread(library_service.set_album_decision, completeness_id, action)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/music/process")
|
||||
async def music_process(request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
options = ProcessOptions.from_dict(body if isinstance(body, dict) else {})
|
||||
album_paths = body.get("album_paths") if isinstance(body, dict) else None
|
||||
result = await asyncio.to_thread(
|
||||
music_service.process_library, options, album_paths=album_paths
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── User Favourites ──────────────────────────────────────────────────────────
|
||||
|
||||
class EmbyClientAdapter:
|
||||
"""Adapts the module-level Emby helpers to the services' client protocol.
|
||||
|
||||
Write operations carry the ``X-Emby-Token`` header (as the image write paths
|
||||
do) and surface Emby errors as HTTPExceptions via ``ensure_emby_success``.
|
||||
"""
|
||||
|
||||
async def get(self, path: str, params: dict | None = None):
|
||||
return await emby_get(path, params)
|
||||
|
||||
async def get_all(self, path: str, params: dict | None = None):
|
||||
return await emby_get_all(path, params)
|
||||
|
||||
async def post(self, path: str, params: dict | None = None, **kwargs):
|
||||
resp = await emby_request("POST", path, params=params, headers={"X-Emby-Token": EMBY_API_KEY}, **kwargs)
|
||||
return ensure_emby_success(resp, context=f"Emby POST {path}")
|
||||
|
||||
async def delete(self, path: str, params: dict | None = None, **kwargs):
|
||||
resp = await emby_request("DELETE", path, params=params, headers={"X-Emby-Token": EMBY_API_KEY}, **kwargs)
|
||||
return ensure_emby_success(resp, context=f"Emby DELETE {path}")
|
||||
|
||||
|
||||
emby_client_adapter = EmbyClientAdapter()
|
||||
|
||||
|
||||
async def _favorites_body(request: Request) -> dict:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return {}
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
@app.get("/api/favorites/users")
|
||||
async def favorites_users():
|
||||
return {"users": await favorites_service.list_favorites_users(emby_client_adapter)}
|
||||
|
||||
|
||||
@app.get("/api/favorites/collections")
|
||||
async def favorites_collections_overview():
|
||||
return await favorites_service.list_collections_overview(emby_client_adapter)
|
||||
|
||||
|
||||
@app.get("/api/favorites/collection/{collection_id}")
|
||||
async def favorites_collection_items(collection_id: str, user_id: str = Query(...)):
|
||||
try:
|
||||
return await favorites_service.get_collection_items_view(emby_client_adapter, collection_id, user_id)
|
||||
except FavoritesError as exc:
|
||||
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
|
||||
|
||||
|
||||
@app.post("/api/favorites/collection/{collection_id}/cleanup")
|
||||
async def favorites_cleanup(collection_id: str, request: Request):
|
||||
body = await _favorites_body(request)
|
||||
user_id = body.get("userId", "")
|
||||
dry_run = bool(body.get("dryRun", True)) # dry-run is the default
|
||||
try:
|
||||
return await favorites_service.cleanup_watched(
|
||||
emby_client_adapter, collection_id, user_id, dry_run=dry_run
|
||||
)
|
||||
except FavoritesError as exc:
|
||||
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
|
||||
|
||||
|
||||
@app.post("/api/favorites/collection/{collection_id}/regenerate")
|
||||
async def favorites_regenerate(collection_id: str, request: Request):
|
||||
body = await _favorites_body(request)
|
||||
user_id = body.get("userId", "")
|
||||
dry_run = bool(body.get("dryRun", True)) # dry-run is the default
|
||||
try:
|
||||
target_size = int(body.get("targetSize", DEFAULT_TARGET_SIZE))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="targetSize must be an integer.") from exc
|
||||
try:
|
||||
return await favorites_service.regenerate(
|
||||
emby_client_adapter, collection_id, user_id, dry_run=dry_run, target_size=target_size
|
||||
)
|
||||
except FavoritesError as exc:
|
||||
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
|
||||
|
||||
|
||||
# ── React SPA (must stay last so /api routes win) ────────────────────────────
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
@app.get("/{full_path:path}", response_class=HTMLResponse)
|
||||
async def serve_spa(full_path: str = ""):
|
||||
"""Serve the built React app, falling back to index.html for client routes."""
|
||||
if full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
index_file = FRONTEND_DIST / "index.html"
|
||||
if not index_file.exists():
|
||||
return HTMLResponse(
|
||||
"<h1>HomelabToolkit</h1><p>Frontend not built. Run "
|
||||
"<code>npm install && npm run build</code> in <code>frontend/</code>.</p>",
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
# Serve real files (favicon, etc.) directly when they exist.
|
||||
if full_path:
|
||||
candidate = FRONTEND_DIST / full_path
|
||||
if candidate.is_file():
|
||||
return FileResponse(candidate)
|
||||
|
||||
return FileResponse(index_file)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8500)
|
||||
|
||||
Reference in New Issue
Block a user