Skip to content

Commit ea47bd3

Browse files
author
Sam Storer
committed
feat: add optional filename_format parameter to download_videos()
- Add _format_filename_default() method to encapsulate default filename formatting - Add filename_format parameter to download_videos() allowing custom filename generation - Update _parse_downloaded_items() to accept and use custom format function - Maintain full backward compatibility (defaults to existing slugified format) - Add test cases for custom format functions in tests/test_download_format.py The filename_format parameter accepts a callable with signature: filename_format(created_at: str, camera_name: str, path: str) -> str Where created_at is an ISO 8601 timestamp string, and the function must return the full filepath including filename and extension. Usage example: def custom_fmt(created_at, camera_name, path): dt = datetime.datetime.fromisoformat(created_at).astimezone( pytz.timezone('US/Eastern') ) clean_name = camera_name.replace(' ', '') return os.path.join(path, f'{dt:%Y%m%d_%H%M%S}_{clean_name}.mp4') await blink.download_videos(path, filename_format=custom_fmt) This change is backward compatible; existing code without the parameter continues to work with the default slugified format.
1 parent 6ef1b53 commit ea47bd3

File tree

2 files changed

+165
-7
lines changed

2 files changed

+165
-7
lines changed

blinkpy/blinkpy.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,14 @@ async def set_status(self, data_dict={}):
369369
return response
370370

371371
async def download_videos(
372-
self, path, since=None, camera="all", stop=10, delay=1, debug=False
372+
self,
373+
path,
374+
since=None,
375+
camera="all",
376+
stop=10,
377+
delay=1,
378+
debug=False,
379+
filename_format=None,
373380
):
374381
"""
375382
Download all videos from server since specified time.
@@ -384,12 +391,17 @@ async def download_videos(
384391
:param delay: Number of seconds to wait in between subsequent video downloads.
385392
:param debug: Set to TRUE to prevent downloading of items.
386393
Instead of downloading, entries will be printed to log.
394+
:param filename_format: Optional callable to format filename.
395+
Signature: filename_format(created_at, camera_name,
396+
path) -> str. If None, uses default slugified format.
387397
"""
388398
if not isinstance(camera, list):
389399
camera = [camera]
390400

391401
results = await self.get_videos_metadata(since=since, stop=stop)
392-
await self._parse_downloaded_items(results, camera, path, delay, debug)
402+
await self._parse_downloaded_items(
403+
results, camera, path, delay, debug, filename_format=filename_format
404+
)
393405

394406
async def get_videos_metadata(self, since=None, camera="all", stop=10):
395407
"""
@@ -438,8 +450,29 @@ async def do_http_get(self, address):
438450
)
439451
return response
440452

441-
async def _parse_downloaded_items(self, result, camera, path, delay, debug):
442-
"""Parse downloaded videos."""
453+
def _format_filename_default(self, created_at, camera_name, path):
454+
"""Format filename using default slugified format."""
455+
filename = f"{camera_name}-{created_at}"
456+
filename = f"{slugify(filename)}.mp4"
457+
return os.path.join(path, filename)
458+
459+
async def _parse_downloaded_items(
460+
self, result, camera, path, delay, debug, filename_format=None
461+
):
462+
"""Parse downloaded videos.
463+
464+
:param result: List of video metadata items.
465+
:param camera: Camera name(s) to filter on.
466+
:param path: Directory path to save videos.
467+
:param delay: Delay between downloads in seconds.
468+
:param debug: If True, log instead of downloading.
469+
:param filename_format: Optional callable(created_at, camera_name, path) -> str
470+
If None, uses default slugified format.
471+
"""
472+
# Use default formatter if none provided
473+
if filename_format is None:
474+
filename_format = self._format_filename_default
475+
443476
for item in result:
444477
try:
445478
created_at = item["created_at"]
@@ -458,9 +491,8 @@ async def _parse_downloaded_items(self, result, camera, path, delay, debug):
458491
_LOGGER.debug("%s: %s is marked as deleted.", camera_name, address)
459492
continue
460493

461-
filename = f"{camera_name}-{created_at}"
462-
filename = f"{slugify(filename)}.mp4"
463-
filename = os.path.join(path, filename)
494+
# Use provided format function to create filename
495+
filename = filename_format(created_at, camera_name, path)
464496

465497
if not debug:
466498
if await aiofiles.ospath.isfile(filename):

tests/test_download_format.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env python3
2+
"""Test custom filename_format parameter in download_videos()."""
3+
4+
import datetime
5+
import os
6+
from unittest import mock, IsolatedAsyncioTestCase
7+
from blinkpy import blinkpy
8+
9+
10+
class TestCustomFilenameFormat(IsolatedAsyncioTestCase):
11+
"""Test custom filename formatting for video downloads."""
12+
13+
def setUp(self):
14+
"""Set up test fixtures."""
15+
self.blink = blinkpy.Blink(session=mock.AsyncMock())
16+
self.blink.last_refresh = 0
17+
18+
def test_default_format_unchanged(self):
19+
"""Verify default format still works (backward compatibility)."""
20+
created_at = "2024-01-15T14:30:22Z"
21+
camera_name = "Front Door"
22+
path = "/tmp/videos"
23+
24+
result = self.blink._format_filename_default(created_at, camera_name, path)
25+
26+
# Should contain path and end with .mp4
27+
assert result.startswith(path)
28+
assert result.endswith(".mp4")
29+
# Should be slugified (lowercase, no spaces)
30+
assert "front-door" in result.lower()
31+
32+
def test_custom_format_simple(self):
33+
"""Test simple custom format."""
34+
35+
def simple_format(created_at, camera_name, path):
36+
"""Create a simple format with camera name and timestamp."""
37+
clean_camera = camera_name.replace(" ", "_")
38+
filename = f"{clean_camera}_{created_at}.mp4"
39+
return os.path.join(path, filename)
40+
41+
created_at = "2024-01-15T14:30:22Z"
42+
camera_name = "Front Door"
43+
path = "/tmp/videos"
44+
45+
result = simple_format(created_at, camera_name, path)
46+
47+
assert result == "/tmp/videos/Front_Door_2024-01-15T14:30:22Z.mp4"
48+
assert "Front_Door" in result
49+
assert created_at in result
50+
51+
def test_custom_format_iso_style(self):
52+
"""Test ISO-style custom format."""
53+
54+
def iso_format(created_at, camera_name, path):
55+
"""Create ISO-style format."""
56+
# Replace 'Z' with '+00:00' for fromisoformat compatibility
57+
dt = datetime.datetime.fromisoformat(created_at.replace("Z", "+00:00"))
58+
clean_camera = camera_name.replace(" ", "_")
59+
filename = f"{dt:%Y-%m-%d_%H-%M-%S}_{clean_camera}.mp4"
60+
return os.path.join(path, filename)
61+
62+
created_at = "2024-01-15T14:30:22Z"
63+
camera_name = "Back Patio"
64+
path = "/videos/archive"
65+
66+
result = iso_format(created_at, camera_name, path)
67+
68+
assert result.startswith("/videos/archive")
69+
assert "2024-01-15" in result
70+
assert "14-30-22" in result
71+
assert "Back_Patio" in result
72+
73+
def test_custom_format_minimal(self):
74+
"""Test minimal format with timestamp only."""
75+
76+
def minimal_format(created_at, camera_name, path):
77+
"""Create minimal format using unix timestamp."""
78+
# Replace 'Z' with '+00:00' for fromisoformat compatibility
79+
dt = datetime.datetime.fromisoformat(created_at.replace("Z", "+00:00"))
80+
timestamp = int(dt.timestamp())
81+
return os.path.join(path, f"{timestamp}.mp4")
82+
83+
created_at = "2024-01-15T14:30:22Z"
84+
camera_name = "Whatever"
85+
path = "/tmp"
86+
87+
result = minimal_format(created_at, camera_name, path)
88+
89+
assert result.startswith("/tmp")
90+
assert result.endswith(".mp4")
91+
assert any(c.isdigit() for c in result)
92+
93+
@mock.patch("blinkpy.blinkpy.api.request_videos")
94+
async def test_download_with_custom_format(self, mock_req):
95+
"""Test download_videos() accepts filename_format parameter."""
96+
97+
def custom_format(created_at, camera_name, path):
98+
"""Create custom format for testing."""
99+
return os.path.join(path, f"{camera_name}_{created_at}.mp4")
100+
101+
# Mock video entry
102+
entry = {
103+
"created_at": "2024-01-15T14:30:22Z",
104+
"device_name": "TestCamera",
105+
"deleted": False,
106+
"media": "/some/media/url",
107+
}
108+
mock_req.return_value = {"media": [entry]}
109+
110+
# Verify the download_videos signature accepts filename_format
111+
try:
112+
await self.blink.download_videos(
113+
"/tmp",
114+
stop=2,
115+
delay=0,
116+
debug=True,
117+
filename_format=custom_format,
118+
)
119+
except TypeError as e:
120+
self.fail(f"download_videos() doesn't support filename_format: {e}")
121+
122+
123+
if __name__ == "__main__":
124+
import unittest
125+
126+
unittest.main()

0 commit comments

Comments
 (0)