Fix live HLS streaming with dynamic quality playlist URLs
Some checks are pending
GPU Worker CI/CD / test (push) Waiting to run
GPU Worker CI/CD / deploy (push) Blocked by required conditions

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:
giles
2026-02-04 21:07:29 +00:00
parent 4647dd52c8
commit 2f56ffc472
4 changed files with 150 additions and 23 deletions

View File

@@ -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
)