Repository: danielfernau/unifi-protect-video-downloader

Issue:
Ignores one of the times
passing in  --start="2020-08-29 18:24:55-0400" --end="2020-08-29 18:26:55-0400" and instead I get video from 2020-08-29 18:24:55-0400 to 2020-08-29 19:00:00-0400

but it seems to randomly (I cannot figure out the pattern...) where it will peg the start time to the hour vs the time I passed in.

so instead of getting 5 min or 8 min I get these hour-long clips



Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/danielfernau/unifi-protect-video-downloader/pull/380?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau) Report
Attention: `5 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`6900656`)](https://app.codecov.io/gh/danielfernau/unifi-protect-video-downloader/commit/69006566fe747aef597472e7b67c74e27777554e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau) 53.19% compared to head [(`e48b487`)](https://app.codecov.io/gh/danielfernau/unifi-protect-video-downloader/pull/380?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau) 56.81%.

| [Files](https://app.codecov.io/gh/danielfernau/unifi-protect-video-downloader/pull/380?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau) | Patch % | Lines |
|---|---|---|
| [protect\_archiver/cli/download.py](https://app.codecov.io/gh/danielfernau/unifi-protect-video-downloader/pull/380?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau#diff-cHJvdGVjdF9hcmNoaXZlci9jbGkvZG93bmxvYWQucHk=) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/danielfernau/unifi-protect-video-downloader/pull/380?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau) |
| [protect\_archiver/downloader/download\_file.py](https://app.codecov.io/gh/danielfernau/unifi-protect-video-downloader/pull/380?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau#diff-cHJvdGVjdF9hcmNoaXZlci9kb3dubG9hZGVyL2Rvd25sb2FkX2ZpbGUucHk=) | 50.00% | [1 Missing :warning: ](https://app.codecov.io/gh/danielfernau/unifi-protect-video-downloader/pull/380?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau) |
| [protect\_archiver/sync.py](https://app.codecov.io/gh/danielfernau/unifi-protect-video-downloader/pull/380?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau#diff-cHJvdGVjdF9hcmNoaXZlci9zeW5jLnB5) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/danielfernau/unifi-protect-video-downloader/pull/380?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #380      +/-   ##
==========================================
+ Coverage   53.19%   56.81%   +3.61%     
==========================================
  Files          23       23              
  Lines         720      778      +58     
==========================================
+ Hits          383      442      +59     
+ Misses        337      336       -1     
```

| [Flag](https://app.codecov.io/gh/danielfernau/unifi-protect-video-downloader/pull/380/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/danielfernau/unifi-protect-video-downloader/pull/380/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau) | `56.81% <94.11%> (+3.61%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/danielfernau/unifi-protect-video-downloader/pull/380?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Fernau).


Patch:
diff --git a/poetry.lock b/poetry.lock
index 1ff9710..f04e2ad 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
 
 [[package]]
 name = "astroid"
diff --git a/protect_archiver/cli/download.py b/protect_archiver/cli/download.py
index 847ba90..1e2fd46 100644
--- a/protect_archiver/cli/download.py
+++ b/protect_archiver/cli/download.py
@@ -140,6 +140,29 @@ from protect_archiver.utils import print_download_stats
         # "If omitted, the time of the last available recording for each camera will be used."
     ),
 )
+@click.option(
+    "--disable-alignment",
+    "disable_alignment",
+    is_flag=True,
+    default=False,
+    show_default=True,
+    help=(
+        "Disables alignment of the 1-hour segments to absolute hours. "
+        "If set, results in 8:45, 9:45, 10:45 instead of 8:45, 9:00, 10:00, 10:45."
+    ),
+)
+@click.option(
+    "--disable-splitting",
+    "disable_splitting",
+    is_flag=True,
+    default=False,
+    show_default=True,
+    help=(
+        "Disables splitting the datetime selection in 1-hour segments. "
+        "USE WITH CAUTION: requesting segments longer than 1 hour via the "
+        "API can cause the Protect console to crash and restart unexpectedly."
+    ),
+)
 @click.option(
     "--snapshot",
     "create_snapshot",
@@ -175,6 +198,8 @@ def download(
     ignore_failed_downloads: bool,
     start: datetime,
     end: datetime,
+    disable_alignment: bool,
+    disable_splitting: bool,
     create_snapshot: bool,
     use_utc_filenames: bool,
 ) -> None:
@@ -224,7 +249,9 @@ def download(
                     f" {camera.name}"
                 )
 
-                Downloader.download_footage(client, start, end, camera)
+                Downloader.download_footage(
+                    client, start, end, camera, disable_alignment, disable_splitting
+                )
         else:
             click.echo(
                 f"Downloading snapshot files for {start.ctime()}"
diff --git a/protect_archiver/config.py b/protect_archiver/config.py
index d900194..4c2a9ef 100644
--- a/protect_archiver/config.py
+++ b/protect_archiver/config.py
@@ -17,6 +17,8 @@ class Config:
     TOUCH_FILES: bool = False
     SKIP_EXISTING_FILES: bool = False
     IGNORE_FAILED_DOWNLOADS: bool = False
+    DISABLE_ALIGNMENT: bool = False
+    DISABLE_SPLITTING: bool = False
     DOWNLOAD_WAIT: int = 0
     DOWNLOAD_TIMEOUT: float = (
         60.0  # aka read_timeout - time to wait until a socket read response happens
diff --git a/protect_archiver/downloader/__init__.py b/protect_archiver/downloader/__init__.py
index d45f99f..49339d5 100644
--- a/protect_archiver/downloader/__init__.py
+++ b/protect_archiver/downloader/__init__.py
@@ -43,8 +43,15 @@ class Downloader:
         return download_file(client, video_export_query, filename)
 
     @staticmethod
-    def download_footage(client: Any, start: datetime, end: datetime, camera: Any) -> Any:
-        return download_footage(client, start, end, camera)
+    def download_footage(
+        client: Any,
+        start: datetime,
+        end: datetime,
+        camera: Any,
+        disable_alignment: bool = Config.DISABLE_ALIGNMENT,
+        disable_splitting: bool = Config.DISABLE_SPLITTING,
+    ) -> Any:
+        return download_footage(client, start, end, camera, disable_alignment, disable_splitting)
 
     @staticmethod
     def download_snapshot(client: Any, start: datetime, camera: Any) -> Any:
diff --git a/protect_archiver/downloader/download_file.py b/protect_archiver/downloader/download_file.py
index 01197c8..8897c6d 100644
--- a/protect_archiver/downloader/download_file.py
+++ b/protect_archiver/downloader/download_file.py
@@ -10,6 +10,7 @@ import requests
 
 from protect_archiver.errors import Errors
 from protect_archiver.utils import format_bytes
+from protect_archiver.utils import print_download_stats
 
 
 def download_file(client: Any, query: str, filename: str) -> None:
@@ -160,7 +161,7 @@ def download_file(client: Any, query: str, filename: str) -> None:
             "To skip failed downloads and continue with next file, add argument"
             " '--ignore-failed-downloads'"
         )
-        client.print_download_stats()
+        print_download_stats(client)
         raise Errors.ProtectError(exit_code)
     else:
         logging.info(
diff --git a/protect_archiver/downloader/download_footage.py b/protect_archiver/downloader/download_footage.py
index e66e2e5..2db80c2 100644
--- a/protect_archiver/downloader/download_footage.py
+++ b/protect_archiver/downloader/download_footage.py
@@ -13,14 +13,26 @@ from protect_archiver.utils import calculate_intervals
 from protect_archiver.utils import make_camera_name_fs_safe
 
 
-def download_footage(client: Any, start: datetime, end: datetime, camera: Camera) -> None:
+def download_footage(
+    client: Any,
+    start: datetime,
+    end: datetime,
+    camera: Camera,
+    disable_alignment: bool = False,
+    disable_splitting: bool = False,
+) -> None:
     # make camera name safe for use in file name
     camera_name_fs_safe = make_camera_name_fs_safe(camera)
 
     logging.info(f"Downloading footage for camera '{camera.name}' ({camera.id})")
 
     # split requested time frame into chunks of 1 hour or less and download them one by one
-    for interval_start, interval_end in calculate_intervals(start, end):
+    for interval_start, interval_end in calculate_intervals(
+        start,
+        end,
+        disable_alignment,
+        disable_splitting,
+    ):
         # wait n seconds before starting next download (if parameter is set)
         if client.download_wait != 0 and client.files_downloaded == 0:
             logging.debug(
diff --git a/protect_archiver/sync.py b/protect_archiver/sync.py
index b429bbd..22f18ba 100644
--- a/protect_archiver/sync.py
+++ b/protect_archiver/sync.py
@@ -52,7 +52,14 @@ class ProtectSync:
                 )
                 end = datetime.now().replace(minute=0, second=0, microsecond=0)
                 for interval_start, interval_end in calculate_intervals(start, end):
-                    Downloader.download_footage(self.client, interval_start, interval_end, camera)
+                    Downloader.download_footage(
+                        self.client,
+                        interval_start,
+                        interval_end,
+                        camera,
+                        disable_alignment=False,
+                        disable_splitting=False,
+                    )
                     state["cameras"][camera.id] = {
                         "last": interval_end,
                         "name": camera.name,
diff --git a/protect_archiver/utils.py b/protect_archiver/utils.py
index 8450876..6167e88 100644
--- a/protect_archiver/utils.py
+++ b/protect_archiver/utils.py
@@ -36,42 +36,60 @@ def diff_round_down_to_full_hour(date_time_object: datetime) -> datetime:
 
 
 # calculate and yield the intervals between the given start and end datetime objects
-# example:
-#   start = 01.01.1970 08:30:00
-#   end = 01.01.1970 13:15:00
-#
-#   returns:
-#       01.01.1970 08:30:00 - 01.01.1970 08:59:59
-#       01.01.1970 09:00:00 - 01.01.1970 09:59:59
-#       01.01.1970 10:00:00 - 01.01.1970 10:59:59
-#       01.01.1970 11:00:00 - 01.01.1970 11:59:59
-#       01.01.1970 12:00:00 - 01.01.1970 12:59:59
-#       01.01.1970 13:00:00 - 01.01.1970 13:14:59
-def calculate_intervals(start: datetime, end: datetime) -> Iterable[Tuple[datetime, datetime]]:
+# - Calculates intervals in 1-hour segments, aligning them with absolute hours.
+#   Shorter segments may be present at the start and/or end.
+# - Supports disabling alignment to absolute hours.
+# - Supports disabling the splitting into 1-hour segments (use with caution).
+def calculate_intervals(
+    start: datetime,
+    end: datetime,
+    disable_alignment: bool = False,
+    disable_splitting: bool = False,
+) -> Iterable[Tuple[datetime, datetime]]:
+    # if true, do not split into 1-hour segments
+    # Caution: this can cause the Protect application to crash and restart unexpectedly!
+    if disable_splitting:
+        yield start, end - timedelta(milliseconds=1)  # yield everything at once
+        return  # exit early
+
+    # if true, disable alignment to absolute hours
+    if disable_alignment:
+        # divide total duration by 60 min, yield 1-hour segments
+        for _ in range(int((end - start).seconds / 3600)):
+            yield start, start + timedelta(minutes=59, seconds=59, milliseconds=999)
+            start = start + timedelta(hours=1)
+        yield start, end - timedelta(milliseconds=1)  # yield remaining segment
+        return  # exit early
+
+    #####
+    # if none of the options above were used, calculate 1-hour segments and align them with absolute hours
+    #####
+
     # calculate time differences to next or past full hour
     start_diff_to_next_full_hour = diff_round_up_to_full_hour(start) - start
     end_diff_to_past_full_hour = end - diff_round_down_to_full_hour(end)
 
-    if start_diff_to_next_full_hour.seconds != 0:
-        # start is not on full hour, yield interval from start to first full hour
-        yield start, start + start_diff_to_next_full_hour
-        start = start + start_diff_to_next_full_hour
-
+    # save original start and end for later
+    original_start = start
     original_end = end
-    if end_diff_to_past_full_hour.seconds != 0:
-        # end is not on full hour
-        full_hour_end = end - end_diff_to_past_full_hour
-        end = end - end_diff_to_past_full_hour
-    else:
-        full_hour_end = end
-
-    # yield all full-hour intervals
-    for n in range(int((end - start).total_seconds() / 3600)):
-        yield start + timedelta(seconds=n * 3600), start + timedelta(seconds=((n + 1) * 3600) - 1)
 
-    if original_end != full_hour_end:
-        # if end is not on full hour, yield the interval between the last full hour and the end
-        yield full_hour_end, original_end - timedelta(seconds=1)
+    # yield interval from start to first full hour and align start to full hours
+    # but only if the end datetime is past the next full hour after start datetime
+    if (
+        start_diff_to_next_full_hour.seconds != 0
+        and (original_end - original_start) >= start_diff_to_next_full_hour
+    ):
+        yield start, start + (start_diff_to_next_full_hour - timedelta(milliseconds=1))
+        start = start + start_diff_to_next_full_hour  # update start time
+
+    # yield all remaining full-hour intervals
+    for _ in range(int((end - start).total_seconds() / 3600)):
+        yield start, start + timedelta(minutes=59, seconds=59, milliseconds=999)
+        start = start + timedelta(minutes=60)  # update start time
+
+    # if end is not on full hour, yield remaining segment
+    if end_diff_to_past_full_hour.seconds != 0:
+        yield start, original_end - timedelta(milliseconds=1)
 
 
 def format_bytes(size: int) -> str:
diff --git a/setup.cfg b/setup.cfg
index 9f1ac0c..366ad05 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -7,4 +7,3 @@ exclude = .git
 
 [tool:pytest]
 addopts = --tb=short -p no:doctest
-looponfailroots = protect_archiver


Test patch:
diff --git a/conftest.py b/conftest.py
index 14f41f7..e80f775 100644
--- a/conftest.py
+++ b/conftest.py
@@ -53,7 +53,7 @@ def client(test_output_dest: str) -> Any:
     return ProtectClient(destination_path=test_output_dest, password="test", download_timeout=0.01)
 
 
-@pytest.yield_fixture
+@pytest.fixture
 def responses() -> Any:
     # XXX(dcramer): pytest-responses doesnt let you change this behavior yet
     with responses_.RequestsMock(assert_all_requests_are_fired=False) as rsps:
diff --git a/protect_archiver/test_client.py b/protect_archiver/test_client.py
index 397c80d..b6e97a0 100644
--- a/protect_archiver/test_client.py
+++ b/protect_archiver/test_client.py
@@ -75,7 +75,9 @@ def test_download_footage(
     start = datetime(2020, 1, 8, 23, 0, 0, tzinfo=timezone.utc)
     end = datetime(2020, 1, 8, 23, 59, 0, tzinfo=timezone.utc)
 
-    Downloader.download_footage(client, start, end, sample_camera)
+    Downloader.download_footage(
+        client, start, end, sample_camera, disable_alignment=False, disable_splitting=False
+    )
 
     file_name = os.path.join(
         test_output_dest,
diff --git a/protect_archiver/test_utils.py b/protect_archiver/test_utils.py
index a9373e9..2dda6ae 100644
--- a/protect_archiver/test_utils.py
+++ b/protect_archiver/test_utils.py
@@ -1,26 +1,129 @@
 from datetime import datetime
 
+import dateutil.parser
+
 from .utils import calculate_intervals
 
 
-def test_calculate_intervals_single_result() -> None:
-    start = datetime(2020, 1, 8, 23, 0, 0)
-    end = datetime(2020, 1, 9, 0, 0, 0)
+def test_calculate_intervals_multiple_partial_no_alignment_1() -> None:
+    start = dateutil.parser.parse("01/01/1970 08:30:00")
+    end = dateutil.parser.parse("01/01/1970 08:45:00")
+
+    result = list(calculate_intervals(start, end, disable_alignment=True))
+    assert result == [(datetime(1970, 1, 1, 8, 30), datetime(1970, 1, 1, 8, 44, 59, 999000))]
+
+
+def test_calculate_intervals_multiple_partial_no_alignment_2() -> None:
+    start = dateutil.parser.parse("01/01/1970 08:30:00")
+    end = dateutil.parser.parse("01/01/1970 13:45:00")
+
+    result = list(calculate_intervals(start, end, disable_alignment=True))
+    assert result == [
+        (datetime(1970, 1, 1, 8, 30), datetime(1970, 1, 1, 9, 29, 59, 999000)),
+        (datetime(1970, 1, 1, 9, 30), datetime(1970, 1, 1, 10, 29, 59, 999000)),
+        (datetime(1970, 1, 1, 10, 30), datetime(1970, 1, 1, 11, 29, 59, 999000)),
+        (datetime(1970, 1, 1, 11, 30), datetime(1970, 1, 1, 12, 29, 59, 999000)),
+        (datetime(1970, 1, 1, 12, 30), datetime(1970, 1, 1, 13, 29, 59, 999000)),
+        (datetime(1970, 1, 1, 13, 30), datetime(1970, 1, 1, 13, 44, 59, 999000)),
+    ]
+
+
+def test_calculate_intervals_multiple_partial_no_alignment_3() -> None:
+    start = dateutil.parser.parse("01/01/1970 08:53:00")
+    end = dateutil.parser.parse("01/01/1970 13:45:00")
+
+    result = list(calculate_intervals(start, end, disable_alignment=True))
+    assert result == [
+        (datetime(1970, 1, 1, 8, 53), datetime(1970, 1, 1, 9, 52, 59, 999000)),
+        (datetime(1970, 1, 1, 9, 53), datetime(1970, 1, 1, 10, 52, 59, 999000)),
+        (datetime(1970, 1, 1, 10, 53), datetime(1970, 1, 1, 11, 52, 59, 999000)),
+        (datetime(1970, 1, 1, 11, 53), datetime(1970, 1, 1, 12, 52, 59, 999000)),
+        (datetime(1970, 1, 1, 12, 53), datetime(1970, 1, 1, 13, 44, 59, 999000)),
+    ]
+
+
+def test_calculate_intervals_no_splitting() -> None:
+    start = dateutil.parser.parse("01/01/1970 08:56:00")
+    end = dateutil.parser.parse("01/01/1970 13:21:00")
+
+    result = list(calculate_intervals(start, end, disable_splitting=True))
+    assert result == [(datetime(1970, 1, 1, 8, 56), datetime(1970, 1, 1, 13, 20, 59, 999000))]
+
+
+def test_calculate_intervals_15m_within_hour() -> None:
+    start = dateutil.parser.parse("01/01/1970 08:30:00")
+    end = dateutil.parser.parse("01/01/1970 08:45:00")
+
+    result = list(calculate_intervals(start, end))
+    assert result == [(datetime(1970, 1, 1, 8, 30), datetime(1970, 1, 1, 8, 44, 59, 999000))]
+
+
+def test_calculate_intervals_30m_crossing_hour() -> None:
+    start = dateutil.parser.parse("01/01/1970 08:30:00")
+    end = dateutil.parser.parse("01/01/1970 09:15:00")
+
+    result = list(calculate_intervals(start, end))
+    assert result == [
+        (datetime(1970, 1, 1, 8, 30), datetime(1970, 1, 1, 8, 59, 59, 999000)),
+        (datetime(1970, 1, 1, 9, 0), datetime(1970, 1, 1, 9, 14, 59, 999000)),
+    ]
+
+
+def test_calculate_intervals_15m_to_full_hour() -> None:
+    start = dateutil.parser.parse("01/01/1970 08:30:00")
+    end = dateutil.parser.parse("01/01/1970 09:00:00")
+
+    result = list(calculate_intervals(start, end))
+    assert result == [(datetime(1970, 1, 1, 8, 30), datetime(1970, 1, 1, 8, 59, 59, 999000))]
+
+
+def test_calculate_intervals_15m_1s_after_the_hour() -> None:
+    start = dateutil.parser.parse("01/01/1970 08:30:00")
+    end = dateutil.parser.parse("01/01/1970 09:00:01")
+
+    result = list(calculate_intervals(start, end))
+    assert result == [
+        (datetime(1970, 1, 1, 8, 30), datetime(1970, 1, 1, 8, 59, 59, 999000)),
+        (datetime(1970, 1, 1, 9, 0), datetime(1970, 1, 1, 9, 0, 0, 999000)),
+    ]
+
+
+def test_calculate_intervals_15m_8s_after_the_hour() -> None:
+    start = dateutil.parser.parse("01/01/1970 08:30:00")
+    end = dateutil.parser.parse("01/01/1970 09:00:08")
+
+    result = list(calculate_intervals(start, end))
+    assert result == [
+        (datetime(1970, 1, 1, 8, 30), datetime(1970, 1, 1, 8, 59, 59, 999000)),
+        (datetime(1970, 1, 1, 9, 0), datetime(1970, 1, 1, 9, 0, 7, 999000)),
+    ]
+
+
+def test_calculate_intervals_5h_15m_same_day() -> None:
+    start = dateutil.parser.parse("01/01/1970 08:30:00")
+    end = dateutil.parser.parse("01/01/1970 13:45:00")
 
     result = list(calculate_intervals(start, end))
     assert result == [
-        (datetime(2020, 1, 8, 23, 0, 0), datetime(2020, 1, 8, 23, 59, 59)),
+        (datetime(1970, 1, 1, 8, 30), datetime(1970, 1, 1, 8, 59, 59, 999000)),
+        (datetime(1970, 1, 1, 9, 0), datetime(1970, 1, 1, 9, 59, 59, 999000)),
+        (datetime(1970, 1, 1, 10, 0), datetime(1970, 1, 1, 10, 59, 59, 999000)),
+        (datetime(1970, 1, 1, 11, 0), datetime(1970, 1, 1, 11, 59, 59, 999000)),
+        (datetime(1970, 1, 1, 12, 0), datetime(1970, 1, 1, 12, 59, 59, 999000)),
+        (datetime(1970, 1, 1, 13, 0), datetime(1970, 1, 1, 13, 44, 59, 999000)),
     ]
 
 
-def test_calculate_intervals_multiple_partial() -> None:
-    start = datetime(2020, 1, 8, 23, 0, 0)
-    end = datetime(2020, 1, 9, 2, 30, 0)
+def test_calculate_intervals_5h_15m_next_day() -> None:
+    start = dateutil.parser.parse("01/01/1970 21:30:00")
+    end = dateutil.parser.parse("01/02/1970 02:45:00")
 
     result = list(calculate_intervals(start, end))
     assert result == [
-        (datetime(2020, 1, 8, 23, 0, 0), datetime(2020, 1, 8, 23, 59, 59)),
-        (datetime(2020, 1, 9, 0, 0), datetime(2020, 1, 9, 0, 59, 59)),
-        (datetime(2020, 1, 9, 1, 0), datetime(2020, 1, 9, 1, 59, 59)),
-        (datetime(2020, 1, 9, 2, 0), datetime(2020, 1, 9, 2, 29, 59)),
+        (datetime(1970, 1, 1, 21, 30), datetime(1970, 1, 1, 21, 59, 59, 999000)),
+        (datetime(1970, 1, 1, 22, 0), datetime(1970, 1, 1, 22, 59, 59, 999000)),
+        (datetime(1970, 1, 1, 23, 0), datetime(1970, 1, 1, 23, 59, 59, 999000)),
+        (datetime(1970, 1, 2, 0, 0), datetime(1970, 1, 2, 0, 59, 59, 999000)),
+        (datetime(1970, 1, 2, 1, 0), datetime(1970, 1, 2, 1, 59, 59, 999000)),
+        (datetime(1970, 1, 2, 2, 0), datetime(1970, 1, 2, 2, 44, 59, 999000)),
     ]