516 lines
16 KiB
Python
516 lines
16 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, 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'<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/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('<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)."""
|
|
ctx = await get_current_user(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>
|
|
''')
|
|
|
|
|
|
# Friendly name editing
|
|
@router.get("/{cid}/name-form", response_class=HTMLResponse)
|
|
async def get_name_form(
|
|
cid: str,
|
|
request: Request,
|
|
cache_service: CacheService = Depends(get_cache_service),
|
|
):
|
|
"""Get friendly name editing form (HTMX)."""
|
|
ctx = await get_current_user(request)
|
|
if not ctx:
|
|
return HTMLResponse('<div class="text-red-400">Login required</div>')
|
|
|
|
# Get current friendly name
|
|
from ..services.naming_service import get_naming_service
|
|
naming = get_naming_service()
|
|
entry = await naming.get_by_cid(ctx.actor_id, cid)
|
|
current_name = entry.get("base_name", "") if entry else ""
|
|
|
|
return HTMLResponse(f'''
|
|
<form hx-post="/cache/{cid}/name"
|
|
hx-target="#friendly-name-section"
|
|
hx-swap="innerHTML"
|
|
class="space-y-3">
|
|
<div>
|
|
<label class="block text-gray-400 text-sm mb-1">Friendly Name</label>
|
|
<input type="text" name="display_name" value="{current_name}"
|
|
placeholder="e.g., my-background-video"
|
|
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
|
<p class="text-gray-500 text-xs mt-1">A name to reference this media in recipes</p>
|
|
</div>
|
|
<div class="flex space-x-2">
|
|
<button type="submit"
|
|
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
|
Save
|
|
</button>
|
|
<button type="button"
|
|
onclick="location.reload()"
|
|
class="px-4 py-2 rounded border border-gray-600 hover:bg-gray-700">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
''')
|
|
|
|
|
|
@router.post("/{cid}/name", response_class=HTMLResponse)
|
|
async def update_friendly_name(
|
|
cid: str,
|
|
request: Request,
|
|
):
|
|
"""Update friendly name (HTMX form handler)."""
|
|
ctx = await get_current_user(request)
|
|
if not ctx:
|
|
return HTMLResponse('<div class="text-red-400">Login required</div>')
|
|
|
|
form_data = await request.form()
|
|
display_name = form_data.get("display_name", "").strip()
|
|
|
|
if not display_name:
|
|
return HTMLResponse('<div class="text-red-400">Name cannot be empty</div>')
|
|
|
|
from ..services.naming_service import get_naming_service
|
|
naming = get_naming_service()
|
|
|
|
try:
|
|
entry = await naming.assign_name(
|
|
cid=cid,
|
|
actor_id=ctx.actor_id,
|
|
item_type="media",
|
|
display_name=display_name,
|
|
)
|
|
|
|
return HTMLResponse(f'''
|
|
<div class="text-green-400 mb-2">Name updated!</div>
|
|
<script>setTimeout(() => location.reload(), 1000);</script>
|
|
''')
|
|
except Exception as e:
|
|
return HTMLResponse(f'<div class="text-red-400">Error: {e}</div>')
|