Fix media friendly names, metadata display, output recording, and plan display

- Add friendly name display to media detail and list pages
- Unpack nested meta fields to top level for template access
- Fix output_cid mismatch: use IPFS CID consistently between cache and database
- Add dual-indexing in cache_manager to map both IPFS CID and local hash
- Fix plan display: accept IPFS CIDs (Qm..., bafy...) not just 64-char hashes
- Add friendly names to recipe listing
- Add recipe upload button and handler to recipes list
- Add debug logging to recipe listing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-12 14:21:39 +00:00
parent 19634a4ac5
commit ee8719ac0b
9 changed files with 183 additions and 11 deletions

View File

@@ -50,7 +50,9 @@ async def get_cached(
auth_service = AuthService(get_redis_client())
ctx = auth_service.get_user_from_cookie(request)
cache_item = await cache_service.get_cache_item(cid)
# 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)

View File

@@ -181,7 +181,13 @@ async def get_run(
plan_sexp = None # Native S-expression if available
recipe_ipfs_cid = None
recipe_id = run.get("recipe")
if recipe_id and len(recipe_id) == 64: # Looks like a hash
# Check for valid recipe ID (64-char hash, IPFS CIDv0 "Qm...", or CIDv1 "bafy...")
is_valid_recipe_id = recipe_id and (
len(recipe_id) == 64 or
recipe_id.startswith("Qm") or
recipe_id.startswith("bafy")
)
if is_valid_recipe_id:
try:
from ..services.recipe_service import RecipeService
recipe_service = RecipeService(get_redis_client(), get_cache_manager())

View File

@@ -95,7 +95,7 @@ class CacheService:
self.cache = cache_manager
self.cache_dir = Path(os.environ.get("CACHE_DIR", "/tmp/artdag-cache"))
async def get_cache_item(self, cid: str) -> Optional[Dict[str, Any]]:
async def get_cache_item(self, cid: str, actor_id: str = None) -> Optional[Dict[str, Any]]:
"""Get cached item with full metadata for display."""
# Check if content exists
if not self.cache.has_content(cid):
@@ -106,14 +106,14 @@ class CacheService:
return None
# Get metadata from database
meta = await self.db.load_item_metadata(cid, None)
meta = await self.db.load_item_metadata(cid, actor_id)
cache_item = await self.db.get_cache_item(cid)
media_type = detect_media_type(path)
mime_type = get_mime_type(path)
size = path.stat().st_size
return {
result = {
"cid": cid,
"path": str(path),
"media_type": media_type,
@@ -123,6 +123,28 @@ class CacheService:
"meta": meta,
}
# Unpack meta fields to top level for template convenience
if meta:
result["title"] = meta.get("title")
result["description"] = meta.get("description")
result["tags"] = meta.get("tags", [])
result["source_type"] = meta.get("source_type")
result["source_note"] = meta.get("source_note")
result["created_at"] = meta.get("created_at")
result["filename"] = meta.get("filename")
# Get friendly name if actor_id provided
if actor_id:
from .naming_service import get_naming_service
naming = get_naming_service()
friendly = await naming.get_by_cid(actor_id, cid)
if friendly:
result["friendly_name"] = friendly["friendly_name"]
result["base_name"] = friendly["base_name"]
result["version_id"] = friendly["version_id"]
return result
async def check_access(self, cid: str, actor_id: str, username: str) -> bool:
"""Check if user has access to content."""
user_hashes = await self._get_user_cache_hashes(username, actor_id)
@@ -508,6 +530,19 @@ class CacheService:
limit=limit,
offset=offset,
)
# Add friendly names to items
if actor_id:
from .naming_service import get_naming_service
naming = get_naming_service()
for item in items:
cid = item.get("cid")
if cid:
friendly = await naming.get_by_cid(actor_id, cid)
if friendly:
item["friendly_name"] = friendly["friendly_name"]
item["base_name"] = friendly["base_name"]
return items
# Legacy compatibility methods

View File

@@ -75,8 +75,9 @@ class RecipeService:
if hasattr(self.cache, 'list_by_type'):
items = self.cache.list_by_type('recipe')
logger.info(f"Found {len(items)} recipes in cache")
logger.info(f"Found {len(items)} recipe CIDs in cache: {items[:5]}...")
for cid in items:
logger.debug(f"Attempting to get recipe {cid[:16]}...")
recipe = await self.get_recipe(cid)
if recipe and not recipe.get("error"):
owner = recipe.get("owner")
@@ -85,9 +86,25 @@ class RecipeService:
# means the recipe is shared/public and visible to all users
if actor_id is None or owner is None or owner == actor_id:
recipes.append(recipe)
elif recipe and recipe.get("error"):
logger.warning(f"Recipe {cid[:16]}... has error: {recipe.get('error')}")
else:
logger.warning(f"Recipe {cid[:16]}... returned None")
else:
logger.warning("Cache does not have list_by_type method")
# Add friendly names
if actor_id:
from .naming_service import get_naming_service
naming = get_naming_service()
for recipe in recipes:
recipe_id = recipe.get("recipe_id")
if recipe_id:
friendly = await naming.get_by_cid(actor_id, recipe_id)
if friendly:
recipe["friendly_name"] = friendly["friendly_name"]
recipe["base_name"] = friendly["base_name"]
# Sort by name
recipes.sort(key=lambda r: r.get("name", ""))

View File

@@ -39,6 +39,17 @@
{% endif %}
</div>
<!-- Friendly Name -->
{% if cache.friendly_name %}
<div class="bg-gray-800 rounded-lg border border-gray-700 p-4 mb-6">
<div class="mb-2">
<span class="text-gray-500 text-sm">Friendly Name</span>
<p class="text-blue-400 font-medium text-lg mt-1">{{ cache.friendly_name }}</p>
</div>
<p class="text-gray-500 text-xs">Use in recipes: <code class="bg-gray-900 px-2 py-0.5 rounded">{{ cache.base_name }}</code></p>
</div>
{% endif %}
<!-- User Metadata (editable) -->
<div id="metadata-section" class="bg-gray-800 rounded-lg border border-gray-700 p-4 mb-6">
<div class="flex items-center justify-between mb-3">
@@ -50,15 +61,19 @@
Edit
</button>
</div>
{% if cache.title or cache.description %}
{% if cache.title or cache.description or cache.filename %}
<div class="space-y-2 mb-4">
{% if cache.title %}
<h4 class="text-white font-medium">{{ cache.title }}</h4>
{% elif cache.filename %}
<h4 class="text-white font-medium">{{ cache.filename }}</h4>
{% endif %}
{% if cache.description %}
<p class="text-gray-400">{{ cache.description }}</p>
{% endif %}
</div>
{% else %}
<p class="text-gray-500 text-sm mb-4">No title or description set. Click Edit to add metadata.</p>
{% endif %}
{% if cache.tags %}
<div class="flex flex-wrap gap-2 mb-4">

View File

@@ -68,7 +68,11 @@
{% endif %}
<div class="p-3">
{% if item.friendly_name %}
<div class="text-xs text-blue-400 font-medium truncate">{{ item.friendly_name }}</div>
{% else %}
<div class="font-mono text-xs text-gray-500 truncate">{{ item.cid[:16] }}...</div>
{% endif %}
{% if item.filename %}
<div class="text-xs text-gray-600 truncate">{{ item.filename }}</div>
{% endif %}

View File

@@ -6,6 +6,10 @@
<div class="max-w-6xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Recipes</h1>
<label class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium cursor-pointer">
Upload Recipe
<input type="file" accept=".sexp,.yaml,.yml" class="hidden" id="recipe-upload" />
</label>
</div>
<p class="text-gray-400 mb-8">
@@ -13,10 +17,10 @@
</p>
{% if recipes %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="recipes-list">
{% for recipe in recipes %}
<a href="/recipes/{{ recipe.recipe_id }}"
class="bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-gray-600 transition-colors">
class="recipe-card bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-gray-600 transition-colors">
<div class="flex items-center justify-between mb-2">
<span class="font-medium text-white">{{ recipe.name }}</span>
{% if recipe.version %}
@@ -42,14 +46,91 @@
{% endfor %}
</div>
{% endif %}
<div class="mt-3 text-xs">
{% if recipe.friendly_name %}
<span class="text-blue-400 font-medium">{{ recipe.friendly_name }}</span>
{% else %}
<span class="text-gray-600 font-mono truncate">{{ recipe.recipe_id[:24] }}...</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% if has_more %}
<div hx-get="/recipes?offset={{ offset + limit }}&limit={{ limit }}"
hx-trigger="revealed"
hx-swap="afterend"
hx-select="#recipes-list > *"
class="h-20 flex items-center justify-center text-gray-500">
Loading more...
</div>
{% endif %}
{% else %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
<p class="text-gray-500 mb-4">No recipes available.</p>
<p class="text-gray-600 text-sm">Recipes are defined in YAML format and submitted via API.</p>
<p class="text-gray-600 text-sm mb-6">
Recipes are S-expression files (.sexp) that define processing pipelines.
</p>
<label class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded font-medium cursor-pointer inline-block">
Upload Your First Recipe
<input type="file" accept=".sexp,.yaml,.yml" class="hidden" id="recipe-upload-empty" />
</label>
</div>
{% endif %}
</div>
<div id="upload-result" class="fixed bottom-4 right-4 max-w-sm"></div>
<script>
function handleRecipeUpload(input) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
fetch('/recipes/upload', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) throw new Error('Upload failed');
return response.json();
})
.then(data => {
const resultDiv = document.getElementById('upload-result');
resultDiv.innerHTML = `
<div class="bg-green-900 border border-green-700 rounded-lg p-4">
<p class="text-green-300 font-medium">Recipe uploaded!</p>
<p class="text-green-400 text-sm mt-1">${data.name} v${data.version}</p>
<p class="text-gray-400 text-xs mt-2 font-mono">${data.recipe_id}</p>
</div>
`;
setTimeout(() => {
window.location.reload();
}, 1500);
})
.catch(error => {
const resultDiv = document.getElementById('upload-result');
resultDiv.innerHTML = `
<div class="bg-red-900 border border-red-700 rounded-lg p-4">
<p class="text-red-300 font-medium">Upload failed</p>
<p class="text-red-400 text-sm mt-1">${error.message}</p>
</div>
`;
});
input.value = '';
}
document.getElementById('recipe-upload')?.addEventListener('change', function() {
handleRecipeUpload(this);
});
document.getElementById('recipe-upload-empty')?.addEventListener('change', function() {
handleRecipeUpload(this);
});
</script>
{% endblock %}