detstream 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. detstream-0.1.0/LICENSE +21 -0
  2. detstream-0.1.0/PKG-INFO +133 -0
  3. detstream-0.1.0/README.md +99 -0
  4. detstream-0.1.0/detstream/__init__.py +6 -0
  5. detstream-0.1.0/detstream/__main__.py +31 -0
  6. detstream-0.1.0/detstream/annotate.py +26 -0
  7. detstream-0.1.0/detstream/config.py +86 -0
  8. detstream-0.1.0/detstream/detectors/__init__.py +26 -0
  9. detstream-0.1.0/detstream/detectors/yolo_world.py +37 -0
  10. detstream-0.1.0/detstream/events.py +18 -0
  11. detstream-0.1.0/detstream/registry.py +53 -0
  12. detstream-0.1.0/detstream/runner.py +118 -0
  13. detstream-0.1.0/detstream/sinks/__init__.py +25 -0
  14. detstream-0.1.0/detstream/sinks/console.py +19 -0
  15. detstream-0.1.0/detstream/sinks/discord.py +40 -0
  16. detstream-0.1.0/detstream/sinks/supabase.py +124 -0
  17. detstream-0.1.0/detstream/sources/__init__.py +73 -0
  18. detstream-0.1.0/detstream/sources/file_device.py +38 -0
  19. detstream-0.1.0/detstream/sources/stream.py +23 -0
  20. detstream-0.1.0/detstream/sources/youtube.py +40 -0
  21. detstream-0.1.0/detstream/state.py +59 -0
  22. detstream-0.1.0/detstream.egg-info/PKG-INFO +133 -0
  23. detstream-0.1.0/detstream.egg-info/SOURCES.txt +34 -0
  24. detstream-0.1.0/detstream.egg-info/dependency_links.txt +1 -0
  25. detstream-0.1.0/detstream.egg-info/entry_points.txt +2 -0
  26. detstream-0.1.0/detstream.egg-info/requires.txt +19 -0
  27. detstream-0.1.0/detstream.egg-info/top_level.txt +1 -0
  28. detstream-0.1.0/pyproject.toml +62 -0
  29. detstream-0.1.0/setup.cfg +4 -0
  30. detstream-0.1.0/tests/test_config.py +119 -0
  31. detstream-0.1.0/tests/test_detectors.py +104 -0
  32. detstream-0.1.0/tests/test_registry.py +52 -0
  33. detstream-0.1.0/tests/test_runner.py +76 -0
  34. detstream-0.1.0/tests/test_sinks.py +130 -0
  35. detstream-0.1.0/tests/test_sources.py +60 -0
  36. detstream-0.1.0/tests/test_state.py +118 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 catherinepereira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: detstream
3
+ Version: 0.1.0
4
+ Summary: Modular object detection for live video feeds
5
+ Author: catherinepereira
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/catherinepereira/detstream
8
+ Project-URL: Repository, https://github.com/catherinepereira/detstream
9
+ Keywords: object-detection,video,streaming,yolo,computer-vision
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
14
+ Classifier: Topic :: Multimedia :: Video
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: numpy>=1.26.0
19
+ Requires-Dist: opencv-python-headless>=4.9.0
20
+ Requires-Dist: pydantic>=2.6.0
21
+ Requires-Dist: pyyaml>=6.0
22
+ Requires-Dist: httpx>=0.27.0
23
+ Provides-Extra: yolo
24
+ Requires-Dist: ultralytics>=8.3.0; extra == "yolo"
25
+ Requires-Dist: torch>=2.2.0; extra == "yolo"
26
+ Provides-Extra: youtube
27
+ Requires-Dist: yt-dlp>=2026.03.17; extra == "youtube"
28
+ Requires-Dist: deno>=2.8.0; extra == "youtube"
29
+ Provides-Extra: supabase
30
+ Requires-Dist: supabase>=2.4.0; extra == "supabase"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # detstream
36
+
37
+ Modular object detection framework for live video feeds.
38
+
39
+ ## Pipeline
40
+
41
+ 1. **Source**: Where frames come from. `youtube`, `stream` (RTSP/HLS/HTTP), `file` (a video
42
+ file or webcam device)
43
+ 2. **Detector**: What to look for. `yolo-world`, an open-vocabulary model prompted with a
44
+ word or phrase (`person`, `forklift`, `bird`). Bring your own by registering one (see Plugins).
45
+ 3. **Tracker**: When a detection counts as a sighting. Hysteresis and cooldown give you one
46
+ alert per sighting instead of one per frame, which is what keeps alerts from becoming spam.
47
+ 4. **Sinks**: Where alerts go. `console`, `supabase` (rows + thumbnails for a website),
48
+ `discord` (rich embeds).
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install detstream # core only
54
+ pip install "detstream[yolo,youtube,supabase]" # for example, everything otterwatch uses
55
+ ```
56
+
57
+ The core install pulls what every feed needs: `numpy`, `opencv-python-headless`
58
+ (decoding and annotation), `pydantic` (config), `pyyaml`, and `httpx`.
59
+
60
+ | Extra | Enables | Pulls in |
61
+ | --- | --- | --- |
62
+ | `yolo` | the `yolo-world` detector | `ultralytics>=8.3.0`, `torch>=2.2.0`. ultralytics fetches CLIP on first use of the detector |
63
+ | `youtube` | the `youtube` source | `yt-dlp>=2026.03.17` and `deno>=2.8.0`, which ships the deno binary yt-dlp runs to solve YouTube's stream challenge |
64
+ | `supabase` | the `supabase` sink | `supabase>=2.4.0` |
65
+
66
+ ## Run
67
+
68
+ ```bash
69
+ detstream --config examples/otters.yaml
70
+ ```
71
+
72
+ A config lists feeds and shared sink settings:
73
+
74
+ ```yaml
75
+ feeds:
76
+ - id: monterey-otters
77
+ name: Monterey Sea Otters
78
+ source: { type: youtube, url: "https://www.youtube.com/watch?v=abbR-Ttd-cA" }
79
+ detector: { type: yolo-world, prompt: otter swimming, confidence_threshold: 0.4 }
80
+ debounce: { enter_frames: 3, exit_frames: 5, cooldown_s: 120, sample_interval_s: 2 }
81
+ sinks: [console, supabase]
82
+ sinks:
83
+ supabase: { bucket: thumbnails, detector_label: yolo-world, retention_hours: 3, thumbnail_width: 960 }
84
+ ```
85
+
86
+ Credentials and webhook URLs are configured in `.env`: `DETSTREAM_SUPABASE_URL`, `DETSTREAM_SUPABASE_KEY`, and `DETSTREAM_DISCORD_WEBHOOK_URL`.
87
+
88
+ ## Plugins
89
+
90
+ Built-in components register themselves on import. To add your own detector (an HF model, a
91
+ cloud API, a fine-tuned ONNX, etc.), register a factory and declare an entry point:
92
+
93
+ ```python
94
+ # mypkg/detector.py
95
+ from detstream.detectors import detectors, Detection
96
+
97
+ class MyDetector:
98
+ def detect(self, frame) -> Detection: ...
99
+
100
+ @detectors.register("my-model")
101
+ def _build(config: dict) -> MyDetector:
102
+ return MyDetector(**config)
103
+ ```
104
+
105
+ ```toml
106
+ # mypkg/pyproject.toml
107
+ [project.entry-points."detstream.detectors"]
108
+ my-model = "mypkg.detector"
109
+ ```
110
+
111
+ After `pip install`, reference it in config as `detector: { type: my-model, ... }`. detstream
112
+ discovers it through the entry point with no change to detstream itself. The same pattern works
113
+ for `detstream.sources` and `detstream.sinks`.
114
+
115
+ ## Layout
116
+
117
+ ```
118
+ detstream/
119
+ registry.py register + create + entry-point discovery
120
+ config.py FeedConfig / AppConfig, loads YAML
121
+ runner.py per-feed asyncio loop
122
+ state.py SightingTracker: hysteresis + cooldown, no I/O
123
+ events.py SightingStarted / SightingEnded
124
+ sources/ youtube, stream, file_device (+ shared reconnect base)
125
+ detectors/ yolo_world
126
+ sinks/ console, supabase, discord
127
+ examples/ otters.yaml, eagles.yaml
128
+ tests/ config, registry, sources, state, sinks, detectors
129
+ ```
130
+
131
+ ## License
132
+
133
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,99 @@
1
+ # detstream
2
+
3
+ Modular object detection framework for live video feeds.
4
+
5
+ ## Pipeline
6
+
7
+ 1. **Source**: Where frames come from. `youtube`, `stream` (RTSP/HLS/HTTP), `file` (a video
8
+ file or webcam device)
9
+ 2. **Detector**: What to look for. `yolo-world`, an open-vocabulary model prompted with a
10
+ word or phrase (`person`, `forklift`, `bird`). Bring your own by registering one (see Plugins).
11
+ 3. **Tracker**: When a detection counts as a sighting. Hysteresis and cooldown give you one
12
+ alert per sighting instead of one per frame, which is what keeps alerts from becoming spam.
13
+ 4. **Sinks**: Where alerts go. `console`, `supabase` (rows + thumbnails for a website),
14
+ `discord` (rich embeds).
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install detstream # core only
20
+ pip install "detstream[yolo,youtube,supabase]" # for example, everything otterwatch uses
21
+ ```
22
+
23
+ The core install pulls what every feed needs: `numpy`, `opencv-python-headless`
24
+ (decoding and annotation), `pydantic` (config), `pyyaml`, and `httpx`.
25
+
26
+ | Extra | Enables | Pulls in |
27
+ | --- | --- | --- |
28
+ | `yolo` | the `yolo-world` detector | `ultralytics>=8.3.0`, `torch>=2.2.0`. ultralytics fetches CLIP on first use of the detector |
29
+ | `youtube` | the `youtube` source | `yt-dlp>=2026.03.17` and `deno>=2.8.0`, which ships the deno binary yt-dlp runs to solve YouTube's stream challenge |
30
+ | `supabase` | the `supabase` sink | `supabase>=2.4.0` |
31
+
32
+ ## Run
33
+
34
+ ```bash
35
+ detstream --config examples/otters.yaml
36
+ ```
37
+
38
+ A config lists feeds and shared sink settings:
39
+
40
+ ```yaml
41
+ feeds:
42
+ - id: monterey-otters
43
+ name: Monterey Sea Otters
44
+ source: { type: youtube, url: "https://www.youtube.com/watch?v=abbR-Ttd-cA" }
45
+ detector: { type: yolo-world, prompt: otter swimming, confidence_threshold: 0.4 }
46
+ debounce: { enter_frames: 3, exit_frames: 5, cooldown_s: 120, sample_interval_s: 2 }
47
+ sinks: [console, supabase]
48
+ sinks:
49
+ supabase: { bucket: thumbnails, detector_label: yolo-world, retention_hours: 3, thumbnail_width: 960 }
50
+ ```
51
+
52
+ Credentials and webhook URLs are configured in `.env`: `DETSTREAM_SUPABASE_URL`, `DETSTREAM_SUPABASE_KEY`, and `DETSTREAM_DISCORD_WEBHOOK_URL`.
53
+
54
+ ## Plugins
55
+
56
+ Built-in components register themselves on import. To add your own detector (an HF model, a
57
+ cloud API, a fine-tuned ONNX, etc.), register a factory and declare an entry point:
58
+
59
+ ```python
60
+ # mypkg/detector.py
61
+ from detstream.detectors import detectors, Detection
62
+
63
+ class MyDetector:
64
+ def detect(self, frame) -> Detection: ...
65
+
66
+ @detectors.register("my-model")
67
+ def _build(config: dict) -> MyDetector:
68
+ return MyDetector(**config)
69
+ ```
70
+
71
+ ```toml
72
+ # mypkg/pyproject.toml
73
+ [project.entry-points."detstream.detectors"]
74
+ my-model = "mypkg.detector"
75
+ ```
76
+
77
+ After `pip install`, reference it in config as `detector: { type: my-model, ... }`. detstream
78
+ discovers it through the entry point with no change to detstream itself. The same pattern works
79
+ for `detstream.sources` and `detstream.sinks`.
80
+
81
+ ## Layout
82
+
83
+ ```
84
+ detstream/
85
+ registry.py register + create + entry-point discovery
86
+ config.py FeedConfig / AppConfig, loads YAML
87
+ runner.py per-feed asyncio loop
88
+ state.py SightingTracker: hysteresis + cooldown, no I/O
89
+ events.py SightingStarted / SightingEnded
90
+ sources/ youtube, stream, file_device (+ shared reconnect base)
91
+ detectors/ yolo_world
92
+ sinks/ console, supabase, discord
93
+ examples/ otters.yaml, eagles.yaml
94
+ tests/ config, registry, sources, state, sinks, detectors
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("detstream")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import asyncio
4
+ import logging
5
+ import sys
6
+ from .config import load_config
7
+ from .runner import run
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser(prog="detstream")
12
+ parser.add_argument("--config", required=True, help="path to a YAML/JSON feed config")
13
+ args = parser.parse_args()
14
+
15
+ # Force line buffering so log lines appear as they happen
16
+ sys.stdout.reconfigure(line_buffering=True)
17
+ sys.stderr.reconfigure(line_buffering=True)
18
+ logging.basicConfig(
19
+ level=logging.INFO,
20
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
21
+ )
22
+
23
+ app = load_config(args.config)
24
+ try:
25
+ asyncio.run(run(app))
26
+ except KeyboardInterrupt:
27
+ pass
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+ import cv2
3
+ import numpy as np
4
+ from .detectors import Detection
5
+
6
+ BOX_COLOR = (208, 147, 0) # BGR (a mid blue)
7
+ BOX_THICKNESS = 3
8
+
9
+
10
+ # Draw the detection box and a confidence label onto a copy of the frame. Returns the
11
+ # frame unchanged when there is no box, so it is safe to call on every sampled frame
12
+ def annotate(frame: np.ndarray, detection: Detection, label: str = "") -> np.ndarray:
13
+ if detection.box is None:
14
+ return frame
15
+ out = frame.copy()
16
+ x1, y1, x2, y2 = (int(v) for v in detection.box)
17
+ cv2.rectangle(out, (x1, y1), (x2, y2), BOX_COLOR, BOX_THICKNESS)
18
+
19
+ text = f"{label} {detection.confidence:.0%}".strip()
20
+ (tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
21
+ ty = max(y1, th + 6)
22
+ cv2.rectangle(out, (x1, ty - th - 6), (x1 + tw + 6, ty), BOX_COLOR, -1)
23
+ cv2.putText(
24
+ out, text, (x1 + 3, ty - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2
25
+ )
26
+ return out
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+ import os
3
+ import re
4
+ from pathlib import Path
5
+ import yaml
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+ _ENV_REF = re.compile(r"\$\{(\w+)\}")
9
+
10
+
11
+ class ComponentConfig(BaseModel):
12
+ type: str
13
+ # Everything else is the component's own config sub-block, passed through to its
14
+ # factory. Sources/detectors vary by type, so the schema is intentionally open
15
+ model_config = {"extra": "allow"}
16
+
17
+ def options(self) -> dict:
18
+ return self.model_dump(exclude={"type"})
19
+
20
+
21
+ class DebounceConfig(BaseModel):
22
+ sample_interval_s: float = 2.0
23
+ enter_frames: int = 3
24
+ exit_frames: int = 5
25
+ cooldown_s: float = 120.0
26
+
27
+
28
+ class FeedConfig(BaseModel):
29
+ id: str
30
+ name: str
31
+ source: ComponentConfig
32
+ detector: ComponentConfig
33
+ debounce: DebounceConfig = Field(default_factory=DebounceConfig)
34
+ sinks: list[str] = Field(default_factory=lambda: ["console"])
35
+
36
+
37
+ class AppConfig(BaseModel):
38
+ feeds: list[FeedConfig]
39
+ # Shared sink settings keyed by sink name, e.g. {"supabase": {...}, "discord": {...}}.
40
+ # A feed enables a sink by listing its name, the settings come from here
41
+ sinks: dict[str, dict] = Field(default_factory=dict)
42
+
43
+ # A `sinks:` block with everything commented out parses as None
44
+ @field_validator("sinks", mode="before")
45
+ @classmethod
46
+ def _none_sinks_to_empty(cls, v):
47
+ return v or {}
48
+
49
+
50
+ def _expand_env(value):
51
+ if isinstance(value, str):
52
+ return _ENV_REF.sub(lambda m: os.environ.get(m.group(1), ""), value)
53
+ if isinstance(value, dict):
54
+ return {k: _expand_env(v) for k, v in value.items()}
55
+ if isinstance(value, list):
56
+ return [_expand_env(v) for v in value]
57
+ return value
58
+
59
+
60
+ def _load_dotenv(path: Path) -> None:
61
+ env_file = path.parent / ".env"
62
+ if not env_file.exists():
63
+ return
64
+ for line in env_file.read_text().splitlines():
65
+ line = line.strip()
66
+ if not line or line.startswith("#") or "=" not in line:
67
+ continue
68
+ key, _, value = line.partition("=")
69
+ key = key.removeprefix("export ").strip()
70
+ value = value.strip()
71
+ if value and value[0] in "'\"":
72
+ # Quoted value: everything up to the matching quote is literal
73
+ quote = value[0]
74
+ end = value.find(quote, 1)
75
+ value = value[1:end] if end != -1 else value[1:]
76
+ else:
77
+ # Unquoted: an inline ` # comment` is not part of the value
78
+ value = value.split(" #", 1)[0].strip()
79
+ os.environ.setdefault(key, value)
80
+
81
+
82
+ def load_config(path: str | Path) -> AppConfig:
83
+ path = Path(path)
84
+ _load_dotenv(path)
85
+ data = _expand_env(yaml.safe_load(path.read_text()))
86
+ return AppConfig(**data)
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import Protocol
4
+ import numpy as np
5
+ from ..registry import Registry
6
+
7
+
8
+ @dataclass
9
+ class Detection:
10
+ present: bool
11
+ confidence: float
12
+ box: tuple[float, float, float, float] | None = None
13
+
14
+
15
+ class Detector(Protocol):
16
+ def detect(self, frame: np.ndarray) -> Detection: ...
17
+
18
+
19
+ detectors: Registry[Detector] = Registry("detstream.detectors")
20
+
21
+ # Import built-ins so they self-register
22
+ # A deployment that did not install the yolo extra can still use a plugin detector without importing ultralytics
23
+ try:
24
+ from . import yolo_world # noqa: F401
25
+ except ImportError:
26
+ pass
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+ import numpy as np
3
+ from . import Detection, detectors
4
+
5
+
6
+ # Open-vocabulary detector prompted with a class name
7
+ class YoloWorldDetector:
8
+ def __init__(self, prompt: str, confidence_threshold: float, weights: str = ""):
9
+ from ultralytics import YOLO
10
+
11
+ self.prompt = prompt
12
+ self.threshold = confidence_threshold
13
+ self.model = YOLO(weights or "yolov8s-world.pt")
14
+ self.model.set_classes([prompt])
15
+
16
+ def detect(self, frame: np.ndarray) -> Detection:
17
+ results = self.model.predict(frame, conf=0.01, verbose=False)
18
+ best = 0.0
19
+ box = None
20
+ for r in results:
21
+ if r.boxes is None or len(r.boxes) == 0:
22
+ continue
23
+ top = int(r.boxes.conf.argmax())
24
+ conf = float(r.boxes.conf[top])
25
+ if conf > best:
26
+ best = conf
27
+ box = tuple(r.boxes.xyxy[top].cpu().tolist())
28
+ return Detection(present=best >= self.threshold, confidence=best, box=box)
29
+
30
+
31
+ @detectors.register("yolo-world")
32
+ def _build(config: dict) -> YoloWorldDetector:
33
+ return YoloWorldDetector(
34
+ prompt=config.get("prompt", "object"),
35
+ confidence_threshold=config.get("confidence_threshold", 0.4),
36
+ weights=config.get("weights", ""),
37
+ )
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ import numpy as np
4
+
5
+
6
+ @dataclass
7
+ class SightingStarted:
8
+ feed_id: str
9
+ confidence: float
10
+
11
+
12
+ @dataclass
13
+ class SightingEnded:
14
+ feed_id: str
15
+ peak_confidence: float
16
+ # The peak-confidence frame, annotated, captured over the sighting's life. The
17
+ # thumbnail is uploaded from this so it matches the stored peak confidence
18
+ peak_frame: np.ndarray | None = None
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ from importlib.metadata import entry_points
4
+ from typing import Callable, Generic, TypeVar
5
+
6
+ log = logging.getLogger(__name__)
7
+
8
+ T = TypeVar("T")
9
+
10
+ # A factory takes the component's config sub-block (a plain dict) and returns an
11
+ # instance. Built-ins register a factory under a name, config selects by that name
12
+ Factory = Callable[[dict], T]
13
+
14
+
15
+ class Registry(Generic[T]):
16
+ def __init__(self, entry_point_group: str):
17
+ self._factories: dict[str, Factory[T]] = {}
18
+ self._group = entry_point_group
19
+ self._loaded_entry_points = False
20
+
21
+ def register(self, name: str) -> Callable[[Factory[T]], Factory[T]]:
22
+ def decorator(factory: Factory[T]) -> Factory[T]:
23
+ if name in self._factories:
24
+ raise ValueError(f"{name!r} is already registered in {self._group}")
25
+ self._factories[name] = factory
26
+ return factory
27
+
28
+ return decorator
29
+
30
+ def create(self, name: str, config: dict) -> T:
31
+ if name not in self._factories:
32
+ self._load_entry_points()
33
+ try:
34
+ factory = self._factories[name]
35
+ except KeyError:
36
+ known = ", ".join(sorted(self._factories)) or "(none)"
37
+ raise ValueError(f"unknown {self._group} {name!r}; known: {known}") from None
38
+ return factory(config)
39
+
40
+ # Discover plugins declared by installed packages under this group. A plugin's
41
+ # entry point points at a callable that registers its factories on import, so
42
+ # loading the module is enough. The value is called if it is callable
43
+ def _load_entry_points(self) -> None:
44
+ if self._loaded_entry_points:
45
+ return
46
+ self._loaded_entry_points = True
47
+ for ep in entry_points(group=self._group):
48
+ try:
49
+ obj = ep.load()
50
+ if callable(obj):
51
+ obj()
52
+ except Exception as e:
53
+ log.warning("failed to load plugin %r in %s: %s", ep.name, self._group, e)
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+ import logging
4
+ import os
5
+ from .annotate import annotate
6
+ from .config import AppConfig, FeedConfig
7
+ from .detectors import Detector, detectors
8
+ from .events import SightingStarted
9
+ from .sinks import Sink, sinks as sink_registry
10
+ from .sources import sources as source_registry
11
+ from .state import SightingTracker
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ def build_sinks(feed: FeedConfig, app: AppConfig) -> list[Sink]:
17
+ built: list[Sink] = []
18
+ for name in feed.sinks:
19
+ config = app.sinks.get(name, {})
20
+ built.append(sink_registry.create(name, config))
21
+ return built
22
+
23
+
24
+ # Restart backoff for a feed whose watch loop crashed
25
+ FEED_RESTART_BACKOFF_S = (5.0, 15.0, 30.0, 60.0)
26
+
27
+
28
+ async def _dispatch(sinks: list[Sink], call) -> None:
29
+ # One sink failing (a network blip on the supabase POST) must not stop the others or
30
+ # kill the feed, so each call is awaited independently and its error logged
31
+ results = await asyncio.gather(*(call(s) for s in sinks), return_exceptions=True)
32
+ for sink, result in zip(sinks, results):
33
+ if isinstance(result, Exception):
34
+ log.warning("sink %s failed: %s", type(sink).__name__, result)
35
+
36
+
37
+ async def watch_feed(feed: FeedConfig, detector: Detector, sinks: list[Sink]) -> None:
38
+ tracker = SightingTracker(
39
+ feed_id=feed.id,
40
+ enter_frames=feed.debounce.enter_frames,
41
+ exit_frames=feed.debounce.exit_frames,
42
+ cooldown_s=feed.debounce.cooldown_s,
43
+ )
44
+ loop = asyncio.get_running_loop()
45
+ source = source_registry.create(
46
+ feed.source.type,
47
+ {**feed.source.options(), "interval_s": feed.debounce.sample_interval_s},
48
+ )
49
+ async for _ts, frame in source.frames():
50
+ det = await asyncio.to_thread(detector.detect, frame)
51
+ # Annotate before the tracker captures the frame, so the saved thumbnail shows
52
+ # the detection box
53
+ annotated = annotate(frame, det, feed.name) if det.present else frame
54
+ event = tracker.update(det.present, det.confidence, annotated, loop.time())
55
+ if event is None:
56
+ continue
57
+ if isinstance(event, SightingStarted):
58
+ await _dispatch(sinks, lambda s: s.on_sighting_start(event, feed.name))
59
+ else:
60
+ await _dispatch(sinks, lambda s: s.on_sighting_end(event))
61
+
62
+
63
+ async def supervise_feed(feed: FeedConfig, detector: Detector, sinks: list[Sink]) -> None:
64
+ attempt = 0
65
+ while True:
66
+ try:
67
+ await watch_feed(feed, detector, sinks)
68
+ return
69
+ except asyncio.CancelledError:
70
+ raise
71
+ except Exception as e:
72
+ backoff = FEED_RESTART_BACKOFF_S[min(attempt, len(FEED_RESTART_BACKOFF_S) - 1)]
73
+ log.exception("feed %s crashed: %s; restarting in %ss", feed.id, e, backoff)
74
+ attempt += 1
75
+ await asyncio.sleep(backoff)
76
+
77
+
78
+ CLEANUP_INTERVAL_S = float(os.environ.get("DETSTREAM_CLEANUP_INTERVAL_S", str(60 * 60)))
79
+
80
+
81
+ # Run each sink's cleanup() on an interval (hourly by default)
82
+ async def cleanup_loop(sinks: list[Sink]) -> None:
83
+ targets = [s for s in sinks if hasattr(s, "cleanup")]
84
+ if not targets:
85
+ return
86
+ while True:
87
+ await asyncio.sleep(CLEANUP_INTERVAL_S)
88
+ for sink in targets:
89
+ try:
90
+ await asyncio.to_thread(sink.cleanup)
91
+ except Exception as e:
92
+ log.warning("cleanup failed for %s: %s", type(sink).__name__, e)
93
+
94
+
95
+ async def run(app: AppConfig) -> None:
96
+ if not app.feeds:
97
+ log.error("no feeds configured")
98
+ return
99
+
100
+ tasks = []
101
+ all_sinks: list[Sink] = []
102
+ for feed in app.feeds:
103
+ detector = detectors.create(feed.detector.type, feed.detector.options())
104
+ sinks = build_sinks(feed, app)
105
+ all_sinks.extend(sinks)
106
+ log.info("watching %s with %d sink(s)", feed.id, len(sinks))
107
+ tasks.append(asyncio.create_task(supervise_feed(feed, detector, sinks)))
108
+
109
+ # Dedupe sinks of the same type so cleanup runs once
110
+ unique_sinks = list({type(s): s for s in all_sinks}.values())
111
+ tasks.append(asyncio.create_task(cleanup_loop(unique_sinks)))
112
+
113
+ try:
114
+ await asyncio.gather(*tasks)
115
+ except asyncio.CancelledError:
116
+ for t in tasks:
117
+ t.cancel()
118
+ raise
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+ from typing import Protocol
3
+ from ..events import SightingEnded, SightingStarted
4
+ from ..registry import Registry
5
+
6
+
7
+ class Sink(Protocol):
8
+ async def on_sighting_start(self, event: SightingStarted, feed_name: str) -> None: ...
9
+ async def on_sighting_end(self, event: SightingEnded) -> None: ...
10
+
11
+
12
+ sinks: Registry[Sink] = Registry("detstream.sinks")
13
+
14
+ # console has no optional deps. The others self-register but their factories import their
15
+ # client libraries lazily, so a deployment only pays for the sinks it enables
16
+ from . import console # noqa: E402,F401
17
+
18
+ try:
19
+ from . import supabase # noqa: F401
20
+ except ImportError:
21
+ pass
22
+ try:
23
+ from . import discord # noqa: F401
24
+ except ImportError:
25
+ pass