Skip to content

Commit 19009ab

Browse files
evancclaude
andcommitted
Add v4 media endpoint support with AI video descriptions
Use the v4 media endpoint (POST /api/v4/accounts/{id}/media) in check_new_videos() to fetch AI video descriptions (Smart Video Descriptions) and top-level CV detection labels from Blink's API. The v4 endpoint returns richer data than v1, including: - ai_vd: AI-generated video descriptions (full and short) - cv_detection: computer vision detection labels (e.g. person, vehicle) New camera attributes: ai_description, ai_description_short, cv_detection. Also fixes clip URL handling for absolute URLs returned by v4. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6ef1b53 commit 19009ab

5 files changed

Lines changed: 340 additions & 8 deletions

File tree

blinkpy/api.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,26 @@ async def request_videos(blink, time=None, page=0):
380380
return await http_get(blink, url)
381381

382382

383+
async def request_videos_v4(blink, page_key=None):
384+
"""
385+
Fetch media via the v4 endpoint which includes AI descriptions.
386+
387+
Uses POST /api/v4/accounts/{id}/media. Returns richer data than the v1
388+
endpoint, including ai_vd (AI video descriptions) and top-level
389+
cv_detection fields.
390+
391+
:param blink: Blink instance.
392+
:param page_key: Optional pagination key from previous response.
393+
"""
394+
url = f"{blink.urls.base_url}/api/v4/accounts/{blink.account_id}/media"
395+
if page_key:
396+
query = urlencode({"pagination_key": page_key})
397+
url = f"{url}?{query}"
398+
399+
body = dumps({"filters": {}})
400+
return await http_post(blink, url, data=body)
401+
402+
383403
async def request_cameras(blink, network):
384404
"""
385405
Request all camera information.

blinkpy/camera.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ def __init__(self, sync):
4343
self.motion_detected = None
4444
self.wifi_strength = None
4545
self.last_record = None
46+
self.ai_description = None
47+
self.ai_description_short = None
48+
self.cv_detection = None
4649
self._cached_image = None
4750
self._cached_video = None
4851
self.camera_type = ""
@@ -74,6 +77,9 @@ def attributes(self):
7477
"sync_signal_strength": self.sync_signal_strength,
7578
"last_record": self.last_record,
7679
"type": self.product_type,
80+
"ai_description": self.ai_description,
81+
"ai_description_short": self.ai_description_short,
82+
"cv_detection": self.cv_detection,
7783
}
7884
return attributes
7985

@@ -341,10 +347,22 @@ def timesort(record):
341347
last_records = sorted(self.sync.last_records[self.name], key=timesort)
342348
for rec in last_records:
343349
clip_addr = rec["clip"]
344-
self.clip = f"{self.sync.urls.base_url}{clip_addr}"
350+
if clip_addr and clip_addr.startswith("http"):
351+
self.clip = clip_addr
352+
else:
353+
self.clip = f"{self.sync.urls.base_url}{clip_addr}"
345354
self.last_record = rec["time"]
355+
# Update AI description and CV detection from latest record
356+
self.ai_description = rec.get("ai_description")
357+
self.ai_description_short = rec.get("ai_description_short")
358+
self.cv_detection = rec.get("cv_detection")
346359
if self.motion_detected:
347-
recent = {"time": self.last_record, "clip": self.clip}
360+
recent = {
361+
"time": self.last_record,
362+
"clip": self.clip,
363+
"ai_description": self.ai_description,
364+
"cv_detection": self.cv_detection,
365+
}
348366
# Prevent duplicates.
349367
if recent not in self.recent_clips:
350368
self.recent_clips.append(recent)

blinkpy/sync_module.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ async def check_new_videos(self):
311311
_LOGGER.info("No new videos since last refresh.")
312312
return False
313313

314-
resp = await api.request_videos(self.blink, time=interval, page=1)
314+
resp = await api.request_videos_v4(self.blink)
315315

316316
last_record = {}
317317
for camera in self.cameras:
@@ -331,14 +331,36 @@ async def check_new_videos(self):
331331
_LOGGER.warning("Could not check for motion. Response: %s", resp)
332332
return False
333333

334+
# The v4 endpoint returns all recent media (no server-side time
335+
# filter), so we use `interval` as the reference time to filter
336+
# entries client-side.
337+
interval_iso = datetime.datetime.fromtimestamp(
338+
interval, tz=datetime.timezone.utc
339+
).isoformat()
340+
334341
for entry in info:
335342
try:
336343
name = entry["device_name"]
337344
clip_url = entry["media"]
338345
timestamp = entry["created_at"]
339-
if self.check_new_video_time(timestamp):
346+
347+
if self.check_new_video_time(timestamp, reference=interval_iso):
340348
self.motion[name] = True and self.arm
341349
record = {"clip": clip_url, "time": timestamp}
350+
351+
# Extract AI video description (from v4 endpoint)
352+
ai_vd = entry.get("ai_vd")
353+
if ai_vd and isinstance(ai_vd, dict):
354+
if ai_vd.get("full_description"):
355+
record["ai_description"] = ai_vd["full_description"]
356+
if ai_vd.get("short_description"):
357+
record["ai_description_short"] = ai_vd["short_description"]
358+
359+
# Extract CV detection labels
360+
cv = entry.get("cv_detection")
361+
if cv:
362+
record["cv_detection"] = cv
363+
342364
self.last_records[name].append(record)
343365
except KeyError:
344366
last_refresh = datetime.datetime.fromtimestamp(self.blink.last_refresh)

tests/test_ai_descriptions.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""Tests for AI video descriptions and v4 media endpoint support."""
2+
3+
from unittest import mock
4+
from unittest import IsolatedAsyncioTestCase
5+
from blinkpy import api
6+
from blinkpy.blinkpy import Blink
7+
from blinkpy.helpers.util import BlinkURLHandler
8+
from blinkpy.sync_module import BlinkSyncModule
9+
from blinkpy.camera import BlinkCamera
10+
11+
# Sample v4 media entry with AI description and CV detection
12+
V4_ENTRY_WITH_AI = {
13+
"device_name": "Front Door",
14+
"media": "/api/v4/accounts/1234/media/999/video/contents",
15+
"created_at": "1990-01-01T00:00:00+00:00",
16+
"ai_vd": {
17+
"full_description": "A person is walking on the driveway.",
18+
"short_description": "Person on driveway.",
19+
},
20+
"cv_detection": ["person"],
21+
}
22+
23+
# Sample v4 media entry without AI description (SVD not enabled)
24+
V4_ENTRY_NO_AI = {
25+
"device_name": "Front Door",
26+
"media": "/api/v4/accounts/1234/media/888/video/contents",
27+
"created_at": "1990-01-01T00:00:00+00:00",
28+
"ai_vd": None,
29+
"cv_detection": None,
30+
}
31+
32+
# Sample v4 entry with empty ai_vd object
33+
V4_ENTRY_EMPTY_AI = {
34+
"device_name": "Front Door",
35+
"media": "/api/v4/accounts/1234/media/777/video/contents",
36+
"created_at": "1990-01-01T00:00:00+00:00",
37+
"ai_vd": {},
38+
"cv_detection": ["vehicle"],
39+
}
40+
41+
# Full v4 response
42+
V4_RESPONSE = {
43+
"media": [V4_ENTRY_WITH_AI],
44+
"moment_gap_time": 25,
45+
"page_size": 200,
46+
"pagination_key": None,
47+
"smart_video_descriptions": True,
48+
}
49+
50+
# Camera config used by TestCameraAIAttributes
51+
CAMERA_CFG = {
52+
"name": "foobar",
53+
"id": 1234,
54+
"network_id": 5678,
55+
"serial": "12345678",
56+
"enabled": False,
57+
"battery_state": "ok",
58+
"battery_voltage": 163,
59+
"wifi_strength": -38,
60+
"signals": {"lfr": 5, "wifi": 4, "battery": 3, "temp": 68},
61+
"thumbnail": "/thumb",
62+
}
63+
64+
65+
@mock.patch("blinkpy.auth.Auth.query")
66+
class TestV4MediaEndpoint(IsolatedAsyncioTestCase):
67+
"""Test the v4 media endpoint API function."""
68+
69+
async def asyncSetUp(self):
70+
"""Set up Blink module."""
71+
self.blink = Blink(session=mock.AsyncMock())
72+
self.blink.urls = BlinkURLHandler("test")
73+
self.blink.auth.account_id = 1234
74+
75+
def tearDown(self):
76+
"""Clean up after test."""
77+
self.blink = None
78+
79+
async def test_request_videos_v4(self, mock_resp):
80+
"""Test v4 media endpoint returns expected data."""
81+
mock_resp.return_value = V4_RESPONSE
82+
result = await api.request_videos_v4(self.blink)
83+
self.assertIn("media", result)
84+
self.assertEqual(len(result["media"]), 1)
85+
self.assertIn("ai_vd", result["media"][0])
86+
87+
async def test_request_videos_v4_empty_response(self, mock_resp):
88+
"""Test v4 endpoint with empty response."""
89+
mock_resp.return_value = None
90+
result = await api.request_videos_v4(self.blink)
91+
self.assertIsNone(result)
92+
93+
async def test_request_videos_v4_no_media(self, mock_resp):
94+
"""Test v4 endpoint with response missing media key."""
95+
mock_resp.return_value = {"error": "something went wrong"}
96+
result = await api.request_videos_v4(self.blink)
97+
self.assertNotIn("media", result)
98+
99+
100+
@mock.patch("blinkpy.auth.Auth.query")
101+
class TestAIDescriptionExtraction(IsolatedAsyncioTestCase):
102+
"""Test AI description extraction in check_new_videos."""
103+
104+
def setUp(self):
105+
"""Set up Blink module."""
106+
self.blink = Blink(motion_interval=0, session=mock.AsyncMock())
107+
self.blink.last_refresh = 1000
108+
self.blink.urls = BlinkURLHandler("test")
109+
self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", "1234", [])
110+
self.blink.sync["test"].network_info = {"network": {"armed": True}}
111+
112+
def tearDown(self):
113+
"""Clean up after test."""
114+
self.blink = None
115+
116+
async def test_v4_ai_description_extracted(self, mock_resp):
117+
"""Test that ai_vd is extracted from v4 media entries."""
118+
mock_resp.return_value = {"media": [V4_ENTRY_WITH_AI]}
119+
sync_module = self.blink.sync["test"]
120+
sync_module.cameras = {"Front Door": None}
121+
self.assertTrue(await sync_module.check_new_videos())
122+
records = sync_module.last_records["Front Door"]
123+
self.assertEqual(len(records), 1)
124+
self.assertEqual(
125+
records[0]["ai_description"],
126+
"A person is walking on the driveway.",
127+
)
128+
self.assertEqual(records[0]["ai_description_short"], "Person on driveway.")
129+
self.assertEqual(records[0]["cv_detection"], ["person"])
130+
131+
async def test_v4_no_ai_description(self, mock_resp):
132+
"""Test graceful handling when ai_vd is None."""
133+
mock_resp.return_value = {"media": [V4_ENTRY_NO_AI]}
134+
sync_module = self.blink.sync["test"]
135+
sync_module.cameras = {"Front Door": None}
136+
self.assertTrue(await sync_module.check_new_videos())
137+
records = sync_module.last_records["Front Door"]
138+
self.assertEqual(len(records), 1)
139+
# No ai_description keys should be present
140+
self.assertNotIn("ai_description", records[0])
141+
self.assertNotIn("ai_description_short", records[0])
142+
143+
async def test_v4_empty_ai_vd_object(self, mock_resp):
144+
"""Test graceful handling when ai_vd is an empty dict."""
145+
mock_resp.return_value = {"media": [V4_ENTRY_EMPTY_AI]}
146+
sync_module = self.blink.sync["test"]
147+
sync_module.cameras = {"Front Door": None}
148+
self.assertTrue(await sync_module.check_new_videos())
149+
records = sync_module.last_records["Front Door"]
150+
self.assertEqual(len(records), 1)
151+
# Empty dict should not produce ai_description keys
152+
self.assertNotIn("ai_description", records[0])
153+
# But cv_detection should still be extracted
154+
self.assertEqual(records[0]["cv_detection"], ["vehicle"])
155+
156+
157+
@mock.patch("blinkpy.auth.Auth.query", return_value={})
158+
class TestCameraAIAttributes(IsolatedAsyncioTestCase):
159+
"""Test AI description attributes on camera objects."""
160+
161+
def setUp(self):
162+
"""Set up Blink module."""
163+
self.blink = Blink(session=mock.AsyncMock())
164+
self.blink.urls = BlinkURLHandler("test")
165+
self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", 1234, [])
166+
self.camera = BlinkCamera(self.blink.sync["test"])
167+
self.camera.name = "foobar"
168+
self.blink.sync["test"].cameras["foobar"] = self.camera
169+
170+
def tearDown(self):
171+
"""Clean up after test."""
172+
self.blink = None
173+
self.camera = None
174+
175+
async def test_camera_attributes_include_ai_fields(self, mock_resp):
176+
"""Test that camera attributes include AI description fields."""
177+
attrs = self.camera.attributes
178+
self.assertIn("ai_description", attrs)
179+
self.assertIn("ai_description_short", attrs)
180+
self.assertIn("cv_detection", attrs)
181+
# Initially None
182+
self.assertIsNone(attrs["ai_description"])
183+
self.assertIsNone(attrs["ai_description_short"])
184+
self.assertIsNone(attrs["cv_detection"])
185+
186+
async def test_camera_ai_description_from_records(self, mock_resp):
187+
"""Test that camera picks up AI description from last records."""
188+
self.camera.sync.last_records["foobar"] = [
189+
{
190+
"clip": "/clip.mp4",
191+
"time": "2024-01-01T00:00:00+00:00",
192+
"ai_description": "A cat sitting on the porch.",
193+
"ai_description_short": "Cat on porch.",
194+
"cv_detection": ["animal"],
195+
}
196+
]
197+
self.camera.sync.motion["foobar"] = True
198+
await self.camera.update(CAMERA_CFG, force_cache=False)
199+
self.assertEqual(self.camera.ai_description, "A cat sitting on the porch.")
200+
self.assertEqual(self.camera.ai_description_short, "Cat on porch.")
201+
self.assertEqual(self.camera.cv_detection, ["animal"])
202+
203+
async def test_camera_no_ai_description_in_records(self, mock_resp):
204+
"""Test camera handles records without AI description gracefully."""
205+
self.camera.sync.last_records["foobar"] = [
206+
{
207+
"clip": "/clip.mp4",
208+
"time": "2024-01-01T00:00:00+00:00",
209+
}
210+
]
211+
self.camera.sync.motion["foobar"] = True
212+
await self.camera.update(CAMERA_CFG, force_cache=False)
213+
# Should be None since record didn't have these keys
214+
self.assertIsNone(self.camera.ai_description)
215+
self.assertIsNone(self.camera.ai_description_short)
216+
self.assertIsNone(self.camera.cv_detection)
217+
218+
async def test_camera_recent_clips_include_ai_fields(self, mock_resp):
219+
"""Test that recent_clips entries include AI description fields."""
220+
self.camera.sync.last_records["foobar"] = [
221+
{
222+
"clip": "/clip.mp4",
223+
"time": "2024-01-01T00:00:00+00:00",
224+
"ai_description": "A delivery person at the door.",
225+
"cv_detection": ["person"],
226+
}
227+
]
228+
self.camera.sync.motion["foobar"] = True
229+
await self.camera.update_images(CAMERA_CFG, expire_clips=False)
230+
self.assertEqual(len(self.camera.recent_clips), 1)
231+
clip = self.camera.recent_clips[0]
232+
self.assertEqual(clip["ai_description"], "A delivery person at the door.")
233+
self.assertEqual(clip["cv_detection"], ["person"])
234+
235+
async def test_clip_url_absolute_not_doubled(self, mock_resp):
236+
"""Test that absolute clip URLs from v4 are not doubled with base_url."""
237+
absolute_url = "https://rest-u009.immedia-semi.com/api/v4/media/123/video"
238+
self.camera.sync.last_records["foobar"] = [
239+
{
240+
"clip": absolute_url,
241+
"time": "2024-01-01T00:00:00+00:00",
242+
}
243+
]
244+
self.camera.sync.motion["foobar"] = False
245+
await self.camera.update(CAMERA_CFG, force_cache=False)
246+
# URL should NOT be doubled (no base_url prepended)
247+
self.assertEqual(self.camera.clip, absolute_url)
248+
249+
async def test_clip_url_relative_gets_base_url(self, mock_resp):
250+
"""Test that relative clip URLs still get base_url prepended."""
251+
self.camera.sync.last_records["foobar"] = [
252+
{
253+
"clip": "/api/v1/media/123.mp4",
254+
"time": "2024-01-01T00:00:00+00:00",
255+
}
256+
]
257+
self.camera.sync.motion["foobar"] = False
258+
await self.camera.update(CAMERA_CFG, force_cache=False)
259+
self.assertEqual(
260+
self.camera.clip,
261+
f"{self.blink.urls.base_url}/api/v1/media/123.mp4",
262+
)

0 commit comments

Comments
 (0)