Files
2025-10-28 18:29:03 -05:00

297 lines
9.7 KiB
Python

from fastapi import FastAPI, HTTPException, File, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import os
import json
import mimetypes
from pathlib import Path
from datetime import datetime
from typing import List, Optional
import uuid
from PIL import Image, ImageOps
import io
app = FastAPI(title="Media Gallery API", version="1.0.0")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3001", "http://localhost:3000", "*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Configuration
MEDIA_DIR = Path("/opt/ComfyUI/output") # Directory containing media files
THUMBNAILS_DIR = Path("thumbnails") # Thumbnail cache directory
JOBS_FILE = Path("jobs.json") # Job queue storage
# Ensure directories exist
MEDIA_DIR.mkdir(exist_ok=True)
THUMBNAILS_DIR.mkdir(exist_ok=True)
# Pydantic models
class MediaFile(BaseModel):
name: str
path: str
size: int
modified: str
type: str # 'image' or 'video'
class GenerateRequest(BaseModel):
trigger: str
timestamp: int
prompt: Optional[str] = None
class Job(BaseModel):
id: str
name: str
status: str # 'pending', 'running', 'completed', 'failed', 'cancelled'
created: str
progress: Optional[int] = None
error: Optional[str] = None
# Helper functions
def load_jobs() -> List[Job]:
"""Load jobs from JSON file"""
if not JOBS_FILE.exists():
return []
try:
with open(JOBS_FILE, 'r') as f:
data = json.load(f)
return [Job(**job) for job in data]
except Exception:
return []
def save_jobs(jobs: List[Job]):
"""Save jobs to JSON file"""
with open(JOBS_FILE, 'w') as f:
json.dump([job.dict() for job in jobs], f, indent=2)
def get_file_type(file_path: Path) -> str:
"""Determine if file is image or video based on extension"""
mime_type, _ = mimetypes.guess_type(str(file_path))
if mime_type:
if mime_type.startswith('image/'):
return 'image'
elif mime_type.startswith('video/'):
return 'video'
return 'unknown'
def generate_thumbnail(image_path: Path, size: tuple = (300, 300)) -> bytes:
"""Generate a thumbnail for an image and return as bytes"""
try:
with Image.open(image_path) as img:
# Convert to RGB if necessary (for PNG with transparency, etc.)
if img.mode in ('RGBA', 'LA', 'P'):
rgb_img = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
rgb_img.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
img = rgb_img
# Create thumbnail
img.thumbnail(size, Image.Resampling.LANCZOS)
# Save to bytes
img_byte_arr = io.BytesIO()
img.save(img_byte_arr, format='JPEG', quality=85, optimize=True)
return img_byte_arr.getvalue()
except Exception as e:
print(f"Error generating thumbnail for {image_path}: {e}")
return None
def scan_media_files() -> List[MediaFile]:
"""Scan media directory and return list of media files"""
media_files = []
# Supported file extensions
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'}
video_extensions = {'.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.wmv'}
supported_extensions = image_extensions | video_extensions
for file_path in MEDIA_DIR.rglob('*'):
if file_path.is_file() and file_path.suffix.lower() in supported_extensions:
try:
stat = file_path.stat()
file_type = get_file_type(file_path)
if file_type in ['image', 'video']:
relative_path = file_path.relative_to(MEDIA_DIR)
media_files.append(MediaFile(
name=file_path.name,
path=str(relative_path),
size=stat.st_size,
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
type=file_type
))
except Exception as e:
print(f"Error processing file {file_path}: {e}")
return sorted(media_files, key=lambda x: x.modified, reverse=True)
# API Endpoints
@app.get("/")
async def root():
return {"message": "Media Gallery API", "version": "1.0.0"}
@app.get("/api/files", response_model=List[MediaFile])
async def get_files():
"""Get list of all media files"""
return scan_media_files()
@app.get("/api/file/{file_path:path}")
async def get_file(file_path: str):
"""Serve a media file"""
full_path = MEDIA_DIR / file_path
if not full_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if not full_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a file")
# Security check - ensure file is within media directory
try:
full_path.resolve().relative_to(MEDIA_DIR.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
return FileResponse(full_path)
@app.get("/api/thumbnail/{file_path:path}")
async def get_thumbnail(file_path: str):
"""Get thumbnail for a media file"""
try:
# Validate path to prevent traversal attacks
full_path = MEDIA_DIR / file_path
full_path.resolve().relative_to(MEDIA_DIR.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
if not full_path.exists():
raise HTTPException(status_code=404, detail="File not found")
# Check if it's an image
if get_file_type(full_path) != 'image':
# For videos, just serve the original file for now
return FileResponse(full_path)
# Generate cache filename
cache_name = f"{file_path.replace('/', '_').replace('\\', '_')}_thumb.jpg"
cache_path = THUMBNAILS_DIR / cache_name
# Check if thumbnail exists and is newer than original
if cache_path.exists() and cache_path.stat().st_mtime > full_path.stat().st_mtime:
return FileResponse(cache_path, media_type="image/jpeg")
# Generate new thumbnail
thumbnail_data = generate_thumbnail(full_path)
if thumbnail_data is None:
# Fall back to original file if thumbnail generation fails
return FileResponse(full_path)
# Save to cache
try:
with open(cache_path, 'wb') as f:
f.write(thumbnail_data)
except Exception as e:
print(f"Failed to cache thumbnail: {e}")
# Return thumbnail
return Response(content=thumbnail_data, media_type="image/jpeg")
@app.delete("/api/delete/{file_path:path}")
async def delete_file(file_path: str):
"""Delete a media file"""
full_path = MEDIA_DIR / file_path
if not full_path.exists():
raise HTTPException(status_code=404, detail="File not found")
# Security check - ensure file is within media directory
try:
full_path.resolve().relative_to(MEDIA_DIR.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
try:
full_path.unlink()
return {"message": f"File {file_path} deleted successfully"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}")
@app.post("/api/generate")
async def generate_image(request: GenerateRequest):
"""Start image generation (mock implementation)"""
jobs = load_jobs()
# Create new job
job = Job(
id=str(uuid.uuid4()),
name="Image Generation",
status="pending",
created=datetime.now().isoformat(),
progress=0
)
jobs.append(job)
save_jobs(jobs)
return {"message": "Generation started", "job_id": job.id}
@app.get("/api/jobs", response_model=dict)
async def get_jobs():
"""Get all jobs"""
jobs = load_jobs()
return {"jobs": [job.dict() for job in jobs]}
@app.post("/api/jobs/{job_id}/cancel")
async def cancel_job(job_id: str):
"""Cancel a job"""
jobs = load_jobs()
for job in jobs:
if job.id == job_id:
if job.status in ['pending', 'running']:
job.status = 'cancelled'
save_jobs(jobs)
return {"message": f"Job {job_id} cancelled"}
else:
raise HTTPException(status_code=400, detail="Job cannot be cancelled")
raise HTTPException(status_code=404, detail="Job not found")
@app.post("/api/upload")
async def upload_file(file: UploadFile = File(...)):
"""Upload a media file"""
# Validate file type
if not file.content_type or not (
file.content_type.startswith('image/') or
file.content_type.startswith('video/')
):
raise HTTPException(status_code=400, detail="Only image and video files are allowed")
# Save file
file_path = MEDIA_DIR / file.filename
try:
with open(file_path, "wb") as buffer:
content = await file.read()
buffer.write(content)
return {"message": f"File {file.filename} uploaded successfully", "path": file.filename}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error uploading file: {str(e)}")
# Mount static files (optional - for serving web app)
# app.mount("/", StaticFiles(directory="../dist", html=True), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)