Repository: fronzbot/blinkpy

Issue:
Add throttling to video downloads to mitigate risk of account suspension
Hi folks,

I would just like to inform you that Blink has suspended my account after using blinkpy-0.17.0 once to download my videos. I have downloaded my videos before using my own script based on the API calls used in blinkpy. Never had an issue (except API changes). Now Blink decides to lock out paying customers that need automation other than Alexa or IFTTT. 

Thank you for your excellent work @fronzbot, it was really appreciated. But I think I am done with Blink now.

BR,
rbrtrs

Discussion:

Patch:
diff --git a/README.rst b/README.rst
index 32d867c..7fcd6d9 100644
--- a/README.rst
+++ b/README.rst
@@ -174,13 +174,13 @@ Similar methods exist for individual cameras:
 
 Download videos
 ----------------
-You can also use this library to download all videos from the server.  In order to do this, you must specify a ``path``.  You may also specifiy a how far back in time to go to retrieve videos via the ``since=`` variable (a simple string such as ``"2017/09/21"`` is sufficient), as well as how many pages to traverse via the ``stop=`` variable.  Note that by default, the library will search the first ten pages which is sufficient in most use cases.  Additionally, you can specify one or more cameras via the ``camera=`` property.  This can be a single string indicating the name of the camera, or a list of camera names.  By default, it is set to the string ``'all'`` to grab videos from all cameras.
+You can also use this library to download all videos from the server.  In order to do this, you must specify a ``path``.  You may also specifiy a how far back in time to go to retrieve videos via the ``since=`` variable (a simple string such as ``"2017/09/21"`` is sufficient), as well as how many pages to traverse via the ``stop=`` variable.  Note that by default, the library will search the first ten pages which is sufficient in most use cases.  Additionally, you can specify one or more cameras via the ``camera=`` property.  This can be a single string indicating the name of the camera, or a list of camera names.  By default, it is set to the string ``'all'`` to grab videos from all cameras. If you are downloading many items, setting the ``delay`` parameter is advised in order to throttle sequential calls to the API. By default this is set to ``1`` but can be any integer representing the number of seconds to delay between calls.
 
-Example usage, which downloads all videos recorded since July 4th, 2018 at 9:34am to the ``/home/blink`` directory:
+Example usage, which downloads all videos recorded since July 4th, 2018 at 9:34am to the ``/home/blink`` directory with a 2s delay between calls:
 
 .. code:: python
 
-    blink.download_videos('/home/blink', since='2018/07/04 09:34')
+    blink.download_videos('/home/blink', since='2018/07/04 09:34', delay=2)
 
 
 .. |Build Status| image:: https://github.com/fronzbot/blinkpy/workflows/build/badge.svg
diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py
index 8205458..eda5eb3 100644
--- a/blinkpy/blinkpy.py
+++ b/blinkpy/blinkpy.py
@@ -264,7 +264,9 @@ class Blink:
         """Save login data to file."""
         util.json_save(self.auth.login_attributes, file_name)
 
-    def download_videos(self, path, since=None, camera="all", stop=10, debug=False):
+    def download_videos(
+        self, path, since=None, camera="all", stop=10, delay=1, debug=False
+    ):
         """
         Download all videos from server since specified time.
 
@@ -275,6 +277,7 @@ class Blink:
         :param camera: Camera name to retrieve.  Defaults to "all".
                        Use a list for multiple cameras.
         :param stop: Page to stop on (~25 items per page. Default page 10).
+        :param delay: Number of seconds to wait in between subsequent video downloads.
         :param debug: Set to TRUE to prevent downloading of items.
                       Instead of downloading, entries will be printed to log.
         """
@@ -301,9 +304,9 @@ class Blink:
                 _LOGGER.info("No videos found on page %s. Exiting.", page)
                 break
 
-            self._parse_downloaded_items(result, camera, path, debug)
+            self._parse_downloaded_items(result, camera, path, delay, debug)
 
-    def _parse_downloaded_items(self, result, camera, path, debug):
+    def _parse_downloaded_items(self, result, camera, path, delay, debug):
         """Parse downloaded videos."""
         for item in result:
             try:
@@ -351,6 +354,8 @@ class Blink:
                         "Address: {address}, Filename: {filename}"
                     )
                 )
+            if delay > 0:
+                time.sleep(delay)
 
 
 class BlinkSetupError(Exception):


Test patch:
diff --git a/tests/test_blink_functions.py b/tests/test_blink_functions.py
index 8a71294..c4c7d7c 100644
--- a/tests/test_blink_functions.py
+++ b/tests/test_blink_functions.py
@@ -2,6 +2,7 @@
 import unittest
 from unittest import mock
 import logging
+import time
 
 from blinkpy import blinkpy
 from blinkpy.sync_module import BlinkSyncModule
@@ -88,9 +89,33 @@ class TestBlinkFunctions(unittest.TestCase):
             "DEBUG:blinkpy.blinkpy:foo: /bar.mp4 is marked as deleted.",
         ]
         with self.assertLogs() as dl_log:
-            blink.download_videos("/tmp", stop=2)
+            blink.download_videos("/tmp", stop=2, delay=0)
         self.assertEqual(dl_log.output, expected_log)
 
+    @mock.patch("blinkpy.blinkpy.api.request_videos")
+    def test_parse_downloaded_throttle(self, mock_req):
+        """Test ability to parse downloaded items list."""
+        generic_entry = {
+            "created_at": "1970",
+            "device_name": "foo",
+            "deleted": False,
+            "media": "/bar.mp4",
+        }
+        result = [generic_entry]
+        mock_req.return_value = {"media": result}
+        self.blink.last_refresh = 0
+        start = time.time()
+        self.blink.download_videos("/tmp", stop=2, delay=0, debug=True)
+        now = time.time()
+        delta = now - start
+        self.assertTrue(delta < 0.1)
+
+        start = time.time()
+        self.blink.download_videos("/tmp", stop=2, delay=0.1, debug=True)
+        now = time.time()
+        delta = now - start
+        self.assertTrue(delta >= 0.1)
+
     @mock.patch("blinkpy.blinkpy.api.request_videos")
     def test_parse_camera_not_in_list(self, mock_req):
         """Test ability to parse downloaded items list."""
@@ -113,7 +138,7 @@ class TestBlinkFunctions(unittest.TestCase):
             "DEBUG:blinkpy.blinkpy:Skipping videos for foo.",
         ]
         with self.assertLogs() as dl_log:
-            blink.download_videos("/tmp", camera="bar", stop=2)
+            blink.download_videos("/tmp", camera="bar", stop=2, delay=0)
         self.assertEqual(dl_log.output, expected_log)
 
     @mock.patch("blinkpy.blinkpy.api.request_network_update")