@@ -2497,7 +2498,16 @@ async def list_media(
'''
content = f'''
-
{''.join(html_parts)}
{infinite_scroll_trigger}
@@ -3068,6 +3078,132 @@ async def republish_cache_to_l2(
}
+# ============ L2 Sync ============
+
+def _fetch_l2_outbox_sync(l2_server: str, username: str) -> list:
+ """Fetch user's outbox from L2 (sync version for asyncio.to_thread)."""
+ try:
+ # Fetch outbox page with activities
+ resp = http_requests.get(
+ f"{l2_server}/users/{username}/outbox?page=true",
+ headers={"Accept": "application/activity+json"},
+ timeout=10
+ )
+ if resp.status_code != 200:
+ logger.warning(f"L2 outbox fetch failed: {resp.status_code}")
+ return []
+ data = resp.json()
+ return data.get("orderedItems", [])
+ except Exception as e:
+ logger.error(f"Failed to fetch L2 outbox: {e}")
+ return []
+
+
+@app.post("/user/sync-l2")
+async def sync_with_l2(ctx: UserContext = Depends(get_required_user_context)):
+ """
+ Sync local L2 share records with user's L2 outbox.
+ Fetches user's published assets from their L2 server and updates local tracking.
+ """
+ l2_server = ctx.l2_server
+ username = ctx.username
+
+ # Fetch outbox activities
+ activities = await asyncio.to_thread(_fetch_l2_outbox_sync, l2_server, username)
+
+ if not activities:
+ return {"synced": 0, "message": "No activities found or L2 unavailable"}
+
+ # Process Create activities for assets
+ synced_count = 0
+ for activity in activities:
+ if activity.get("type") != "Create":
+ continue
+
+ obj = activity.get("object", {})
+ if not isinstance(obj, dict):
+ continue
+
+ # Get asset info - look for content_hash in attachment or directly
+ content_hash = None
+ asset_name = obj.get("name", "")
+
+ # Check attachments for content hash
+ for attachment in obj.get("attachment", []):
+ if attachment.get("name") == "content_hash":
+ content_hash = attachment.get("value")
+ break
+
+ # Also check if there's a hash in the object URL or ID
+ if not content_hash:
+ # Try to extract from object ID like /objects/{hash}
+ obj_id = obj.get("id", "")
+ if "/objects/" in obj_id:
+ content_hash = obj_id.split("/objects/")[-1].split("/")[0]
+
+ if not content_hash or not asset_name:
+ continue
+
+ # Check if we have this content locally
+ cache_path = get_cache_path(content_hash)
+ if not cache_path:
+ continue # We don't have this content, skip
+
+ # Determine content type from object type
+ obj_type = obj.get("type", "")
+ if obj_type == "Video":
+ content_type = "video"
+ elif obj_type == "Image":
+ content_type = "image"
+ else:
+ content_type = "media"
+
+ # Update local L2 share record
+ await database.save_l2_share(
+ content_hash=content_hash,
+ actor_id=ctx.actor_id,
+ l2_server=l2_server,
+ asset_name=asset_name,
+ content_type=content_type
+ )
+ synced_count += 1
+
+ return {"synced": synced_count, "total_activities": len(activities)}
+
+
+@app.post("/ui/sync-l2", response_class=HTMLResponse)
+async def ui_sync_with_l2(request: Request):
+ """HTMX handler: sync with L2 server."""
+ ctx = get_user_context_from_cookie(request)
+ if not ctx:
+ return '
Login required
'
+
+ try:
+ result = await sync_with_l2(ctx)
+ synced = result.get("synced", 0)
+ total = result.get("total_activities", 0)
+
+ if synced > 0:
+ return f'''
+
+ Synced {synced} asset(s) from L2 ({total} activities found)
+
+ '''
+ else:
+ return f'''
+
+ No new assets to sync ({total} activities found)
+
+ '''
+ except Exception as e:
+ logger.error(f"L2 sync failed: {e}")
+ return f'''
+
+ Sync failed: {str(e)}
+
+ '''
+
+
# ============ Folder & Collection Management ============
@app.get("/user/folders")
@@ -3687,6 +3823,17 @@ async def ui_media_list(
ipfs_cid = cache_item.get("ipfs_cid") if cache_item else None
ipfs_badge = '
IPFS' if ipfs_cid else ''
+ # Check L2 publish status
+ l2_shares = item["meta"].get("l2_shares", [])
+ if l2_shares:
+ first_share = l2_shares[0]
+ l2_server = first_share.get("l2_server", "")
+ asset_name = first_share.get("asset_name", "")
+ asset_url = f"{l2_server}/assets/{asset_name}"
+ published_badge = f'
L2'
+ else:
+ published_badge = ''
+
# Format size
size = item["size"]
if size > 1024*1024:
@@ -3703,6 +3850,7 @@ async def ui_media_list(
{media_type}
{ipfs_badge}
+ {published_badge}
{size_str}