Build 2602262241: live request page updates
This commit is contained in:
@@ -1 +1 @@
|
|||||||
2602262204
|
2602262241
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
BUILD_NUMBER = "2602262204"
|
BUILD_NUMBER = "2602262241"
|
||||||
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'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import time
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from ..auth import get_current_user_event_stream
|
from ..auth import get_current_user_event_stream
|
||||||
@@ -20,6 +20,58 @@ def _sse_json(payload: Dict[str, Any]) -> str:
|
|||||||
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
|
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonable(value: Any) -> Any:
|
||||||
|
if hasattr(value, "model_dump"):
|
||||||
|
try:
|
||||||
|
return value.model_dump(mode="json")
|
||||||
|
except TypeError:
|
||||||
|
return value.model_dump()
|
||||||
|
if hasattr(value, "dict"):
|
||||||
|
try:
|
||||||
|
return value.dict()
|
||||||
|
except TypeError:
|
||||||
|
return value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _request_history_brief(entries: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"request_id": entry.get("request_id"),
|
||||||
|
"state": entry.get("state"),
|
||||||
|
"state_reason": entry.get("state_reason"),
|
||||||
|
"created_at": entry.get("created_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"request_id": entry.get("request_id"),
|
||||||
|
"action_id": entry.get("action_id"),
|
||||||
|
"label": entry.get("label"),
|
||||||
|
"status": entry.get("status"),
|
||||||
|
"message": entry.get("message"),
|
||||||
|
"created_at": entry.get("created_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream")
|
@router.get("/stream")
|
||||||
async def events_stream(
|
async def events_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -110,3 +162,88 @@ async def events_stream(
|
|||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
}
|
}
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests/{request_id}/stream")
|
||||||
|
async def request_events_stream(
|
||||||
|
request_id: str,
|
||||||
|
request: Request,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
request_id = str(request_id).strip()
|
||||||
|
if not request_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing request id")
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
yield "retry: 2000\n\n"
|
||||||
|
last_signature: Optional[str] = None
|
||||||
|
next_refresh_at = 0.0
|
||||||
|
heartbeat_counter = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
sent_any = False
|
||||||
|
|
||||||
|
if now >= next_refresh_at:
|
||||||
|
next_refresh_at = now + 2.0
|
||||||
|
try:
|
||||||
|
snapshot = await requests_router.get_snapshot(request_id=request_id, user=user)
|
||||||
|
history_payload = await requests_router.request_history(
|
||||||
|
request_id=request_id, limit=5, user=user
|
||||||
|
)
|
||||||
|
actions_payload = await requests_router.request_actions(
|
||||||
|
request_id=request_id, limit=5, user=user
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"type": "request_live",
|
||||||
|
"request_id": request_id,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"snapshot": _jsonable(snapshot),
|
||||||
|
"history": _request_history_brief(
|
||||||
|
history_payload.get("snapshots", []) if isinstance(history_payload, dict) else []
|
||||||
|
),
|
||||||
|
"actions": _request_actions_brief(
|
||||||
|
actions_payload.get("actions", []) if isinstance(actions_payload, dict) else []
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except HTTPException as exc:
|
||||||
|
payload = {
|
||||||
|
"type": "request_live",
|
||||||
|
"request_id": request_id,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"error": str(exc.detail),
|
||||||
|
"status_code": int(exc.status_code),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
payload = {
|
||||||
|
"type": "request_live",
|
||||||
|
"request_id": request_id,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
||||||
|
if signature != last_signature:
|
||||||
|
last_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)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, 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, getEventStreamToken, getToken } from '../../lib/auth'
|
||||||
|
|
||||||
type TimelineHop = {
|
type TimelineHop = {
|
||||||
service: string
|
service: string
|
||||||
@@ -254,6 +254,64 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
|
|||||||
load()
|
load()
|
||||||
}, [params.id, router])
|
}, [params.id, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getToken()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
let closed = false
|
||||||
|
let source: EventSource | null = null
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
try {
|
||||||
|
const streamToken = await getEventStreamToken()
|
||||||
|
if (closed) return
|
||||||
|
const streamUrl = `${baseUrl}/events/requests/${encodeURIComponent(
|
||||||
|
params.id
|
||||||
|
)}/stream?stream_token=${encodeURIComponent(streamToken)}`
|
||||||
|
source = new EventSource(streamUrl)
|
||||||
|
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
if (closed) return
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data)
|
||||||
|
if (!payload || typeof payload !== 'object' || payload.type !== 'request_live') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (String(payload.request_id ?? '') !== String(params.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (payload.snapshot && typeof payload.snapshot === 'object') {
|
||||||
|
setSnapshot(payload.snapshot as Snapshot)
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.history)) {
|
||||||
|
setHistorySnapshots(payload.history as SnapshotHistory[])
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.actions)) {
|
||||||
|
setHistoryActions(payload.actions as ActionHistory[])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onerror = () => {
|
||||||
|
if (closed) return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (closed) return
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void connect()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true
|
||||||
|
source?.close()
|
||||||
|
}
|
||||||
|
}, [params.id])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card">
|
||||||
|
|||||||
Reference in New Issue
Block a user