Shoraky commited on
Commit
a66365f
·
1 Parent(s): f514c45

Stream session video ranges directly from storage

Browse files
.dockerignore CHANGED
@@ -11,4 +11,4 @@
11
  !start.py
12
  !storage_listing.py
13
  !playback_timing.py
14
- !backfill_playback_times.py
 
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
- path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
1033
- filename=f'{row["path"]}/videos/{filename}', token=os.environ["HF_TOKEN"])
1034
- return FileResponse(path)
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))