77import json
88import base64 # 追加: Base64エンコード用
99import mimetypes # 追加: MIMEタイプ判定用
10+ import logging # 追加: Logging
1011from contextlib import asynccontextmanager
1112from fastapi import FastAPI , HTTPException , BackgroundTasks , UploadFile , File , Form , Request # Request を追加
1213from fastapi .responses import FileResponse , StreamingResponse # JSONResponse を削除
1516from PIL import Image
1617import numpy as np
1718from typing import List , Optional # Import Optional (Dict removed as unused)
19+ from watchdog .observers import Observer # 追加: Watchdog Observer
1820
1921# Import modules created earlier (relative imports)
2022from . import settings
2123from . import models
2224from . import queue_manager
2325from . import worker
26+ from . import video_watcher
2427
2528# --- Global State ---
2629# Dictionary to hold loaded models
3033worker_thread = None
3134# Variable to store the ID of the currently processing job
3235currently_processing_job_id : str | None = None
36+ # --- Video Watcher State ---
37+ sse_clients : List [asyncio .Queue ] = [] # List to hold client queues for SSE
38+ observer : Observer | None = None # type: ignore # Watchdog observer instance
3339
3440
3541# --- Lifespan Context Manager ---
3642@asynccontextmanager # Use the imported decorator directly
3743async def lifespan (app : FastAPI ):
3844 # Startup logic
39- global loaded_models , worker_running , worker_thread
45+ global loaded_models , worker_running , worker_thread , observer , sse_clients # Add observer and sse_clients
4046 print ("API starting up via lifespan..." )
4147 # Load models
4248 try :
@@ -60,10 +66,32 @@ async def lifespan(app: FastAPI):
6066 else :
6167 print ("Worker already running? Skipping start in lifespan." )
6268
69+ # Start video watcher
70+ try :
71+ print (f"Attempting to start video watcher for directory: { settings .VIDEO_DIR } " )
72+ # Pass the global sse_clients list to the watcher
73+ observer = video_watcher .start_watcher (settings .VIDEO_DIR , sse_clients )
74+ print ("Video watcher started successfully via lifespan." )
75+ except Exception as e :
76+ print (f"FATAL: Failed to start video watcher on startup: { e } " )
77+ traceback .print_exc ()
78+ observer = None # Ensure observer is None if startup failed
79+
6380 yield
6481
6582 # Shutdown logic
6683 print ("API shutting down via lifespan..." )
84+
85+ # Stop video watcher first
86+ if observer :
87+ try :
88+ print ("Stopping video watcher..." )
89+ video_watcher .stop_watcher (observer )
90+ print ("Video watcher stopped." )
91+ except Exception as e :
92+ print (f"Error stopping video watcher: { e } " )
93+ traceback .print_exc ()
94+
6795 # Stop background worker
6896 if worker_running :
6997 worker_running = False
@@ -572,8 +600,93 @@ async def list_loras():
572600 return LoraListResponse (loras = lora_files ) # Correct indentation for return
573601
574602
603+ # === Video Streaming Endpoints ===
604+
605+ @app .get ("/video_stream" )
606+ async def video_stream (request : Request ):
607+ """
608+ Streams new video filenames using Server-Sent Events (SSE).
609+ """
610+ client_queue = asyncio .Queue ()
611+ sse_clients .append (client_queue )
612+ logging .info (f"SSE client connected. Total clients: { len (sse_clients )} " )
613+
614+ async def event_generator ():
615+ try :
616+ while True :
617+ # Check connection status first
618+ if await request .is_disconnected ():
619+ logging .info ("SSE client disconnected." )
620+ break
621+
622+ try :
623+ # Wait for a new filename from the queue
624+ filename = await asyncio .wait_for (client_queue .get (), timeout = 1.0 )
625+ logging .info (f"Sending SSE data: { filename } " )
626+ yield f"data: { filename } \n \n "
627+ client_queue .task_done ()
628+ except asyncio .TimeoutError :
629+ # No new file, continue loop to check connection status
630+ continue
631+ except Exception as e :
632+ logging .error (f"Error in SSE generator: { e } " )
633+ # Optionally send an error event to the client
634+ # yield f"event: error\ndata: {json.dumps({'message': 'Internal server error'})}\n\n"
635+ break # Stop streaming on unexpected errors
636+ finally :
637+ # Cleanup when client disconnects or loop breaks
638+ if client_queue in sse_clients :
639+ sse_clients .remove (client_queue )
640+ logging .info (f"SSE client queue removed. Total clients: { len (sse_clients )} " )
641+
642+ return StreamingResponse (event_generator (), media_type = "text/event-stream" )
643+
644+
645+ @app .get ("/videos/{filename}" )
646+ async def get_video (filename : str ):
647+ """
648+ Serves a specific video file from the VIDEO_DIR.
649+ """
650+ # Basic security check: prevent directory traversal
651+ if ".." in filename or filename .startswith ("/" ):
652+ raise HTTPException (status_code = 400 , detail = "Invalid filename." )
653+
654+ filepath = os .path .join (settings .VIDEO_DIR , filename )
655+ logging .info (f"Request for video file: { filepath } " )
656+
657+ if not os .path .exists (filepath ) or not os .path .isfile (filepath ):
658+ logging .warning (f"Video file not found: { filepath } " )
659+ raise HTTPException (status_code = 404 , detail = "Video file not found" )
660+
661+ # Check if the file is an mp4 file (optional but recommended)
662+ if not filename .lower ().endswith (".mp4" ):
663+ raise HTTPException (status_code = 400 , detail = "Invalid file type, only MP4 is supported." )
664+
665+ return FileResponse (filepath , media_type = "video/mp4" , filename = filename )
666+
667+
668+ @app .get ("/videos" , response_model = List [str ])
669+ async def list_videos ():
670+ """
671+ Lists all .mp4 files currently in the VIDEO_DIR.
672+ """
673+ try :
674+ all_files = os .listdir (settings .VIDEO_DIR )
675+ mp4_files = sorted ([f for f in all_files if f .lower ().endswith (".mp4" ) and os .path .isfile (os .path .join (settings .VIDEO_DIR , f ))])
676+ logging .info (f"Found { len (mp4_files )} MP4 files in { settings .VIDEO_DIR } " )
677+ return mp4_files
678+ except FileNotFoundError :
679+ logging .error (f"VIDEO_DIR not found: { settings .VIDEO_DIR } " )
680+ raise HTTPException (status_code = 500 , detail = "Video directory not found on server." )
681+ except Exception as e :
682+ logging .error (f"Error listing videos in { settings .VIDEO_DIR } : { e } " )
683+ raise HTTPException (status_code = 500 , detail = "Error listing video files." )
684+
685+
575686# --- Main execution (for running with uvicorn) ---
576687if __name__ == "__main__" :
577688 import uvicorn
689+ # Configure logging for the main execution context as well
690+ logging .basicConfig (level = logging .INFO , format = '%(asctime)s - %(levelname)s - %(message)s' )
578691 print (f"Starting Uvicorn server on { settings .API_HOST } :{ settings .API_PORT } " )
579- uvicorn .run (app , host = settings .API_HOST , port = settings .API_PORT )
692+ uvicorn .run ("api.api: app" , host = settings .API_HOST , port = settings .API_PORT , reload = True ) # Use string import for reload
0 commit comments