Skip to content

Commit ab0e0b8

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 ab0e0b8

5 files changed

Lines changed: 357 additions & 8 deletions

File tree

blinkpy/api.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,35 @@ 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, start_time=None, end_time=None, 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 start_time: Optional ISO format start time filter.
393+
:param end_time: Optional ISO format end time filter.
394+
:param page_key: Optional pagination key from previous response.
395+
"""
396+
url = f"{blink.urls.base_url}/api/v4/accounts/{blink.account_id}/media"
397+
params = {}
398+
if start_time:
399+
params["start_time"] = start_time
400+
if end_time:
401+
params["end_time"] = end_time
402+
if page_key:
403+
params["pagination_key"] = page_key
404+
if params:
405+
query = urlencode(params)
406+
url = f"{url}?{query}"
407+
408+
body = dumps({"filters": {}})
409+
return await http_post(blink, url, data=body)
410+
411+
383412
async def request_cameras(blink, network):
384413
"""
385414
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: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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+
# Full v4 response without AI descriptions (account without SVD)
51+
V4_RESPONSE_NO_AI = {
52+
"media": [V4_ENTRY_NO_AI],
53+
"moment_gap_time": 25,
54+
"page_size": 200,
55+
"pagination_key": None,
56+
}
57+
58+
# Camera config used by TestCameraAIAttributes
59+
CAMERA_CFG = {
60+
"name": "foobar",
61+
"id": 1234,
62+
"network_id": 5678,
63+
"serial": "12345678",
64+
"enabled": False,
65+
"battery_state": "ok",
66+
"battery_voltage": 163,
67+
"wifi_strength": -38,
68+
"signals": {"lfr": 5, "wifi": 4, "battery": 3, "temp": 68},
69+
"thumbnail": "/thumb",
70+
}
71+
72+
73+
@mock.patch("blinkpy.auth.Auth.query")
74+
class TestV4MediaEndpoint(IsolatedAsyncioTestCase):
75+
"""Test the v4 media endpoint API function."""
76+
77+
async def asyncSetUp(self):
78+
"""Set up Blink module."""
79+
self.blink = Blink(session=mock.AsyncMock())
80+
self.blink.urls = BlinkURLHandler("test")
81+
self.blink.auth.account_id = 1234
82+
83+
def tearDown(self):
84+
"""Clean up after test."""
85+
self.blink = None
86+
87+
async def test_request_videos_v4(self, mock_resp):
88+
"""Test v4 media endpoint returns expected data."""
89+
mock_resp.return_value = V4_RESPONSE
90+
result = await api.request_videos_v4(self.blink)
91+
self.assertIn("media", result)
92+
self.assertEqual(len(result["media"]), 1)
93+
self.assertIn("ai_vd", result["media"][0])
94+
95+
async def test_request_videos_v4_empty_response(self, mock_resp):
96+
"""Test v4 endpoint with empty response."""
97+
mock_resp.return_value = None
98+
result = await api.request_videos_v4(self.blink)
99+
self.assertIsNone(result)
100+
101+
async def test_request_videos_v4_no_media(self, mock_resp):
102+
"""Test v4 endpoint with response missing media key."""
103+
mock_resp.return_value = {"error": "something went wrong"}
104+
result = await api.request_videos_v4(self.blink)
105+
self.assertNotIn("media", result)
106+
107+
108+
@mock.patch("blinkpy.auth.Auth.query")
109+
class TestAIDescriptionExtraction(IsolatedAsyncioTestCase):
110+
"""Test AI description extraction in check_new_videos."""
111+
112+
def setUp(self):
113+
"""Set up Blink module."""
114+
self.blink = Blink(motion_interval=0, session=mock.AsyncMock())
115+
self.blink.last_refresh = 1000
116+
self.blink.urls = BlinkURLHandler("test")
117+
self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", "1234", [])
118+
self.blink.sync["test"].network_info = {"network": {"armed": True}}
119+
120+
def tearDown(self):
121+
"""Clean up after test."""
122+
self.blink = None
123+
124+
async def test_v4_ai_description_extracted(self, mock_resp):
125+
"""Test that ai_vd is extracted from v4 media entries."""
126+
mock_resp.return_value = {"media": [V4_ENTRY_WITH_AI]}
127+
sync_module = self.blink.sync["test"]
128+
sync_module.cameras = {"Front Door": None}
129+
self.assertTrue(await sync_module.check_new_videos())
130+
records = sync_module.last_records["Front Door"]
131+
self.assertEqual(len(records), 1)
132+
self.assertEqual(
133+
records[0]["ai_description"],
134+
"A person is walking on the driveway.",
135+
)
136+
self.assertEqual(records[0]["ai_description_short"], "Person on driveway.")
137+
self.assertEqual(records[0]["cv_detection"], ["person"])
138+
139+
async def test_v4_no_ai_description(self, mock_resp):
140+
"""Test graceful handling when ai_vd is None."""
141+
mock_resp.return_value = {"media": [V4_ENTRY_NO_AI]}
142+
sync_module = self.blink.sync["test"]
143+
sync_module.cameras = {"Front Door": None}
144+
self.assertTrue(await sync_module.check_new_videos())
145+
records = sync_module.last_records["Front Door"]
146+
self.assertEqual(len(records), 1)
147+
# No ai_description keys should be present
148+
self.assertNotIn("ai_description", records[0])
149+
self.assertNotIn("ai_description_short", records[0])
150+
151+
async def test_v4_empty_ai_vd_object(self, mock_resp):
152+
"""Test graceful handling when ai_vd is an empty dict."""
153+
mock_resp.return_value = {"media": [V4_ENTRY_EMPTY_AI]}
154+
sync_module = self.blink.sync["test"]
155+
sync_module.cameras = {"Front Door": None}
156+
self.assertTrue(await sync_module.check_new_videos())
157+
records = sync_module.last_records["Front Door"]
158+
self.assertEqual(len(records), 1)
159+
# Empty dict should not produce ai_description keys
160+
self.assertNotIn("ai_description", records[0])
161+
# But cv_detection should still be extracted
162+
self.assertEqual(records[0]["cv_detection"], ["vehicle"])
163+
164+
165+
@mock.patch("blinkpy.auth.Auth.query", return_value={})
166+
class TestCameraAIAttributes(IsolatedAsyncioTestCase):
167+
"""Test AI description attributes on camera objects."""
168+
169+
def setUp(self):
170+
"""Set up Blink module."""
171+
self.blink = Blink(session=mock.AsyncMock())
172+
self.blink.urls = BlinkURLHandler("test")
173+
self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", 1234, [])
174+
self.camera = BlinkCamera(self.blink.sync["test"])
175+
self.camera.name = "foobar"
176+
self.blink.sync["test"].cameras["foobar"] = self.camera
177+
178+
def tearDown(self):
179+
"""Clean up after test."""
180+
self.blink = None
181+
self.camera = None
182+
183+
async def test_camera_attributes_include_ai_fields(self, mock_resp):
184+
"""Test that camera attributes include AI description fields."""
185+
attrs = self.camera.attributes
186+
self.assertIn("ai_description", attrs)
187+
self.assertIn("ai_description_short", attrs)
188+
self.assertIn("cv_detection", attrs)
189+
# Initially None
190+
self.assertIsNone(attrs["ai_description"])
191+
self.assertIsNone(attrs["ai_description_short"])
192+
self.assertIsNone(attrs["cv_detection"])
193+
194+
async def test_camera_ai_description_from_records(self, mock_resp):
195+
"""Test that camera picks up AI description from last records."""
196+
self.camera.sync.last_records["foobar"] = [
197+
{
198+
"clip": "/clip.mp4",
199+
"time": "2024-01-01T00:00:00+00:00",
200+
"ai_description": "A cat sitting on the porch.",
201+
"ai_description_short": "Cat on porch.",
202+
"cv_detection": ["animal"],
203+
}
204+
]
205+
self.camera.sync.motion["foobar"] = True
206+
await self.camera.update(CAMERA_CFG, force_cache=False)
207+
self.assertEqual(self.camera.ai_description, "A cat sitting on the porch.")
208+
self.assertEqual(self.camera.ai_description_short, "Cat on porch.")
209+
self.assertEqual(self.camera.cv_detection, ["animal"])
210+
211+
async def test_camera_no_ai_description_in_records(self, mock_resp):
212+
"""Test camera handles records without AI description gracefully."""
213+
self.camera.sync.last_records["foobar"] = [
214+
{
215+
"clip": "/clip.mp4",
216+
"time": "2024-01-01T00:00:00+00:00",
217+
}
218+
]
219+
self.camera.sync.motion["foobar"] = True
220+
await self.camera.update(CAMERA_CFG, force_cache=False)
221+
# Should be None since record didn't have these keys
222+
self.assertIsNone(self.camera.ai_description)
223+
self.assertIsNone(self.camera.ai_description_short)
224+
self.assertIsNone(self.camera.cv_detection)
225+
226+
async def test_camera_recent_clips_include_ai_fields(self, mock_resp):
227+
"""Test that recent_clips entries include AI description fields."""
228+
self.camera.sync.last_records["foobar"] = [
229+
{
230+
"clip": "/clip.mp4",
231+
"time": "2024-01-01T00:00:00+00:00",
232+
"ai_description": "A delivery person at the door.",
233+
"cv_detection": ["person"],
234+
}
235+
]
236+
self.camera.sync.motion["foobar"] = True
237+
await self.camera.update_images(CAMERA_CFG, expire_clips=False)
238+
self.assertEqual(len(self.camera.recent_clips), 1)
239+
clip = self.camera.recent_clips[0]
240+
self.assertEqual(clip["ai_description"], "A delivery person at the door.")
241+
self.assertEqual(clip["cv_detection"], ["person"])
242+
243+
async def test_clip_url_absolute_not_doubled(self, mock_resp):
244+
"""Test that absolute clip URLs from v4 are not doubled with base_url."""
245+
absolute_url = "https://rest-u009.immedia-semi.com/api/v4/media/123/video"
246+
self.camera.sync.last_records["foobar"] = [
247+
{
248+
"clip": absolute_url,
249+
"time": "2024-01-01T00:00:00+00:00",
250+
}
251+
]
252+
self.camera.sync.motion["foobar"] = False
253+
await self.camera.update(CAMERA_CFG, force_cache=False)
254+
# URL should NOT be doubled (no base_url prepended)
255+
self.assertEqual(self.camera.clip, absolute_url)
256+
257+
async def test_clip_url_relative_gets_base_url(self, mock_resp):
258+
"""Test that relative clip URLs still get base_url prepended."""
259+
self.camera.sync.last_records["foobar"] = [
260+
{
261+
"clip": "/api/v1/media/123.mp4",
262+
"time": "2024-01-01T00:00:00+00:00",
263+
}
264+
]
265+
self.camera.sync.motion["foobar"] = False
266+
await self.camera.update(CAMERA_CFG, force_cache=False)
267+
self.assertEqual(
268+
self.camera.clip,
269+
f"{self.blink.urls.base_url}/api/v1/media/123.mp4",
270+
)

0 commit comments

Comments
 (0)