Feed ranking (anonymous users):
- score * 0.6 + recency * 0.3 + random * 0.1
- Recency uses 72-hour half-life decay
- 10% randomness prevents filter bubbles
Feed ranking (authenticated users):
- score * 0.5 + recency * 0.2 + tag_affinity * 0.2 + random * 0.1
- Tag affinity built from engagement history:
- Upvoted shader tags: +1.0 per tag
- Downvoted: -0.5 per tag
- Dwell >10s: +0.3, >30s: +0.6
- Over-fetches 3x candidates, re-ranks with affinity, returns top N
Similar shaders endpoint:
- GET /api/v1/feed/similar/{shader_id}
- Finds shaders with overlapping tags
- Ranks by tag overlap count, breaks ties by score
- MCP tool: get_similar_shaders
Fix: PostgreSQL text[] && varchar[] type mismatch
- Used type_coerce() instead of cast() for ARRAY overlap operator
- Affects both shaders search-by-tags and similar-by-tags queries
416 lines
15 KiB
Python
416 lines
15 KiB
Python
"""Shaders router — CRUD, versioning, drafts, 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, ShaderVersion
|
|
from app.schemas import ShaderCreate, ShaderUpdate, ShaderPublic, ShaderVersionPublic
|
|
from app.middleware.auth import get_current_user, get_optional_user
|
|
from app.services.glsl_validator import validate_glsl
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Public list / search ──────────────────────────────────
|
|
|
|
@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"),
|
|
is_system: bool | None = Query(None, description="Filter to system/platform shaders"),
|
|
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.status == "published",
|
|
)
|
|
|
|
if q:
|
|
query = query.where(Shader.title.ilike(f"%{q}%"))
|
|
if tags:
|
|
from sqlalchemy import type_coerce, Text
|
|
from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY
|
|
query = query.where(Shader.tags.overlap(type_coerce(tags, PG_ARRAY(Text))))
|
|
if shader_type:
|
|
query = query.where(Shader.shader_type == shader_type)
|
|
if is_system is not None:
|
|
query = query.where(Shader.is_system == is_system)
|
|
|
|
if sort == "new":
|
|
query = query.order_by(Shader.created_at.desc())
|
|
elif sort == "top":
|
|
query = query.order_by(Shader.score.desc())
|
|
else:
|
|
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()
|
|
|
|
|
|
# ── My shaders (workspace) ───────────────────────────────
|
|
|
|
@router.get("/mine", response_model=list[ShaderPublic])
|
|
async def my_shaders(
|
|
status_filter: str | None = Query(None, alias="status", description="draft, published, archived"),
|
|
limit: int = Query(50, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""List the authenticated user's shaders — drafts, published, archived."""
|
|
query = select(Shader).where(Shader.author_id == user.id)
|
|
if status_filter:
|
|
query = query.where(Shader.status == status_filter)
|
|
query = query.order_by(Shader.updated_at.desc()).limit(limit).offset(offset)
|
|
result = await db.execute(query)
|
|
return result.scalars().all()
|
|
|
|
|
|
# ── Single shader ─────────────────────────────────────────
|
|
|
|
@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")
|
|
|
|
# Drafts are only visible to their author
|
|
if shader.status == "draft" and (not user or user.id != shader.author_id):
|
|
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")
|
|
|
|
shader.view_count += 1
|
|
return shader
|
|
|
|
|
|
# ── Version history ───────────────────────────────────────
|
|
|
|
@router.get("/{shader_id}/versions", response_model=list[ShaderVersionPublic])
|
|
async def list_versions(
|
|
shader_id: UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User | None = Depends(get_optional_user),
|
|
):
|
|
"""Get the version history of a shader."""
|
|
shader = (await db.execute(select(Shader).where(Shader.id == shader_id))).scalar_one_or_none()
|
|
if not shader:
|
|
raise HTTPException(status_code=404, detail="Shader not found")
|
|
if shader.status == "draft" and (not user or user.id != shader.author_id):
|
|
raise HTTPException(status_code=404, detail="Shader not found")
|
|
|
|
result = await db.execute(
|
|
select(ShaderVersion)
|
|
.where(ShaderVersion.shader_id == shader_id)
|
|
.order_by(ShaderVersion.version_number.desc())
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.get("/{shader_id}/versions/{version_number}", response_model=ShaderVersionPublic)
|
|
async def get_version(
|
|
shader_id: UUID,
|
|
version_number: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(ShaderVersion).where(
|
|
ShaderVersion.shader_id == shader_id,
|
|
ShaderVersion.version_number == version_number,
|
|
)
|
|
)
|
|
version = result.scalar_one_or_none()
|
|
if not version:
|
|
raise HTTPException(status_code=404, detail="Version not found")
|
|
return version
|
|
|
|
|
|
# ── Create shader (draft or published) ───────────────────
|
|
|
|
@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 published shaders for free tier (drafts are unlimited)
|
|
if body.status == "published" and 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.status == "published",
|
|
Shader.created_at >= month_start,
|
|
)
|
|
)
|
|
monthly_count = count_result.scalar()
|
|
if monthly_count >= 5:
|
|
raise HTTPException(status_code=429, detail="Free tier: 5 published shaders/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 if body.status == "published" else False,
|
|
status=body.status,
|
|
style_metadata=body.style_metadata,
|
|
render_status="ready" if body.status == "draft" else "pending",
|
|
current_version=1,
|
|
)
|
|
db.add(shader)
|
|
await db.flush()
|
|
|
|
# Create version 1 snapshot
|
|
v1 = ShaderVersion(
|
|
shader_id=shader.id,
|
|
version_number=1,
|
|
glsl_code=body.glsl_code,
|
|
title=body.title,
|
|
description=body.description,
|
|
tags=body.tags,
|
|
style_metadata=body.style_metadata,
|
|
change_note="Initial version",
|
|
)
|
|
db.add(v1)
|
|
|
|
# Enqueue render for published shaders
|
|
if body.status == "published":
|
|
from app.worker import celery_app
|
|
try:
|
|
celery_app.send_task("render_shader", args=[str(shader.id)])
|
|
except Exception:
|
|
shader.render_status = "ready"
|
|
|
|
# Link to desire if fulfilling
|
|
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
|
|
|
|
|
|
# ── Update shader (creates new version) ──────────────────
|
|
|
|
@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)
|
|
change_note = updates.pop("change_note", None)
|
|
code_changed = "glsl_code" in updates
|
|
|
|
# Re-validate GLSL if code changed
|
|
if code_changed:
|
|
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,
|
|
})
|
|
|
|
# Apply updates
|
|
for field, value in updates.items():
|
|
setattr(shader, field, value)
|
|
|
|
# Create a new version snapshot if code or metadata changed
|
|
if code_changed or "title" in updates or "description" in updates or "tags" in updates:
|
|
shader.current_version += 1
|
|
new_version = ShaderVersion(
|
|
shader_id=shader.id,
|
|
version_number=shader.current_version,
|
|
glsl_code=shader.glsl_code,
|
|
title=shader.title,
|
|
description=shader.description,
|
|
tags=shader.tags,
|
|
style_metadata=shader.style_metadata,
|
|
change_note=change_note,
|
|
)
|
|
db.add(new_version)
|
|
|
|
# Re-render if code changed and shader is published
|
|
if code_changed and shader.status == "published":
|
|
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"
|
|
|
|
# If publishing a draft, ensure it's public and queue render
|
|
if "status" in updates and updates["status"] == "published" and shader.render_status != "ready":
|
|
shader.is_public = True
|
|
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"
|
|
|
|
shader.updated_at = datetime.now(timezone.utc)
|
|
return shader
|
|
|
|
|
|
# ── Delete ────────────────────────────────────────────────
|
|
|
|
@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)
|
|
|
|
|
|
# ── Fork ──────────────────────────────────────────────────
|
|
|
|
@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 and original.status != "published":
|
|
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,
|
|
status="draft", # Forks start as drafts
|
|
is_public=False,
|
|
render_status="ready",
|
|
current_version=1,
|
|
)
|
|
db.add(forked)
|
|
await db.flush()
|
|
|
|
v1 = ShaderVersion(
|
|
shader_id=forked.id,
|
|
version_number=1,
|
|
glsl_code=original.glsl_code,
|
|
title=forked.title,
|
|
description=forked.description,
|
|
tags=original.tags,
|
|
style_metadata=original.style_metadata,
|
|
change_note=f"Forked from {original.title}",
|
|
)
|
|
db.add(v1)
|
|
|
|
return forked
|
|
|
|
|
|
# ── Restore a version ────────────────────────────────────
|
|
|
|
@router.post("/{shader_id}/versions/{version_number}/restore", response_model=ShaderPublic)
|
|
async def restore_version(
|
|
shader_id: UUID,
|
|
version_number: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""Restore a shader to a previous version (creates a new version snapshot)."""
|
|
shader = (await db.execute(select(Shader).where(Shader.id == shader_id))).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")
|
|
|
|
version = (await db.execute(
|
|
select(ShaderVersion).where(
|
|
ShaderVersion.shader_id == shader_id,
|
|
ShaderVersion.version_number == version_number,
|
|
)
|
|
)).scalar_one_or_none()
|
|
if not version:
|
|
raise HTTPException(status_code=404, detail="Version not found")
|
|
|
|
# Apply version data to shader
|
|
shader.glsl_code = version.glsl_code
|
|
shader.title = version.title
|
|
shader.description = version.description
|
|
shader.tags = version.tags
|
|
shader.style_metadata = version.style_metadata
|
|
shader.current_version += 1
|
|
shader.updated_at = datetime.now(timezone.utc)
|
|
|
|
# Create a new version snapshot for the restore
|
|
restore_v = ShaderVersion(
|
|
shader_id=shader.id,
|
|
version_number=shader.current_version,
|
|
glsl_code=version.glsl_code,
|
|
title=version.title,
|
|
description=version.description,
|
|
tags=version.tags,
|
|
style_metadata=version.style_metadata,
|
|
change_note=f"Restored from version {version_number}",
|
|
)
|
|
db.add(restore_v)
|
|
|
|
# Re-render if published
|
|
if shader.status == "published":
|
|
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"
|
|
|
|
return shader
|