diff --git a/.build_number b/.build_number index e930301..8f8cb84 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -2602262204 \ No newline at end of file +2602262241 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 23e3934..4639903 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -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' diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 3acdeca..ae8c547 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -6,7 +6,7 @@ import time from datetime import datetime, timezone 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 ..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" +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") async def events_stream( request: Request, @@ -110,3 +162,88 @@ async def events_stream( "X-Accel-Buffering": "no", } 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) diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index c965f59..9375780 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' -import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' +import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../../lib/auth' type TimelineHop = { service: string @@ -254,6 +254,64 @@ export default function RequestTimelinePage({ params }: { params: { id: string } load() }, [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) { return (