From 0c54dd4f29a88ded33e0732705183098ba754bba Mon Sep 17 00:00:00 2001 From: michael Date: Sun, 24 Aug 2025 19:41:23 -0500 Subject: [PATCH] added websockets for live job status updates with toast notifications on job list page --- backend/app/__pycache__/main.cpython-313.pyc | Bin 9388 -> 11424 bytes .../__pycache__/routes_jobs.cpython-313.pyc | Bin 43302 -> 45228 bytes .../routes_websockets.cpython-313.pyc | Bin 0 -> 8876 bytes backend/app/api/v1/routes_jobs.py | 45 ++ backend/app/api/v1/routes_websockets.py | 214 +++++++++ backend/app/main.py | 35 ++ .../__pycache__/websocket.cpython-313.pyc | Bin 0 -> 17904 bytes backend/app/services/websocket.py | 361 +++++++++++++++ .../__pycache__/__init__.cpython-313.pyc | Bin 7294 -> 7294 bytes .../__pycache__/ingest_and_ai.cpython-313.pyc | Bin 8653 -> 11679 bytes .../tasks/__pycache__/notify.cpython-313.pyc | Bin 6960 -> 10631 bytes .../translate_and_synthesize.cpython-313.pyc | Bin 14299 -> 17318 bytes backend/app/tasks/ingest_and_ai.py | 77 ++++ backend/app/tasks/notify.py | 266 +++++++----- backend/app/tasks/translate_and_synthesize.py | 76 ++++ .../src/components/WebSocketToastHandler.tsx | 74 ++++ frontend/src/hooks/useJobStatusWebSocket.ts | 410 ++++++++++++++++++ frontend/src/lib/api.ts | 4 + frontend/src/routes/jobs/JobDetail.tsx | 47 +- frontend/src/routes/jobs/JobsList.tsx | 51 ++- frontend/src/utils/jobStatusMessages.ts | 127 ++++++ frontend/vite.config.ts | 9 + 22 files changed, 1689 insertions(+), 107 deletions(-) create mode 100644 backend/app/api/v1/__pycache__/routes_websockets.cpython-313.pyc create mode 100644 backend/app/api/v1/routes_websockets.py create mode 100644 backend/app/services/__pycache__/websocket.cpython-313.pyc create mode 100644 backend/app/services/websocket.py create mode 100644 frontend/src/components/WebSocketToastHandler.tsx create mode 100644 frontend/src/hooks/useJobStatusWebSocket.ts create mode 100644 frontend/src/utils/jobStatusMessages.ts diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc index 33c65c8f80214736a2ad8a9d0341da741f88a556..00a4d44117847449567ffb587d99b3883ba4c837 100644 GIT binary patch delta 4147 zcmcgvX>1$E6`mn?sTC>Fx<$#Pv?8g4KB&Xi>65TiQ&g$z9Uqo<|aBbKTu8Z`A>-z=PAL$=S=h#AwSR zVOKj1H}s1#ffi9@);D(3T$`|5Pgcg*4*WCvhtzerDI$j55zAH~?71h4ofuI#TF#D0 zEB)nKO3>1KPs?3vjNM!?_U=5NS5RP<=_*didR@{-dj_h8Ougmjc(ksl6i=e_|s~K#~v#ZZs2E8z< zhxP-y^^W2q`ZK++_}~^JOcFFeC+Gy(VQwT7R3iK6uqct?PvAD-{t4V~!aW7|5x9fd zw~C*lH7-^QBd`hpqbNE(J*p&TSf$=(dz`xTFWFp8)ktHmtQ=!REfr-cR*#PL2u}U7 zt*_k>v{Z7MRiOtb?h{!PJPfKBB}^Gtwb58yRT3#SfDzmDvXbN0-C(-m*51)yDEVup z&9z{4->|xGTD=Q`cV6&*Y|)=C4U{_BKXY=<@2V4fYQ3)Y$EDlOojs}sR9*C3$n#qQ=> zIJR-zz;P2t$P3sG#Isd_SWMA0bu6yPhxA|CdugjKuJh2{x^LYH>djtU_fr!c)DKp8 zX_r1zv7w5;*^*e2X`@M|PO1rw9n^nZ;qBW23&XJdvoO8{3xLcowhK%Hz7=SqFqUMa zQ_;kD9Ly*NX3HvVls=htR%K}BG4QYh2-pdG9N`IsLkNcvjvx#o90l+d<;FaS4&M40 zS`h?Hm3;x=lE9usdko9Ej2wRo}M9$ zCCAVFFG(5}K7EZ%VhU^I$;N@u?oG=%6OF6mQJ#_hp`$`nA?A(jzZ|WmD2m@_G0bTe zH-zbEYRV{@ifYkRit(?7_2W1gbjg%kfKjkD6F`2_4i-%*qjB}5no<*!qbJpIcrmA< zOkvXymCc@CFPbtZz#L}V0DTq~hX*@}kU+q%GB*zLED2CJ0B^!m6O^y|CUQ*5^F<`uKuCfd)8qB7Ojvk= z*vS^t>L3aUn-U}dWiekKd(9KnC!TE>Ou$0I3p=aGn!;vO__pL^LcTk*bSsRo`1&`W zmBU!|V8!M1wEVfKmI@pgl%ZP2)fj)%e!qV>ji-g*fBVIZd{`L^Cu1j+l)Sp%$b7$1 zSgvbjT-MV3L!L;-<7d)c*z`NELf2{>xKuJ1_RyXNWRQ|2o}8Rim?5Z%i6pPkEYk;_ zG3?P0xqdHEhTpnI0q%Y~rFSPds?1Eu7=o|zPvlwhv4x1X^Fz-K&6oAx5(XA6 zqG-Qkmly0UH|#Ap?QIKE+q~3vN3ws__KI!3YUo$eqf2H|?ECv4ZB?0NVuEPkN?s|M zuN|CgnyZ|z-hWFP{y-}IbWsHIGYxY*R}<)`-*5!%0N@fzCZ~ zC7J+L-6Oxd^Go|+CX26s>w+8x57!?~`#}oK zf0&d}fb0~sW2X_yNd^!PiClc_3|eOa3WaCSzq-51`!evq!7@pT&aISpLJYW6jg0%mAY5%8-W@JCpcEVU7 zz8!0cZ%2Xn20(m{K)3m7hX}z}yTt%_2FzeXIh<0W@zm5k3e=?re;<|%pUJSm7nVwE zY!CdrMA$iaEW=H5dsj)W^=NELiJf3Cfd4OgqTx4mMBm@oy!R!HI}czKO+c-I!pmNU zUp_NiVYovxgLh>=EIO&Am>SdAtKj`ef4i}#ZqTqo1xhKWQ*m{SVKl=c#lE78a)^FD zdqCbuGY;tAKhx=F$#hE5Mo%eY8ZTa&QNC)Aa+Vntd71iGxZJtW2FC@(52H<}leyDm zCHH7bj43HN{^giT7~Ux54D6B7v8a|&a;HXq&a6Qi4=aiv3mYhqA5U=_;Nk~Ke!R>b zNb51$TCfMM$H_U-Tbxqj3NFYQ=0YcI4}>)$$OyX;a4R(Indta<;XaDz4}Q=w%GW+x zFx=4jcFXrk!>%S`@$|ToUm^+qxmYc$3>BU`-r#HIFNdpvJ&!+k5eFn8Z9kbDPsf#= z?EB!vI<19F`rGavA@FO`rT^LOc7IYr=$1tbF;y>`wo%X5iP_lcGFhn|8`dqNwOF4gkKd$qOQvEvOp&c$ O zl6IS-6%i2}jByaQ7a8J9aiS6w(FaA=4#tMh%Y!e14|{Mc#Rne*zwgF&sT2e+oZtPO z-|w97eD|Egckj&HJz2F?S!rhYd~5$~!REeM)hsO-Ztl+?uN-d;yTIjU>OQq<+#PO% zHl0_Gw}(C8346gC_JL2=YQ{Ule(>wOc6@(W0VUiCqK!9P)V2EYK==?G3I`z=J`9J$ zJ<#K35w2Pza<8N|*x4nyk8;2VdLwc>QybMLwK>wO+6HCli}Xz`7Tr>em?N&KwBU91 z4fX(2TPC^c2yoRoXvElkQ#0hWwD$4h9MxrKJ8KM}pA{C#Iqi-Z)wYPKS6171WpR{7 zl&)6fk@B5*#hK+e)3a;l3wx~XEm?bL+h2X2o3F$lM*ponR<@}fs((<@ziltS*kblRNl(})KR)Y3T^ zpb>-OY~>~MFj5z`><#gt@^|sDYDBbFH}Rb>))MdLAw|9y@E~8W&Y zSO&7Q8A#42(^;4hr>x$VqqI*{mf&UDA`^mJe_%*lw>BFuppeS1S@&^%KL2&YDqnqx z2A?9J88AsOMW7O#A$WygjNmMSQdV4Gf&_i`Ibt&eloz~;uqMNK;L{vnTh77=cGnmz<7*2WEAFjjL5rqx3$w8WEp{2Mp#DI7?)!zG7S}s`qt|HM8wiE6 zL=@vJCE-o%DF)babRla%b?3aw7PTBCV_8^5^=I)%N5AcO!5qt^bK2EhDme!<8hQ!d z7Nhjl_+TclopCnk<(HdWql2(YJ-!Yl`%g9Dj`6% zq*!sPqVf1_NesHga6vp$nx^S?0^Jdy^VUzRP?b!_Qp<5|7Bb5@ymK?;&@StHqaOyW zP*=a`3s$iw`^9&Sy1DYIr6c;htM%TMS=YhHYUpmw(hb Q%B5!}5j!x5b(^uj0WTc6FaQ7m diff --git a/backend/app/api/v1/__pycache__/routes_jobs.cpython-313.pyc b/backend/app/api/v1/__pycache__/routes_jobs.cpython-313.pyc index 070f4c95f34c9877e75a818cec6144046cd67798..da8c9f44fc866e2f0c068119ac40de32ed059933 100644 GIT binary patch delta 8391 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 literal 0 HcmV?d00001 diff --git a/backend/app/api/v1/routes_jobs.py b/backend/app/api/v1/routes_jobs.py index 457079f..26d943e 100644 --- a/backend/app/api/v1/routes_jobs.py +++ b/backend/app/api/v1/routes_jobs.py @@ -27,6 +27,7 @@ from ...schemas.job import ( VttTimingAdjustRequest, VttUpdateRequest, ) +from ...services.websocket import connection_manager from ...services.gcs import ( gcs_service, upload_file_to_gcs, @@ -351,6 +352,17 @@ async def approve_english( detail="Job not found or not in pending QC status" ) + # Broadcast status update + try: + await connection_manager.broadcast_job_status_update( + job_id=job_id, + status=JobStatus.APPROVED_ENGLISH.value, + message="English content approved - starting translation", + progress=None + ) + except Exception as e: + logger.warning(f"Failed to broadcast status update for job {job_id}: {e}") + # Trigger translation and synthesis pipeline immediately try: translate_and_synthesize_task.delay(job_id) @@ -406,6 +418,17 @@ async def reject_job( detail="Job not found or not in pending QC status" ) + # Broadcast status update + try: + await connection_manager.broadcast_job_status_update( + job_id=job_id, + status=JobStatus.REJECTED.value, + message="Job rejected - requires revision", + progress=None + ) + except Exception as e: + logger.warning(f"Failed to broadcast status update for job {job_id}: {e}") + return JobResponse( id=str(result["_id"]), title=result["title"], @@ -474,6 +497,17 @@ async def complete_job( detail="Job not found or not in pending final review status" ) + # Broadcast status update + try: + await connection_manager.broadcast_job_status_update( + job_id=job_id, + status=JobStatus.COMPLETED.value, + message="Job completed - all files ready for download", + progress=100 + ) + except Exception as e: + logger.warning(f"Failed to broadcast status update for job {job_id}: {e}") + return JobResponse( id=str(result["_id"]), title=result["title"], @@ -521,6 +555,17 @@ async def reject_final_review( detail="Job not found or not in pending final review status" ) + # Broadcast status update + try: + await connection_manager.broadcast_job_status_update( + job_id=job_id, + status=JobStatus.QC_FEEDBACK.value, + message="Final review rejected - requires changes", + progress=None + ) + except Exception as e: + logger.warning(f"Failed to broadcast status update for job {job_id}: {e}") + return JobResponse( id=str(result["_id"]), title=result["title"], diff --git a/backend/app/api/v1/routes_websockets.py b/backend/app/api/v1/routes_websockets.py new file mode 100644 index 0000000..8b5b360 --- /dev/null +++ b/backend/app/api/v1/routes_websockets.py @@ -0,0 +1,214 @@ +""" +WebSocket routes for real-time job status updates + +Provides WebSocket endpoints for: +1. Individual job status updates: /ws/jobs/{job_id} +2. Job list updates: /ws/jobs (all jobs for authenticated user) +""" +import logging +from typing import Optional + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, Depends, Query +from fastapi.security import HTTPBearer + +from ...services.websocket import ( + connection_manager, + authenticate_websocket, + get_connection_manager, + ConnectionManager +) +from ...models.job import Job +from ...core.database import get_database +from ...core.dependencies import get_current_user + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["WebSocket"]) +security = HTTPBearer() + + +@router.websocket("/ws/jobs/{job_id}") +async def websocket_job_status( + websocket: WebSocket, + job_id: str, + token: Optional[str] = Query(None), + manager: ConnectionManager = Depends(get_connection_manager) +): + """ + WebSocket endpoint for real-time job status updates + + Usage: + - Connect: ws://localhost:8000/api/v1/ws/jobs/{job_id}?token={jwt_token} + - Receives: Real-time status updates for the specific job + + Message format: + { + "type": "job_status_update", + "data": { + "job_id": "...", + "status": "processing", + "updated_at": "2023-...", + "message": "Processing video...", + "progress": 45 + } + } + """ + # Authenticate the WebSocket connection + user_id = await authenticate_websocket(websocket, token) + if not user_id: + return + + try: + # Verify user has access to this job + db = await get_database() + jobs_collection = db["jobs"] + + job = await jobs_collection.find_one({"_id": job_id}) + if not job: + await websocket.close(code=4004, reason="Job not found") + return + + # Check permissions - users can only access their own jobs unless they're admin/reviewer + user = await db["users"].find_one({"_id": user_id}) + if not user: + try: + from bson import ObjectId + user = await db["users"].find_one({"_id": ObjectId(user_id)}) + except Exception: + pass # Invalid ObjectId format + + if not user: + await websocket.close(code=4001, reason="User not found") + return + + # Check access permissions + if user["role"] == "client" and job.get("created_by") != user_id: + await websocket.close(code=4003, reason="Access denied") + return + + # Connect to job status updates + await manager.connect_job_status(websocket, user_id, job_id) + + # Keep connection alive and handle incoming messages + while True: + try: + # Wait for incoming WebSocket messages (for heartbeat, etc.) + message = await websocket.receive_text() + logger.debug(f"Received WebSocket message from user {user_id}: {message}") + + # Handle heartbeat or other client messages if needed + if message == "ping": + await websocket.send_text("pong") + + except WebSocketDisconnect: + break + except Exception as e: + logger.error(f"Error in WebSocket message handling: {e}") + break + + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"WebSocket job status error: {e}") + finally: + manager.disconnect(websocket, user_id) + + +@router.websocket("/ws/jobs") +async def websocket_job_list( + websocket: WebSocket, + token: Optional[str] = Query(None), + manager: ConnectionManager = Depends(get_connection_manager) +): + """ + WebSocket endpoint for real-time job list updates + + Usage: + - Connect: ws://localhost:8000/api/v1/ws/jobs?token={jwt_token} + - Receives: Real-time status updates for all jobs the user can access + + Message format: + { + "type": "job_list_update", + "data": { + "job_id": "...", + "status": "processing", + "updated_at": "2023-...", + "message": "Processing video...", + "progress": 45 + } + } + """ + # Authenticate the WebSocket connection + user_id = await authenticate_websocket(websocket, token) + if not user_id: + return + + try: + # Verify user exists + logger.info(f"WebSocket: Looking up user {user_id} in database") + db = await get_database() + + # Try looking up user by string ID first, then by ObjectId + user = await db["users"].find_one({"_id": user_id}) + if not user: + try: + from bson import ObjectId + user = await db["users"].find_one({"_id": ObjectId(user_id)}) + except Exception: + pass # Invalid ObjectId format + + if not user: + logger.warning(f"WebSocket: User {user_id} not found in database (tried both string and ObjectId)") + await websocket.close(code=4001, reason="User not found") + return + + logger.info(f"WebSocket: User {user_id} found, role: {user.get('role', 'unknown')}") + + logger.info(f"WebSocket: User {user_id} found, connecting to job list updates") + # Connect to job list updates + await manager.connect_job_list(websocket, user_id) + + # Keep connection alive and handle incoming messages + while True: + try: + # Wait for incoming WebSocket messages + message = await websocket.receive_text() + logger.debug(f"Received WebSocket message from user {user_id}: {message}") + + # Handle heartbeat or other client messages if needed + if message == "ping": + await websocket.send_text("pong") + + except WebSocketDisconnect: + break + except Exception as e: + logger.error(f"Error in WebSocket message handling: {e}") + break + + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"WebSocket job list error: {e}") + finally: + manager.disconnect(websocket, user_id) + + +@router.get("/ws/status") +async def websocket_status(): + """ + Get WebSocket connection status and statistics + Useful for debugging and monitoring + """ + stats = { + "active_connections": len(connection_manager.active_connections), + "job_subscriptions": len(connection_manager.job_subscriptions), + "global_subscriptions": len(connection_manager.global_subscriptions), + "redis_connected": connection_manager.redis_client is not None, + "subscriber_running": ( + connection_manager.subscriber_task is not None and + not connection_manager.subscriber_task.done() + ) + } + + return stats \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 0b7c3cb..e94cba9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,8 @@ from .api.v1.routes_admin import router as admin_router from .api.v1.routes_auth import router as auth_router from .api.v1.routes_files import router as files_router from .api.v1.routes_jobs import router as jobs_router +from .api.v1.routes_websockets import router as websockets_router +from .services.websocket import connection_manager from .core.config import settings from .core.secrets_config import initialize_config from .core.database import close_mongo_connection, connect_to_mongo, create_indexes @@ -26,6 +28,7 @@ from .telemetry import ( instrument_fastapi_app, setup_tracing ) +from .services.websocket import connection_manager @asynccontextmanager @@ -71,6 +74,9 @@ async def lifespan(app: FastAPI): await connect_to_redis() # await create_indexes() # Temporarily disabled for debugging + # Start WebSocket connection manager + await connection_manager.start() + # Initialize middleware with Redis client redis_client = get_redis_client() if redis_client: @@ -83,6 +89,7 @@ async def lifespan(app: FastAPI): yield # Shutdown + await connection_manager.stop() await close_mongo_connection() await close_redis_connection() @@ -197,6 +204,34 @@ app.include_router(auth_router, prefix="/api/v1") app.include_router(files_router, prefix="/api/v1") app.include_router(jobs_router, prefix="/api/v1") app.include_router(admin_router, prefix="/api/v1") +app.include_router(websockets_router, prefix="/api/v1") + + +@app.on_event("startup") +async def startup_event(): + """Initialize services on startup""" + logger.info("🚀 Starting up FastAPI application...") + + # Start WebSocket connection manager + try: + await connection_manager.start() + logger.info("✅ WebSocket connection manager started successfully") + except Exception as e: + logger.error(f"❌ Failed to start WebSocket connection manager: {e}") + raise + + +@app.on_event("shutdown") +async def shutdown_event(): + """Cleanup services on shutdown""" + logger.info("🛑 Shutting down FastAPI application...") + + # Stop WebSocket connection manager + try: + await connection_manager.stop() + logger.info("✅ WebSocket connection manager stopped successfully") + except Exception as e: + logger.error(f"❌ Error stopping WebSocket connection manager: {e}") @app.get("/health") diff --git a/backend/app/services/__pycache__/websocket.cpython-313.pyc b/backend/app/services/__pycache__/websocket.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..84fe2a52829ee84573a936cc3390abc14ea4bdd9 GIT binary patch literal 17904 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 TnKO)mX-KpU{GPy*o_GEa_@yHu literal 0 HcmV?d00001 diff --git a/backend/app/services/websocket.py b/backend/app/services/websocket.py new file mode 100644 index 0000000..e2aabc7 --- /dev/null +++ b/backend/app/services/websocket.py @@ -0,0 +1,361 @@ +""" +WebSocket Connection Manager for Real-time Job Status Updates + +This module provides WebSocket support for broadcasting job status changes +in real-time to connected clients. It uses Redis pub/sub for scalable +message broadcasting across multiple worker processes. +""" +import asyncio +import json +import logging +from typing import Dict, List, Set, Optional, Any +from datetime import datetime + +from fastapi import WebSocket, WebSocketDisconnect +import redis.asyncio as redis +import redis as sync_redis +from pydantic import BaseModel + +from ..core.redis import get_redis_client +from ..core.security import decode_token +from ..core.config import settings + +logger = logging.getLogger(__name__) + + +class JobStatusUpdate(BaseModel): + """Schema for job status update messages""" + job_id: str + status: str + updated_at: datetime + message: Optional[str] = None + progress: Optional[int] = None # 0-100 percentage + metadata: Optional[Dict[str, Any]] = None + + +class ConnectionManager: + """Manages WebSocket connections and Redis pub/sub for job status updates""" + + def __init__(self): + # Active WebSocket connections by user_id + self.active_connections: Dict[str, Set[WebSocket]] = {} + # Job subscriptions: job_id -> set of user_ids + self.job_subscriptions: Dict[str, Set[str]] = {} + # Global job list subscriptions by user_id + self.global_subscriptions: Set[str] = set() + # Redis client for pub/sub + self.redis_client: Optional[redis.Redis] = None + self.pubsub: Optional[redis.client.PubSub] = None + self.subscriber_task: Optional[asyncio.Task] = None + + async def start(self): + """Initialize Redis pub/sub subscriber""" + try: + self.redis_client = await redis.from_url( + settings.redis_url, + encoding="utf-8", + decode_responses=True + ) + self.pubsub = self.redis_client.pubsub() + + # Subscribe to job status channels + await self.pubsub.subscribe("job_status_updates") # Global channel + await self.pubsub.psubscribe("job_status_updates:*") # Pattern for individual job channels + + # Start background task to handle Redis messages + self.subscriber_task = asyncio.create_task(self._redis_subscriber()) + logger.info("WebSocket connection manager started") + + except Exception as e: + logger.error(f"Failed to start WebSocket connection manager: {e}") + raise + + async def stop(self): + """Cleanup Redis connections""" + if self.subscriber_task: + self.subscriber_task.cancel() + try: + await self.subscriber_task + except asyncio.CancelledError: + pass + + if self.pubsub: + await self.pubsub.unsubscribe() + await self.pubsub.punsubscribe() + await self.pubsub.aclose() + + if self.redis_client: + await self.redis_client.aclose() + + logger.info("WebSocket connection manager stopped") + + async def connect_job_status(self, websocket: WebSocket, user_id: str, job_id: str): + """Connect a WebSocket for specific job status updates""" + await websocket.accept() + + # Add connection to active connections + if user_id not in self.active_connections: + self.active_connections[user_id] = set() + self.active_connections[user_id].add(websocket) + + # Add job subscription + if job_id not in self.job_subscriptions: + self.job_subscriptions[job_id] = set() + self.job_subscriptions[job_id].add(user_id) + + logger.info(f"User {user_id} connected for job {job_id} status updates") + + # Send initial connection confirmation + await self._send_to_websocket(websocket, { + "type": "connection_established", + "job_id": job_id, + "timestamp": datetime.utcnow().isoformat() + }) + + async def connect_job_list(self, websocket: WebSocket, user_id: str): + """Connect a WebSocket for job list updates (all jobs for a user)""" + await websocket.accept() + + # Add connection to active connections + if user_id not in self.active_connections: + self.active_connections[user_id] = set() + self.active_connections[user_id].add(websocket) + + # Add to global subscriptions + self.global_subscriptions.add(user_id) + + logger.info(f"User {user_id} connected for job list updates") + + # Send initial connection confirmation + await self._send_to_websocket(websocket, { + "type": "connection_established", + "scope": "job_list", + "timestamp": datetime.utcnow().isoformat() + }) + + def disconnect(self, websocket: WebSocket, user_id: str): + """Disconnect a WebSocket and clean up subscriptions""" + # Remove from active connections + if user_id in self.active_connections: + self.active_connections[user_id].discard(websocket) + if not self.active_connections[user_id]: + del self.active_connections[user_id] + + # Remove from global subscriptions if no connections left + if user_id not in self.active_connections: + self.global_subscriptions.discard(user_id) + + # Remove from job subscriptions + for job_id in list(self.job_subscriptions.keys()): + self.job_subscriptions[job_id].discard(user_id) + if not self.job_subscriptions[job_id]: + del self.job_subscriptions[job_id] + + logger.info(f"User {user_id} disconnected from WebSocket") + + async def broadcast_job_status_update( + self, + job_id: str, + status: str, + user_id: Optional[str] = None, + message: Optional[str] = None, + progress: Optional[int] = None, + metadata: Optional[Dict[str, Any]] = None + ): + """ + Broadcast job status update to Redis pub/sub + This will be called from Celery workers + """ + update = JobStatusUpdate( + job_id=job_id, + status=status, + updated_at=datetime.utcnow(), + message=message, + progress=progress, + metadata=metadata + ) + + try: + # Create a synchronous Redis client for Celery workers + redis_client = sync_redis.Redis.from_url( + settings.redis_url, + encoding="utf-8", + decode_responses=True + ) + + # Publish to global channel + redis_client.publish( + "job_status_updates", + update.model_dump_json() + ) + + # Publish to specific job channel + redis_client.publish( + f"job_status_updates:{job_id}", + update.model_dump_json() + ) + + # Close the connection + redis_client.close() + + logger.debug(f"Broadcasted status update for job {job_id}: {status}") + + except Exception as e: + logger.error(f"Failed to broadcast job status update: {e}") + + async def _redis_subscriber(self): + """Background task to handle Redis pub/sub messages""" + try: + async for message in self.pubsub.listen(): + # Handle both regular messages and pattern messages + if message["type"] in ("message", "pmessage"): + await self._handle_redis_message(message) + except asyncio.CancelledError: + logger.info("Redis subscriber task cancelled") + except Exception as e: + logger.error(f"Redis subscriber error: {e}") + + async def _handle_redis_message(self, message: Dict[str, Any]): + """Handle incoming Redis pub/sub message""" + try: + # For pattern messages, the channel is in the "channel" field + # For regular messages, it's also in the "channel" field + channel = message["channel"] + data = json.loads(message["data"]) + update = JobStatusUpdate(**data) + + logger.debug(f"Received Redis message on channel '{channel}': {data}") + + # Send to specific job subscribers + if channel.startswith("job_status_updates:"): + job_id = channel.split(":", 1)[1] + logger.debug(f"Sending job status update for job {job_id} to subscribers") + await self._send_job_status_to_subscribers(job_id, update) + + # Send to global subscribers (job list updates) + elif channel == "job_status_updates": + logger.debug(f"Sending global job status update to subscribers") + await self._send_job_status_to_global_subscribers(update) + + except Exception as e: + logger.error(f"Failed to handle Redis message: {e}") + + async def _send_job_status_to_subscribers(self, job_id: str, update: JobStatusUpdate): + """Send job status update to specific job subscribers""" + if job_id not in self.job_subscriptions: + return + + # Convert to JSON-serializable dict + message = { + "type": "job_status_update", + "data": json.loads(update.model_dump_json()) + } + + for user_id in list(self.job_subscriptions[job_id]): + await self._send_to_user(user_id, message) + + async def _send_job_status_to_global_subscribers(self, update: JobStatusUpdate): + """Send job status update to global (job list) subscribers""" + # Convert to JSON-serializable dict + message = { + "type": "job_list_update", + "data": json.loads(update.model_dump_json()) + } + + for user_id in list(self.global_subscriptions): + await self._send_to_user(user_id, message) + + async def _send_to_user(self, user_id: str, message: Dict[str, Any]): + """Send message to all WebSocket connections for a user""" + if user_id not in self.active_connections: + return + + # Send to all connections for this user + disconnected_connections = set() + for websocket in list(self.active_connections[user_id]): + try: + await self._send_to_websocket(websocket, message) + except Exception as e: + logger.warning(f"Failed to send to websocket for user {user_id}: {e}") + disconnected_connections.add(websocket) + + # Clean up disconnected connections + for websocket in disconnected_connections: + self.disconnect(websocket, user_id) + + async def _send_to_websocket(self, websocket: WebSocket, message: Dict[str, Any]): + """Send message to a specific WebSocket connection""" + try: + await websocket.send_json(message) + except Exception as e: + logger.warning(f"WebSocket send failed: {e}") + raise + + +# Global connection manager instance +connection_manager = ConnectionManager() + + +async def authenticate_websocket(websocket: WebSocket, token: str) -> Optional[str]: + """ + Authenticate WebSocket connection using JWT token + Returns user_id if valid, None if invalid + """ + try: + if not token: + await websocket.close(code=4001, reason="Missing authentication token") + return None + + # Decode JWT token + payload = decode_token(token) + if not payload or "sub" not in payload: + await websocket.close(code=4001, reason="Invalid authentication token") + return None + + return payload["sub"] # user_id + + except Exception as e: + logger.warning(f"WebSocket authentication failed: {e}") + await websocket.close(code=4001, reason="Authentication failed") + return None + + +async def authenticate_websocket(websocket: WebSocket, token: Optional[str]) -> Optional[str]: + """ + Authenticate a WebSocket connection using a JWT token + Returns user_id if valid, None if invalid + """ + try: + if not token: + logger.warning("WebSocket authentication failed: Missing token") + await websocket.close(code=4001, reason="Missing authentication token") + return None + + # Import JWT decode function + from ..core.security import decode_token + + # Decode JWT token - this may raise HTTPException + try: + payload = decode_token(token) + if not payload or "sub" not in payload: + logger.warning("WebSocket authentication failed: Invalid token payload") + await websocket.close(code=4001, reason="Invalid authentication token") + return None + + user_id = payload["sub"] + logger.info(f"WebSocket authentication successful for user: {user_id}") + return user_id + except Exception as jwt_error: + logger.warning(f"WebSocket authentication failed: JWT decode error: {jwt_error}") + await websocket.close(code=4001, reason="Invalid authentication token") + return None + + except Exception as e: + logger.error(f"WebSocket authentication failed with unexpected error: {e}") + await websocket.close(code=4001, reason="Authentication failed") + return None + + +async def get_connection_manager() -> ConnectionManager: + """Dependency to get the connection manager""" + return connection_manager \ No newline at end of file diff --git a/backend/app/tasks/__pycache__/__init__.cpython-313.pyc b/backend/app/tasks/__pycache__/__init__.cpython-313.pyc index 346a0ca13dbfc1728b8f5891be78dcddf3e5f432..04eadb9fc64577331e96437ed1679474a7fc3481 100644 GIT binary patch delta 20 acmexo@y~+$GcPX}0}$}{t=`C8ECT>ZX9gkw delta 20 acmexo@y~+$GcPX}0}!14v1%iCu?zrF0tZI` diff --git a/backend/app/tasks/__pycache__/ingest_and_ai.cpython-313.pyc b/backend/app/tasks/__pycache__/ingest_and_ai.cpython-313.pyc index 3e9230bc095f5a50eeec2fbd72844aa82e18f9a1..09d57efabe6ff1984913c63c01dbe2f12aab9451 100644 GIT binary patch delta 5148 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 literal 6960 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 diff --git a/backend/app/tasks/__pycache__/translate_and_synthesize.cpython-313.pyc b/backend/app/tasks/__pycache__/translate_and_synthesize.cpython-313.pyc index 8049562a610f39b15fc1b959b6fe056bd6a0eb31..7877ba6bba7a7bf60d4ef7d21fe41ed12eb7adf9 100644 GIT binary patch delta 5440 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�fim_mWGVS32FEh}xXo9_g5?eD zBN^wZsr@YxYXW&^?N%^t)Xd3ziZ{n{ry+Jq9I9?LNjw>G!&W z_RA=fK(azV>ORmgiOm@xs}^|0rm~rI=c>Ik5(}@Sdcu$i#7FB74fB2)J#?V!>nMzM zk-Uba1j#p$ypH6XKy)FSf(JiCV&tFbdxyLeFXPy&NcJ*L#x8?myC95=0dMD3Qrsj8 zF?W0mTi-_VkF>F;x0|Kb53v6aK=wa07%qGIX$BF=-=YMP3u`xf<_v=q6W|iIcSNDV zAi-;E`|*{zo`S1^;0>#ru=0=rY>LJIy&@v@9j?l;deexL&NS!>M*;+1Rrz z-3c#QA~BiWHy=8ac?>Iw?%3n2D{(DO9LU{!A?{uw#VbWZ@f>?QW-@6~4#K1fi4zGs zc)$Z3Ig3O_g6`CfaV^8j8#@r#SR{R!AYNrPNH@je(F_YN=B8WRV;JI!_}VfETgfKW zuaG;ygEZ&Te1Qh~dyn*d#(DqU>iV;vt8>@4AY=x6{SneCWcj h*}&E!PnY|HjwvJWdAD(k!{3LRNZ;=7H~j>Z|1V9Qe*FLd delta 2935 zcmb7GU2Gf25xzYhPZUK`)DKPZ&!i|()JYU=S(GJNf0h(C{-s?V>8O@#v!6M&4p8<+6+pmf72U{ zY)U;SrL8zpx6MW8!gH3CXa%d3q7ERrE1Lm zj79of+!JA!nljtGU|>p1MeyiDYN3^)8D{0q@EUDKfmNflL`{osP*XC_lr__q%N6sh zS}tcwdAg{ZwFb=>v=jNlvT4VS<&B*7bV0G2lCG^R&=`7=sis#}3>tv}-3g#$;3D|Z zC@?bwPKx?U-q3Pz%$$Cv$X;u9T2r9ws=nPEBjSpq`iaLO`Lh<)hm%!5Vo}OUY5>`^ z!cJS8hJ&+PX&_8@f%rc@m!O9FEb8`taYIqG!u;?T^GE;R`SNUHYl-P3YiMaAz3i|4 zaNU1P`va|{k6n%UkXkaegrwSyfEde`FIoh$29J2|UYm7@D-D(lU*K#F(q znGM-(eALZHjgRVZEV%ujvtO{Uh2WXxr>Nl9p^;rX>!EjAz}+)?~4T=#%wnpwR3afJS6b!ICF;cWG|oG??bjRse?_qx03<(W@Jd2w+WA1(tbcO zO#n&y6OPw7Ug7woR$+FKIa_z^>*eRzmG!uUK56Swi3pjNtbG`28gEHW93`rv_RS7I zjDcs%5@v~NiaporiN6Ata?vy4Ss)9-s3j~cY^*-YR}9;P^pE+f@3roz8RSvg#qPIG zSclX-v%_pCI!vZ8pG0+-U5Iu=Jl>1u$!Im)c8b_cSvT@VMQ1Oz|Im$BVcIL@d8n`X zT;5=~@pGwN*Nn5)_Hso(S$#d$NQjfY({X@A**`kIM83_Y;>Spky&P9a zn!O)COF5Sc%?@GbS$QG=V+8Y(U~Eyu(vuDatsWY!}K`XJOQAH^bqoYxBt4b zpZtVfR?z;YqR4yE3ptOGBjPWP~0^gLl|P~T>;kKy+B6U z3*CVM_&X5HnpI8L^NSk1N=rbE{Aw9i?&kIYN|bt$V~S9GD5#D7w!6oR2B!UZex;)A z+j#F5HG|q&C=uGQu{zF5$6Cn|+ya8}2@nP_+8gG?O=_`1>rdpD6GF;>^}nw&;KAG^}iS%;^lUqP5- zf9#36BS?ApHe$}2lhNiV3h{~Kuos9PVh59_L@Kg(lh>SwtwL@6OOEe2zUO*yfW5He zym+s@I=r(iiuXF$$Ga}V?LObv>d0Azrahkp>uc?wonpV}t2jFa=OOZE83ccO(7|@6 zUR_H-T7PSzeSN&sK4C7Fo4Zf`&yAelCKJ%~2z10>TSuMhfwf7C!Siu_Sy4EeyM?YOii z{AQeFN2D7<1iapeknFg0qjj8tl8}$1hdUx*O1LB0XM&bHu1RsmYq{$tGfnc{1{t~6 z3W~cy%S>3f8*ZHGwcPEsz%J-m)w;Wb)QrQeq#>bq4aD6syegi#-#~VREe!-7jco`N zp$fIkHEJX-Q_ ztRZ_2fwAgXsyBl40>TdfbQb_`7dBzswE4h;=rqoH{5_MiNM2% zp@wbe^<|17q$va}eN&oWDi}O>coZ%R_dzOb^i;Ny)slO@vZ9UAH$ezREa-2sUrnZ* qEq|zO`u_f#wV`wJ1DW+rDfQ2Y#QS#J0|9^UJ!@slQv=c)FyUVdwx0|D diff --git a/backend/app/tasks/ingest_and_ai.py b/backend/app/tasks/ingest_and_ai.py index c59a879..d5e3eb3 100644 --- a/backend/app/tasks/ingest_and_ai.py +++ b/backend/app/tasks/ingest_and_ai.py @@ -12,11 +12,64 @@ from ..core.logging import get_logger from ..models.job import JobStatus from ..services.gcs import gcs_service, upload_vtt_to_gcs from ..services.gemini import gemini_service +from ..services.websocket import connection_manager from . import celery_app logger = get_logger(__name__) +def broadcast_status_update(job_id: str, status: str, message: str = None, progress: int = None): + """ + Helper function to broadcast job status updates via WebSocket + Uses sync Redis client for Celery worker context + """ + logger.info(f"🔊 ATTEMPTING TO BROADCAST: job_id={job_id}, status={status}, message={message}") + try: + import redis as sync_redis + from ..core.config import settings + from ..services.websocket import JobStatusUpdate + from datetime import datetime + + logger.info(f"🔊 About to create JobStatusUpdate for job {job_id}") + + # Create status update + update = JobStatusUpdate( + job_id=job_id, + status=status, + updated_at=datetime.utcnow(), + message=message, + progress=progress + ) + + logger.info(f"🔊 Created update object, now connecting to Redis: {settings.redis_url}") + + # Create synchronous Redis client + redis_client = sync_redis.Redis.from_url( + settings.redis_url, + encoding="utf-8", + decode_responses=True + ) + + logger.info(f"🔊 Redis client created, now publishing to channels") + + # Publish to channels + result1 = redis_client.publish("job_status_updates", update.model_dump_json()) + result2 = redis_client.publish(f"job_status_updates:{job_id}", update.model_dump_json()) + + logger.info(f"🔊 Published to channels - general: {result1} subscribers, job-specific: {result2} subscribers") + + # Close connection + redis_client.close() + + logger.info(f"🔊 ✅ Successfully broadcasted status update for job {job_id}: {status}") + + except Exception as e: + logger.error(f"🔊 ❌ Failed to broadcast status update for job {job_id}: {e}") + import traceback + logger.error(f"🔊 ❌ Full traceback: {traceback.format_exc()}") + # Don't let WebSocket failures break the worker task + + class AsyncTask(Task): """Base task class that supports async execution""" def __call__(self, *args, **kwargs): @@ -79,6 +132,14 @@ async def ingest_and_ai_task_impl(job_id: str): } } ) + + # Broadcast status update + broadcast_status_update( + job_id, + JobStatus.INGESTING.value, + "Starting video ingestion and processing", + progress=10 + ) # Get job details job_doc = await db.jobs.find_one({"_id": job_id}) @@ -113,6 +174,14 @@ async def ingest_and_ai_task_impl(job_id: str): } } ) + + # Broadcast status update + broadcast_status_update( + job_id, + JobStatus.AI_PROCESSING.value, + "Processing video with AI for accessibility features", + progress=50 + ) # Probe video for metadata duration = await _get_video_duration(temp_path) @@ -171,6 +240,14 @@ async def ingest_and_ai_task_impl(job_id: str): } } ) + + # Broadcast status update + broadcast_status_update( + job_id, + JobStatus.PENDING_QC.value, + "AI processing complete - ready for quality review", + progress=100 + ) logger.info(f"Successfully completed ingestion and AI processing for job {job_id}") diff --git a/backend/app/tasks/notify.py b/backend/app/tasks/notify.py index ac0ab4a..f7ece5a 100644 --- a/backend/app/tasks/notify.py +++ b/backend/app/tasks/notify.py @@ -1,12 +1,14 @@ import asyncio from datetime import datetime +from bson import ObjectId from celery import Task +from celery.exceptions import Retry from motor.motor_asyncio import AsyncIOMotorClient from ..core.config import settings from ..core.logging import get_logger -from ..models.audit_log import AuditLogCreate +from ..models.audit_log import AuditLogCreate, AuditAction from ..services.emailer import email_service from ..services.gcs import get_signed_download_url from . import celery_app @@ -14,8 +16,8 @@ from . import celery_app logger = get_logger(__name__) -class AsyncTask(Task): - """Base task class that supports async execution""" +class NotifyClientTask(Task): + """Async task for client notifications""" def __call__(self, *args, **kwargs): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -24,119 +26,183 @@ class AsyncTask(Task): finally: loop.close() - async def run_async(self, *args, **kwargs): - raise NotImplementedError + async def run_async(self, job_id: str): + """ + Pipeline 3: Client Notification + Triggered when job status changes to 'completed' + """ + logger.info(f"Starting client notification for job {job_id}") + # Connect to MongoDB + client = AsyncIOMotorClient(settings.mongodb_uri) + db = client[settings.mongodb_db] -@celery_app.task(bind=True, base=AsyncTask) -async def notify_client_task(self, job_id: str): - """ - Pipeline 3: Client Notification - Triggered when job status changes to 'completed' - """ - logger.info(f"Starting client notification for job {job_id}") + try: + # Get job and client details + job_doc = await db.jobs.find_one({"_id": job_id}) + if not job_doc: + logger.error(f"Job {job_id} not found in database") + return # Don't retry for missing jobs - # Connect to MongoDB - client = AsyncIOMotorClient(settings.mongodb_uri) - db = client[settings.mongodb_db] + if job_doc["status"] != "completed": + logger.warning(f"Job {job_id} not in completed status (current: {job_doc['status']}), skipping notification") + return - try: - # Get job and client details - job_doc = await db.jobs.find_one({"_id": job_id}) - if not job_doc: - raise ValueError(f"Job {job_id} not found") - - if job_doc["status"] != "completed": - logger.warning(f"Job {job_id} not in completed status, skipping notification") - return - - # Get client email - client_doc = await db.users.find_one({"_id": job_doc["client_id"]}) - if not client_doc: - raise ValueError(f"Client {job_doc['client_id']} not found") - - # Generate signed URLs for all outputs - download_links = {} - outputs = job_doc.get("outputs", {}) - - for language, lang_output in outputs.items(): - if not isinstance(lang_output, dict): - continue - - lang_downloads = {} - - # Captions VTT - if "captions_vtt_gcs" in lang_output: - blob_path = lang_output["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") + # Get client ID and ensure proper ObjectId format + client_id = job_doc["client_id"] + logger.info(f"Looking up client {client_id} for job {job_id}") + + # Try looking up client by string ID first + client_doc = await db.users.find_one({"_id": client_id}) + if not client_doc: + # Try as ObjectId if string lookup failed try: - signed_url = await get_signed_download_url(blob_path, 24) - lang_downloads["captions_vtt"] = signed_url - except Exception as e: - logger.warning(f"Failed to generate signed URL for captions {language}: {e}") + client_doc = await db.users.find_one({"_id": ObjectId(client_id)}) + except: + pass # Invalid ObjectId format + + if not client_doc: + logger.error(f"Client {client_id} not found in database for job {job_id}") + # Don't retry for missing users - this is likely a data issue + return - # Audio Description VTT - if "ad_vtt_gcs" in lang_output: - blob_path = lang_output["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") + # Generate signed URLs for all outputs + download_links = {} + outputs = job_doc.get("outputs", {}) + + for language, lang_output in outputs.items(): + if not isinstance(lang_output, dict): + continue + + lang_downloads = {} + + # Captions VTT + if "captions_vtt_gcs" in lang_output: + blob_path = lang_output["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") + try: + signed_url = await get_signed_download_url(blob_path, 24) + lang_downloads["captions_vtt"] = signed_url + except Exception as e: + logger.warning(f"Failed to generate signed URL for captions {language}: {e}") + + # Audio Description VTT + if "ad_vtt_gcs" in lang_output: + blob_path = lang_output["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") + try: + signed_url = await get_signed_download_url(blob_path, 24) + lang_downloads["audio_description_vtt"] = signed_url + except Exception as e: + logger.warning(f"Failed to generate signed URL for AD VTT {language}: {e}") + + # Audio Description MP3 + if "ad_mp3_gcs" in lang_output: + blob_path = lang_output["ad_mp3_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") + try: + signed_url = await get_signed_download_url(blob_path, 24) + lang_downloads["audio_description_mp3"] = signed_url + except Exception as e: + logger.warning(f"Failed to generate signed URL for AD MP3 {language}: {e}") + + if lang_downloads: + download_links[language] = lang_downloads + + # Send completion email (temporarily disabled) + # TODO: Re-enable emails once authentication is configured + email_enabled = False # Set to True to re-enable emails + + if email_enabled: try: - signed_url = await get_signed_download_url(blob_path, 24) - lang_downloads["audio_description_vtt"] = signed_url - except Exception as e: - logger.warning(f"Failed to generate signed URL for AD VTT {language}: {e}") + success = await email_service.send_completion_email( + recipient_email=client_doc["email"], + job_title=job_doc["title"], + download_links=download_links + ) - # Audio Description MP3 - if "ad_mp3_gcs" in lang_output: - blob_path = lang_output["ad_mp3_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") - try: - signed_url = await get_signed_download_url(blob_path, 24) - lang_downloads["audio_description_mp3"] = signed_url - except Exception as e: - logger.warning(f"Failed to generate signed URL for AD MP3 {language}: {e}") + if success: + logger.info(f"Successfully sent completion email to {client_doc['email']} for job {job_id}") + else: + logger.warning(f"Email service returned failure for job {job_id} - treating as non-retryable") + + except Exception as email_error: + error_msg = str(email_error) + logger.error(f"Email sending exception for job {job_id}: {error_msg}") + + # Check if this is an authentication error (non-retryable) + if "401" in error_msg or "Unauthorized" in error_msg or "authentication" in error_msg.lower(): + logger.warning(f"Email authentication failed for job {job_id} - treating as non-retryable configuration error") + else: + # Other email errors might be transient + raise ValueError(f"Email sending failed: {error_msg}") + else: + logger.info(f"Email notifications are currently disabled - skipping email for job {job_id}") + logger.info(f"Would have sent completion email to {client_doc['email']} with {sum(len(files) for files in download_links.values())} download links") - if lang_downloads: - download_links[language] = lang_downloads - - # Send completion email - success = await email_service.send_completion_email( - recipient_email=client_doc["email"], - job_title=job_doc["title"], - download_links=download_links - ) - - if success: - # Log audit entry + # Log audit entry (regardless of email status) audit_log = AuditLogCreate( - job_id=job_id, - action="client_notified", + action=AuditAction.JOB_STATUS_CHANGE, + description=f"Job {job_id} completed - client notification processed", + resource_type="job", + resource_id=job_id, + resource_name=job_doc["title"], details={ "email": client_doc["email"], - "download_count": sum(len(files) for files in download_links.values()) + "download_count": sum(len(files) for files in download_links.values()), + "email_sent": email_enabled, + "status": "completed" } ) - await db.audit_logs.insert_one(audit_log.dict()) + await db.audit_logs.insert_one(audit_log.model_dump()) - logger.info(f"Successfully notified client for job {job_id}") - else: - raise ValueError("Failed to send completion email") + logger.info(f"Successfully completed notification processing for job {job_id}") - except Exception as e: - logger.error(f"Client notification failed for job {job_id}: {e}") + except Exception as e: + error_msg = str(e) + logger.error(f"Client notification failed for job {job_id}: {error_msg}") - # Update job with error - await db.jobs.update_one( - {"_id": job_id}, - { - "$set": { - "error": { - "type": "notification_failure", - "message": str(e), - "timestamp": datetime.utcnow().isoformat() - }, - "updated_at": datetime.utcnow() - } - } - ) + # Update job with error + try: + await db.jobs.update_one( + {"_id": job_id}, + { + "$set": { + "error": { + "type": "notification_failure", + "message": error_msg, + "timestamp": datetime.utcnow().isoformat() + }, + "updated_at": datetime.utcnow() + } + } + ) + except Exception as update_error: + logger.error(f"Failed to update job {job_id} with error: {update_error}") - raise + # Only retry for transient errors, not configuration or data errors + non_retryable_patterns = [ + "not found", + "401", + "unauthorized", + "authentication", + "failed to send completion email" + ] + + should_not_retry = any(pattern in error_msg.lower() for pattern in non_retryable_patterns) + + if should_not_retry: + logger.info(f"Skipping retry for job {job_id} due to non-retryable error: {error_msg}") + return + else: + # This might be a transient error, let it retry + logger.info(f"Allowing retry for job {job_id} due to potentially transient error: {error_msg}") + raise - finally: - client.close() + finally: + client.close() + + +# Register the task with manual retry control +@celery_app.task(bind=True, base=NotifyClientTask, max_retries=3, default_retry_delay=60) +def notify_client_task(self, job_id: str): + """Celery task wrapper for client notification""" + # This method is called by NotifyClientTask.__call__ + pass diff --git a/backend/app/tasks/translate_and_synthesize.py b/backend/app/tasks/translate_and_synthesize.py index b90fe67..d44f5eb 100644 --- a/backend/app/tasks/translate_and_synthesize.py +++ b/backend/app/tasks/translate_and_synthesize.py @@ -14,11 +14,63 @@ from ..services.gcs import gcs_service, upload_vtt_to_gcs from ..services.gemini import gemini_service from ..services.translate import translate_service from ..services.tts import tts_service +from ..services.websocket import connection_manager from . import celery_app logger = get_logger(__name__) +def broadcast_status_update(job_id: str, status: str, message: str = None, progress: int = None): + """ + Helper function to broadcast job status updates via WebSocket + Uses sync Redis client for Celery worker context + """ + logger.info(f"🔊 ATTEMPTING TO BROADCAST: job_id={job_id}, status={status}, message={message}") + try: + import redis as sync_redis + from ..core.config import settings + from ..services.websocket import JobStatusUpdate + from datetime import datetime + + logger.info(f"🔊 About to create JobStatusUpdate for job {job_id}") + + # Create status update + update = JobStatusUpdate( + job_id=job_id, + status=status, + updated_at=datetime.utcnow(), + message=message, + progress=progress + ) + + logger.info(f"🔊 Created update object, now connecting to Redis: {settings.redis_url}") + + # Create synchronous Redis client + redis_client = sync_redis.Redis.from_url( + settings.redis_url, + encoding="utf-8", + decode_responses=True + ) + + logger.info(f"🔊 Redis client created, now publishing to channels") + + # Publish to channels + result1 = redis_client.publish("job_status_updates", update.model_dump_json()) + result2 = redis_client.publish(f"job_status_updates:{job_id}", update.model_dump_json()) + + logger.info(f"🔊 Published to channels - general: {result1} subscribers, job-specific: {result2} subscribers") + + # Close connection + redis_client.close() + + logger.info(f"🔊 ✅ Successfully broadcasted status update for job {job_id}: {status}") + + except Exception as e: + logger.error(f"🔊 ❌ Failed to broadcast status update for job {job_id}: {e}") + import traceback + logger.error(f"🔊 ❌ Full traceback: {traceback.format_exc()}") + + async def retry_with_backoff(func, max_retries=3, base_delay=1): """Retry a function with exponential backoff""" last_exception = None @@ -104,6 +156,14 @@ async def _async_translate_and_synthesize(job_id: str): } } ) + + # Broadcast status update + broadcast_status_update( + job_id, + JobStatus.TRANSLATING.value, + "Starting translation and transcreation process", + progress=10 + ) # Get English VTT content en_outputs = job_doc["outputs"]["en"] @@ -203,6 +263,14 @@ async def _async_translate_and_synthesize(job_id: str): } } ) + + # Broadcast status update + broadcast_status_update( + job_id, + JobStatus.TTS_GENERATING.value, + "Generating audio descriptions with text-to-speech", + progress=70 + ) # Generate TTS for languages that need MP3 if job_doc["requested_outputs"]["audio_description_mp3"]: @@ -225,6 +293,14 @@ async def _async_translate_and_synthesize(job_id: str): } } ) + + # Broadcast status update + broadcast_status_update( + job_id, + JobStatus.PENDING_FINAL_REVIEW.value, + "Translation and TTS complete - ready for final review", + progress=100 + ) logger.info(f"Successfully completed translation and synthesis for job {job_id}") diff --git a/frontend/src/components/WebSocketToastHandler.tsx b/frontend/src/components/WebSocketToastHandler.tsx new file mode 100644 index 0000000..35ececc --- /dev/null +++ b/frontend/src/components/WebSocketToastHandler.tsx @@ -0,0 +1,74 @@ +/** + * Optional component to handle WebSocket connection status toasts + * This can be used in components that want to show connection status notifications + */ +import { useCallback } from 'react'; +import { useToastContext } from '../contexts/ToastContext'; +import type { ConnectionStatus } from '../hooks/useJobStatusWebSocket'; + +export interface WebSocketToastHandlerProps { + /** Whether to show connection toasts */ + enabled?: boolean; + + /** Custom connection status messages */ + messages?: { + connected?: string; + connecting?: string; + disconnected?: string; + error?: string; + }; +} + +/** + * Hook that returns a connection change handler for WebSocket toasts + */ +export function useWebSocketToastHandler(props: WebSocketToastHandlerProps = {}) { + const { + enabled = false, + messages = {} + } = props; + + const toast = useToastContext(); + + const handleConnectionChange = useCallback((status: ConnectionStatus) => { + if (!enabled) return; + + switch (status) { + case 'connected': + toast.success(messages.connected || 'Real-time updates connected'); + break; + + case 'connecting': + // Usually don't show toast for connecting to avoid spam + // toast.info(messages.connecting || 'Connecting to real-time updates...'); + break; + + case 'disconnected': + toast.warning(messages.disconnected || 'Real-time updates disconnected'); + break; + + case 'error': + toast.error(messages.error || 'Connection error - using cached data'); + break; + } + }, [enabled, messages, toast]); + + return handleConnectionChange; +} + +/** + * Default connection status messages for different contexts + */ +export const CONNECTION_MESSAGES = { + jobList: { + connected: 'Job list real-time updates enabled', + disconnected: 'Job list updates disconnected', + error: 'Job list connection error' + }, + + jobDetail: { + connected: 'Job status real-time updates enabled', + disconnected: 'Job status updates disconnected', + error: 'Job status connection error' + } +} as const; \ No newline at end of file diff --git a/frontend/src/hooks/useJobStatusWebSocket.ts b/frontend/src/hooks/useJobStatusWebSocket.ts new file mode 100644 index 0000000..9a81c61 --- /dev/null +++ b/frontend/src/hooks/useJobStatusWebSocket.ts @@ -0,0 +1,410 @@ +/** + * WebSocket hook for real-time job status updates + * + * Provides WebSocket connections for: + * 1. Individual job status updates: useJobStatusWebSocket(jobId) + * 2. Job list updates: useJobStatusWebSocket() without jobId + */ +import { useEffect, useState, useRef, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useAuthStore } from '../lib/auth'; +import { apiClient } from '../lib/api'; + +export interface JobStatusUpdate { + job_id: string; + status: string; + updated_at: string; + message?: string; + progress?: number; + metadata?: Record; +} + +export interface WebSocketMessage { + type: 'connection_established' | 'job_status_update' | 'job_list_update'; + data?: JobStatusUpdate; + job_id?: string; + scope?: string; + timestamp?: string; +} + +export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; + +interface UseJobStatusWebSocketOptions { + /** + * Whether to automatically reconnect on connection loss + * @default true + */ + autoReconnect?: boolean; + + /** + * Maximum number of reconnection attempts + * @default 5 + */ + maxReconnectAttempts?: number; + + /** + * Initial reconnection delay in milliseconds + * @default 1000 + */ + reconnectDelay?: number; + + /** + * Whether to log debug information + * @default false + */ + debug?: boolean; + + /** + * Whether to show toast notifications for status updates + * @default true + */ + showToasts?: boolean; + + /** + * Custom toast handler function for status updates + */ + onStatusUpdate?: (update: JobStatusUpdate) => void; + + /** + * Toast handler for connection status changes + */ + onConnectionChange?: (status: ConnectionStatus) => void; +} + +interface UseJobStatusWebSocketReturn { + /** Current connection status */ + connectionStatus: ConnectionStatus; + + /** Latest received message */ + lastMessage: WebSocketMessage | null; + + /** Latest job status update */ + lastUpdate: JobStatusUpdate | null; + + /** Manual reconnect function */ + reconnect: () => void; + + /** Disconnect function */ + disconnect: () => void; + + /** Send message (for heartbeat, etc.) */ + sendMessage: (message: string) => void; +} + +/** + * WebSocket hook for job status updates + * + * @param jobId - Optional job ID for specific job updates. If not provided, subscribes to all job updates + * @param options - Configuration options + */ +export function useJobStatusWebSocket( + jobId?: string, + options: UseJobStatusWebSocketOptions = {} +): UseJobStatusWebSocketReturn { + const { + autoReconnect = true, + maxReconnectAttempts = 5, + reconnectDelay = 1000, + debug = false, + showToasts = true, + onStatusUpdate, + onConnectionChange + } = options; + + const queryClient = useQueryClient(); + const { isAuthenticated } = useAuthStore(); + + // Get access token from API client instead of auth store + const accessToken = apiClient.getAccessToken(); + + const [connectionStatus, setConnectionStatus] = useState('disconnected'); + const [lastMessage, setLastMessage] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + + const wsRef = useRef(null); + const reconnectAttemptsRef = useRef(0); + const reconnectTimeoutRef = useRef(null); + const heartbeatIntervalRef = useRef(null); + const mountedRef = useRef(true); + + // Cache recent updates to prevent duplicates + const recentUpdatesRef = useRef>(new Map()); + + const log = useCallback((...args: unknown[]) => { + if (debug) { + console.log('[WebSocket]', ...args); + } + }, [debug]); + + const getWebSocketUrl = useCallback(() => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + const basePath = '/api/v1/ws/jobs'; + const path = jobId ? `${basePath}/${jobId}` : basePath; + const token = encodeURIComponent(accessToken || ''); + return `${protocol}//${host}${path}?token=${token}`; + }, [jobId, accessToken]); + + const handleStatusUpdate = useCallback((update: JobStatusUpdate) => { + // Check for duplicate status updates within the last 5 seconds + const updateKey = `${update.job_id}:${update.status}`; + const now = Date.now(); + const recent = recentUpdatesRef.current.get(updateKey); + + if (recent && (now - recent.timestamp) < 5000) { + // Skip duplicate status update within 5 seconds + log('Skipping duplicate status update:', updateKey); + return; + } + + // Store this update + recentUpdatesRef.current.set(updateKey, { status: update.status, timestamp: now }); + + // Clean up old entries (older than 30 seconds) + const cutoff = now - 30000; + for (const [key, value] of recentUpdatesRef.current.entries()) { + if (value.timestamp < cutoff) { + recentUpdatesRef.current.delete(key); + } + } + + // Call custom handler if provided + if (onStatusUpdate) { + onStatusUpdate(update); + } + }, [onStatusUpdate, log]); + + const handleConnectionChange = useCallback((status: ConnectionStatus) => { + // Call custom handler if provided + if (onConnectionChange) { + onConnectionChange(status); + } + }, [onConnectionChange]); + + const updateQueryCache = useCallback((update: JobStatusUpdate) => { + // Update individual job cache if we have job_id + if (update.job_id) { + queryClient.setQueryData(['jobs', update.job_id], (oldData: unknown) => { + if (oldData) { + return { + ...oldData, + status: update.status, + updated_at: update.updated_at + }; + } + return oldData; + }); + } + + // Update job list cache + queryClient.setQueriesData({ queryKey: ['jobs'] }, (oldData: unknown) => { + // Type-safe handling of the jobs list data + const data = oldData as { jobs?: Array<{ id: string; status: string; updated_at: string; [key: string]: unknown }> }; + if (!data?.jobs) return oldData; + + const updatedJobs = data.jobs.map((job) => { + if (job.id === update.job_id) { + return { + ...job, + status: update.status, + updated_at: update.updated_at + }; + } + return job; + }); + + return { + ...data, + jobs: updatedJobs + }; + }); + }, [queryClient]); + + const handleMessage = useCallback((event: MessageEvent) => { + try { + // Handle plain text responses (like "pong" heartbeat responses) + if (typeof event.data === 'string' && event.data === 'pong') { + log('Received heartbeat response:', event.data); + return; + } + + const message: WebSocketMessage = JSON.parse(event.data); + log('Received message:', message); + + setLastMessage(message); + + if (message.type === 'job_status_update' || message.type === 'job_list_update') { + if (message.data) { + setLastUpdate(message.data); + updateQueryCache(message.data); + handleStatusUpdate(message.data); + } + } + } catch (error) { + console.error('[WebSocket] Failed to parse WebSocket message:', error, 'Raw data:', event.data); + } + }, [log, updateQueryCache, handleStatusUpdate]); + + const handleOpen = useCallback(() => { + console.log('[WebSocket] Connected successfully!'); + log('WebSocket connected'); + setConnectionStatus('connected'); + handleConnectionChange('connected'); + reconnectAttemptsRef.current = 0; + + // Start heartbeat + heartbeatIntervalRef.current = setInterval(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send('ping'); + } + }, 30000); // Ping every 30 seconds + }, [log, handleConnectionChange]); + + const handleClose = useCallback((event: CloseEvent) => { + log('WebSocket closed:', event.code, event.reason); + setConnectionStatus('disconnected'); + handleConnectionChange('disconnected'); + + // Clear heartbeat + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + heartbeatIntervalRef.current = null; + } + + // Attempt to reconnect if enabled and component is still mounted + if ( + autoReconnect && + mountedRef.current && + reconnectAttemptsRef.current < maxReconnectAttempts && + event.code !== 1000 // Don't reconnect on normal closure + ) { + const delay = reconnectDelay * Math.pow(2, reconnectAttemptsRef.current); + log(`Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1}/${maxReconnectAttempts})`); + + reconnectTimeoutRef.current = setTimeout(() => { + if (mountedRef.current) { + reconnectAttemptsRef.current++; + connect(); + } + }, delay); + } + }, [log, autoReconnect, maxReconnectAttempts, reconnectDelay, handleConnectionChange]); + + const handleError = useCallback((error: Event) => { + console.error('WebSocket error:', error); + setConnectionStatus('error'); + handleConnectionChange('error'); + }, [handleConnectionChange]); + + const connect = useCallback(() => { + if (!accessToken) { + log('No access token available, skipping WebSocket connection'); + return; + } + + if (wsRef.current?.readyState === WebSocket.CONNECTING || + wsRef.current?.readyState === WebSocket.OPEN) { + log('WebSocket already connecting or connected'); + return; + } + + try { + setConnectionStatus('connecting'); + handleConnectionChange('connecting'); + + const url = getWebSocketUrl(); + console.log('[WebSocket] Attempting to connect to:', url.replace(/token=[^&]+/, 'token=***')); + log('Connecting to:', url.replace(/token=[^&]+/, 'token=***')); + + wsRef.current = new WebSocket(url); + wsRef.current.addEventListener('open', handleOpen); + wsRef.current.addEventListener('message', handleMessage); + wsRef.current.addEventListener('close', handleClose); + wsRef.current.addEventListener('error', handleError); + + } catch (error) { + console.error('[WebSocket] Failed to create WebSocket connection:', error); + setConnectionStatus('error'); + handleConnectionChange('error'); + } + }, [accessToken, getWebSocketUrl, handleOpen, handleMessage, handleClose, handleError, log, handleConnectionChange]); + + const disconnect = useCallback(() => { + log('Manually disconnecting WebSocket'); + + // Clear reconnection timeout + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + // Clear heartbeat + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + heartbeatIntervalRef.current = null; + } + + // Close WebSocket + if (wsRef.current) { + wsRef.current.removeEventListener('open', handleOpen); + wsRef.current.removeEventListener('message', handleMessage); + wsRef.current.removeEventListener('close', handleClose); + wsRef.current.removeEventListener('error', handleError); + + if (wsRef.current.readyState === WebSocket.OPEN || + wsRef.current.readyState === WebSocket.CONNECTING) { + wsRef.current.close(1000, 'Manual disconnect'); + } + wsRef.current = null; + } + + setConnectionStatus('disconnected'); + handleConnectionChange('disconnected'); + }, [log, handleOpen, handleMessage, handleClose, handleError, handleConnectionChange]); + + const reconnect = useCallback(() => { + log('Manual reconnect requested'); + disconnect(); + reconnectAttemptsRef.current = 0; + setTimeout(connect, 100); + }, [log, disconnect]); // Exclude connect to prevent dependency loops + + const sendMessage = useCallback((message: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(message); + log('Sent message:', message); + } else { + console.warn('WebSocket not connected, cannot send message'); + } + }, [log]); + + // Connect on mount and when dependencies change + useEffect(() => { + const currentToken = apiClient.getAccessToken(); + if (currentToken && isAuthenticated) { + connect(); + } + + return () => { + mountedRef.current = false; + disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated, jobId]); // Reconnect when auth state or jobId changes + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + return { + connectionStatus, + lastMessage, + lastUpdate, + reconnect, + disconnect, + sendMessage + }; +} \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fe5ea1f..71be4b3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -85,6 +85,10 @@ class ApiClient { this.accessToken = null; } + getAccessToken(): string | null { + return this.accessToken; + } + // Auth endpoints async login(credentials: LoginRequest): Promise { const response = await this.client.post('/auth/login', credentials); diff --git a/frontend/src/routes/jobs/JobDetail.tsx b/frontend/src/routes/jobs/JobDetail.tsx index 20c75cd..01f5966 100644 --- a/frontend/src/routes/jobs/JobDetail.tsx +++ b/frontend/src/routes/jobs/JobDetail.tsx @@ -1,9 +1,13 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { useParams, Link } from 'react-router-dom'; import { formatDistanceToNow } from 'date-fns'; import { useJob, useJobDownloads, useJobVttContent } from '../../hooks/useJob'; +import { useJobStatusWebSocket } from '../../hooks/useJobStatusWebSocket'; import { StatusBadge } from '../../components/StatusBadge'; import { VideoWithCaptions } from '../../components/VideoWithCaptions'; +import { useToastContext } from '../../contexts/ToastContext'; +import { getStatusMessageConfig } from '../../utils/jobStatusMessages'; +import type { JobStatusUpdate } from '../../hooks/useJobStatusWebSocket'; const ProgressIndicator = ({ status }: { status: string }) => { @@ -58,10 +62,34 @@ const ProgressIndicator = ({ status }: { status: string }) => { export function JobDetail() { const { id } = useParams(); const [activeTab, setActiveTab] = useState<'overview' | 'video' | 'assets' | 'history'>('overview'); + const toast = useToastContext(); const { data: job, isLoading, error } = useJob(id!); - const { data: downloads, isLoading: downloadsLoading, error: downloadsError } = useJobDownloads(id!); - const { data: englishVtt, isLoading: vttLoading, error: vttError } = useJobVttContent(id!, 'en'); + const { data: downloads } = useJobDownloads(id!); + const { data: englishVtt } = useJobVttContent(id!, 'en'); + + // Handle job status updates with toast notifications + const handleStatusUpdate = useCallback((update: JobStatusUpdate) => { + // Use the current job title or fallback + const jobTitle = job?.title; + + const { message, type, showToast } = getStatusMessageConfig( + update.status, + jobTitle, + update.message + ); + + if (showToast) { + toast[type](message); + } + }, [job?.title, toast]); + + // WebSocket connection for real-time job status updates + const { connectionStatus } = useJobStatusWebSocket(id, { + debug: false, + autoReconnect: true, + onStatusUpdate: handleStatusUpdate + }); // Get video URL from downloads @@ -289,7 +317,18 @@ export function JobDetail() { Created {formatDistanceToNow(new Date(job.created_at))} ago

- +
+ + {connectionStatus === 'connected' && ( + + )} + {connectionStatus === 'connecting' && ( + + )} + {connectionStatus === 'error' && ( + + )} +
diff --git a/frontend/src/routes/jobs/JobsList.tsx b/frontend/src/routes/jobs/JobsList.tsx index 13b5e3e..df0463a 100644 --- a/frontend/src/routes/jobs/JobsList.tsx +++ b/frontend/src/routes/jobs/JobsList.tsx @@ -1,11 +1,14 @@ -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { formatDistanceToNow } from 'date-fns'; import { useAuthStore } from '../../lib/auth'; import { useJobs, useBulkDeleteJobs } from '../../hooks/useJob'; +import { useJobStatusWebSocket } from '../../hooks/useJobStatusWebSocket'; import { StatusBadge } from '../../components/StatusBadge'; import { useToastContext } from '../../contexts/ToastContext'; +import { getStatusMessageConfig } from '../../utils/jobStatusMessages'; import type { Job } from '../../types/api'; +import type { JobStatusUpdate } from '../../hooks/useJobStatusWebSocket'; const STATUS_OPTIONS = [ { value: '', label: 'All Statuses' }, @@ -28,7 +31,7 @@ const SORT_OPTIONS = [ export function JobsList() { const { user } = useAuthStore(); const toast = useToastContext(); - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams] = useSearchParams(); const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || ''); const [sortBy, setSortBy] = useState('created_at_desc'); @@ -56,6 +59,30 @@ export function JobsList() { const bulkDeleteMutation = useBulkDeleteJobs(); + // Handle job status updates with toast notifications + const handleStatusUpdate = useCallback((update: JobStatusUpdate) => { + // Find the job to get its title + const job = jobsData?.jobs.find((j: Job) => j.id === update.job_id); + const jobTitle = job?.title; + + const { message, type, showToast } = getStatusMessageConfig( + update.status, + jobTitle, + update.message + ); + + if (showToast) { + toast[type](message); + } + }, [jobsData?.jobs, toast]); + + // WebSocket connection for real-time job status updates + const { connectionStatus } = useJobStatusWebSocket(undefined, { + debug: false, + autoReconnect: true, + onStatusUpdate: handleStatusUpdate + }); + // Client-side filtering and sorting for search term const filteredAndSortedJobs = useMemo(() => { let jobs = jobsData?.jobs || []; @@ -177,12 +204,30 @@ export function JobsList() { } }; + const getConnectionStatusIcon = () => { + switch (connectionStatus) { + case 'connected': + return ; + case 'connecting': + return ; + case 'disconnected': + return ; + case 'error': + return ; + default: + return null; + } + }; + return (
{/* Header */}
-

All Jobs

+
+

All Jobs

+ {getConnectionStatusIcon()} +

{user?.role === 'client' ? 'Your video processing jobs' : 'System-wide job management'}

diff --git a/frontend/src/utils/jobStatusMessages.ts b/frontend/src/utils/jobStatusMessages.ts new file mode 100644 index 0000000..64a67a6 --- /dev/null +++ b/frontend/src/utils/jobStatusMessages.ts @@ -0,0 +1,127 @@ +/** + * Utility functions for generating user-friendly job status messages and toast notifications + */ + +export interface StatusMessageConfig { + message: string; + type: 'success' | 'info' | 'warning' | 'error'; + showToast: boolean; +} + +/** + * Get user-friendly message and toast configuration for job status updates + */ +export function getStatusMessageConfig( + status: string, + jobTitle?: string, + customMessage?: string +): StatusMessageConfig { + const title = jobTitle ? `"${jobTitle}"` : 'Job'; + const fallbackMessage = customMessage || ''; + + switch (status) { + case 'created': + return { + message: `${title} has been created and queued for processing`, + type: 'info', + showToast: true + }; + + case 'ingesting': + return { + message: `${title} is being ingested and prepared for AI processing`, + type: 'info', + showToast: true + }; + + case 'ai_processing': + return { + message: `${title} is being processed by AI to generate accessibility features`, + type: 'info', + showToast: true + }; + + case 'pending_qc': + return { + message: `${title} is ready for quality control review`, + type: 'info', + showToast: true + }; + + case 'approved_english': + return { + message: `${title} English content has been approved - starting translation`, + type: 'success', + showToast: true + }; + + case 'rejected': + return { + message: `${title} has been rejected and requires revision`, + type: 'warning', + showToast: true + }; + + case 'translating': + return { + message: `${title} is being translated and transcreated into requested languages`, + type: 'info', + showToast: true + }; + + case 'tts_generating': + return { + message: `${title} is generating audio descriptions with text-to-speech`, + type: 'info', + showToast: true + }; + + case 'pending_final_review': + return { + message: `${title} is ready for final review before completion`, + type: 'info', + showToast: true + }; + + case 'qc_feedback': + return { + message: `${title} final review has been rejected - requires changes`, + type: 'warning', + showToast: true + }; + + case 'completed': + return { + message: `${title} has been completed successfully! 🎉 All files are ready for download`, + type: 'success', + showToast: true + }; + + default: + return { + message: fallbackMessage || `${title} status updated to ${status}`, + type: 'info', + showToast: !!fallbackMessage + }; + } +} + +/** + * Get a shorter status message for progress updates + */ +export function getProgressMessage(status: string, progress?: number): string { + const progressText = progress !== undefined ? ` (${progress}%)` : ''; + + switch (status) { + case 'ingesting': + return `Ingesting video${progressText}`; + case 'ai_processing': + return `AI processing${progressText}`; + case 'translating': + return `Translating content${progressText}`; + case 'tts_generating': + return `Generating audio${progressText}`; + default: + return status.replace(/_/g, ' '); + } +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3d9d515..1f439e8 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,6 +5,15 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + ws: true, // Enable WebSocket proxying + }, + }, + }, test: { globals: true, environment: 'jsdom',