Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
950b844
Centralize all backend environment access into a single typed, valida…
aaronstevenwhite Jun 17, 2026
3cc1193
Merge pull request #145 from parafovea/refactor/phase-1a-backend-config
aaronstevenwhite Jun 17, 2026
360f1e8
Add a create-release job to release.yml that extracts the matching CH…
aaronstevenwhite Jun 17, 2026
fac87bb
Merge pull request #146 from parafovea/fix/release-workflow-auto-gith…
aaronstevenwhite Jun 17, 2026
d0c633b
Centralize all frontend environment access into a single typed, deep-…
aaronstevenwhite Jun 17, 2026
3bc662d
Merge pull request #147 from parafovea/refactor/phase-1b-frontend-config
aaronstevenwhite Jun 17, 2026
10038a1
Promote the model-service to a single typed pydantic-settings Setting…
aaronstevenwhite Jun 17, 2026
df2c545
Merge pull request #148 from parafovea/refactor/phase-1c-model-servic…
aaronstevenwhite Jun 17, 2026
075576c
Add a CI configuration-drift guard (scripts/check-env-example.mjs, wi…
aaronstevenwhite Jun 17, 2026
fbb6cd3
Merge pull request #149 from parafovea/refactor/phase-1d-config-drift…
aaronstevenwhite Jun 17, 2026
96a7454
Pin Node and pnpm in one place by adding a root .nvmrc (Node 22) and …
aaronstevenwhite Jun 17, 2026
7b27938
Merge pull request #150 from parafovea/refactor/phase-2a-node-pnpm-pin
aaronstevenwhite Jun 17, 2026
206314a
Add a verify-compose CI job that validates all ten docker compose con…
aaronstevenwhite Jun 17, 2026
909a288
Merge pull request #151 from parafovea/refactor/phase-2b-compose-vali…
aaronstevenwhite Jun 17, 2026
eb03c3a
Delete the duplicate server/docker-compose.dev.yml that redefined Pos…
aaronstevenwhite Jun 17, 2026
e6b19c8
Merge pull request #152 from parafovea/refactor/phase-2c-dev-infra-co…
aaronstevenwhite Jun 17, 2026
9b2541c
Consolidate the model-service's orphaned tests directory into the con…
aaronstevenwhite Jun 17, 2026
252963a
Merge pull request #153 from parafovea/refactor/phase-3a-consolidate-…
aaronstevenwhite Jun 17, 2026
7453b6e
Add a root Makefile as the single install/lint/typecheck/test/build e…
aaronstevenwhite Jun 17, 2026
1a11923
Merge pull request #154 from parafovea/refactor/phase-3b-unified-test…
aaronstevenwhite Jun 17, 2026
2994852
Reconcile CI documentation with reality by rewriting the stale workfl…
aaronstevenwhite Jun 17, 2026
dcf10da
Merge pull request #155 from parafovea/refactor/phase-3c-ci-reconcili…
aaronstevenwhite Jun 17, 2026
c7cfc3b
Stop the startup config validation from requiring API_KEY_ENCRYPTION_…
aaronstevenwhite Jun 18, 2026
3f194e0
Merge pull request #156 from parafovea/fix/api-key-encryption-startup…
aaronstevenwhite Jun 18, 2026
b09a655
Extract the personas domain into a PersonaRepository (pure data acces…
aaronstevenwhite Jun 18, 2026
25dddcc
Make the persistent test persona name unique per test (workerIndex pl…
aaronstevenwhite Jun 18, 2026
9f14854
Merge pull request #157 from parafovea/refactor/phase-4-1-personas-se…
aaronstevenwhite Jun 18, 2026
ca12444
Extract the projects domain into a ProjectRepository and ProjectServi…
aaronstevenwhite Jun 18, 2026
bb90695
Merge pull request #158 from parafovea/refactor/phase-4-2-projects-se…
aaronstevenwhite Jun 18, 2026
6950e35
Extract the claims domain into a ClaimRepository and ClaimService, re…
aaronstevenwhite Jun 18, 2026
19caa51
Merge pull request #159 from parafovea/refactor/phase-4-3-claims-serv…
aaronstevenwhite Jun 18, 2026
e77e2f9
Extract the world-state domain into a WorldStateRepository and WorldS…
aaronstevenwhite Jun 18, 2026
646e6d8
Merge pull request #160 from parafovea/refactor/phase-4-4-world-servi…
aaronstevenwhite Jun 18, 2026
2e1bee8
Split the 2232-line import-handler.ts import state machine into focus…
aaronstevenwhite Jun 18, 2026
aaf2a87
Merge pull request #161 from parafovea/refactor/phase-4-5-import-hand…
aaronstevenwhite Jun 18, 2026
293795c
Stand up the OpenAPI to frontend-types generation pipeline by emittin…
aaronstevenwhite Jun 18, 2026
ccac9ec
Merge pull request #162 from parafovea/refactor/phase-4-6a-openapi-fr…
aaronstevenwhite Jun 18, 2026
5e3750b
Decouple the model-service contract layer from the ML stack by removi…
aaronstevenwhite Jun 19, 2026
1d9832d
Merge pull request #163 from parafovea/refactor/phase-4-6b-model-serv…
aaronstevenwhite Jun 19, 2026
4ae665e
Add the server↔model-service contract pipeline by emitting a committe…
aaronstevenwhite Jun 19, 2026
bb3d97d
Merge pull request #164 from parafovea/refactor/phase-4-6b2-model-ser…
aaronstevenwhite Jun 19, 2026
8c76b87
Resolve cross-user object names server-side by adding an optional lin…
aaronstevenwhite Jun 19, 2026
e060f7b
Merge pull request #165 from parafovea/refactor/phase-4-6c-linked-obj…
aaronstevenwhite Jun 19, 2026
2ac946d
Make POST /api/annotations idempotent on an optional client-supplied …
aaronstevenwhite Jun 19, 2026
28cdd11
Merge pull request #166 from parafovea/refactor/phase-4-7-annotation-…
aaronstevenwhite Jun 19, 2026
7db24c2
Fix the annotation autosave idle loop and box duplication by strippin…
aaronstevenwhite Jun 19, 2026
aba44e8
Merge pull request #167 from parafovea/refactor/phase-4-8-annotation-…
aaronstevenwhite Jun 19, 2026
cc4c627
Split the model-service vlm and detection loader modules into one-cla…
aaronstevenwhite Jun 19, 2026
7b82384
Merge pull request #168 from parafovea/refactor/phase-4-9-model-servi…
aaronstevenwhite Jun 19, 2026
7b88130
Consolidate the scattered demo-mode RBAC read-widening into a single …
aaronstevenwhite Jun 19, 2026
39fbf23
Merge pull request #169 from parafovea/refactor/phase-4-10-demo-gatin…
aaronstevenwhite Jun 19, 2026
62c0315
Decompose the 1403-line AnnotationWorkspace component into three cohe…
aaronstevenwhite Jun 20, 2026
abb35c2
Merge pull request #170 from parafovea/refactor/phase-4-8b-annotation…
aaronstevenwhite Jun 20, 2026
34e1f4b
Finalize the 0.5.0 changelog date to the release date.
aaronstevenwhite Jun 20, 2026
b627ef2
Wrap the literal video tag in the useAnnotationState return-type JSDo…
aaronstevenwhite Jun 20, 2026
8f4c60d
Reword the import-module JSDoc and the ImportResultDialog summary so …
aaronstevenwhite Jun 20, 2026
2a3749d
fix: resolve a batch of field-reported bugs (0.5.1) (#173)
aaronstevenwhite Jun 22, 2026
212884f
Finalize the 0.5.1 changelog date to the 2026-06-22 release date in b…
aaronstevenwhite Jun 22, 2026
07ada4c
Merge main into release/0.5.x to reconcile the squash-flattened linea…
aaronstevenwhite Jun 22, 2026
bf61da5
Parse suffix and edge HTTP byte ranges per RFC 7233 in the video stre…
aaronstevenwhite Jun 22, 2026
75f95d0
Make clicking a claim card select it in place on the Claims tab
aaronstevenwhite Jun 24, 2026
ff1ac1b
Bump the version to 0.5.3 and record the claim-card selection fix in …
aaronstevenwhite Jun 24, 2026
2278ad3
fix: stamp project scope on summary and claim writes so collaborators…
aaronstevenwhite Jun 25, 2026
5ecf50a
Merge main into release/0.5.x to reconcile the squash-flattened linea…
aaronstevenwhite Jun 25, 2026
4b0e204
Merge main into release/0.5.x to reconcile the v0.5.4 squash-promote …
aaronstevenwhite Jun 27, 2026
51febf3
Fix ten audit-reported defects spanning group-delete ability-cache in…
aaronstevenwhite Jun 29, 2026
83ada36
Merge main into release/0.5.x to reconcile the v0.5.5 squash-promote …
aaronstevenwhite Jun 29, 2026
dd25365
Backend correctness and security hardening: authz/access-control fixe…
aaronstevenwhite Jun 30, 2026
4d60f3c
Merge main into release/0.5.x to reconcile the v0.5.6 squash-promote …
aaronstevenwhite Jun 30, 2026
dee0ea7
fix: frontend data-loss & cache hardening (v0.5.7) (#190)
aaronstevenwhite Jun 30, 2026
33a7601
Merge main into release/0.5.x to reconcile the v0.5.7 squash-promote …
aaronstevenwhite Jun 30, 2026
1e69a43
Harden the model-service: release VideoCapture handles on every exit …
aaronstevenwhite Jun 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to the Fovea project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.8] - 2026-06-30

The 0.5.8 patch is the third and final audit-driven hardening release — the model-service slice — fixing resource and concurrency defects in the Python inference service ([#192](https://github.com/parafovea/fovea/pull/192)). Nothing is breaking.

### Fixed

- The `/detection/detect` and `/tracking/track` routes leaked an OpenCV `VideoCapture` handle (and decoder memory) whenever a frame read or mask decode failed, since the capture was released only on the success path. The capture is now released in a `try/finally` on every exit path, and an open failure raises a clean error (`model-service/.../routes/detection.py`, `tracking.py`).
- Synchronous VLM, transcription, and diarization inference ran on the asyncio event loop, so a single request froze all concurrent requests, the `/health` probe, and telemetry export until it finished. The blocking calls are now offloaded with `asyncio.to_thread` (`model-service/.../use_cases/summarize_video.py`, `routes/transcribe.py`, `routes/diarize.py`).
- ffprobe/ffmpeg subprocesses were awaited with no timeout, so a malformed or slow media file could wedge a request indefinitely and leak the child process. These calls now go through a shared helper that bounds the wait and kills and reaps the process on timeout (`model-service/.../services/audio_processing.py`).
- `ModelManager` had no concurrency guard, so two requests loading the same not-yet-loaded model could both pass the "already loaded?" check and double-load (risking OOM). Load, unload, and eviction are now serialized by a reentrant model lock with a post-acquire re-check (`model-service/.../services/model_management.py`).
- Id-based video resolution hardcoded `/videos`, returning 404 on any deployment whose video volume is mounted elsewhere; it now reads the configured `video_data_root` (`model-service/.../use_cases/summarize_video.py`).
- The admin reconfigure token is now compared with `hmac.compare_digest` (constant-time) instead of `!=` (`model-service/.../routes/admin.py`).

## [0.5.7] - 2026-06-30

The 0.5.7 patch is the second of three audit-driven hardening releases — the frontend slice — fixing data-loss and stale-cache defects in the annotation UI ([#190](https://github.com/parafovea/fovea/pull/190)). Nothing is breaking.
Expand Down
2 changes: 1 addition & 1 deletion annotation-tool/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@fovea/annotation-tool",
"private": true,
"version": "0.5.7",
"version": "0.5.8",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
13 changes: 13 additions & 0 deletions docs/docs/project/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ All notable changes to the Fovea project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.8] - 2026-06-30

The 0.5.8 patch is the third and final audit-driven hardening release — the model-service slice — fixing resource and concurrency defects in the Python inference service ([#192](https://github.com/parafovea/fovea/pull/192)). Nothing is breaking.

### Fixed

- The `/detection/detect` and `/tracking/track` routes leaked an OpenCV `VideoCapture` handle (and decoder memory) whenever a frame read or mask decode failed, since the capture was released only on the success path. The capture is now released in a `try/finally` on every exit path, and an open failure raises a clean error (`model-service/.../routes/detection.py`, `tracking.py`).
- Synchronous VLM, transcription, and diarization inference ran on the asyncio event loop, so a single request froze all concurrent requests, the `/health` probe, and telemetry export until it finished. The blocking calls are now offloaded with `asyncio.to_thread` (`model-service/.../use_cases/summarize_video.py`, `routes/transcribe.py`, `routes/diarize.py`).
- ffprobe/ffmpeg subprocesses were awaited with no timeout, so a malformed or slow media file could wedge a request indefinitely and leak the child process. These calls now go through a shared helper that bounds the wait and kills and reaps the process on timeout (`model-service/.../services/audio_processing.py`).
- `ModelManager` had no concurrency guard, so two requests loading the same not-yet-loaded model could both pass the "already loaded?" check and double-load (risking OOM). Load, unload, and eviction are now serialized by a reentrant model lock with a post-acquire re-check (`model-service/.../services/model_management.py`).
- Id-based video resolution hardcoded `/videos`, returning 404 on any deployment whose video volume is mounted elsewhere; it now reads the configured `video_data_root` (`model-service/.../use_cases/summarize_video.py`).
- The admin reconfigure token is now compared with `hmac.compare_digest` (constant-time) instead of `!=` (`model-service/.../routes/admin.py`).

## [0.5.7] - 2026-06-30

The 0.5.7 patch is the second of three audit-driven hardening releases — the frontend slice — fixing data-loss and stale-cache defects in the annotation UI ([#190](https://github.com/parafovea/fovea/pull/190)). Nothing is breaking.
Expand Down
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "docs",
"version": "0.5.7",
"version": "0.5.8",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
Expand Down
2 changes: 1 addition & 1 deletion model-service/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fovea-model-service",
"version": "0.5.7",
"version": "0.5.8",
"description": "AI model inference service for video annotation",
"scripts": {
"dev": "source venv/bin/activate && uvicorn src.main:app --reload --host 0.0.0.0 --port 8000",
Expand Down
2 changes: 1 addition & 1 deletion model-service/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "fovea-model-service"
version = "0.5.7"
version = "0.5.8"
description = "Model service for fovea video annotation tool"
requires-python = ">=3.12"
dependencies = [
Expand Down
57 changes: 52 additions & 5 deletions model-service/src/application/services/audio_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,51 @@ class AudioProcessingError(Exception):
"""Raised when audio processing operations fail."""


#: Bounded wait for an ffprobe metadata probe. Probes read only a few
#: stream headers and should return in well under a second; the ceiling
#: keeps a wedged ffprobe from hanging a request forever.
_FFPROBE_TIMEOUT_SECONDS = 30

#: Bounded wait for an ffmpeg decode/transcode that streams full audio.
#: Larger than the probe ceiling because decoding a long file legitimately
#: takes longer.
_FFMPEG_DECODE_TIMEOUT_SECONDS = 300


async def _communicate_with_timeout(
process: asyncio.subprocess.Process, timeout: float
) -> tuple[bytes, bytes]:
"""Await ``process.communicate()`` with a bounded timeout.

On timeout the process is killed and reaped before the error
propagates, so a wedged ffprobe/ffmpeg cannot leak a child process or
hang the request indefinitely.

Parameters
----------
process : asyncio.subprocess.Process
The running subprocess.
timeout : float
Maximum seconds to wait for completion.

Returns
-------
tuple[bytes, bytes]
The process stdout and stderr.

Raises
------
AudioProcessingError
If the process does not finish within ``timeout``.
"""
try:
return await asyncio.wait_for(process.communicate(), timeout=timeout)
except TimeoutError as exc:
process.kill()
await process.wait()
raise AudioProcessingError(f"Audio subprocess timed out after {timeout:g}s") from exc


async def has_audio_stream(video_path: str) -> bool:
"""Check if video file contains audio streams.

Expand Down Expand Up @@ -56,7 +101,7 @@ async def has_audio_stream(video_path: str) -> bool:
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await process.communicate()
stdout, _ = await _communicate_with_timeout(process, _FFPROBE_TIMEOUT_SECONDS)

has_audio = stdout.decode().strip() == "audio"
span.set_attribute("audio.has_stream", has_audio)
Expand Down Expand Up @@ -109,7 +154,7 @@ async def get_audio_info(video_path: str) -> dict[str, str | int | float]:
stderr=asyncio.subprocess.PIPE,
)

stdout, stderr = await process.communicate()
stdout, stderr = await _communicate_with_timeout(process, _FFPROBE_TIMEOUT_SECONDS)

if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
Expand Down Expand Up @@ -277,7 +322,7 @@ async def chunk_audio_file(
stderr=asyncio.subprocess.PIPE,
)

stdout, stderr = await process.communicate()
stdout, stderr = await _communicate_with_timeout(process, _FFPROBE_TIMEOUT_SECONDS)

if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
Expand Down Expand Up @@ -351,7 +396,9 @@ async def load_audio_array(
stderr=asyncio.subprocess.PIPE,
)

stdout, stderr = await process.communicate()
stdout, stderr = await _communicate_with_timeout(
process, _FFMPEG_DECODE_TIMEOUT_SECONDS
)

if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
Expand Down Expand Up @@ -403,7 +450,7 @@ async def get_audio_duration(audio_path: str) -> float:
stderr=asyncio.subprocess.PIPE,
)

stdout, stderr = await process.communicate()
stdout, stderr = await _communicate_with_timeout(process, _FFPROBE_TIMEOUT_SECONDS)

if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
Expand Down
78 changes: 61 additions & 17 deletions model-service/src/application/services/model_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@

from __future__ import annotations

import asyncio
import logging
import time
from collections import OrderedDict
from collections.abc import Callable
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -166,6 +168,14 @@ def __init__(
self.model_load_times: dict[str, float] = {}
self.model_memory_usage: dict[str, int] = {}

# Serializes load/unload/evict so two concurrent loads of the same
# not-yet-loaded task cannot both pass the "already loaded?" check
# (the eviction await inside load_model is a yield point). The lock
# is reentrant within a single task via _mutate_models so that
# load_model can drive eviction/unload while holding it.
self._model_lock = asyncio.Lock()
self._lock_holder: asyncio.Task[Any] | None = None

self.device = self._detect_device()
self.cpu_only_mode = self.device == "cpu"

Expand Down Expand Up @@ -265,39 +275,73 @@ def get_lru_model(self) -> str | None:

# --- Loading / unloading ------------------------------------------------------

@asynccontextmanager
async def _mutate_models(self) -> AsyncIterator[None]:
"""Hold the model lock for the duration of the block.

Reentrant within a single asyncio task: ``load_model`` acquires the
lock and then calls ``evict_lru_model``/``unload_model``, which use
this same guard. The nested calls run in the same task that already
holds the lock, so they pass through without re-acquiring (an
``asyncio.Lock`` is not reentrant and would otherwise deadlock).
"""
current = asyncio.current_task()
if self._lock_holder is current:
yield
return

async with self._model_lock:
self._lock_holder = current
try:
yield
finally:
self._lock_holder = None

@tracer.start_as_current_span("evict_lru_model")
async def evict_lru_model(self) -> str | None:
"""Evict the least recently used model from memory."""
lru_task = self.get_lru_model()
if lru_task is None:
logger.warning("No models to evict")
return None
async with self._mutate_models():
lru_task = self.get_lru_model()
if lru_task is None:
logger.warning("No models to evict")
return None

logger.info(f"Evicting LRU model: {lru_task}")
await self.unload_model(lru_task)
return lru_task
logger.info(f"Evicting LRU model: {lru_task}")
await self.unload_model(lru_task)
return lru_task

@tracer.start_as_current_span("unload_model")
async def unload_model(self, task_type: str) -> None:
"""Unload a model from memory."""
if task_type not in self.loaded_models:
logger.warning(f"Model {task_type} not loaded")
return
async with self._mutate_models():
if task_type not in self.loaded_models:
logger.warning(f"Model {task_type} not loaded")
return

logger.info(f"Unloading model: {task_type}")
del self.loaded_models[task_type]
del self.model_load_times[task_type]
del self.model_memory_usage[task_type]
logger.info(f"Unloading model: {task_type}")
del self.loaded_models[task_type]
del self.model_load_times[task_type]
del self.model_memory_usage[task_type]

self._probe().empty_cache()
logger.info(f"Model {task_type} unloaded successfully")
self._probe().empty_cache()
logger.info(f"Model {task_type} unloaded successfully")

@tracer.start_as_current_span("load_model")
async def load_model(self, task_type: str) -> Any:
"""Load a model for the specified task type."""
if task_type not in self.tasks:
raise ValueError(f"Invalid task type: {task_type}")

async with self._mutate_models():
return await self._load_model_locked(task_type)

async def _load_model_locked(self, task_type: str) -> Any:
"""Load a model with the model lock already held.

Re-checks ``loaded_models`` after the lock is acquired so a
coroutine that lost the race returns the instance loaded by the
winner instead of double-loading and risking OOM.
"""
if task_type in self.loaded_models:
self.loaded_models.move_to_end(task_type)
logger.info(f"Model {task_type} already loaded, moved to end")
Expand Down
24 changes: 18 additions & 6 deletions model-service/src/application/use_cases/summarize_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from __future__ import annotations

import asyncio
import io
import logging
import os
Expand Down Expand Up @@ -287,14 +288,18 @@ async def execute_with_vlm(
) = await self._maybe_transcribe(request, video_path, span)

logger.info(f"Loading VLM model: {model_name}")
self._vlm.load()
# VLM load/generate/unload are blocking CPU/GPU calls; run
# them off the event loop so concurrent requests, /health,
# and OTLP export are not starved.
await asyncio.to_thread(self._vlm.load)

try:
prompt = get_persona_prompt(persona_role, information_need)
logger.info(f"Generating summary with {len(images)} frames")
visual_start_time = time.time()
overrides = request.generation_overrides
reasoned = self._vlm.generate_reasoned_from_images(
reasoned = await asyncio.to_thread(
self._vlm.generate_reasoned_from_images,
images,
prompt,
max_tokens=(
Expand Down Expand Up @@ -361,7 +366,7 @@ async def execute_with_vlm(
reasoning_trace=reasoned.thinking,
)
finally:
self._vlm.unload()
await asyncio.to_thread(self._vlm.unload)
logger.info("VLM model unloaded")

except SummarizationError:
Expand Down Expand Up @@ -700,21 +705,28 @@ def _safe(value: str) -> str:
return str(value).replace("\r", "").replace("\n", "")


def get_video_path_for_id(video_id: str, data_dir: str = "/videos") -> str | None:
def get_video_path_for_id(video_id: str, data_dir: str | None = None) -> str | None:
"""Resolve video ID to file path.

Parameters
----------
video_id : str
Video identifier from request.
data_dir : str
Base directory containing video files.
data_dir : str | None
Base directory containing video files. When None, the configured
``video_data_root`` setting is used so deployments whose videos do
not live at ``/videos`` resolve correctly.

Returns
-------
str | None
Full path to video file, or None if not found.
"""
if data_dir is None:
from src.infrastructure.config.settings import get_settings

data_dir = str(get_settings().video_data_root)

# CodeQL sanitizer: ``re.fullmatch`` with a constant pattern restricts
# ``video_id`` to a safe character set. The matched branch clears the
# taint tracker's path-injection flag.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from __future__ import annotations

import hmac
import logging
from typing import Literal

Expand Down Expand Up @@ -113,7 +114,7 @@ def _require_admin_token(x_admin_token: str | None) -> None:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Admin reconfigure is not enabled on this service",
)
if not x_admin_token or x_admin_token != expected:
if not x_admin_token or not hmac.compare_digest(x_admin_token or "", expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing admin token",
Expand Down
Loading
Loading