Build 2602261523: live updates, invite cleanup and nuclear resync

This commit is contained in:
2026-02-26 15:24:10 +13:00
parent 5dfe614d15
commit 50be0b6b57
12 changed files with 939 additions and 230 deletions

View File

@@ -1 +1 @@
2602261442 2602261523

View File

@@ -1,5 +1,5 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Any from typing import Dict, Any, Optional
from fastapi import Depends, HTTPException, status, Request from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
@@ -38,7 +38,7 @@ def _extract_client_ip(request: Request) -> str:
return "unknown" return "unknown"
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]: def _load_current_user_from_token(token: str, request: Optional[Request] = None) -> Dict[str, Any]:
try: try:
payload = safe_decode_token(token) payload = safe_decode_token(token)
except TokenError as exc: except TokenError as exc:
@@ -73,7 +73,32 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
} }
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
return _load_current_user_from_token(token, request)
def get_current_user_event_stream(request: Request) -> Dict[str, Any]:
"""EventSource cannot send Authorization headers, so allow a query token here only."""
token = None
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
token = request.query_params.get("access_token")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
return _load_current_user_from_token(token, None)
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
if user.get("role") != "admin": if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user return user
def require_admin_event_stream(
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> Dict[str, Any]:
if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user

View File

@@ -1,2 +1,2 @@
BUILD_NUMBER = "2602261442" BUILD_NUMBER = "2602261523"
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container' CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'

View File

@@ -1991,6 +1991,29 @@ def clear_history() -> Dict[str, int]:
return {"actions": actions, "snapshots": snapshots} return {"actions": actions, "snapshots": snapshots}
def clear_user_objects_nuclear() -> Dict[str, int]:
with _connect() as conn:
# Preserve admin accounts, but remove invite/profile references so profile rows can be deleted safely.
admin_reset = conn.execute(
"""
UPDATE users
SET profile_id = NULL,
invited_by_code = NULL,
invited_at = NULL
WHERE role = 'admin'
"""
).rowcount
users = conn.execute("DELETE FROM users WHERE role != 'admin'").rowcount
invites = conn.execute("DELETE FROM signup_invites").rowcount
profiles = conn.execute("DELETE FROM user_profiles").rowcount
return {
"users": users,
"invites": invites,
"profiles": profiles,
"adminsReset": admin_reset,
}
def cleanup_history(days: int) -> Dict[str, int]: def cleanup_history(days: int) -> Dict[str, int]:
if days <= 0: if days <= 0:
return {"actions": 0, "snapshots": 0} return {"actions": 0, "snapshots": 0}

View File

@@ -13,12 +13,13 @@ from .routers.requests import (
run_daily_db_cleanup, run_daily_db_cleanup,
) )
from .routers.auth import router as auth_router from .routers.auth import router as auth_router
from .routers.admin import router as admin_router from .routers.admin import router as admin_router, events_router as admin_events_router
from .routers.images import router as images_router from .routers.images import router as images_router
from .routers.branding import router as branding_router from .routers.branding import router as branding_router
from .routers.status import router as status_router from .routers.status import router as status_router
from .routers.feedback import router as feedback_router from .routers.feedback import router as feedback_router
from .routers.site import router as site_router from .routers.site import router as site_router
from .routers.events import router as events_router
from .services.jellyfin_sync import run_daily_jellyfin_sync from .services.jellyfin_sync import run_daily_jellyfin_sync
from .logging_config import configure_logging from .logging_config import configure_logging
from .runtime import get_runtime_settings from .runtime import get_runtime_settings
@@ -53,8 +54,10 @@ async def startup() -> None:
app.include_router(requests_router) app.include_router(requests_router)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(admin_events_router)
app.include_router(images_router) app.include_router(images_router)
app.include_router(branding_router) app.include_router(branding_router)
app.include_router(status_router) app.include_router(status_router)
app.include_router(feedback_router) app.include_router(feedback_router)
app.include_router(site_router) app.include_router(site_router)
app.include_router(events_router)

View File

@@ -1,15 +1,18 @@
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import asyncio
import ipaddress import ipaddress
import json
import os import os
import secrets import secrets
import sqlite3 import sqlite3
import string import string
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Request
from fastapi.responses import StreamingResponse
from ..auth import require_admin, get_current_user from ..auth import require_admin, get_current_user, require_admin_event_stream
from ..config import settings as env_settings from ..config import settings as env_settings
from ..db import ( from ..db import (
delete_setting, delete_setting,
@@ -37,6 +40,7 @@ from ..db import (
vacuum_db, vacuum_db,
clear_requests_cache, clear_requests_cache,
clear_history, clear_history,
clear_user_objects_nuclear,
cleanup_history, cleanup_history,
update_request_cache_title, update_request_cache_title,
repair_request_cache_titles, repair_request_cache_titles,
@@ -65,6 +69,7 @@ from ..services.user_cache import (
match_jellyseerr_user_id, match_jellyseerr_user_id,
save_jellyfin_users_cache, save_jellyfin_users_cache,
save_jellyseerr_users_cache, save_jellyseerr_users_cache,
clear_user_import_caches,
) )
import logging import logging
from ..logging_config import configure_logging from ..logging_config import configure_logging
@@ -72,6 +77,7 @@ from ..routers import requests as requests_router
from ..routers.branding import save_branding_image from ..routers.branding import save_branding_image
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)]) router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SENSITIVE_KEYS = { SENSITIVE_KEYS = {
@@ -130,6 +136,36 @@ SETTING_KEYS: List[str] = [
"site_banner_tone", "site_banner_tone",
] ]
def _admin_live_state_snapshot() -> Dict[str, Any]:
return {
"type": "admin_live_state",
"requestsSync": requests_router.get_requests_sync_state(),
"artworkPrefetch": requests_router.get_artwork_prefetch_state(),
}
def _sse_encode(data: Dict[str, Any]) -> str:
payload = json.dumps(data, ensure_ascii=True, separators=(",", ":"), default=str)
return f"data: {payload}\n\n"
def _read_log_tail_lines(lines: int) -> List[str]:
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 list(tail)
def _normalize_username(value: str) -> str: def _normalize_username(value: str) -> str:
normalized = value.strip().lower() normalized = value.strip().lower()
if "@" in normalized: if "@" in normalized:
@@ -608,22 +644,65 @@ async def requests_sync_status() -> Dict[str, Any]:
return {"status": "ok", "sync": requests_router.get_requests_sync_state()} return {"status": "ok", "sync": requests_router.get_requests_sync_state()}
@events_router.get("/stream")
async def admin_events_stream(
request: Request,
include_logs: bool = False,
log_lines: int = 200,
_: Dict[str, Any] = Depends(require_admin_event_stream),
) -> StreamingResponse:
async def event_generator():
# Advise client reconnect timing once per stream.
yield "retry: 2000\n\n"
last_snapshot: Optional[str] = None
heartbeat_counter = 0
log_refresh_counter = 5 if include_logs else 0
latest_logs_payload: Optional[Dict[str, Any]] = None
while True:
if await request.is_disconnected():
break
snapshot_payload = _admin_live_state_snapshot()
if include_logs:
log_refresh_counter += 1
if log_refresh_counter >= 5:
log_refresh_counter = 0
try:
latest_logs_payload = {
"lines": _read_log_tail_lines(log_lines),
"count": max(1, min(int(log_lines or 200), 1000)),
}
except HTTPException as exc:
latest_logs_payload = {
"error": str(exc.detail) if exc.detail else "Could not read logs",
}
except Exception as exc:
latest_logs_payload = {"error": str(exc)}
snapshot_payload["logs"] = latest_logs_payload
snapshot = _sse_encode(snapshot_payload)
if snapshot != last_snapshot:
last_snapshot = snapshot
yield snapshot
heartbeat_counter = 0
else:
heartbeat_counter += 1
# Keep the stream alive through proxies even when state is unchanged.
if heartbeat_counter >= 15:
yield ": ping\n\n"
heartbeat_counter = 0
await asyncio.sleep(1.0)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
@router.get("/logs") @router.get("/logs")
async def read_logs(lines: int = 200) -> Dict[str, Any]: async def read_logs(lines: int = 200) -> Dict[str, Any]:
runtime = get_runtime_settings() return {"lines": _read_log_tail_lines(lines)}
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") @router.get("/requests/cache")
@@ -689,9 +768,23 @@ async def repair_database() -> Dict[str, Any]:
async def flush_database() -> Dict[str, Any]: async def flush_database() -> Dict[str, Any]:
cleared = clear_requests_cache() cleared = clear_requests_cache()
history = clear_history() history = clear_history()
user_objects = clear_user_objects_nuclear()
user_caches = clear_user_import_caches()
delete_setting("requests_sync_last_at") delete_setting("requests_sync_last_at")
logger.warning("Database flush executed: requests_cache=%s history=%s", cleared, history) logger.warning(
return {"status": "ok", "requestsCleared": cleared, "historyCleared": history} "Database flush executed: requests_cache=%s history=%s user_objects=%s user_caches=%s",
cleared,
history,
user_objects,
user_caches,
)
return {
"status": "ok",
"requestsCleared": cleared,
"historyCleared": history,
"userObjectsCleared": user_objects,
"userCachesCleared": user_caches,
}
@router.post("/maintenance/cleanup") @router.post("/maintenance/cleanup")

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
import asyncio
import json
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from ..auth import get_current_user_event_stream
from . import requests as requests_router
from .status import services_status
router = APIRouter(prefix="/events", tags=["events"])
def _sse_json(payload: Dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
@router.get("/stream")
async def events_stream(
request: Request,
recent_days: int = 90,
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> StreamingResponse:
recent_days = max(0, min(int(recent_days or 90), 3650))
recent_take = 50 if user.get("role") == "admin" else 6
async def event_generator():
yield "retry: 2000\n\n"
last_recent_signature: Optional[str] = None
last_services_signature: Optional[str] = None
next_recent_at = 0.0
next_services_at = 0.0
heartbeat_counter = 0
while True:
if await request.is_disconnected():
break
now = time.monotonic()
sent_any = False
if now >= next_recent_at:
next_recent_at = now + 15.0
try:
recent_payload = await requests_router.recent_requests(
take=recent_take,
skip=0,
days=recent_days,
user=user,
)
results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
payload = {
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"results": results if isinstance(results, list) else [],
}
except Exception as exc:
payload = {
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_recent_signature:
last_recent_signature = signature
yield _sse_json(payload)
sent_any = True
if now >= next_services_at:
next_services_at = now + 30.0
try:
status_payload = await services_status()
payload = {
"type": "home_services",
"ts": datetime.now(timezone.utc).isoformat(),
"status": status_payload,
}
except Exception as exc:
payload = {
"type": "home_services",
"ts": datetime.now(timezone.utc).isoformat(),
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_services_signature:
last_services_signature = signature
yield _sse_json(payload)
sent_any = True
if sent_any:
heartbeat_counter = 0
else:
heartbeat_counter += 1
if heartbeat_counter >= 15:
yield ": ping\n\n"
heartbeat_counter = 0
await asyncio.sleep(1.0)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)

View File

@@ -3,7 +3,7 @@ import logging
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ..db import get_setting, set_setting from ..db import get_setting, set_setting, delete_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -142,3 +142,17 @@ def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]: def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes) return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
def clear_user_import_caches() -> Dict[str, int]:
cleared = 0
for key in (
JELLYSEERR_CACHE_KEY,
JELLYSEERR_CACHE_AT_KEY,
JELLYFIN_CACHE_KEY,
JELLYFIN_CACHE_AT_KEY,
):
delete_setting(key)
cleared += 1
logger.debug("Cleared user import cache keys: %s", cleared)
return {"settingsKeysCleared": cleared}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell' import AdminShell from '../ui/AdminShell'
@@ -141,6 +141,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null) const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null)
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null) const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false) const [maintenanceBusy, setMaintenanceBusy] = useState(false)
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const requestsSyncRef = useRef<any | null>(null)
const artworkPrefetchRef = useRef<any | null>(null)
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async () => {
const baseUrl = getApiBase() const baseUrl = getApiBase()
@@ -338,6 +341,14 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return false return false
} }
useEffect(() => {
requestsSyncRef.current = requestsSync
}, [requestsSync])
useEffect(() => {
artworkPrefetchRef.current = artworkPrefetch
}, [artworkPrefetch])
const settingDescriptions: Record<string, string> = { const settingDescriptions: Record<string, string> = {
jellyseerr_base_url: jellyseerr_base_url:
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.', 'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
@@ -576,7 +587,100 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
useEffect(() => { useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status !== 'running') { const shouldSubscribe = showRequestsExtras || showArtworkExtras || showLogs
if (!shouldSubscribe) {
setLiveStreamConnected(false)
return
}
const token = getToken()
if (!token) {
setLiveStreamConnected(false)
return
}
const baseUrl = getApiBase()
const params = new URLSearchParams()
params.set('access_token', token)
if (showLogs) {
params.set('include_logs', '1')
params.set('log_lines', String(logsCount))
}
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
let closed = false
const source = new EventSource(streamUrl)
source.onopen = () => {
if (closed) return
setLiveStreamConnected(true)
}
source.onmessage = (event) => {
if (closed) return
setLiveStreamConnected(true)
try {
const payload = JSON.parse(event.data)
if (!payload || payload.type !== 'admin_live_state') {
return
}
const rawSync =
payload.requestsSync && typeof payload.requestsSync === 'object'
? payload.requestsSync
: null
const nextSync = rawSync?.status === 'idle' ? null : rawSync
const prevSync = requestsSyncRef.current
requestsSyncRef.current = nextSync
setRequestsSync(nextSync)
if (prevSync?.status === 'running' && nextSync?.status && nextSync.status !== 'running') {
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
}
const rawArtwork =
payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
? payload.artworkPrefetch
: null
const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
const prevArtwork = artworkPrefetchRef.current
artworkPrefetchRef.current = nextArtwork
setArtworkPrefetch(nextArtwork)
if (
prevArtwork?.status === 'running' &&
nextArtwork?.status &&
nextArtwork.status !== 'running'
) {
setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
if (showArtworkExtras) {
void loadArtworkSummary()
}
}
if (payload.logs && typeof payload.logs === 'object') {
if (Array.isArray(payload.logs.lines)) {
setLogsLines(payload.logs.lines)
setLogsStatus(null)
} else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
setLogsStatus(payload.logs.error)
}
}
} catch (err) {
console.error(err)
}
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
return () => {
closed = true
setLiveStreamConnected(false)
source.close()
}
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
useEffect(() => {
if (liveStreamConnected || !artworkPrefetch || artworkPrefetch.status !== 'running') {
return return
} }
let active = true let active = true
@@ -602,7 +706,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false active = false
clearInterval(timer) clearInterval(timer)
} }
}, [artworkPrefetch, loadArtworkSummary]) }, [artworkPrefetch, liveStreamConnected, loadArtworkSummary])
useEffect(() => { useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') { if (!artworkPrefetch || artworkPrefetch.status === 'running') {
@@ -615,7 +719,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}, [artworkPrefetch]) }, [artworkPrefetch])
useEffect(() => { useEffect(() => {
if (!requestsSync || requestsSync.status !== 'running') { if (liveStreamConnected || !requestsSync || requestsSync.status !== 'running') {
return return
} }
let active = true let active = true
@@ -640,7 +744,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false active = false
clearInterval(timer) clearInterval(timer)
} }
}, [requestsSync]) }, [liveStreamConnected, requestsSync])
useEffect(() => { useEffect(() => {
if (!requestsSync || requestsSync.status === 'running') { if (!requestsSync || requestsSync.status === 'running') {
@@ -683,12 +787,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (!showLogs) { if (!showLogs) {
return return
} }
if (liveStreamConnected) {
return
}
void loadLogs() void loadLogs()
const timer = setInterval(() => { const timer = setInterval(() => {
void loadLogs() void loadLogs()
}, 5000) }, 5000)
return () => clearInterval(timer) return () => clearInterval(timer)
}, [loadLogs, showLogs]) }, [liveStreamConnected, loadLogs, showLogs])
const loadCache = async () => { const loadCache = async () => {
setCacheStatus(null) setCacheStatus(null)
@@ -763,7 +870,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setMaintenanceBusy(true) setMaintenanceBusy(true)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const ok = window.confirm( const ok = window.confirm(
'This will clear cached requests and history, then re-sync from Jellyseerr. Continue?' 'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from Jellyseerr. Continue?'
) )
if (!ok) { if (!ok) {
setMaintenanceBusy(false) setMaintenanceBusy(false)
@@ -772,7 +879,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
setMaintenanceStatus('Flushing database...') setMaintenanceStatus('Running nuclear flush...')
const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, { const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, {
method: 'POST', method: 'POST',
}) })
@@ -780,12 +887,25 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const text = await flushResponse.text() const text = await flushResponse.text()
throw new Error(text || 'Flush failed') throw new Error(text || 'Flush failed')
} }
setMaintenanceStatus('Database flushed. Starting re-sync...') const flushData = await flushResponse.json()
const usersCleared = Number(flushData?.userObjectsCleared?.users ?? 0)
setMaintenanceStatus(`Nuclear flush complete. Cleared ${usersCleared} non-admin users. Re-syncing users...`)
const usersResyncResponse = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
method: 'POST',
})
if (!usersResyncResponse.ok) {
const text = await usersResyncResponse.text()
throw new Error(text || 'User resync failed')
}
const usersResyncData = await usersResyncResponse.json()
setMaintenanceStatus(
`Users re-synced (${usersResyncData?.imported ?? 0} imported). Starting request re-sync...`
)
await syncRequests() await syncRequests()
setMaintenanceStatus('Database flushed. Re-sync running now.') setMaintenanceStatus('Nuclear flush complete. User and request re-sync running now.')
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setMaintenanceStatus('Flush + resync failed.') setMaintenanceStatus('Nuclear flush + resync failed.')
} finally { } finally {
setMaintenanceBusy(false) setMaintenanceBusy(false)
} }
@@ -1452,7 +1572,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<h2>Maintenance</h2> <h2>Maintenance</h2>
</div> </div>
<div className="status-banner"> <div className="status-banner">
Emergency tools. Use with care: flush will clear saved requests and history. Emergency tools. Use with care: flush + resync now performs a nuclear wipe of non-admin users, invite links, profiles, cached requests, and history before re-syncing Jellyseerr users/requests.
</div> </div>
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>} {maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
<div className="maintenance-grid"> <div className="maintenance-grid">
@@ -1471,7 +1591,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
onClick={runFlushAndResync} onClick={runFlushAndResync}
disabled={maintenanceBusy} disabled={maintenanceBusy}
> >
Flush database + resync Nuclear flush + resync
</button> </button>
</div> </div>
</section> </section>

View File

@@ -477,26 +477,6 @@ export default function AdminInviteManagementPage() {
<button type="button" onClick={loadData} disabled={loading}> <button type="button" onClick={loadData} disabled={loading}>
{loading ? 'Loading…' : 'Reload'} {loading ? 'Loading…' : 'Reload'}
</button> </button>
<button
type="button"
className="ghost-button"
onClick={() => {
resetInviteEditor()
setActiveTab('invites')
}}
>
New invite
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
resetProfileEditor()
setActiveTab('profiles')
}}
>
New profile
</button>
</div> </div>
} }
> >
@@ -504,29 +484,48 @@ export default function AdminInviteManagementPage() {
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>} {status && <div className="status-banner">{status}</div>}
<div className="invite-admin-summary-grid"> <div className="admin-panel invite-admin-summary-panel">
<div className="admin-summary-tile invite-admin-summary-tile"> <div className="invite-admin-summary-header">
<div>
<h2>Overview</h2>
<p className="lede">
Quick counts for invite links, profiles, and managed user defaults.
</p>
</div>
</div>
<div className="invite-admin-summary-list">
<div className="invite-admin-summary-row">
<span className="label">Invites</span> <span className="label">Invites</span>
<div className="invite-admin-summary-row__value">
<strong>{invites.length}</strong> <strong>{invites.length}</strong>
<small>{usableInvites} usable {disabledInvites} disabled</small> <span>{usableInvites} usable {disabledInvites} disabled</span>
</div> </div>
<div className="admin-summary-tile invite-admin-summary-tile"> </div>
<div className="invite-admin-summary-row">
<span className="label">Profiles</span> <span className="label">Profiles</span>
<div className="invite-admin-summary-row__value">
<strong>{profiles.length}</strong> <strong>{profiles.length}</strong>
<small>{activeProfiles} active profiles</small> <span>{activeProfiles} active profiles</span>
</div> </div>
<div className="admin-summary-tile invite-admin-summary-tile"> </div>
<div className="invite-admin-summary-row">
<span className="label">Non-admin users</span> <span className="label">Non-admin users</span>
<div className="invite-admin-summary-row__value">
<strong>{nonAdminUsers.length}</strong> <strong>{nonAdminUsers.length}</strong>
<small>{profiledUsers} with profile</small> <span>{profiledUsers} with profile</span>
</div> </div>
<div className="admin-summary-tile invite-admin-summary-tile"> </div>
<div className="invite-admin-summary-row">
<span className="label">Expiry rules</span> <span className="label">Expiry rules</span>
<div className="invite-admin-summary-row__value">
<strong>{expiringUsers}</strong> <strong>{expiringUsers}</strong>
<small>users with custom expiry</small> <span>users with custom expiry</span>
</div>
</div>
</div> </div>
</div> </div>
<div className="invite-admin-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Invite management sections"> <div className="admin-segmented" role="tablist" aria-label="Invite management sections">
<button <button
type="button" type="button"
@@ -556,6 +555,29 @@ export default function AdminInviteManagementPage() {
Invites Invites
</button> </button>
</div> </div>
<div className="admin-inline-actions invite-admin-tab-actions">
<button
type="button"
className="ghost-button"
onClick={() => {
resetInviteEditor()
setActiveTab('invites')
}}
>
New invite
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
resetProfileEditor()
setActiveTab('profiles')
}}
>
New profile
</button>
</div>
</div>
{activeTab === 'bulk' && ( {activeTab === 'bulk' && (
<div className="admin-split-grid invite-admin-bulk-grid"> <div className="admin-split-grid invite-admin-bulk-grid">
@@ -815,10 +837,15 @@ export default function AdminInviteManagementPage() {
<p className="lede"> <p className="lede">
Link an invite to a profile to apply account defaults at sign-up. Link an invite to a profile to apply account defaults at sign-up.
</p> </p>
<form onSubmit={saveInvite} className="admin-form compact-form"> <form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
<div className="admin-fields-grid"> <div className="invite-form-row">
<div className="invite-form-row-label">
<span>Identity</span>
<small>Code and label used to identify the invite link.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label> <label>
Code (optional) <span>Code (optional)</span>
<input <input
value={inviteForm.code} value={inviteForm.code}
onChange={(e) => onChange={(e) =>
@@ -828,7 +855,7 @@ export default function AdminInviteManagementPage() {
/> />
</label> </label>
<label> <label>
Label <span>Label</span>
<input <input
value={inviteForm.label} value={inviteForm.label}
onChange={(e) => onChange={(e) =>
@@ -838,8 +865,14 @@ export default function AdminInviteManagementPage() {
/> />
</label> </label>
</div> </div>
<label> </div>
Description
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Description</span>
<small>Optional note shown on the signup page.</small>
</div>
<div className="invite-form-row-control">
<textarea <textarea
rows={3} rows={3}
value={inviteForm.description} value={inviteForm.description}
@@ -848,10 +881,17 @@ export default function AdminInviteManagementPage() {
} }
placeholder="Optional note shown on the signup page" placeholder="Optional note shown on the signup page"
/> />
</label> </div>
<div className="admin-fields-grid"> </div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Defaults</span>
<small>Choose a profile and optional role override for sign-up.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label> <label>
Profile <span>Profile</span>
<select <select
value={inviteForm.profile_id} value={inviteForm.profile_id}
onChange={(e) => onChange={(e) =>
@@ -867,7 +907,7 @@ export default function AdminInviteManagementPage() {
</select> </select>
</label> </label>
<label> <label>
Role override <span>Role override</span>
<select <select
value={inviteForm.role} value={inviteForm.role}
onChange={(e) => onChange={(e) =>
@@ -883,9 +923,16 @@ export default function AdminInviteManagementPage() {
</select> </select>
</label> </label>
</div> </div>
<div className="admin-fields-grid"> </div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Limits</span>
<small>Usage cap and optional expiry date/time for the invite.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label> <label>
Max uses <span>Max uses</span>
<input <input
value={inviteForm.max_uses} value={inviteForm.max_uses}
onChange={(e) => onChange={(e) =>
@@ -896,7 +943,7 @@ export default function AdminInviteManagementPage() {
/> />
</label> </label>
<label> <label>
Invite expiry (ISO datetime) <span>Invite expiry (ISO datetime)</span>
<input <input
value={inviteForm.expires_at} value={inviteForm.expires_at}
onChange={(e) => onChange={(e) =>
@@ -906,6 +953,14 @@ export default function AdminInviteManagementPage() {
/> />
</label> </label>
</div> </div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
<small>Enable or disable the invite before sharing.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label className="inline-checkbox"> <label className="inline-checkbox">
<input <input
type="checkbox" type="checkbox"
@@ -926,6 +981,8 @@ export default function AdminInviteManagementPage() {
</button> </button>
)} )}
</div> </div>
</div>
</div>
</form> </form>
</div> </div>
<div className="admin-panel"> <div className="admin-panel">

View File

@@ -1027,6 +1027,30 @@ button span {
gap: 12px; gap: 12px;
} }
/* Header account menu layering fix */
.header {
position: relative;
overflow: visible;
}
.header-right {
position: relative;
z-index: 40;
}
.signed-in-menu {
z-index: 50;
}
.signed-in-dropdown {
z-index: 2000;
}
.header-nav {
position: relative;
z-index: 1;
}
.admin-toolbar { .admin-toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -4442,6 +4466,149 @@ button:hover:not(:disabled) {
grid-template-columns: minmax(360px, 1.2fr) minmax(300px, 0.8fr); grid-template-columns: minmax(360px, 1.2fr) minmax(300px, 0.8fr);
} }
.invite-admin-summary-panel {
display: grid;
gap: 10px;
margin-bottom: 12px;
}
.invite-admin-summary-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.invite-admin-summary-header h2 {
margin: 0;
font-size: 0.98rem;
}
.invite-admin-summary-header .lede {
margin: 4px 0 0;
}
.invite-admin-summary-list {
display: grid;
gap: 6px;
}
.invite-admin-summary-row {
display: grid;
grid-template-columns: minmax(150px, 200px) minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.015);
}
.invite-admin-summary-row .label {
font-size: 0.78rem;
color: #9ea7b6;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.invite-admin-summary-row__value {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 8px 12px;
}
.invite-admin-summary-row__value strong {
color: #eef2f7;
font-size: 1rem;
}
.invite-admin-summary-row__value span {
color: #b3bcc8;
font-size: 0.82rem;
}
.invite-admin-tabbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 12px;
margin-bottom: 12px;
}
.invite-admin-tabbar .admin-segmented {
margin-bottom: 0;
}
.invite-admin-tab-actions {
justify-content: flex-end;
}
.invite-form-layout {
gap: 10px;
}
.invite-form-row {
display: grid;
grid-template-columns: minmax(150px, 190px) minmax(0, 1fr);
gap: 12px;
align-items: start;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.015);
border-radius: 10px;
}
.invite-form-row-label {
display: grid;
gap: 4px;
}
.invite-form-row-label > span {
color: #e6ebf2;
font-size: 0.85rem;
font-weight: 600;
}
.invite-form-row-label > small {
color: #9ea7b6;
font-size: 0.76rem;
line-height: 1.3;
}
.invite-form-row-control {
display: grid;
gap: 8px;
}
.invite-form-row-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.invite-form-row-grid > label {
display: grid;
gap: 6px;
}
.invite-form-row-grid > label > span {
color: #9ea7b6;
font-size: 0.76rem;
}
.invite-form-row-control textarea,
.invite-form-row-control input,
.invite-form-row-control select {
width: 100%;
}
.invite-form-row-control--stacked {
gap: 10px;
}
.admin-panel > h2 + .lede { .admin-panel > h2 + .lede {
margin-top: -2px; margin-top: -2px;
} }
@@ -4462,6 +4629,14 @@ button:hover:not(:disabled) {
.invite-admin-bulk-grid { .invite-admin-bulk-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.invite-form-row {
grid-template-columns: 1fr;
}
.invite-form-row-grid {
grid-template-columns: 1fr;
}
} }
@media (max-width: 980px) { @media (max-width: 980px) {
@@ -4499,4 +4674,21 @@ button:hover:not(:disabled) {
.invite-admin-summary-grid { .invite-admin-summary-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.invite-admin-summary-row {
grid-template-columns: 1fr;
align-items: start;
}
.invite-admin-summary-row__value {
justify-content: flex-start;
}
.invite-admin-tabbar {
align-items: stretch;
}
.invite-admin-tab-actions {
justify-content: flex-start;
}
} }

View File

@@ -4,6 +4,24 @@ import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth' import { authFetch, getApiBase, getToken, clearToken } from './lib/auth'
const normalizeRecentResults = (items: any[]) =>
items
.filter((item: any) => item?.id)
.map((item: any) => {
const id = item.id
const rawTitle = item.title
const placeholder =
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
return {
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
export default function HomePage() { export default function HomePage() {
const router = useRouter() const router = useRouter()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
@@ -33,6 +51,7 @@ export default function HomePage() {
const [servicesError, setServicesError] = useState<string | null>(null) const [servicesError, setServicesError] = useState<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({}) const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({}) const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const submit = (event: React.FormEvent) => { const submit = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
@@ -137,25 +156,7 @@ export default function HomePage() {
} }
const data = await response.json() const data = await response.json()
if (Array.isArray(data?.results)) { if (Array.isArray(data?.results)) {
setRecent( setRecent(normalizeRecentResults(data.results))
data.results
.filter((item: any) => item?.id)
.map((item: any) => {
const id = item.id
const rawTitle = item.title
const placeholder =
typeof rawTitle === 'string' &&
rawTitle.trim().toLowerCase() === `request ${id}`
return {
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
)
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -196,10 +197,79 @@ export default function HomePage() {
} }
} }
load() void load()
if (liveStreamConnected) {
return
}
const timer = setInterval(load, 30000) const timer = setInterval(load, 30000)
return () => clearInterval(timer) return () => clearInterval(timer)
}, [authReady, router]) }, [authReady, liveStreamConnected, router])
useEffect(() => {
if (!authReady) {
setLiveStreamConnected(false)
return
}
const token = getToken()
if (!token) {
setLiveStreamConnected(false)
return
}
const baseUrl = getApiBase()
const streamUrl = `${baseUrl}/events/stream?access_token=${encodeURIComponent(token)}&recent_days=${encodeURIComponent(String(recentDays))}`
let closed = false
const source = new EventSource(streamUrl)
source.onopen = () => {
if (closed) return
setLiveStreamConnected(true)
}
source.onmessage = (event) => {
if (closed) return
setLiveStreamConnected(true)
try {
const payload = JSON.parse(event.data)
if (!payload || typeof payload !== 'object') {
return
}
if (payload.type === 'home_recent') {
if (Array.isArray(payload.results)) {
setRecent(normalizeRecentResults(payload.results))
setRecentError(null)
setRecentLoading(false)
} else if (typeof payload.error === 'string' && payload.error.trim()) {
setRecentError('Recent requests are not available right now.')
setRecentLoading(false)
}
return
}
if (payload.type === 'home_services') {
if (payload.status && typeof payload.status === 'object') {
setServicesStatus(payload.status)
setServicesError(null)
setServicesLoading(false)
} else if (typeof payload.error === 'string' && payload.error.trim()) {
setServicesError('Service status is not available right now.')
setServicesLoading(false)
}
}
} catch (error) {
console.error(error)
}
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
return () => {
closed = true
setLiveStreamConnected(false)
source.close()
}
}, [authReady, recentDays])
const runSearch = async (term: string) => { const runSearch = async (term: string) => {
try { try {