add a monitoring camera setting for cetana

This commit is contained in:
Your Name
2026-10-31 21:08:26 +03:30
parent 33aebe9b14
commit 093e0b9d1a
11 changed files with 732 additions and 9 deletions
+8
View File
@@ -24,6 +24,14 @@ PAYMENT_GATEWAY_MERCHANT_ID=
SMS_PROVIDER=mock SMS_PROVIDER=mock
SMS_PROVIDER_API_KEY= SMS_PROVIDER_API_KEY=
# ---- Live camera monitoring (overrides settings panel defaults) ----
CAMERA_ENABLED=1
CAMERA_DEFAULT_PROTOCOL=hls
CAMERA_STREAM_TTL_SECONDS=3600
CAMERA_PREFER_SECURE=1
CAMERA_MEDIA_GATEWAY_URL=
CAMERA_MEDIA_GATEWAY_PATH_TEMPLATE={camera_id}/index.m3u8
# ---- Celery ---- # ---- Celery ----
CELERY_BROKER_URL=redis://localhost:6379/0 CELERY_BROKER_URL=redis://localhost:6379/0
+175
View File
@@ -5,7 +5,180 @@ is rendered from this list, so adding a new integration/provider only requires
adding entries here — no frontend or model changes. adding entries here — no frontend or model changes.
Field types: ``string``, ``secret``, ``boolean``, ``choice``, ``number``. Field types: ``string``, ``secret``, ``boolean``, ``choice``, ``number``.
Optional keys:
- ``depends_on``: ``{"other.key": ["value_a", "value_b"]}`` — field is shown
only when every referenced setting currently holds one of the listed values.
- ``options``: list of strings or ``{"value", "label"}`` dicts (for ``choice``).
""" """
from apps.live_monitoring.protocols import (
DEFAULT_PROTOCOL,
PROTOCOL_KEYS,
STREAM_PROTOCOLS,
protocol_choice_options,
)
def _camera_settings() -> list:
"""Build Live Monitoring settings from the protocol catalog.
Shared defaults always appear. Protocol-specific fields use ``depends_on``
so the settings panel stays dynamic as standards are added to the catalog.
"""
entries = [
{
"key": "camera.enabled",
"label": "Enable live camera monitoring",
"type": "boolean",
"category": "Live Monitoring",
"default": True,
},
{
"key": "camera.default_protocol",
"label": "Default stream protocol",
"type": "choice",
"category": "Live Monitoring",
"options": protocol_choice_options(),
"default": DEFAULT_PROTOCOL,
},
{
"key": "camera.stream_ttl_seconds",
"label": "Stream access token TTL (seconds)",
"type": "number",
"category": "Live Monitoring",
"default": 3600,
},
{
"key": "camera.prefer_secure",
"label": "Prefer secure schemes (HTTPS / RTSPS / RTMPS)",
"type": "boolean",
"category": "Live Monitoring",
"default": True,
},
{
"key": "camera.media_gateway_url",
"label": "Media gateway base URL",
"type": "string",
"category": "Live Monitoring",
"default": "",
"placeholder": "https://media.example.com",
},
{
"key": "camera.media_gateway_path_template",
"label": "Gateway path template",
"type": "string",
"category": "Live Monitoring",
"default": "{camera_id}/index.m3u8",
"placeholder": "{camera_id}/index.m3u8",
},
{
"key": "camera.default_username",
"label": "Default camera username",
"type": "string",
"category": "Live Monitoring",
"default": "",
},
{
"key": "camera.default_password",
"label": "Default camera password",
"type": "secret",
"category": "Live Monitoring",
"default": "",
},
{
"key": "camera.default_auth_method",
"label": "Default stream auth method",
"type": "choice",
"category": "Live Monitoring",
"options": [
{"value": "none", "label": "None"},
{"value": "basic", "label": "HTTP Basic"},
{"value": "digest", "label": "HTTP Digest"},
{"value": "bearer", "label": "Bearer token"},
{"value": "query_token", "label": "Query token"},
{"value": "ws_username_token", "label": "WS-UsernameToken (ONVIF)"},
{"value": "passphrase", "label": "Passphrase (SRT)"},
],
"default": "none",
},
]
# Protocol-specific defaults — visibility follows camera.default_protocol.
for key in PROTOCOL_KEYS:
meta = STREAM_PROTOCOLS[key]
dep = {"camera.default_protocol": [key]}
entries.append(
{
"key": f"camera.{key}.default_port",
"label": f"{meta['label']} — default port",
"type": "number",
"category": "Live Monitoring",
"default": meta["default_port"],
"depends_on": dep,
}
)
entries.append(
{
"key": f"camera.{key}.default_path",
"label": f"{meta['label']} — default path",
"type": "string",
"category": "Live Monitoring",
"default": meta["default_path"],
"placeholder": meta["default_path"] or "/",
"depends_on": dep,
}
)
if meta.get("transports"):
entries.append(
{
"key": f"camera.{key}.transport",
"label": f"{meta['label']} — transport",
"type": "choice",
"category": "Live Monitoring",
"options": list(meta["transports"]),
"default": meta["transports"][0],
"depends_on": dep,
}
)
# WebRTC needs ICE servers when selected.
entries.append(
{
"key": "camera.webrtc.ice_servers",
"label": "WebRTC ICE servers (JSON)",
"type": "string",
"category": "Live Monitoring",
"default": '[{"urls":"stun:stun.l.google.com:19302"}]',
"placeholder": '[{"urls":"stun:stun.l.google.com:19302"}]',
"depends_on": {"camera.default_protocol": ["webrtc"]},
}
)
# ONVIF profile token when ONVIF is the default.
entries.append(
{
"key": "camera.onvif.profile_token",
"label": "ONVIF media profile token",
"type": "string",
"category": "Live Monitoring",
"default": "",
"placeholder": "Profile_1",
"depends_on": {"camera.default_protocol": ["onvif"]},
}
)
# SRT passphrase (secret) when SRT is selected.
entries.append(
{
"key": "camera.srt.passphrase",
"label": "SRT passphrase",
"type": "secret",
"category": "Live Monitoring",
"default": "",
"depends_on": {"camera.default_protocol": ["srt"]},
}
)
return entries
SETTINGS_SCHEMA = [ SETTINGS_SCHEMA = [
# --- Odoo integration --- # --- Odoo integration ---
{"key": "odoo.enabled", "label": "Enable Odoo integration", "type": "boolean", "category": "Odoo", "default": False}, {"key": "odoo.enabled", "label": "Enable Odoo integration", "type": "boolean", "category": "Odoo", "default": False},
@@ -25,6 +198,8 @@ SETTINGS_SCHEMA = [
{"key": "reservation.working_hours_end", "label": "Working hours end", "type": "string", "category": "Reservation", "default": "17:00", "placeholder": "HH:MM"}, {"key": "reservation.working_hours_end", "label": "Working hours end", "type": "string", "category": "Reservation", "default": "17:00", "placeholder": "HH:MM"},
{"key": "reservation.slot_duration_minutes", "label": "Slot duration (minutes)", "type": "number", "category": "Reservation", "default": 30}, {"key": "reservation.slot_duration_minutes", "label": "Slot duration (minutes)", "type": "number", "category": "Reservation", "default": 30},
{"key": "reservation.default_capacity", "label": "Default capacity per slot", "type": "number", "category": "Reservation", "default": 2}, {"key": "reservation.default_capacity", "label": "Default capacity per slot", "type": "number", "category": "Reservation", "default": 2},
# --- Live camera monitoring (protocol catalog driven) ---
*_camera_settings(),
] ]
SCHEMA_BY_KEY = {entry["key"]: entry for entry in SETTINGS_SCHEMA} SCHEMA_BY_KEY = {entry["key"]: entry for entry in SETTINGS_SCHEMA}
+14
View File
@@ -12,6 +12,8 @@ class SettingsServiceTests(TestCase):
service = SettingsService() service = SettingsService()
self.assertEqual(service.get("sms.provider"), "mock") self.assertEqual(service.get("sms.provider"), "mock")
self.assertIs(service.get("odoo.enabled"), False) self.assertIs(service.get("odoo.enabled"), False)
self.assertEqual(service.get("camera.default_protocol"), "hls")
self.assertIs(service.get("camera.enabled"), True)
def test_set_and_get(self): def test_set_and_get(self):
service = SettingsService() service = SettingsService()
@@ -24,6 +26,18 @@ class SettingsServiceTests(TestCase):
values = service.all_values() values = service.all_values()
self.assertEqual(values["odoo.api_key"], "••••••") self.assertEqual(values["odoo.api_key"], "••••••")
def test_camera_protocol_settings_are_in_schema(self):
from apps.core.settings_registry import SETTINGS_SCHEMA
keys = {e["key"] for e in SETTINGS_SCHEMA}
self.assertIn("camera.default_protocol", keys)
self.assertIn("camera.rtsp.transport", keys)
self.assertIn("camera.webrtc.ice_servers", keys)
proto = next(e for e in SETTINGS_SCHEMA if e["key"] == "camera.default_protocol")
self.assertTrue(any(isinstance(o, dict) and o.get("value") == "rtsp" for o in proto["options"]))
rtsp_transport = next(e for e in SETTINGS_SCHEMA if e["key"] == "camera.rtsp.transport")
self.assertEqual(rtsp_transport["depends_on"], {"camera.default_protocol": ["rtsp"]})
class JalaliTests(TestCase): class JalaliTests(TestCase):
"""The PDF and the customer SMS are the only places dates are rendered for """The PDF and the customer SMS are the only places dates are rendered for
+38
View File
@@ -0,0 +1,38 @@
from django.contrib import admin
from apps.live_monitoring.models import Camera, LiveStream, WorkflowStepCamera
@admin.register(Camera)
class CameraAdmin(admin.ModelAdmin):
list_display = ("name", "facility", "processing_line", "protocol", "active", "updated_at")
list_filter = ("active", "protocol", "facility")
search_fields = ("name", "facility", "processing_line", "stream_url")
readonly_fields = ("id", "created_at", "updated_at")
fieldsets = (
(None, {"fields": ("name", "facility", "processing_line", "active")}),
(
"Stream",
{
"fields": ("protocol", "stream_url", "auth_config"),
"description": (
"Protocol blank = use Live Monitoring settings default. "
"auth_config stays server-side (username, password, token, host, port, path…)."
),
},
),
("Meta", {"fields": ("id", "created_at", "updated_at")}),
)
@admin.register(LiveStream)
class LiveStreamAdmin(admin.ModelAdmin):
list_display = ("name", "camera", "active", "updated_at")
list_filter = ("active",)
@admin.register(WorkflowStepCamera)
class WorkflowStepCameraAdmin(admin.ModelAdmin):
list_display = ("step", "camera", "id")
list_filter = ("camera",)
search_fields = ("camera__name",)
@@ -0,0 +1,36 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("live_monitoring", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="camera",
name="protocol",
field=models.CharField(
blank=True,
choices=[
("rtsp", "RTSP (Real Time Streaming Protocol)"),
("rtmp", "RTMP (Real-Time Messaging Protocol)"),
("hls", "HLS (HTTP Live Streaming)"),
("dash", "MPEG-DASH"),
("webrtc", "WebRTC"),
("mjpeg", "MJPEG over HTTP"),
("http", "HTTP Progressive (MP4/WebM)"),
("onvif", "ONVIF Media Profile"),
("srt", "SRT (Secure Reliable Transport)"),
],
default="",
help_text="Streaming standard. Leave blank to use the system default protocol.",
max_length=32,
),
),
migrations.AlterField(
model_name="camera",
name="stream_url",
field=models.CharField(max_length=1000),
),
]
+20 -1
View File
@@ -1,20 +1,39 @@
from django.db import models from django.db import models
from apps.core.models import TimeStampedModel, UUIDModel from apps.core.models import TimeStampedModel, UUIDModel
from apps.live_monitoring.protocols import DEFAULT_PROTOCOL, PROTOCOL_CHOICES
class Camera(UUIDModel, TimeStampedModel): class Camera(UUIDModel, TimeStampedModel):
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
facility = models.CharField(max_length=200, blank=True) facility = models.CharField(max_length=200, blank=True)
processing_line = models.CharField(max_length=200, blank=True) processing_line = models.CharField(max_length=200, blank=True)
stream_url = models.URLField() # Blank = inherit camera.default_protocol from Live Monitoring settings.
protocol = models.CharField(
max_length=32,
choices=PROTOCOL_CHOICES,
blank=True,
default="",
help_text="Streaming standard. Leave blank to use the system default protocol.",
)
# CharField (not URLField) so RTSP/RTMP/SRT URLs are accepted.
stream_url = models.CharField(max_length=1000)
# Never expose credentials to the frontend; keep auth config server-side. # Never expose credentials to the frontend; keep auth config server-side.
# Expected keys: username, password, token, auth_method, host, port, path, transport…
auth_config = models.JSONField(default=dict, blank=True) auth_config = models.JSONField(default=dict, blank=True)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
def resolved_protocol(self) -> str:
if self.protocol:
return self.protocol
from apps.core.settings_service import SettingsService
value = SettingsService().get("camera.default_protocol", DEFAULT_PROTOCOL)
return value if value in dict(PROTOCOL_CHOICES) else DEFAULT_PROTOCOL
class LiveStream(UUIDModel, TimeStampedModel): class LiveStream(UUIDModel, TimeStampedModel):
camera = models.ForeignKey(Camera, on_delete=models.CASCADE, related_name="streams") camera = models.ForeignKey(Camera, on_delete=models.CASCADE, related_name="streams")
+165
View File
@@ -0,0 +1,165 @@
"""Standard IP-camera / live-stream protocol catalog.
This is the single source of truth for which streaming standards Cetana
supports. Settings schema entries and Camera.protocol choices are derived
from this registry, so adding a new standard only requires one entry here.
"""
from __future__ import annotations
from typing import Any
# Each protocol describes how streams are addressed and whether a browser can
# play them natively (without a media gateway / WebRTC stack).
STREAM_PROTOCOLS: dict[str, dict[str, Any]] = {
"rtsp": {
"label": "RTSP (Real Time Streaming Protocol)",
"schemes": ("rtsp", "rtsps"),
"default_port": 554,
"default_path": "stream1",
"browser_native": False,
"mime_type": "",
"transports": ("tcp", "udp"),
"auth_methods": ("none", "basic", "digest"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Most IP cameras. Needs a media gateway (e.g. MediaMTX) for browser playback.",
},
"rtmp": {
"label": "RTMP (Real-Time Messaging Protocol)",
"schemes": ("rtmp", "rtmps"),
"default_port": 1935,
"default_path": "live/stream",
"browser_native": False,
"mime_type": "",
"transports": (),
"auth_methods": ("none", "basic"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Common for encoders/NVR publish. Needs HLS/WebRTC remux for browsers.",
},
"hls": {
"label": "HLS (HTTP Live Streaming)",
"schemes": ("https", "http"),
"default_port": 443,
"default_path": "live/index.m3u8",
"browser_native": True,
"mime_type": "application/vnd.apple.mpegurl",
"transports": (),
"auth_methods": ("none", "basic", "bearer", "query_token"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Browser-friendly (.m3u8). Preferred default for web monitoring.",
},
"dash": {
"label": "MPEG-DASH",
"schemes": ("https", "http"),
"default_port": 443,
"default_path": "live/manifest.mpd",
"browser_native": True,
"mime_type": "application/dash+xml",
"transports": (),
"auth_methods": ("none", "basic", "bearer", "query_token"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Adaptive HTTP streaming via MPD manifest.",
},
"webrtc": {
"label": "WebRTC",
"schemes": ("https", "http", "wss", "ws"),
"default_port": 443,
"default_path": "webrtc/offer",
"browser_native": True,
"mime_type": "",
"transports": ("udp", "tcp"),
"auth_methods": ("none", "bearer", "query_token"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Low-latency browser playback via a signaling endpoint.",
},
"mjpeg": {
"label": "MJPEG over HTTP",
"schemes": ("https", "http"),
"default_port": 80,
"default_path": "video.mjpg",
"browser_native": True,
"mime_type": "multipart/x-mixed-replace",
"transports": (),
"auth_methods": ("none", "basic", "digest"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Simple motion-JPEG snapshot stream; plays in <img> or <video> depending on camera.",
},
"http": {
"label": "HTTP Progressive (MP4/WebM)",
"schemes": ("https", "http"),
"default_port": 443,
"default_path": "live.mp4",
"browser_native": True,
"mime_type": "video/mp4",
"transports": (),
"auth_methods": ("none", "basic", "bearer", "query_token"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Progressive download / pseudo-live file URL.",
},
"onvif": {
"label": "ONVIF Media Profile",
"schemes": ("http", "https"),
"default_port": 80,
"default_path": "onvif/device_service",
"browser_native": False,
"mime_type": "",
"transports": (),
"auth_methods": ("none", "digest", "ws_username_token"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Device discovery/control. Stream URI is usually resolved to RTSP by the NVR.",
},
"srt": {
"label": "SRT (Secure Reliable Transport)",
"schemes": ("srt",),
"default_port": 9000,
"default_path": "",
"browser_native": False,
"mime_type": "",
"transports": ("udp",),
"auth_methods": ("none", "passphrase"),
"url_template": "{scheme}://{host}:{port}",
"notes": "Low-latency contribution protocol. Needs a gateway for browsers.",
},
}
PROTOCOL_CHOICES = [(key, meta["label"]) for key, meta in STREAM_PROTOCOLS.items()]
PROTOCOL_KEYS = tuple(STREAM_PROTOCOLS.keys())
DEFAULT_PROTOCOL = "hls"
def get_protocol(key: str | None) -> dict[str, Any]:
if key and key in STREAM_PROTOCOLS:
return STREAM_PROTOCOLS[key]
return STREAM_PROTOCOLS[DEFAULT_PROTOCOL]
def protocol_choice_options() -> list[dict[str, str]]:
"""Settings-panel friendly {value, label} options."""
return [{"value": key, "label": meta["label"]} for key, meta in STREAM_PROTOCOLS.items()]
def detect_protocol_from_url(url: str) -> str | None:
"""Best-effort guess from URL scheme / path."""
lower = (url or "").strip().lower()
if not lower:
return None
if lower.startswith("rtsps://") or lower.startswith("rtsp://"):
return "rtsp"
if lower.startswith("rtmps://") or lower.startswith("rtmp://"):
return "rtmp"
if lower.startswith("srt://"):
return "srt"
if ".m3u8" in lower:
return "hls"
if ".mpd" in lower:
return "dash"
if any(token in lower for token in (".mjpg", ".mjpeg", "multipart")):
return "mjpeg"
if "webrtc" in lower or lower.startswith("wss://") or lower.startswith("ws://"):
return "webrtc"
if "onvif" in lower:
return "onvif"
if lower.startswith("http://") or lower.startswith("https://"):
return "http"
return None
+10 -2
View File
@@ -4,7 +4,15 @@ from apps.live_monitoring.models import Camera
class CameraSerializer(serializers.ModelSerializer): class CameraSerializer(serializers.ModelSerializer):
# auth_config is never exposed to the frontend. """Public camera card. Stream URLs and credentials are never listed here —
clients must go through the tokenized ``access`` / ``verify`` flow.
"""
protocol = serializers.SerializerMethodField()
class Meta: class Meta:
model = Camera model = Camera
fields = ["id", "name", "facility", "processing_line", "stream_url", "active"] fields = ["id", "name", "facility", "processing_line", "protocol", "active"]
def get_protocol(self, obj: Camera) -> str:
return obj.resolved_protocol()
+167 -3
View File
@@ -2,17 +2,29 @@
Camera credentials are never exposed. A customer can request short-lived, Camera credentials are never exposed. A customer can request short-lived,
HMAC-signed access tokens for streams authorized for their own orders; the HMAC-signed access tokens for streams authorized for their own orders; the
`verify` endpoint validates the token and returns the stream URL. `verify` endpoint validates the token and returns a protocol-aware playable
stream descriptor (URL rewritten through a media gateway when needed).
""" """
from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import hmac import hmac
import json import json
import time import time
from typing import Any
from urllib.parse import urlencode, urlsplit, urlunsplit
from django.conf import settings from django.conf import settings
from apps.core.settings_service import SettingsService
from apps.live_monitoring.models import Camera, WorkflowStepCamera from apps.live_monitoring.models import Camera, WorkflowStepCamera
from apps.live_monitoring.protocols import (
DEFAULT_PROTOCOL,
STREAM_PROTOCOLS,
detect_protocol_from_url,
get_protocol,
)
from apps.orders.models import ProcessingOrder from apps.orders.models import ProcessingOrder
from apps.workflows.models import WorkflowStep from apps.workflows.models import WorkflowStep
@@ -31,8 +43,17 @@ class StreamAccessService:
).values_list("id", flat=True) ).values_list("id", flat=True)
return WorkflowStepCamera.objects.filter(camera=camera, step_id__in=step_ids).exists() return WorkflowStepCamera.objects.filter(camera=camera, step_id__in=step_ids).exists()
def issue_token(self, camera: Camera, ttl: int = 3600) -> tuple: def _ttl(self, ttl: int | None) -> int:
payload = {"camera": str(camera.id), "exp": int(time.time()) + ttl} if ttl is not None:
return int(ttl)
try:
return int(SettingsService().get("camera.stream_ttl_seconds", 3600))
except (TypeError, ValueError):
return 3600
def issue_token(self, camera: Camera, ttl: int | None = None) -> tuple:
resolved_ttl = self._ttl(ttl)
payload = {"camera": str(camera.id), "exp": int(time.time()) + resolved_ttl}
raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
sig = hmac.new(settings.SECRET_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest() sig = hmac.new(settings.SECRET_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()
return f"{raw}.{sig}", payload["exp"] return f"{raw}.{sig}", payload["exp"]
@@ -53,3 +74,146 @@ class StreamAccessService:
if payload.get("exp", 0) < int(time.time()): if payload.get("exp", 0) < int(time.time()):
return None return None
return payload return payload
def resolve_stream(self, camera: Camera) -> dict[str, Any]:
"""Build a client-safe stream descriptor for an authorized camera.
Credentials from ``auth_config`` / global settings are applied
server-side only. When the protocol is not browser-native and a media
gateway is configured, the URL is rewritten to the gateway playlist.
"""
service = SettingsService()
if not service.get_bool("camera.enabled", True):
return {
"protocol": camera.resolved_protocol(),
"stream_url": None,
"browser_native": False,
"mime_type": "",
"player": "none",
"error": "Live monitoring is disabled in settings.",
}
protocol_key = camera.protocol or detect_protocol_from_url(camera.stream_url) or camera.resolved_protocol()
if protocol_key not in STREAM_PROTOCOLS:
protocol_key = DEFAULT_PROTOCOL
meta = get_protocol(protocol_key)
playable_url = self._build_playable_url(camera, protocol_key, service)
browser_native = bool(meta.get("browser_native"))
# Non-browser protocols need a gateway remux (typically to HLS).
if not browser_native:
gateway = (service.get("camera.media_gateway_url", "") or "").rstrip("/")
if gateway:
template = service.get("camera.media_gateway_path_template", "{camera_id}/index.m3u8") or "{camera_id}/index.m3u8"
path = template.format(camera_id=str(camera.id), protocol=protocol_key)
playable_url = f"{gateway}/{path.lstrip('/')}"
protocol_key = "hls"
meta = get_protocol("hls")
browser_native = True
else:
return {
"protocol": protocol_key,
"stream_url": None,
"browser_native": False,
"mime_type": meta.get("mime_type", ""),
"player": "none",
"transport": self._transport(protocol_key, camera, service),
"error": (
f"{meta['label']} is not playable in the browser. "
"Configure a media gateway URL in Live Monitoring settings."
),
}
player = self._player_hint(protocol_key)
result: dict[str, Any] = {
"protocol": protocol_key,
"stream_url": playable_url,
"browser_native": browser_native,
"mime_type": meta.get("mime_type", ""),
"player": player,
"transport": self._transport(protocol_key, camera, service),
}
if protocol_key == "webrtc":
ice = service.get("camera.webrtc.ice_servers", "")
if ice:
result["ice_servers"] = ice
return result
def _transport(self, protocol_key: str, camera: Camera, service: SettingsService) -> str | None:
auth = camera.auth_config or {}
if auth.get("transport"):
return auth["transport"]
value = service.get(f"camera.{protocol_key}.transport")
return value or None
def _player_hint(self, protocol_key: str) -> str:
return {
"hls": "hls",
"dash": "dash",
"webrtc": "webrtc",
"mjpeg": "mjpeg",
"http": "video",
}.get(protocol_key, "none")
def _build_playable_url(self, camera: Camera, protocol_key: str, service: SettingsService) -> str:
"""Return a URL safe to hand to the client (no embedded passwords)."""
raw = (camera.stream_url or "").strip()
auth = dict(camera.auth_config or {})
# Absolute URL already provided — strip userinfo credentials.
if "://" in raw:
return self._strip_userinfo(raw)
# Relative / host-based config: assemble from protocol defaults + auth_config.
meta = get_protocol(protocol_key)
prefer_secure = service.get_bool("camera.prefer_secure", True)
schemes = meta["schemes"]
scheme = schemes[0]
if prefer_secure:
for candidate in schemes:
if candidate.endswith("s") or candidate in {"https", "wss", "rtsps", "rtmps"}:
scheme = candidate
break
host = auth.get("host") or raw
if not host:
return raw
port = auth.get("port")
if port in (None, ""):
port = service.get(f"camera.{protocol_key}.default_port", meta["default_port"])
path = (auth.get("path") or service.get(f"camera.{protocol_key}.default_path", meta["default_path"]) or "").lstrip("/")
netloc = f"{host}:{port}" if port not in (None, "") else host
url = urlunsplit((scheme, netloc, path, "", ""))
# Query-token auth can be appended without embedding secrets in userinfo.
auth_method = (
auth.get("auth_method")
or service.get("camera.default_auth_method", "none")
or "none"
)
token = auth.get("token") or auth.get("password")
if auth_method == "query_token" and token:
sep = "&" if "?" in url else "?"
url = f"{url}{sep}{urlencode({'token': token})}"
return url
@staticmethod
def _strip_userinfo(url: str) -> str:
try:
parts = urlsplit(url)
except ValueError:
return url
if not parts.username and not parts.password:
return url
host = parts.hostname or ""
if parts.port:
host = f"{host}:{parts.port}"
# Bracket IPv6
if parts.hostname and ":" in parts.hostname and not host.startswith("["):
host = f"[{parts.hostname}]"
if parts.port:
host = f"{host}:{parts.port}"
return urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment))
+78 -1
View File
@@ -1,12 +1,18 @@
from django.test import TestCase from django.test import TestCase
from apps.core.settings_service import SettingsService
from apps.live_monitoring.models import Camera from apps.live_monitoring.models import Camera
from apps.live_monitoring.protocols import PROTOCOL_KEYS, detect_protocol_from_url
from apps.live_monitoring.services import StreamAccessService from apps.live_monitoring.services import StreamAccessService
class StreamAccessTests(TestCase): class StreamAccessTests(TestCase):
def setUp(self): def setUp(self):
self.camera = Camera.objects.create(name="Cam1", stream_url="https://example.com/live.m3u8") self.camera = Camera.objects.create(
name="Cam1",
protocol="hls",
stream_url="https://example.com/live.m3u8",
)
self.service = StreamAccessService() self.service = StreamAccessService()
def test_issue_and_verify_token(self): def test_issue_and_verify_token(self):
@@ -24,3 +30,74 @@ class StreamAccessTests(TestCase):
def test_expired_token_rejected(self): def test_expired_token_rejected(self):
token, _ = self.service.issue_token(self.camera, ttl=-1) token, _ = self.service.issue_token(self.camera, ttl=-1)
self.assertIsNone(self.service.verify_token(token)) self.assertIsNone(self.service.verify_token(token))
def test_ttl_comes_from_settings(self):
SettingsService().set("camera.stream_ttl_seconds", 120)
token, exp = self.service.issue_token(self.camera)
payload = self.service.verify_token(token)
self.assertAlmostEqual(payload["exp"], exp, delta=1)
# Token should expire roughly 120s from now (not the old 3600 default).
import time
self.assertLess(payload["exp"] - int(time.time()), 130)
def test_resolve_hls_stream(self):
result = self.service.resolve_stream(self.camera)
self.assertEqual(result["protocol"], "hls")
self.assertEqual(result["stream_url"], "https://example.com/live.m3u8")
self.assertTrue(result["browser_native"])
self.assertEqual(result["player"], "hls")
def test_rtsp_requires_gateway_or_errors(self):
cam = Camera.objects.create(
name="RTSP Cam",
protocol="rtsp",
stream_url="rtsp://user:secret@192.168.1.10:554/stream1",
)
result = self.service.resolve_stream(cam)
self.assertIsNone(result["stream_url"])
self.assertIn("media gateway", result["error"].lower())
def test_rtsp_rewrites_through_gateway(self):
SettingsService().set("camera.media_gateway_url", "https://media.example.com")
cam = Camera.objects.create(
name="RTSP Cam",
protocol="rtsp",
stream_url="rtsp://user:secret@192.168.1.10:554/stream1",
)
result = self.service.resolve_stream(cam)
self.assertEqual(result["protocol"], "hls")
self.assertEqual(result["stream_url"], f"https://media.example.com/{cam.id}/index.m3u8")
self.assertNotIn("secret", result["stream_url"])
def test_strips_embedded_credentials(self):
cam = Camera.objects.create(
name="Cred Cam",
protocol="http",
stream_url="https://user:pass@cdn.example.com/live.mp4",
)
result = self.service.resolve_stream(cam)
self.assertEqual(result["stream_url"], "https://cdn.example.com/live.mp4")
def test_inherits_default_protocol_from_settings(self):
SettingsService().set("camera.default_protocol", "mjpeg")
cam = Camera.objects.create(name="Inherit", stream_url="https://cam/video.mjpg")
self.assertEqual(cam.resolved_protocol(), "mjpeg")
def test_monitoring_disabled(self):
SettingsService().set("camera.enabled", False)
result = self.service.resolve_stream(self.camera)
self.assertIsNone(result["stream_url"])
self.assertIn("disabled", result["error"].lower())
class ProtocolCatalogTests(TestCase):
def test_all_standard_protocols_registered(self):
for key in ("rtsp", "rtmp", "hls", "dash", "webrtc", "mjpeg", "http", "onvif", "srt"):
self.assertIn(key, PROTOCOL_KEYS)
def test_detect_from_url(self):
self.assertEqual(detect_protocol_from_url("rtsp://x/y"), "rtsp")
self.assertEqual(detect_protocol_from_url("https://x/a.m3u8"), "hls")
self.assertEqual(detect_protocol_from_url("https://x/a.mpd"), "dash")
self.assertEqual(detect_protocol_from_url("srt://1.2.3.4:9000"), "srt")
+21 -2
View File
@@ -1,6 +1,7 @@
from rest_framework import decorators, mixins, permissions, response, status, views, viewsets from rest_framework import decorators, mixins, permissions, response, status, views, viewsets
from apps.core.errors import error_response from apps.core.errors import error_response
from apps.core.settings_service import SettingsService
from apps.live_monitoring.models import Camera from apps.live_monitoring.models import Camera
from apps.live_monitoring.serializers import CameraSerializer from apps.live_monitoring.serializers import CameraSerializer
from apps.live_monitoring.services import StreamAccessService from apps.live_monitoring.services import StreamAccessService
@@ -13,8 +14,18 @@ class CameraViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.G
permission_classes = [ActionPermission] permission_classes = [ActionPermission]
code_map = {"list": "processing.view", "retrieve": "processing.view", "access": "processing.view"} code_map = {"list": "processing.view", "retrieve": "processing.view", "access": "processing.view"}
def list(self, request, *args, **kwargs):
if not SettingsService().get_bool("camera.enabled", True):
return response.Response({"count": 0, "next": None, "previous": None, "results": []})
return super().list(request, *args, **kwargs)
@decorators.action(detail=True, methods=["post"]) @decorators.action(detail=True, methods=["post"])
def access(self, request, pk=None): def access(self, request, pk=None):
if not SettingsService().get_bool("camera.enabled", True):
return response.Response(
error_response("MONITORING_DISABLED", "Live monitoring is disabled."),
status=status.HTTP_403_FORBIDDEN,
)
camera = self.get_object() camera = self.get_object()
service = StreamAccessService() service = StreamAccessService()
if not service.can_access(request.user, camera): if not service.can_access(request.user, camera):
@@ -31,8 +42,14 @@ class VerifyStreamView(views.APIView):
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
def get(self, request): def get(self, request):
if not SettingsService().get_bool("camera.enabled", True):
return response.Response(
error_response("MONITORING_DISABLED", "Live monitoring is disabled."),
status=status.HTTP_403_FORBIDDEN,
)
token = request.query_params.get("token") token = request.query_params.get("token")
payload = StreamAccessService().verify_token(token) service = StreamAccessService()
payload = service.verify_token(token)
if not payload: if not payload:
return response.Response( return response.Response(
error_response("INVALID_TOKEN", "Invalid or expired stream token."), error_response("INVALID_TOKEN", "Invalid or expired stream token."),
@@ -45,4 +62,6 @@ class VerifyStreamView(views.APIView):
error_response("CAMERA_NOT_FOUND", "Camera not found."), error_response("CAMERA_NOT_FOUND", "Camera not found."),
status=status.HTTP_404_NOT_FOUND, status=status.HTTP_404_NOT_FOUND,
) )
return response.Response({"stream_url": camera.stream_url}) stream = service.resolve_stream(camera)
# Keep ``url`` alias for older clients; ``stream_url`` is canonical.
return response.Response({**stream, "url": stream.get("stream_url")})