""" 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, Form 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.""" ctx = await get_current_user(request) # Pass actor_id to get friendly name and user-specific metadata actor_id = ctx.actor_id if ctx else None cache_item = await cache_service.get_cache_item(cid, actor_id=actor_id) 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") from ..dependencies import get_nav_counts nav_counts = await get_nav_counts(ctx.actor_id) templates = get_templates(request) return render(templates, "cache/detail.html", request, cache=cache_item, user=ctx, nav_counts=nav_counts, 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'{error}') raise HTTPException(400, error) if wants_html(request): return HTMLResponse(f'Published: {ipfs_cid[:16]}...') 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/chunk") async def upload_chunk( request: Request, chunk: UploadFile = File(...), upload_id: str = Form(...), chunk_index: int = Form(...), total_chunks: int = Form(...), filename: str = Form(...), display_name: Optional[str] = Form(None), ctx: UserContext = Depends(require_auth), cache_service: CacheService = Depends(get_cache_service), ): """Upload a file chunk. Assembles file when all chunks received.""" import tempfile import os # Create temp dir for this upload chunk_dir = Path(tempfile.gettempdir()) / "uploads" / upload_id chunk_dir.mkdir(parents=True, exist_ok=True) # Save this chunk chunk_path = chunk_dir / f"chunk_{chunk_index:05d}" chunk_data = await chunk.read() chunk_path.write_bytes(chunk_data) # Check if all chunks received received = len(list(chunk_dir.glob("chunk_*"))) if received < total_chunks: return {"status": "partial", "received": received, "total": total_chunks} # All chunks received - assemble file final_path = chunk_dir / filename with open(final_path, 'wb') as f: for i in range(total_chunks): cp = chunk_dir / f"chunk_{i:05d}" f.write(cp.read_bytes()) cp.unlink() # Clean up chunk # Read assembled file content = final_path.read_bytes() final_path.unlink() chunk_dir.rmdir() # Now do the normal upload flow cid, ipfs_cid, error = await cache_service.upload_content( content=content, filename=filename, actor_id=ctx.actor_id, ) if error: raise HTTPException(400, error) # Assign friendly name final_cid = ipfs_cid or cid from ..services.naming_service import get_naming_service naming = get_naming_service() friendly_entry = await naming.assign_name( cid=final_cid, actor_id=ctx.actor_id, item_type="media", display_name=display_name, filename=filename, ) return { "status": "complete", "cid": final_cid, "friendly_name": friendly_entry["friendly_name"], "filename": filename, "size": len(content), "uploaded": True, } @router.post("/upload") async def upload_content( file: UploadFile = File(...), display_name: Optional[str] = Form(None), ctx: UserContext = Depends(require_auth), cache_service: CacheService = Depends(get_cache_service), ): """Upload content to cache and IPFS. Args: file: The file to upload display_name: Optional custom name for the media (used as friendly name) """ 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) # Assign friendly name (use IPFS CID if available, otherwise local hash) final_cid = ipfs_cid or cid from ..services.naming_service import get_naming_service naming = get_naming_service() friendly_entry = await naming.assign_name( cid=final_cid, actor_id=ctx.actor_id, item_type="media", display_name=display_name, # Use custom name if provided filename=file.filename, ) return { "cid": final_cid, "content_hash": cid, # Legacy, for backwards compatibility "friendly_name": friendly_entry["friendly_name"], "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), ctx: UserContext = Depends(require_auth), ): """List all media in cache.""" 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} from ..dependencies import get_nav_counts nav_counts = await get_nav_counts(ctx.actor_id) templates = get_templates(request) return render(templates, "cache/media_list.html", request, items=items, user=ctx, nav_counts=nav_counts, 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).""" ctx = await get_current_user(request) if not ctx: return HTMLResponse('