Fix live HLS streaming with dynamic quality playlist URLs
The problem: HLS.js caches quality playlist URLs from the master playlist.
Even when we update the master playlist CID, HLS.js keeps polling the same
static quality CID URL, so it never sees new segments.
The fix:
- Store quality-level CIDs in database (quality_playlists JSONB column)
- Generate master playlist with dynamic URLs (/runs/{id}/quality/{name}/playlist.m3u8)
- Add quality endpoint that fetches LATEST CID from database
- HLS.js now polls our dynamic endpoints which return fresh content
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1230,10 +1230,10 @@ async def serve_hls_content(
|
||||
|
||||
@router.get("/{run_id}/playlist.m3u8")
|
||||
async def get_playlist(run_id: str, request: Request):
|
||||
"""Get live HLS playlist for a streaming run.
|
||||
"""Get live HLS master playlist for a streaming run.
|
||||
|
||||
Returns the latest playlist content directly, allowing HLS players
|
||||
to poll this URL for updates without dealing with changing IPFS CIDs.
|
||||
For multi-resolution streams: generates a master playlist with DYNAMIC quality URLs.
|
||||
For single-resolution streams: returns the playlist directly from IPFS.
|
||||
"""
|
||||
import database
|
||||
import os
|
||||
@@ -1246,24 +1246,112 @@ async def get_playlist(run_id: str, request: Request):
|
||||
if not pending:
|
||||
raise HTTPException(404, "Run not found")
|
||||
|
||||
ipfs_playlist_cid = pending.get("ipfs_playlist_cid")
|
||||
if not ipfs_playlist_cid:
|
||||
raise HTTPException(404, "Playlist not yet available")
|
||||
quality_playlists = pending.get("quality_playlists")
|
||||
|
||||
# Multi-resolution stream: generate master playlist with dynamic quality URLs
|
||||
if quality_playlists:
|
||||
lines = ["#EXTM3U", "#EXT-X-VERSION:3"]
|
||||
|
||||
for name, info in quality_playlists.items():
|
||||
if not info.get("cid"):
|
||||
continue
|
||||
|
||||
lines.append(
|
||||
f"#EXT-X-STREAM-INF:BANDWIDTH={info['bitrate'] * 1000},"
|
||||
f"RESOLUTION={info['width']}x{info['height']},"
|
||||
f"NAME=\"{name}\""
|
||||
)
|
||||
# Use dynamic URL that fetches latest CID from database
|
||||
lines.append(f"/runs/{run_id}/quality/{name}/playlist.m3u8")
|
||||
|
||||
if len(lines) <= 2:
|
||||
raise HTTPException(404, "No quality playlists available")
|
||||
|
||||
playlist_content = "\n".join(lines) + "\n"
|
||||
|
||||
else:
|
||||
# Single-resolution stream: fetch directly from IPFS
|
||||
ipfs_playlist_cid = pending.get("ipfs_playlist_cid")
|
||||
if not ipfs_playlist_cid:
|
||||
raise HTTPException(404, "HLS playlist not created - rendering likely failed")
|
||||
|
||||
ipfs_api = os.environ.get("IPFS_API_URL", "http://celery_ipfs:5001")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(f"{ipfs_api}/api/v0/cat?arg={ipfs_playlist_cid}")
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(502, "Failed to fetch playlist from IPFS")
|
||||
playlist_content = resp.text
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(502, f"IPFS error: {e}")
|
||||
|
||||
# Rewrite IPFS URLs to use our proxy endpoint
|
||||
import re
|
||||
gateway = os.environ.get("IPFS_GATEWAY_URL", "https://celery-artdag.rose-ash.com/ipfs")
|
||||
|
||||
playlist_content = re.sub(
|
||||
rf'{re.escape(gateway)}/([A-Za-z0-9]+)',
|
||||
rf'/runs/{run_id}/ipfs-proxy/\1',
|
||||
playlist_content
|
||||
)
|
||||
playlist_content = re.sub(
|
||||
r'/ipfs(?:-ts)?/([A-Za-z0-9]+)',
|
||||
rf'/runs/{run_id}/ipfs-proxy/\1',
|
||||
playlist_content
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=playlist_content,
|
||||
media_type="application/vnd.apple.mpegurl",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{run_id}/quality/{quality}/playlist.m3u8")
|
||||
async def get_quality_playlist(run_id: str, quality: str, request: Request):
|
||||
"""Get quality-level HLS playlist for a streaming run.
|
||||
|
||||
Fetches the LATEST CID for this quality from the database,
|
||||
so HLS.js always gets updated content.
|
||||
"""
|
||||
import database
|
||||
import os
|
||||
import httpx
|
||||
from fastapi.responses import Response
|
||||
|
||||
await database.init_db()
|
||||
|
||||
pending = await database.get_pending_run(run_id)
|
||||
if not pending:
|
||||
raise HTTPException(404, "Run not found")
|
||||
|
||||
quality_playlists = pending.get("quality_playlists")
|
||||
if not quality_playlists or quality not in quality_playlists:
|
||||
raise HTTPException(404, f"Quality '{quality}' not found")
|
||||
|
||||
quality_cid = quality_playlists[quality].get("cid")
|
||||
if not quality_cid:
|
||||
raise HTTPException(404, f"Quality '{quality}' playlist not ready")
|
||||
|
||||
# Fetch playlist from local IPFS node
|
||||
ipfs_api = os.environ.get("IPFS_API_URL", "http://celery_ipfs:5001")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(f"{ipfs_api}/api/v0/cat?arg={ipfs_playlist_cid}")
|
||||
resp = await client.post(f"{ipfs_api}/api/v0/cat?arg={quality_cid}")
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(502, "Failed to fetch playlist from IPFS")
|
||||
raise HTTPException(502, f"Failed to fetch quality playlist from IPFS: {quality_cid}")
|
||||
playlist_content = resp.text
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(502, f"IPFS error: {e}")
|
||||
|
||||
# Rewrite IPFS URLs to use our proxy endpoint so HLS.js polls us instead of static IPFS
|
||||
# This handles both /ipfs/{cid} and https://gateway/ipfs/{cid} patterns
|
||||
# Rewrite segment URLs to use our proxy (segments are still static IPFS content)
|
||||
import re
|
||||
gateway = os.environ.get("IPFS_GATEWAY_URL", "https://celery-artdag.rose-ash.com/ipfs")
|
||||
|
||||
@@ -1273,9 +1361,9 @@ async def get_playlist(run_id: str, request: Request):
|
||||
rf'/runs/{run_id}/ipfs-proxy/\1',
|
||||
playlist_content
|
||||
)
|
||||
# Also handle /ipfs/ paths
|
||||
# Also handle /ipfs/ paths and /ipfs-ts/ paths
|
||||
playlist_content = re.sub(
|
||||
r'/ipfs/([A-Za-z0-9]+)',
|
||||
r'/ipfs(?:-ts)?/([A-Za-z0-9]+)',
|
||||
rf'/runs/{run_id}/ipfs-proxy/\1',
|
||||
playlist_content
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user