feat: iOS video support with on-the-fly MKV→MP4 transcoding
- Add /cache/{hash}/mp4 endpoint that transcodes MKV to MP4
- First request transcodes (using ffmpeg), result cached as {hash}.mp4
- Subsequent requests serve cached MP4 directly
- Detect iOS devices and use MP4 endpoint for video src
- Works with Cloudflare caching for efficiency
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
83
server.py
83
server.py
@@ -364,6 +364,67 @@ async def get_cached(content_hash: str):
|
|||||||
return FileResponse(cache_path)
|
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)
|
@app.get("/ui/cache/{content_hash}", response_class=HTMLResponse)
|
||||||
async def ui_cache_view(content_hash: str, request: Request):
|
async def ui_cache_view(content_hash: str, request: Request):
|
||||||
"""View cached content with appropriate display."""
|
"""View cached content with appropriate display."""
|
||||||
@@ -432,7 +493,8 @@ async def ui_cache_view(content_hash: str, request: Request):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if media_type == "video":
|
if media_type == "video":
|
||||||
html += f'<video src="/cache/{content_hash}" controls autoplay muted loop playsinline style="max-width:100%;max-height:500px;"></video>'
|
video_src = video_src_for_request(content_hash, request)
|
||||||
|
html += f'<video src="{video_src}" controls autoplay muted loop playsinline style="max-width:100%;max-height:500px;"></video>'
|
||||||
elif media_type == "image":
|
elif media_type == "image":
|
||||||
html += f'<img src="/cache/{content_hash}" alt="{content_hash}" style="max-width:100%;max-height:500px;">'
|
html += f'<img src="/cache/{content_hash}" alt="{content_hash}" style="max-width:100%;max-height:500px;">'
|
||||||
else:
|
else:
|
||||||
@@ -962,6 +1024,19 @@ async def delete_collection(name: str, username: str = Depends(get_required_user
|
|||||||
raise HTTPException(404, "Collection not found")
|
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:
|
def detect_media_type(cache_path: Path) -> str:
|
||||||
"""Detect if file is image or video based on magic bytes."""
|
"""Detect if file is image or video based on magic bytes."""
|
||||||
with open(cache_path, "rb") as f:
|
with open(cache_path, "rb") as f:
|
||||||
@@ -1627,7 +1702,8 @@ async def ui_detail_page(run_id: str, request: Request):
|
|||||||
<div class="media-container">
|
<div class="media-container">
|
||||||
'''
|
'''
|
||||||
if input_media_type == "video":
|
if input_media_type == "video":
|
||||||
html += f'<video src="/cache/{input_hash}" controls muted loop playsinline></video>'
|
input_video_src = video_src_for_request(input_hash, request)
|
||||||
|
html += f'<video src="{input_video_src}" controls muted loop playsinline></video>'
|
||||||
elif input_media_type == "image":
|
elif input_media_type == "image":
|
||||||
html += f'<img src="/cache/{input_hash}" alt="input">'
|
html += f'<img src="/cache/{input_hash}" alt="input">'
|
||||||
html += '</div></div>'
|
html += '</div></div>'
|
||||||
@@ -1641,7 +1717,8 @@ async def ui_detail_page(run_id: str, request: Request):
|
|||||||
<div class="media-container">
|
<div class="media-container">
|
||||||
'''
|
'''
|
||||||
if output_media_type == "video":
|
if output_media_type == "video":
|
||||||
html += f'<video src="/cache/{output_hash}" controls autoplay muted loop playsinline></video>'
|
output_video_src = video_src_for_request(output_hash, request)
|
||||||
|
html += f'<video src="{output_video_src}" controls autoplay muted loop playsinline></video>'
|
||||||
elif output_media_type == "image":
|
elif output_media_type == "image":
|
||||||
html += f'<img src="/cache/{output_hash}" alt="output">'
|
html += f'<img src="/cache/{output_hash}" alt="output">'
|
||||||
html += '</div></div>'
|
html += '</div></div>'
|
||||||
|
|||||||
Reference in New Issue
Block a user