Spaces:
Running
Running
Stream session video ranges directly from storage
Browse files- .dockerignore +1 -1
- Dockerfile +1 -1
- app.py +9 -8
- src/analysis.ts +1 -0
- src/server.ts +1 -1
- tests/test_video_stream.py +120 -0
- tests/video-session.test.ts +58 -0
- video_stream.py +53 -0
.dockerignore
CHANGED
|
@@ -11,4 +11,4 @@
|
|
| 11 |
!start.py
|
| 12 |
!storage_listing.py
|
| 13 |
!playback_timing.py
|
| 14 |
-
!
|
|
|
|
| 11 |
!start.py
|
| 12 |
!storage_listing.py
|
| 13 |
!playback_timing.py
|
| 14 |
+
!video_stream.py
|
Dockerfile
CHANGED
|
@@ -17,7 +17,7 @@ COPY requirements.txt ./
|
|
| 17 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
COPY --from=node-build --chown=appuser:appuser /app/dist ./dist
|
| 19 |
COPY --from=node-build --chown=appuser:appuser /app/node_modules ./node_modules
|
| 20 |
-
COPY --chown=appuser:appuser package.json app.py start.py storage_listing.py playback_timing.py ./
|
| 21 |
USER appuser
|
| 22 |
EXPOSE 7860
|
| 23 |
CMD ["python", "start.py"]
|
|
|
|
| 17 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
COPY --from=node-build --chown=appuser:appuser /app/dist ./dist
|
| 19 |
COPY --from=node-build --chown=appuser:appuser /app/node_modules ./node_modules
|
| 20 |
+
COPY --chown=appuser:appuser package.json app.py start.py storage_listing.py playback_timing.py video_stream.py ./
|
| 21 |
USER appuser
|
| 22 |
EXPOSE 7860
|
| 23 |
CMD ["python", "start.py"]
|
app.py
CHANGED
|
@@ -32,6 +32,8 @@ from huggingface_hub import HfApi, hf_hub_download, snapshot_download
|
|
| 32 |
from huggingface_hub.errors import RemoteEntryNotFoundError
|
| 33 |
from storage_listing import session_files
|
| 34 |
from playback_timing import action_times, frame_times
|
|
|
|
|
|
|
| 35 |
|
| 36 |
load_dotenv(Path(__file__).with_name(".env"))
|
| 37 |
jobs = {}
|
|
@@ -774,8 +776,8 @@ def cleanup_deleted_session_files(session_id, storage_path, job_paths):
|
|
| 774 |
logger.info("Finished background storage cleanup for session %s", session_id)
|
| 775 |
|
| 776 |
|
| 777 |
-
def session_row(session_id, user_id, is_admin=False):
|
| 778 |
-
row = backend(f"/{session_id}", user_id, is_admin=is_admin)
|
| 779 |
if row["path"] != f"sessions/{session_id}":
|
| 780 |
raise HTTPException(409, "Session uses a different storage layout")
|
| 781 |
return row
|
|
@@ -1025,18 +1027,17 @@ def results(session_id: UUID, request: Request):
|
|
| 1025 |
|
| 1026 |
|
| 1027 |
@app.api_route("/storage/{session_id}/videos/{filename}", methods=["GET", "HEAD"])
|
| 1028 |
-
def video(session_id: UUID, filename: str, request: Request):
|
| 1029 |
-
row = session_row(session_id, request.state.user_id, request.state.is_admin)
|
| 1030 |
if Path(filename).name != filename or "\\" in filename:
|
| 1031 |
raise HTTPException(404, "Video not found")
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
|
| 1036 |
|
| 1037 |
@app.api_route("/storage/{session_id}/posters/{filename}", methods=["GET", "HEAD"])
|
| 1038 |
def video_poster(session_id: UUID, filename: str, request: Request):
|
| 1039 |
-
row = session_row(session_id, request.state.user_id, request.state.is_admin)
|
| 1040 |
if Path(filename).name != filename or "\\" in filename or not filename.endswith(".webp"):
|
| 1041 |
raise HTTPException(404, "Preview not found")
|
| 1042 |
try:
|
|
|
|
| 32 |
from huggingface_hub.errors import RemoteEntryNotFoundError
|
| 33 |
from storage_listing import session_files
|
| 34 |
from playback_timing import action_times, frame_times
|
| 35 |
+
from video_stream import video_response
|
| 36 |
+
from starlette.concurrency import run_in_threadpool
|
| 37 |
|
| 38 |
load_dotenv(Path(__file__).with_name(".env"))
|
| 39 |
jobs = {}
|
|
|
|
| 776 |
logger.info("Finished background storage cleanup for session %s", session_id)
|
| 777 |
|
| 778 |
|
| 779 |
+
def session_row(session_id, user_id, is_admin=False, playback_times=True):
|
| 780 |
+
row = backend(f"/{session_id}" + ("" if playback_times else "?playbackTimes=false"), user_id, is_admin=is_admin)
|
| 781 |
if row["path"] != f"sessions/{session_id}":
|
| 782 |
raise HTTPException(409, "Session uses a different storage layout")
|
| 783 |
return row
|
|
|
|
| 1027 |
|
| 1028 |
|
| 1029 |
@app.api_route("/storage/{session_id}/videos/{filename}", methods=["GET", "HEAD"])
|
| 1030 |
+
async def video(session_id: UUID, filename: str, request: Request):
|
|
|
|
| 1031 |
if Path(filename).name != filename or "\\" in filename:
|
| 1032 |
raise HTTPException(404, "Video not found")
|
| 1033 |
+
row = await run_in_threadpool(session_row, session_id, request.state.user_id, request.state.is_admin, False)
|
| 1034 |
+
return await video_response(request, os.environ["STORAGE_REPO_ID"],
|
| 1035 |
+
f'{row["path"]}/videos/{filename}', os.environ["HF_TOKEN"])
|
| 1036 |
|
| 1037 |
|
| 1038 |
@app.api_route("/storage/{session_id}/posters/{filename}", methods=["GET", "HEAD"])
|
| 1039 |
def video_poster(session_id: UUID, filename: str, request: Request):
|
| 1040 |
+
row = session_row(session_id, request.state.user_id, request.state.is_admin, playback_times=False)
|
| 1041 |
if Path(filename).name != filename or "\\" in filename or not filename.endswith(".webp"):
|
| 1042 |
raise HTTPException(404, "Preview not found")
|
| 1043 |
try:
|
src/analysis.ts
CHANGED
|
@@ -119,6 +119,7 @@ export function analysisRouter(db: SupabaseClient, secret: string, onCompleted?:
|
|
| 119 |
const { data, error } = await query.maybeSingle();
|
| 120 |
if (error) return res.status(503).json({ error: "Unable to load session" });
|
| 121 |
if (!data) return res.sendStatus(404);
|
|
|
|
| 122 |
const { data: actionTimes, error: timesError } = await db.from("actions")
|
| 123 |
.select("action_id,start_time_seconds,end_time_seconds").eq("session_id", sessionId);
|
| 124 |
if (timesError) {
|
|
|
|
| 119 |
const { data, error } = await query.maybeSingle();
|
| 120 |
if (error) return res.status(503).json({ error: "Unable to load session" });
|
| 121 |
if (!data) return res.sendStatus(404);
|
| 122 |
+
if (req.query.playbackTimes === "false") return res.json(data);
|
| 123 |
const { data: actionTimes, error: timesError } = await db.from("actions")
|
| 124 |
.select("action_id,start_time_seconds,end_time_seconds").eq("session_id", sessionId);
|
| 125 |
if (timesError) {
|
src/server.ts
CHANGED
|
@@ -1243,7 +1243,7 @@ async function proxyAnalysisRequest(req: Request, res: Response) {
|
|
| 1243 |
}
|
| 1244 |
res.status(upstream.status);
|
| 1245 |
res.set("Cross-Origin-Resource-Policy", "cross-origin");
|
| 1246 |
-
for (const name of ["content-type", "content-disposition", "cache-control", "accept-ranges", "content-range", "last-modified"]) {
|
| 1247 |
const value = upstream.headers.get(name);
|
| 1248 |
if (value) res.set(name, value);
|
| 1249 |
}
|
|
|
|
| 1243 |
}
|
| 1244 |
res.status(upstream.status);
|
| 1245 |
res.set("Cross-Origin-Resource-Policy", "cross-origin");
|
| 1246 |
+
for (const name of ["content-type", "content-length", "content-disposition", "cache-control", "accept-ranges", "content-range", "etag", "last-modified"]) {
|
| 1247 |
const value = upstream.headers.get(name);
|
| 1248 |
if (value) res.set(name, value);
|
| 1249 |
}
|
tests/test_video_stream.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from unittest.mock import AsyncMock, patch
|
| 3 |
+
from uuid import UUID
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
from fastapi import HTTPException
|
| 7 |
+
from starlette.requests import Request
|
| 8 |
+
|
| 9 |
+
import video_stream
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ProbeStream(httpx.AsyncByteStream):
|
| 13 |
+
def __init__(self):
|
| 14 |
+
self.reads = 0
|
| 15 |
+
self.closed = False
|
| 16 |
+
|
| 17 |
+
async def __aiter__(self):
|
| 18 |
+
for chunk in (b"first", b"second"):
|
| 19 |
+
self.reads += 1
|
| 20 |
+
yield chunk
|
| 21 |
+
|
| 22 |
+
async def aclose(self):
|
| 23 |
+
self.closed = True
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def request(method="GET", headers=None):
|
| 27 |
+
return Request({"type": "http", "method": method, "path": "/storage/test/videos/camera_1.mp4",
|
| 28 |
+
"headers": [(key.encode(), value.encode()) for key, value in (headers or {}).items()]})
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class VideoStreamingTest(unittest.IsolatedAsyncioTestCase):
|
| 32 |
+
async def test_streams_ranges_lazily_and_closes_after_client_cancels(self):
|
| 33 |
+
body = ProbeStream()
|
| 34 |
+
seen = []
|
| 35 |
+
|
| 36 |
+
def handler(req):
|
| 37 |
+
seen.append(req)
|
| 38 |
+
return httpx.Response(206, headers={"Content-Range": "bytes 0-10/1000000", "Content-Length": "11",
|
| 39 |
+
"Accept-Ranges": "bytes", "Content-Type": "video/mp4"}, stream=body)
|
| 40 |
+
|
| 41 |
+
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
| 42 |
+
with patch.object(video_stream.httpx, "AsyncClient", return_value=client):
|
| 43 |
+
response = await video_stream.video_response(request(headers={"range": "bytes=0-10", "cookie": "private-cookie"}),
|
| 44 |
+
"test/storage", "sessions/id/videos/camera_1.mp4", "storage-token")
|
| 45 |
+
self.assertEqual(body.reads, 0)
|
| 46 |
+
self.assertEqual(response.status_code, 206)
|
| 47 |
+
self.assertEqual(response.headers["content-range"], "bytes 0-10/1000000")
|
| 48 |
+
self.assertEqual(seen[0].headers["range"], "bytes=0-10")
|
| 49 |
+
self.assertNotIn("cookie", seen[0].headers)
|
| 50 |
+
self.assertEqual(await anext(response.body_iterator), b"first")
|
| 51 |
+
await response.body_iterator.aclose()
|
| 52 |
+
await response.background()
|
| 53 |
+
self.assertEqual(body.reads, 1)
|
| 54 |
+
self.assertTrue(body.closed)
|
| 55 |
+
self.assertTrue(client.is_closed)
|
| 56 |
+
|
| 57 |
+
async def test_cdn_redirect_strips_authorization_and_keeps_range(self):
|
| 58 |
+
seen = []
|
| 59 |
+
|
| 60 |
+
def handler(req):
|
| 61 |
+
seen.append(req)
|
| 62 |
+
if req.url.host == "huggingface.co":
|
| 63 |
+
return httpx.Response(302, headers={"Location": "https://cdn.example/video?signature=test"})
|
| 64 |
+
return httpx.Response(206, headers={"Content-Range": "bytes 0-1/10"}, stream=ProbeStream())
|
| 65 |
+
|
| 66 |
+
client = httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=True)
|
| 67 |
+
with patch.object(video_stream.httpx, "AsyncClient", return_value=client):
|
| 68 |
+
response = await video_stream.video_response(request(headers={"range": "bytes=0-1"}),
|
| 69 |
+
"test/storage", "video.mp4", "storage-token")
|
| 70 |
+
self.assertEqual(seen[0].headers["authorization"], "Bearer storage-token")
|
| 71 |
+
self.assertNotIn("authorization", seen[1].headers)
|
| 72 |
+
self.assertEqual(seen[1].headers["range"], "bytes=0-1")
|
| 73 |
+
await response.background()
|
| 74 |
+
|
| 75 |
+
async def test_head_preserves_size_without_reading_video(self):
|
| 76 |
+
body = ProbeStream()
|
| 77 |
+
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda req: httpx.Response(
|
| 78 |
+
200, headers={"Content-Length": "1000000", "Accept-Ranges": "bytes"}, stream=body)))
|
| 79 |
+
with patch.object(video_stream.httpx, "AsyncClient", return_value=client):
|
| 80 |
+
response = await video_stream.video_response(request("HEAD"), "test/storage", "video.mp4", "token")
|
| 81 |
+
self.assertEqual(response.headers["content-length"], "1000000")
|
| 82 |
+
self.assertEqual(response.body, b"")
|
| 83 |
+
self.assertEqual(body.reads, 0)
|
| 84 |
+
self.assertTrue(client.is_closed)
|
| 85 |
+
|
| 86 |
+
async def test_invalid_range_keeps_total_size_and_omits_storage_error_body(self):
|
| 87 |
+
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda req: httpx.Response(
|
| 88 |
+
416, headers={"Content-Range": "bytes */100", "Content-Length": "100"}, stream=ProbeStream())))
|
| 89 |
+
with patch.object(video_stream.httpx, "AsyncClient", return_value=client):
|
| 90 |
+
response = await video_stream.video_response(request(headers={"range": "bytes=999-"}), "test/storage", "video.mp4", "token")
|
| 91 |
+
self.assertEqual(response.status_code, 416)
|
| 92 |
+
self.assertEqual(response.headers["content-range"], "bytes */100")
|
| 93 |
+
self.assertEqual(response.headers["content-length"], "0")
|
| 94 |
+
self.assertTrue(client.is_closed)
|
| 95 |
+
|
| 96 |
+
async def test_upstream_errors_close_connections_and_hide_storage_details(self):
|
| 97 |
+
for status, expected in ((404, 404), (401, 502), (503, 502)):
|
| 98 |
+
with self.subTest(status=status):
|
| 99 |
+
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda req: httpx.Response(status, stream=ProbeStream())))
|
| 100 |
+
with patch.object(video_stream.httpx, "AsyncClient", return_value=client):
|
| 101 |
+
with self.assertRaises(HTTPException) as error:
|
| 102 |
+
await video_stream.video_response(request(), "test/storage", "video.mp4", "private-token")
|
| 103 |
+
self.assertEqual(error.exception.status_code, expected)
|
| 104 |
+
self.assertNotIn("private-token", error.exception.detail)
|
| 105 |
+
self.assertTrue(client.is_closed)
|
| 106 |
+
|
| 107 |
+
async def test_video_route_checks_ownership_before_streaming(self):
|
| 108 |
+
import app as analysis_app
|
| 109 |
+
req = request()
|
| 110 |
+
req.state.user_id = "11111111-1111-4111-8111-111111111111"
|
| 111 |
+
req.state.is_admin = False
|
| 112 |
+
with patch.object(analysis_app, "session_row", side_effect=HTTPException(404, "Session not found")), \
|
| 113 |
+
patch.object(analysis_app, "video_response", new_callable=AsyncMock) as stream:
|
| 114 |
+
with self.assertRaises(HTTPException):
|
| 115 |
+
await analysis_app.video(UUID("22222222-2222-4222-8222-222222222222"), "camera_1.mp4", req)
|
| 116 |
+
stream.assert_not_called()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
if __name__ == "__main__":
|
| 120 |
+
unittest.main()
|
tests/video-session.test.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from "node:assert/strict";
|
| 2 |
+
import { createServer } from "node:http";
|
| 3 |
+
import type { AddressInfo } from "node:net";
|
| 4 |
+
import { test } from "node:test";
|
| 5 |
+
import express from "express";
|
| 6 |
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
| 7 |
+
|
| 8 |
+
test("media lookups skip timing data while retaining session ownership checks", async () => {
|
| 9 |
+
Object.assign(process.env, {
|
| 10 |
+
NODE_ENV: "test", FRONTEND_URL: "http://127.0.0.1:3000", BACKEND_URL: "http://127.0.0.1:4000",
|
| 11 |
+
ANALYSIS_PROXY_SECRET: "test-analysis-proxy-secret-32-chars", SUPABASE_URL: "http://127.0.0.1:54321",
|
| 12 |
+
SUPABASE_ANON_KEY: "test-anon-key", SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key",
|
| 13 |
+
});
|
| 14 |
+
const { analysisRouter } = await import("../src/analysis.js");
|
| 15 |
+
const sessionId = "11111111-1111-4111-8111-111111111111";
|
| 16 |
+
const userId = "22222222-2222-4222-8222-222222222222";
|
| 17 |
+
const times = [{ action_id: sessionId, start_time_seconds: 1, end_time_seconds: 2 }];
|
| 18 |
+
const filters: [string, string][] = [];
|
| 19 |
+
const tables: string[] = [];
|
| 20 |
+
let owned = true;
|
| 21 |
+
const db = {
|
| 22 |
+
from(table: string) {
|
| 23 |
+
tables.push(table);
|
| 24 |
+
const query = {
|
| 25 |
+
select() { return query; },
|
| 26 |
+
eq(column: string, value: string) { filters.push([column, value]); return query; },
|
| 27 |
+
async maybeSingle() { return { data: owned ? { session_id: sessionId, user_id: userId } : null, error: null }; },
|
| 28 |
+
then(resolve: (value: unknown) => void) { return Promise.resolve({ data: times, error: null }).then(resolve); },
|
| 29 |
+
};
|
| 30 |
+
return query;
|
| 31 |
+
},
|
| 32 |
+
} as unknown as SupabaseClient;
|
| 33 |
+
const app = express();
|
| 34 |
+
const secret = process.env.ANALYSIS_PROXY_SECRET!;
|
| 35 |
+
app.use("/internal/analysis", analysisRouter(db, secret));
|
| 36 |
+
const server = createServer(app);
|
| 37 |
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
| 38 |
+
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/internal/analysis/${sessionId}`;
|
| 39 |
+
const headers = { "x-sporalize-proxy-secret": secret, "x-sporalize-user-id": userId };
|
| 40 |
+
try {
|
| 41 |
+
const media = await fetch(`${url}?playbackTimes=false`, { headers });
|
| 42 |
+
assert.equal(media.status, 200);
|
| 43 |
+
assert.deepEqual(tables, ["sessions"]);
|
| 44 |
+
assert.ok(filters.some(([column, value]) => column === "user_id" && value === userId));
|
| 45 |
+
tables.length = 0;
|
| 46 |
+
const results = await fetch(url, { headers });
|
| 47 |
+
assert.equal(results.status, 200);
|
| 48 |
+
assert.deepEqual((await results.json()).action_times, times);
|
| 49 |
+
assert.deepEqual(tables, ["sessions", "actions"]);
|
| 50 |
+
owned = false;
|
| 51 |
+
tables.length = 0;
|
| 52 |
+
const denied = await fetch(`${url}?playbackTimes=false`, { headers });
|
| 53 |
+
assert.equal(denied.status, 404);
|
| 54 |
+
assert.deepEqual(tables, ["sessions"]);
|
| 55 |
+
} finally {
|
| 56 |
+
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
| 57 |
+
}
|
| 58 |
+
});
|
video_stream.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stream authenticated storage video ranges without downloading the full file first."""
|
| 2 |
+
|
| 3 |
+
import httpx
|
| 4 |
+
from fastapi import HTTPException, Request
|
| 5 |
+
from huggingface_hub import hf_hub_url
|
| 6 |
+
from starlette.background import BackgroundTask
|
| 7 |
+
from starlette.responses import Response, StreamingResponse
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def video_response(request: Request, repo_id: str, path: str, token: str):
|
| 11 |
+
# Browser cookies and user headers never go to the storage service.
|
| 12 |
+
headers = {"Authorization": f"Bearer {token}", "Accept-Encoding": "identity"}
|
| 13 |
+
for name in ("range", "if-range"):
|
| 14 |
+
if request.headers.get(name):
|
| 15 |
+
headers[name] = request.headers[name]
|
| 16 |
+
client = httpx.AsyncClient(follow_redirects=True, timeout=httpx.Timeout(60, connect=15))
|
| 17 |
+
try:
|
| 18 |
+
upstream = await client.send(client.build_request(
|
| 19 |
+
request.method, hf_hub_url(repo_id, path, repo_type="dataset"), headers=headers), stream=True)
|
| 20 |
+
except httpx.HTTPError:
|
| 21 |
+
await client.aclose()
|
| 22 |
+
raise HTTPException(502, "Video storage is temporarily unavailable") from None
|
| 23 |
+
|
| 24 |
+
async def close():
|
| 25 |
+
try:
|
| 26 |
+
await upstream.aclose()
|
| 27 |
+
finally:
|
| 28 |
+
await client.aclose()
|
| 29 |
+
|
| 30 |
+
if upstream.status_code not in (200, 206, 416):
|
| 31 |
+
await close()
|
| 32 |
+
raise HTTPException(404 if upstream.status_code == 404 else 502,
|
| 33 |
+
"Video not found" if upstream.status_code == 404 else "Video storage is temporarily unavailable")
|
| 34 |
+
response_headers = {name: upstream.headers[name] for name in (
|
| 35 |
+
"content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified"
|
| 36 |
+
) if name in upstream.headers}
|
| 37 |
+
response_headers["Cache-Control"] = "private, no-store"
|
| 38 |
+
if request.method == "HEAD" or upstream.status_code == 416:
|
| 39 |
+
await close()
|
| 40 |
+
# A 416 may have a storage error body: don't forward its body length with an empty response.
|
| 41 |
+
if upstream.status_code == 416:
|
| 42 |
+
response_headers.pop("content-length", None)
|
| 43 |
+
return Response(status_code=upstream.status_code, headers=response_headers)
|
| 44 |
+
|
| 45 |
+
async def chunks():
|
| 46 |
+
try:
|
| 47 |
+
async for chunk in upstream.aiter_raw():
|
| 48 |
+
yield chunk
|
| 49 |
+
finally:
|
| 50 |
+
await close()
|
| 51 |
+
|
| 52 |
+
return StreamingResponse(chunks(), status_code=upstream.status_code, headers=response_headers,
|
| 53 |
+
background=BackgroundTask(close))
|