Files
celery/app/routers/cache.py
gilesb faa54b2e85 Add database migration for content_hash -> cid rename
- Add DO block to migrate existing columns:
  - cache_items.content_hash -> cid
  - item_types.content_hash -> cid
  - l2_shares.content_hash -> cid
  - storage_pins.content_hash -> cid
  - run_cache.output_hash -> output_cid
- Fix duplicate key bug in upload response

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:18:23 +00:00

344 lines
10 KiB
Python

"""
Cache and media routes for L1 server.
Handles content retrieval, metadata, media preview, and publishing.
"""
import logging
from pathlib import Path
from typing import Optional, Dict, Any
from fastapi import APIRouter, Request, Depends, HTTPException, UploadFile, File
from fastapi.responses import HTMLResponse, FileResponse
from pydantic import BaseModel
from artdag_common import render
from artdag_common.middleware import wants_html, wants_json
from artdag_common.middleware.auth import UserContext
from ..dependencies import (
require_auth, get_templates, get_redis_client,
get_cache_manager, get_current_user
)
from ..services.auth_service import AuthService
from ..services.cache_service import CacheService
router = APIRouter()
logger = logging.getLogger(__name__)
class UpdateMetadataRequest(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
tags: Optional[list] = None
custom: Optional[Dict[str, Any]] = None
def get_cache_service():
"""Get cache service instance."""
import database
return CacheService(database, get_cache_manager())
@router.get("/{cid}")
async def get_cached(
cid: str,
request: Request,
cache_service: CacheService = Depends(get_cache_service),
):
"""Get cached content by hash. Content negotiation: HTML for browsers, JSON for APIs."""
auth_service = AuthService(get_redis_client())
ctx = auth_service.get_user_from_cookie(request)
cache_item = await cache_service.get_cache_item(cid)
if not cache_item:
if wants_html(request):
templates = get_templates(request)
return render(templates, "cache/not_found.html", request,
cid=cid,
user=ctx,
active_tab="media",
)
raise HTTPException(404, f"Content {cid} not in cache")
# JSON response
if wants_json(request):
return cache_item
# HTML response
if not ctx:
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/auth", status_code=302)
# Check access
has_access = await cache_service.check_access(cid, ctx.actor_id, ctx.username)
if not has_access:
raise HTTPException(403, "Access denied")
templates = get_templates(request)
return render(templates, "cache/detail.html", request,
cache=cache_item,
user=ctx,
active_tab="media",
)
@router.get("/{cid}/raw")
async def get_cached_raw(
cid: str,
cache_service: CacheService = Depends(get_cache_service),
):
"""Get raw cached content (file download)."""
file_path, media_type, filename = await cache_service.get_raw_file(cid)
if not file_path:
raise HTTPException(404, f"Content {cid} not in cache")
return FileResponse(file_path, media_type=media_type, filename=filename)
@router.get("/{cid}/mp4")
async def get_cached_mp4(
cid: str,
cache_service: CacheService = Depends(get_cache_service),
):
"""Get cached content as MP4 (transcodes MKV on first request)."""
mp4_path, error = await cache_service.get_as_mp4(cid)
if error:
raise HTTPException(400 if "not a video" in error else 404, error)
return FileResponse(mp4_path, media_type="video/mp4")
@router.get("/{cid}/meta")
async def get_metadata(
cid: str,
ctx: UserContext = Depends(require_auth),
cache_service: CacheService = Depends(get_cache_service),
):
"""Get content metadata."""
meta = await cache_service.get_metadata(cid, ctx.actor_id)
if meta is None:
raise HTTPException(404, "Content not found")
return meta
@router.patch("/{cid}/meta")
async def update_metadata(
cid: str,
req: UpdateMetadataRequest,
ctx: UserContext = Depends(require_auth),
cache_service: CacheService = Depends(get_cache_service),
):
"""Update content metadata."""
success, error = await cache_service.update_metadata(
cid=cid,
actor_id=ctx.actor_id,
title=req.title,
description=req.description,
tags=req.tags,
custom=req.custom,
)
if error:
raise HTTPException(400, error)
return {"updated": True}
@router.post("/{cid}/publish")
async def publish_content(
cid: str,
request: Request,
ctx: UserContext = Depends(require_auth),
cache_service: CacheService = Depends(get_cache_service),
):
"""Publish content to L2 and IPFS."""
ipfs_cid, error = await cache_service.publish_to_l2(
cid=cid,
actor_id=ctx.actor_id,
l2_server=ctx.l2_server,
auth_token=request.cookies.get("auth_token"),
)
if error:
if wants_html(request):
return HTMLResponse(f'<span class="text-red-400">{error}</span>')
raise HTTPException(400, error)
if wants_html(request):
return HTMLResponse(f'<span class="text-green-400">Published: {ipfs_cid[:16]}...</span>')
return {"ipfs_cid": ipfs_cid, "published": True}
@router.delete("/{cid}")
async def delete_content(
cid: str,
ctx: UserContext = Depends(require_auth),
cache_service: CacheService = Depends(get_cache_service),
):
"""Delete content from cache."""
success, error = await cache_service.delete_content(cid, ctx.actor_id)
if error:
raise HTTPException(400 if "Cannot" in error or "pinned" in error else 404, error)
return {"deleted": True}
@router.post("/import")
async def import_from_ipfs(
ipfs_cid: str,
ctx: UserContext = Depends(require_auth),
cache_service: CacheService = Depends(get_cache_service),
):
"""Import content from IPFS."""
cid, error = await cache_service.import_from_ipfs(ipfs_cid, ctx.actor_id)
if error:
raise HTTPException(400, error)
return {"cid": cid, "imported": True}
@router.post("/upload")
async def upload_content(
file: UploadFile = File(...),
ctx: UserContext = Depends(require_auth),
cache_service: CacheService = Depends(get_cache_service),
):
"""Upload content to cache and IPFS."""
content = await file.read()
cid, ipfs_cid, error = await cache_service.upload_content(
content=content,
filename=file.filename,
actor_id=ctx.actor_id,
)
if error:
raise HTTPException(400, error)
return {
"cid": ipfs_cid or cid,
"content_hash": cid, # Legacy, for backwards compatibility
"filename": file.filename,
"size": len(content),
"uploaded": True,
}
# Media listing endpoint
@router.get("")
async def list_media(
request: Request,
offset: int = 0,
limit: int = 24,
media_type: Optional[str] = None,
cache_service: CacheService = Depends(get_cache_service),
):
"""List all media in cache."""
auth_service = AuthService(get_redis_client())
ctx = auth_service.get_user_from_cookie(request)
if not ctx:
if wants_json(request):
raise HTTPException(401, "Authentication required")
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/auth", status_code=302)
items = await cache_service.list_media(
actor_id=ctx.actor_id,
username=ctx.username,
offset=offset,
limit=limit,
media_type=media_type,
)
has_more = len(items) >= limit
if wants_json(request):
return {"items": items, "offset": offset, "limit": limit, "has_more": has_more}
templates = get_templates(request)
return render(templates, "cache/media_list.html", request,
items=items,
user=ctx,
offset=offset,
limit=limit,
has_more=has_more,
active_tab="media",
)
# HTMX metadata form
@router.get("/{cid}/meta-form", response_class=HTMLResponse)
async def get_metadata_form(
cid: str,
request: Request,
cache_service: CacheService = Depends(get_cache_service),
):
"""Get metadata editing form (HTMX)."""
auth_service = AuthService(get_redis_client())
ctx = auth_service.get_user_from_cookie(request)
if not ctx:
return HTMLResponse('<div class="text-red-400">Login required</div>')
meta = await cache_service.get_metadata(cid, ctx.actor_id)
return HTMLResponse(f'''
<h2 class="text-lg font-semibold mb-4">Metadata</h2>
<form hx-patch="/cache/{cid}/meta"
hx-target="#metadata-section"
hx-swap="innerHTML"
class="space-y-4">
<div>
<label class="block text-gray-400 text-sm mb-1">Title</label>
<input type="text" name="title" value="{meta.get('title', '') if meta else ''}"
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
</div>
<div>
<label class="block text-gray-400 text-sm mb-1">Description</label>
<textarea name="description" rows="3"
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white"
>{meta.get('description', '') if meta else ''}</textarea>
</div>
<button type="submit"
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
Save Metadata
</button>
</form>
''')
@router.patch("/{cid}/meta", response_class=HTMLResponse)
async def update_metadata_htmx(
cid: str,
request: Request,
cache_service: CacheService = Depends(get_cache_service),
):
"""Update metadata (HTMX form handler)."""
auth_service = AuthService(get_redis_client())
ctx = auth_service.get_user_from_cookie(request)
if not ctx:
return HTMLResponse('<div class="text-red-400">Login required</div>')
form_data = await request.form()
success, error = await cache_service.update_metadata(
cid=cid,
actor_id=ctx.actor_id,
title=form_data.get("title"),
description=form_data.get("description"),
)
if error:
return HTMLResponse(f'<div class="text-red-400">{error}</div>')
return HTMLResponse('''
<div class="text-green-400 mb-4">Metadata saved!</div>
<script>setTimeout(() => location.reload(), 1000);</script>
''')