Skip to content

Commit 70b9635

Browse files
committed
fix: code review cleanup for HLS playback fixes
- Remove unauthenticated /debug/playlist endpoint (security) - Remove unused _RE_VERSION regex (dead code from CODECS injection) - Simplify _rewrite_playlist: drop unused video_codec/audio_codec/camera_id params - Update _rewrite_playlist docstring to describe CODECS removal, not injection - Fix stale comment in cameras.py about codec injection - Restore origin check in HlsPlayer xhrSetup with relative URL handling - Clean ancillary dicts in _evict_stale_cameras (playlist_update_count, etc.) - Update test module docstring to describe CODECS stripping behavior
1 parent 10a9b34 commit 70b9635

4 files changed

Lines changed: 110 additions & 67 deletions

File tree

backend/app/api/cameras.py

Lines changed: 91 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,17 @@ async def list_cameras(
4444
node_ids = {cam.node_id for cam in cameras if cam.node_id}
4545
if node_ids:
4646
existing_node_ids = {
47-
n.id for n in db.query(CameraNode.id).filter(CameraNode.id.in_(node_ids)).all()
47+
n.id
48+
for n in db.query(CameraNode.id)
49+
.filter(CameraNode.id.in_(node_ids))
50+
.all()
4851
}
4952
for cam in cameras:
5053
if cam.node_id and cam.node_id not in existing_node_ids:
5154
logger.warning(
52-
"Orphan camera %s (node_id=%s not found)", cam.camera_id, cam.node_id
55+
"Orphan camera %s (node_id=%s not found)",
56+
cam.camera_id,
57+
cam.node_id,
5358
)
5459

5560
result = [c.to_dict() for c in cameras]
@@ -96,7 +101,10 @@ async def take_snapshot(
96101

97102
try:
98103
result = await manager.send_command(
99-
node.node_id, "take_snapshot", {"camera_id": camera_id}, timeout=15.0,
104+
node.node_id,
105+
"take_snapshot",
106+
{"camera_id": camera_id},
107+
timeout=15.0,
100108
)
101109
return result
102110
except TimeoutError:
@@ -136,7 +144,10 @@ async def toggle_recording(
136144
command = "start_recording" if recording else "stop_recording"
137145
try:
138146
result = await manager.send_command(
139-
node.node_id, command, {"camera_id": camera_id}, timeout=10.0,
147+
node.node_id,
148+
command,
149+
{"camera_id": camera_id},
150+
timeout=10.0,
140151
)
141152
write_audit(
142153
db,
@@ -240,13 +251,17 @@ async def get_all_settings(
240251
user: AuthUser = Depends(require_view), db: Session = Depends(get_db)
241252
):
242253
"""Get all settings for the user's organization."""
243-
vals = Setting.get_many(db, user.org_id, {
244-
"scheduled_recording": "false",
245-
"scheduled_start": "06:00",
246-
"scheduled_end": "17:00",
247-
"continuous_24_7": "false",
248-
**_NOTIFICATION_SETTING_DEFAULTS,
249-
})
254+
vals = Setting.get_many(
255+
db,
256+
user.org_id,
257+
{
258+
"scheduled_recording": "false",
259+
"scheduled_start": "06:00",
260+
"scheduled_end": "17:00",
261+
"continuous_24_7": "false",
262+
**_NOTIFICATION_SETTING_DEFAULTS,
263+
},
264+
)
250265
return {
251266
"recording": {
252267
"scheduled_recording": vals["scheduled_recording"] == "true",
@@ -256,8 +271,10 @@ async def get_all_settings(
256271
},
257272
"notifications": {
258273
"motion_notifications": vals["motion_notifications"] == "true",
259-
"camera_transition_notifications": vals["camera_transition_notifications"] == "true",
260-
"node_transition_notifications": vals["node_transition_notifications"] == "true",
274+
"camera_transition_notifications": vals["camera_transition_notifications"]
275+
== "true",
276+
"node_transition_notifications": vals["node_transition_notifications"]
277+
== "true",
261278
},
262279
}
263280

@@ -267,12 +284,16 @@ async def get_recording_settings(
267284
user: AuthUser = Depends(require_view), db: Session = Depends(get_db)
268285
):
269286
"""Get recording settings."""
270-
vals = Setting.get_many(db, user.org_id, {
271-
"scheduled_recording": "false",
272-
"scheduled_start": "06:00",
273-
"scheduled_end": "17:00",
274-
"continuous_24_7": "false",
275-
})
287+
vals = Setting.get_many(
288+
db,
289+
user.org_id,
290+
{
291+
"scheduled_recording": "false",
292+
"scheduled_start": "06:00",
293+
"scheduled_end": "17:00",
294+
"continuous_24_7": "false",
295+
},
296+
)
276297
return {
277298
"scheduled_recording": vals["scheduled_recording"] == "true",
278299
"scheduled_start": vals["scheduled_start"],
@@ -317,6 +338,7 @@ async def update_recording_settings(
317338
# GET is view-level (every member needs to know what's on), POST is
318339
# admin-only (same audit-worthy gate as the other per-org toggles).
319340

341+
320342
@router.get("/settings/notifications")
321343
async def get_notification_settings(
322344
user: AuthUser = Depends(require_view), db: Session = Depends(get_db)
@@ -330,8 +352,10 @@ async def get_notification_settings(
330352
vals = Setting.get_many(db, user.org_id, _NOTIFICATION_SETTING_DEFAULTS)
331353
return {
332354
"motion_notifications": vals["motion_notifications"] == "true",
333-
"camera_transition_notifications": vals["camera_transition_notifications"] == "true",
334-
"node_transition_notifications": vals["node_transition_notifications"] == "true",
355+
"camera_transition_notifications": vals["camera_transition_notifications"]
356+
== "true",
357+
"node_transition_notifications": vals["node_transition_notifications"]
358+
== "true",
335359
}
336360

337361

@@ -349,13 +373,19 @@ async def update_notification_settings(
349373
key/value table can store it without a schema change — same
350374
convention the recording toggles use.
351375
"""
352-
Setting.set(db, user.org_id, "motion_notifications", str(data.motion_notifications).lower())
353376
Setting.set(
354-
db, user.org_id, "camera_transition_notifications",
377+
db, user.org_id, "motion_notifications", str(data.motion_notifications).lower()
378+
)
379+
Setting.set(
380+
db,
381+
user.org_id,
382+
"camera_transition_notifications",
355383
str(data.camera_transition_notifications).lower(),
356384
)
357385
Setting.set(
358-
db, user.org_id, "node_transition_notifications",
386+
db,
387+
user.org_id,
388+
"node_transition_notifications",
359389
str(data.node_transition_notifications).lower(),
360390
)
361391
write_audit(
@@ -366,7 +396,9 @@ async def update_notification_settings(
366396
username=audit_label(user),
367397
details={
368398
"motion_notifications": bool(data.motion_notifications),
369-
"camera_transition_notifications": bool(data.camera_transition_notifications),
399+
"camera_transition_notifications": bool(
400+
data.camera_transition_notifications
401+
),
370402
"node_transition_notifications": bool(data.node_transition_notifications),
371403
},
372404
request=request,
@@ -443,11 +475,13 @@ async def report_camera_codec(
443475
if not video_codec:
444476
raise HTTPException(status_code=400, detail="video_codec is required")
445477

446-
# Codec strings are injected into HLS #EXT-X-CODECS headers
478+
# Codec strings are stored for diagnostics and MCP tool reporting
447479
# reject newlines or absurd lengths to prevent playlist corruption.
448-
if len(video_codec) > 64 or '\n' in video_codec or '\r' in video_codec:
480+
if len(video_codec) > 64 or "\n" in video_codec or "\r" in video_codec:
449481
raise HTTPException(status_code=400, detail="Invalid video_codec format")
450-
if audio_codec and (len(audio_codec) > 64 or '\n' in audio_codec or '\r' in audio_codec):
482+
if audio_codec and (
483+
len(audio_codec) > 64 or "\n" in audio_codec or "\r" in audio_codec
484+
):
451485
raise HTTPException(status_code=400, detail="Invalid audio_codec format")
452486

453487
# Defensive sanitization — older CloudNode builds shipped garbage
@@ -467,18 +501,27 @@ async def report_camera_codec(
467501
node.audio_codec = camera.audio_codec
468502
node.codec_detected_at = datetime.now(tz=timezone.utc).replace(tzinfo=None)
469503
logger.info(
470-
"Updated node %s codec: video=%s, audio=%s", node.node_id, video_codec, camera.audio_codec
504+
"Updated node %s codec: video=%s, audio=%s",
505+
node.node_id,
506+
video_codec,
507+
camera.audio_codec,
471508
)
472509

473510
db.commit()
474511

475-
logger.info("Updated codec for camera %s: video=%s, audio=%s", camera_id, video_codec, camera.audio_codec)
512+
logger.info(
513+
"Updated codec for camera %s: video=%s, audio=%s",
514+
camera_id,
515+
video_codec,
516+
camera.audio_codec,
517+
)
476518

477519
return {"success": True, "message": "Codec updated"}
478520

479521

480522
# ── Danger Zone ──────────────────────────────────────────────────────
481523

524+
482525
@router.post("/settings/danger/wipe-logs")
483526
@limiter.limit("5/hour")
484527
async def wipe_stream_logs(
@@ -496,9 +539,12 @@ async def wipe_stream_logs(
496539

497540
count = db.query(StreamAccessLog).filter_by(org_id=user.org_id).delete()
498541
from app.models.models import McpActivityLog
542+
499543
mcp_count = db.query(McpActivityLog).filter_by(org_id=user.org_id).delete()
500544
db.commit()
501-
logger.warning("Admin wiped %d stream + %d MCP logs (org redacted)", count, mcp_count)
545+
logger.warning(
546+
"Admin wiped %d stream + %d MCP logs (org redacted)", count, mcp_count
547+
)
502548
write_audit(
503549
db,
504550
org_id=user.org_id,
@@ -544,7 +590,10 @@ async def full_reset(
544590
# Tell CloudNode to wipe local data
545591
try:
546592
from app.api.ws import manager
547-
result = await manager.send_command(node.node_id, "wipe_data", {}, timeout=10)
593+
594+
result = await manager.send_command(
595+
node.node_id, "wipe_data", {}, timeout=10
596+
)
548597
if result and result.get("status") == "success":
549598
results["nodes_wiped"] += 1
550599
except Exception as e:
@@ -558,14 +607,21 @@ async def full_reset(
558607
results["nodes_deleted"] += 1
559608

560609
# 2. Wipe stream access logs
561-
results["logs_deleted"] = db.query(StreamAccessLog).filter_by(org_id=user.org_id).delete()
610+
results["logs_deleted"] = (
611+
db.query(StreamAccessLog).filter_by(org_id=user.org_id).delete()
612+
)
562613

563614
# 3. Wipe MCP activity logs
564615
from app.models.models import McpActivityLog
565-
results["mcp_logs_deleted"] = db.query(McpActivityLog).filter_by(org_id=user.org_id).delete()
616+
617+
results["mcp_logs_deleted"] = (
618+
db.query(McpActivityLog).filter_by(org_id=user.org_id).delete()
619+
)
566620

567621
# 4. Wipe settings
568-
results["settings_deleted"] = db.query(Setting).filter_by(org_id=user.org_id).delete()
622+
results["settings_deleted"] = (
623+
db.query(Setting).filter_by(org_id=user.org_id).delete()
624+
)
569625

570626
# 5. Wipe audit logs
571627
db.query(AuditLog).filter_by(org_id=user.org_id).delete()

backend/app/api/hls.py

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
re.MULTILINE,
3434
)
3535
_RE_CODECS = re.compile(r"^#EXT-X-CODECS:.*$", re.MULTILINE)
36-
_RE_VERSION = re.compile(r"(#EXT-X-VERSION:\d+)")
3736
_RE_SEGMENT_FILENAME = re.compile(r"^segment_\d+\.ts$")
3837

3938
# ── Rewritten playlist cache ──────────────────────────────────────────
@@ -113,6 +112,9 @@ def _evict_stale_cameras():
113112
for camera_id in stale:
114113
del _segment_cache[camera_id]
115114
_playlist_cache.pop(camera_id, None)
115+
_playlist_update_count.pop(camera_id, None)
116+
_first_playlist_logged.discard(camera_id)
117+
_first_stream_get_logged.discard(camera_id)
116118

117119

118120
def _evict_caches():
@@ -173,22 +175,22 @@ def _maybe_log_access(
173175
db.rollback()
174176

175177

176-
def _rewrite_playlist(
177-
raw_playlist: str,
178-
camera_id: str,
179-
video_codec: str = "avc1.42e01e",
180-
audio_codec: str = "mp4a.40.2",
181-
) -> str:
178+
def _rewrite_playlist(raw_playlist: str) -> str:
182179
"""
183180
Rewrite raw HLS playlist: replace segment URIs with relative proxy
184-
URLs (``segment/<filename>``) and inject codec headers. Pure
185-
string manipulation — no I/O.
181+
URLs (``segment/<filename>``) and remove invalid ``#EXT-X-CODECS``
182+
lines. Pure string manipulation — no I/O.
186183
187184
The incoming URI can be either a bare basename or a path-prefixed
188185
name (see ``_RE_SEGMENT_URI``). We strip any prefix and emit the
189186
canonical ``segment/<basename>`` so the browser always resolves to
190187
``/api/cameras/{id}/segment/<basename>`` — the endpoint backed by
191188
the in-memory cache.
189+
190+
``#EXT-X-CODECS`` is only valid in Master Playlists; injecting it
191+
into a Media Playlist causes hls.js to attempt master-playlist
192+
parsing and never fire ``MANIFEST_PARSED``, leaving the player
193+
stuck at "Connecting…".
192194
"""
193195
# Normalize segment URIs to relative proxy paths. The capture
194196
# group is the basename only — prefix is discarded.
@@ -401,7 +403,7 @@ async def update_hls_playlist(
401403
camera.audio_codec or (node.audio_codec if node else None) or "mp4a.40.2"
402404
)
403405

404-
rewritten = _rewrite_playlist(playlist_content, camera_id, video_codec, audio_codec)
406+
rewritten = _rewrite_playlist(playlist_content)
405407
_playlist_cache[camera_id] = (rewritten, time.monotonic())
406408

407409
# First-push diagnostic log — capture the first raw segment URI so
@@ -487,21 +489,3 @@ async def push_motion_event(
487489
)
488490

489491
return {"success": True}
490-
491-
492-
@router.get("/debug/playlist")
493-
async def debug_playlist(
494-
camera_id: str,
495-
):
496-
cam_segs = _segment_cache.get(camera_id, {})
497-
seg_list = sorted(cam_segs.keys())
498-
pl = _playlist_cache.get(camera_id)
499-
return {
500-
"camera_id": camera_id,
501-
"cached_segments": len(seg_list),
502-
"oldest": seg_list[0] if seg_list else None,
503-
"newest": seg_list[-1] if seg_list else None,
504-
"playlist_cached": pl is not None,
505-
"playlist_age_s": round(time.monotonic() - pl[1], 1) if pl else None,
506-
"playlist_content": pl[0] if pl else None,
507-
}

backend/tests/test_hls.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
- Playlist rewriting: raw playlist text pushed by the CloudNode is
1010
served back with segment filenames proxied through this backend
1111
(``segment/segment_00001.ts``) — no absolute URLs leak through.
12-
- Codec injection: the rewritten playlist always advertises an
13-
``#EXT-X-CODECS`` line derived from the DB row, so MSE decoders
14-
don't have to sniff the first TS packet.
12+
- Codec stripping: ``#EXT-X-CODECS`` lines are removed from media
13+
playlists since they're only valid in master playlists and break
14+
hls.js parsing.
1515
- Cache eviction bounds: pushing more than
1616
``SEGMENT_CACHE_MAX_PER_CAMERA`` segments drops the oldest.
1717
- ``stream.m3u8`` returns 404 when no playlist has ever been pushed.

frontend/src/components/HlsPlayer.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ function HlsPlayer({ cameraId, cameraName }) {
4444

4545
xhrSetup: (xhr, url) => {
4646
const token = LOCAL_TEST_MODE ? null : getCurrentToken()
47-
if (token) {
47+
// hls.js may pass relative URLs (e.g. "segment/segment_00042.ts")
48+
// or absolute URLs (e.g. "https://...stream.m3u8"). Always attach
49+
// the token for our own API endpoints; skip third-party origins.
50+
if (token && (url.startsWith(ownOrigin) || url.startsWith("/"))) {
4851
xhr.setRequestHeader("Authorization", `Bearer ${token}`)
4952
// Prevent browser from serving cached playlist/segment
5053
xhr.setRequestHeader("Cache-Control", "no-cache")

0 commit comments

Comments
 (0)