"""Shaders router — CRUD, submit, fork, search.""" from uuid import UUID from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from app.database import get_db from app.models import User, Shader from app.schemas import ShaderCreate, ShaderUpdate, ShaderPublic from app.middleware.auth import get_current_user, get_optional_user from app.services.glsl_validator import validate_glsl router = APIRouter() @router.get("", response_model=list[ShaderPublic]) async def list_shaders( q: str | None = Query(None, description="Search query"), tags: list[str] | None = Query(None, description="Filter by tags"), shader_type: str | None = Query(None, description="Filter by type: 2d, 3d, audio-reactive"), sort: str = Query("trending", description="Sort: trending, new, top"), limit: int = Query(20, ge=1, le=50), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), ): query = select(Shader).where(Shader.is_public == True, Shader.render_status == "ready") if q: query = query.where(Shader.title.ilike(f"%{q}%")) if tags: query = query.where(Shader.tags.overlap(tags)) if shader_type: query = query.where(Shader.shader_type == shader_type) if sort == "new": query = query.order_by(Shader.created_at.desc()) elif sort == "top": query = query.order_by(Shader.score.desc()) else: # trending query = query.order_by(Shader.score.desc(), Shader.created_at.desc()) query = query.limit(limit).offset(offset) result = await db.execute(query) return result.scalars().all() @router.get("/{shader_id}", response_model=ShaderPublic) async def get_shader( shader_id: UUID, db: AsyncSession = Depends(get_db), user: User | None = Depends(get_optional_user), ): result = await db.execute(select(Shader).where(Shader.id == shader_id)) shader = result.scalar_one_or_none() if not shader: raise HTTPException(status_code=404, detail="Shader not found") if not shader.is_public and (not user or user.id != shader.author_id): raise HTTPException(status_code=404, detail="Shader not found") # Increment view count shader.view_count += 1 return shader @router.post("", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED) async def create_shader( body: ShaderCreate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): # Rate limit: free tier gets 5 submissions/month if user.subscription_tier == "free": month_start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0) count_result = await db.execute( select(func.count()).select_from(Shader).where( Shader.author_id == user.id, Shader.created_at >= month_start, ) ) monthly_count = count_result.scalar() if monthly_count >= 5: raise HTTPException( status_code=429, detail="Free tier: 5 shader submissions per month. Upgrade to Pro for unlimited." ) # Validate GLSL validation = validate_glsl(body.glsl_code, body.shader_type) if not validation.valid: raise HTTPException( status_code=422, detail={ "message": "GLSL validation failed", "errors": validation.errors, "warnings": validation.warnings, } ) shader = Shader( author_id=user.id, title=body.title, description=body.description, glsl_code=body.glsl_code, tags=body.tags, shader_type=body.shader_type, is_public=body.is_public, style_metadata=body.style_metadata, render_status="pending", ) db.add(shader) await db.flush() # Enqueue render job from app.worker import celery_app try: celery_app.send_task("render_shader", args=[str(shader.id)]) except Exception: # If Celery isn't available (dev without worker), mark as ready # with no thumbnail — the frontend can still render live shader.render_status = "ready" # If this shader fulfills a desire, link them if body.fulfills_desire_id: from app.models import Desire desire = (await db.execute( select(Desire).where(Desire.id == body.fulfills_desire_id, Desire.status == "open") )).scalar_one_or_none() if desire: desire.status = "fulfilled" desire.fulfilled_by_shader = shader.id desire.fulfilled_at = datetime.now(timezone.utc) return shader @router.put("/{shader_id}", response_model=ShaderPublic) async def update_shader( shader_id: UUID, body: ShaderUpdate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): result = await db.execute(select(Shader).where(Shader.id == shader_id)) shader = result.scalar_one_or_none() if not shader: raise HTTPException(status_code=404, detail="Shader not found") if shader.author_id != user.id and user.role != "admin": raise HTTPException(status_code=403, detail="Not the shader owner") updates = body.model_dump(exclude_unset=True) # Re-validate GLSL if code changed if "glsl_code" in updates: validation = validate_glsl(updates["glsl_code"], shader.shader_type) if not validation.valid: raise HTTPException( status_code=422, detail={ "message": "GLSL validation failed", "errors": validation.errors, "warnings": validation.warnings, } ) # Re-render if code changed shader.render_status = "pending" from app.worker import celery_app try: celery_app.send_task("render_shader", args=[str(shader.id)]) except Exception: shader.render_status = "ready" for field, value in updates.items(): setattr(shader, field, value) shader.updated_at = datetime.now(timezone.utc) return shader @router.delete("/{shader_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_shader( shader_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): result = await db.execute(select(Shader).where(Shader.id == shader_id)) shader = result.scalar_one_or_none() if not shader: raise HTTPException(status_code=404, detail="Shader not found") if shader.author_id != user.id and user.role != "admin": raise HTTPException(status_code=403, detail="Not the shader owner") await db.delete(shader) @router.post("/{shader_id}/fork", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED) async def fork_shader( shader_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): result = await db.execute(select(Shader).where(Shader.id == shader_id)) original = result.scalar_one_or_none() if not original: raise HTTPException(status_code=404, detail="Shader not found") if not original.is_public: raise HTTPException(status_code=404, detail="Shader not found") forked = Shader( author_id=user.id, title=f"Fork of {original.title}", description=f"Forked from {original.title}", glsl_code=original.glsl_code, tags=original.tags, shader_type=original.shader_type, forked_from=original.id, style_metadata=original.style_metadata, render_status="pending", ) db.add(forked) await db.flush() # Enqueue render for the fork from app.worker import celery_app try: celery_app.send_task("render_shader", args=[str(forked.id)]) except Exception: forked.render_status = "ready" return forked