diff --git a/server.py b/server.py
index 023b976..09fa2df 100644
--- a/server.py
+++ b/server.py
@@ -364,6 +364,67 @@ async def get_cached(content_hash: str):
return FileResponse(cache_path)
+@app.get("/cache/{content_hash}/mp4")
+async def get_cached_mp4(content_hash: str):
+ """Get cached content as MP4 (transcodes MKV on first request, caches result)."""
+ cache_path = CACHE_DIR / content_hash
+ mp4_path = CACHE_DIR / f"{content_hash}.mp4"
+
+ if not cache_path.exists():
+ raise HTTPException(404, f"Content {content_hash} not in cache")
+
+ # If MP4 already cached, serve it
+ if mp4_path.exists():
+ return FileResponse(mp4_path, media_type="video/mp4")
+
+ # Check if source is already MP4
+ media_type = detect_media_type(cache_path)
+ if media_type != "video":
+ raise HTTPException(400, "Content is not a video")
+
+ # Check if already MP4 format
+ import subprocess
+ try:
+ result = subprocess.run(
+ ["ffprobe", "-v", "error", "-select_streams", "v:0",
+ "-show_entries", "format=format_name", "-of", "csv=p=0", str(cache_path)],
+ capture_output=True, text=True, timeout=10
+ )
+ if "mp4" in result.stdout.lower() or "mov" in result.stdout.lower():
+ # Already MP4-compatible, just serve original
+ return FileResponse(cache_path, media_type="video/mp4")
+ except Exception:
+ pass # Continue with transcoding
+
+ # Transcode to MP4 (H.264 + AAC)
+ transcode_path = CACHE_DIR / f"{content_hash}.transcoding.mp4"
+ try:
+ result = subprocess.run(
+ ["ffmpeg", "-y", "-i", str(cache_path),
+ "-c:v", "libx264", "-preset", "fast", "-crf", "23",
+ "-c:a", "aac", "-b:a", "128k",
+ "-movflags", "+faststart",
+ str(transcode_path)],
+ capture_output=True, text=True, timeout=600 # 10 min timeout
+ )
+ if result.returncode != 0:
+ raise HTTPException(500, f"Transcoding failed: {result.stderr[:200]}")
+
+ # Move to final location
+ transcode_path.rename(mp4_path)
+
+ except subprocess.TimeoutExpired:
+ if transcode_path.exists():
+ transcode_path.unlink()
+ raise HTTPException(500, "Transcoding timed out")
+ except Exception as e:
+ if transcode_path.exists():
+ transcode_path.unlink()
+ raise HTTPException(500, f"Transcoding failed: {e}")
+
+ return FileResponse(mp4_path, media_type="video/mp4")
+
+
@app.get("/ui/cache/{content_hash}", response_class=HTMLResponse)
async def ui_cache_view(content_hash: str, request: Request):
"""View cached content with appropriate display."""
@@ -432,7 +493,8 @@ async def ui_cache_view(content_hash: str, request: Request):
"""
if media_type == "video":
- html += f''
+ video_src = video_src_for_request(content_hash, request)
+ html += f''
elif media_type == "image":
html += f''
else:
@@ -962,6 +1024,19 @@ async def delete_collection(name: str, username: str = Depends(get_required_user
raise HTTPException(404, "Collection not found")
+def is_ios_request(request: Request) -> bool:
+ """Check if request is from iOS device."""
+ ua = request.headers.get("user-agent", "").lower()
+ return "iphone" in ua or "ipad" in ua
+
+
+def video_src_for_request(content_hash: str, request: Request) -> str:
+ """Get video src URL, using MP4 endpoint for iOS."""
+ if is_ios_request(request):
+ return f"/cache/{content_hash}/mp4"
+ return f"/cache/{content_hash}"
+
+
def detect_media_type(cache_path: Path) -> str:
"""Detect if file is image or video based on magic bytes."""
with open(cache_path, "rb") as f:
@@ -1627,7 +1702,8 @@ async def ui_detail_page(run_id: str, request: Request):