This commit is contained in:
Sebastian Goscik
2025-04-10 01:30:07 +01:00
parent 3750847055
commit 474d3c32fa
12 changed files with 68 additions and 46 deletions

View File

@@ -12,7 +12,7 @@ repos:
args: [ --unsafe ] args: [ --unsafe ]
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.5.7 rev: v0.11.4
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff - id: ruff

View File

@@ -63,8 +63,14 @@ line-length = 120
target-version = "py310" target-version = "py310"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E","F","D","B","W"]
ignore = ["D203", "D213"]
[tool.ruff.format] [tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "lf"
docstring-code-format = true
[tool.mypy] [tool.mypy]
allow_redefinition = true allow_redefinition = true

View File

@@ -29,7 +29,7 @@ def _parse_detection_types(ctx, param, value):
def parse_rclone_retention(ctx, param, retention) -> relativedelta: def parse_rclone_retention(ctx, param, retention) -> relativedelta:
"""Parses the rclone `retention` parameter into a relativedelta which can then be used to calculate datetimes.""" """Parse the rclone `retention` parameter into a relativedelta which can then be used to calculate datetimes."""
matches = {k: int(v) for v, k in re.findall(r"([\d]+)(ms|s|m|h|d|w|M|y)", retention)} matches = {k: int(v) for v, k in re.findall(r"([\d]+)(ms|s|m|h|d|w|M|y)", retention)}
# Check that we matched the whole string # Check that we matched the whole string
@@ -248,8 +248,7 @@ a lot of failed downloads with the default downloader.
help="Max number of parallel uploads to allow", help="Max number of parallel uploads to allow",
) )
def main(**kwargs): def main(**kwargs):
"""A Python based tool for backing up Unifi Protect event clips as they occur.""" """Python based tool for backing up Unifi Protect event clips as they occur."""
try: try:
# Validate only one of the camera select arguments was given # Validate only one of the camera select arguments was given
if kwargs.get("cameras") and kwargs.get("ignore_cameras"): if kwargs.get("cameras") and kwargs.get("ignore_cameras"):

View File

@@ -27,7 +27,7 @@ from unifi_protect_backup.utils import (
async def get_video_length(video: bytes) -> float: async def get_video_length(video: bytes) -> float:
"""Uses ffprobe to get the length of the video file passed in as a byte stream.""" """Use ffprobe to get the length of the video file passed in as a byte stream."""
returncode, stdout, stderr = await run_command( returncode, stdout, stderr = await run_command(
"ffprobe -v quiet -show_streams -select_streams v:0 -of json -", video "ffprobe -v quiet -show_streams -select_streams v:0 -of json -", video
) )
@@ -62,6 +62,7 @@ class VideoDownloader:
color_logging (bool): Whether or not to add color to logging output color_logging (bool): Whether or not to add color to logging output
download_rate_limit (float): Limit how events can be downloaded in one minute", download_rate_limit (float): Limit how events can be downloaded in one minute",
max_event_length (timedelta): Maximum length in seconds for an event to be considered valid and downloaded max_event_length (timedelta): Maximum length in seconds for an event to be considered valid and downloaded
""" """
self._protect: ProtectApiClient = protect self._protect: ProtectApiClient = protect
self._db: aiosqlite.Connection = db self._db: aiosqlite.Connection = db
@@ -86,7 +87,7 @@ class VideoDownloader:
self._has_ffprobe = False self._has_ffprobe = False
async def start(self): async def start(self):
"""Main loop.""" """Run main loop."""
self.logger.info("Starting Downloader") self.logger.info("Starting Downloader")
while True: while True:
if self._limiter: if self._limiter:
@@ -174,7 +175,7 @@ class VideoDownloader:
self.logger.error(f"Unexpected exception occurred, abandoning event {event.id}:", exc_info=e) self.logger.error(f"Unexpected exception occurred, abandoning event {event.id}:", exc_info=e)
async def _download(self, event: Event) -> Optional[bytes]: async def _download(self, event: Event) -> Optional[bytes]:
"""Downloads the video clip for the given event.""" """Download the video clip for the given event."""
self.logger.debug(" Downloading video...") self.logger.debug(" Downloading video...")
for x in range(5): for x in range(5):
assert isinstance(event.camera_id, str) assert isinstance(event.camera_id, str)
@@ -185,7 +186,7 @@ class VideoDownloader:
assert isinstance(video, bytes) assert isinstance(video, bytes)
break break
except (AssertionError, ClientPayloadError, TimeoutError) as e: except (AssertionError, ClientPayloadError, TimeoutError) as e:
self.logger.warning(f" Failed download attempt {x+1}, retying in 1s", exc_info=e) self.logger.warning(f" Failed download attempt {x + 1}, retying in 1s", exc_info=e)
await asyncio.sleep(1) await asyncio.sleep(1)
else: else:
self.logger.error(f"Download failed after 5 attempts, abandoning event {event.id}:") self.logger.error(f"Download failed after 5 attempts, abandoning event {event.id}:")
@@ -210,7 +211,7 @@ class VideoDownloader:
""" """
try: try:
downloaded_duration = await get_video_length(video) downloaded_duration = await get_video_length(video)
msg = f" Downloaded video length: {downloaded_duration:.3f}s" f"({downloaded_duration - duration:+.3f}s)" msg = f" Downloaded video length: {downloaded_duration:.3f}s ({downloaded_duration - duration:+.3f}s)"
if downloaded_duration < duration: if downloaded_duration < duration:
self.logger.warning(msg) self.logger.warning(msg)
else: else:

View File

@@ -27,7 +27,7 @@ from unifi_protect_backup.utils import (
async def get_video_length(video: bytes) -> float: async def get_video_length(video: bytes) -> float:
"""Uses ffprobe to get the length of the video file passed in as a byte stream.""" """Use ffprobe to get the length of the video file passed in as a byte stream."""
returncode, stdout, stderr = await run_command( returncode, stdout, stderr = await run_command(
"ffprobe -v quiet -show_streams -select_streams v:0 -of json -", video "ffprobe -v quiet -show_streams -select_streams v:0 -of json -", video
) )
@@ -62,6 +62,7 @@ class VideoDownloaderExperimental:
color_logging (bool): Whether or not to add color to logging output color_logging (bool): Whether or not to add color to logging output
download_rate_limit (float): Limit how events can be downloaded in one minute", download_rate_limit (float): Limit how events can be downloaded in one minute",
max_event_length (timedelta): Maximum length in seconds for an event to be considered valid and downloaded max_event_length (timedelta): Maximum length in seconds for an event to be considered valid and downloaded
""" """
self._protect: ProtectApiClient = protect self._protect: ProtectApiClient = protect
self._db: aiosqlite.Connection = db self._db: aiosqlite.Connection = db
@@ -86,7 +87,7 @@ class VideoDownloaderExperimental:
self._has_ffprobe = False self._has_ffprobe = False
async def start(self): async def start(self):
"""Main loop.""" """Run main loop."""
self.logger.info("Starting Downloader") self.logger.info("Starting Downloader")
while True: while True:
if self._limiter: if self._limiter:
@@ -180,7 +181,7 @@ class VideoDownloaderExperimental:
) )
async def _download(self, event: Event) -> Optional[bytes]: async def _download(self, event: Event) -> Optional[bytes]:
"""Downloads the video clip for the given event.""" """Download the video clip for the given event."""
self.logger.debug(" Downloading video...") self.logger.debug(" Downloading video...")
for x in range(5): for x in range(5):
assert isinstance(event.camera_id, str) assert isinstance(event.camera_id, str)
@@ -196,7 +197,7 @@ class VideoDownloaderExperimental:
assert isinstance(video, bytes) assert isinstance(video, bytes)
break break
except (AssertionError, ClientPayloadError, TimeoutError) as e: except (AssertionError, ClientPayloadError, TimeoutError) as e:
self.logger.warning(f" Failed download attempt {x+1}, retying in 1s", exc_info=e) self.logger.warning(f" Failed download attempt {x + 1}, retying in 1s", exc_info=e)
await asyncio.sleep(1) await asyncio.sleep(1)
else: else:
self.logger.error(f"Download failed after 5 attempts, abandoning event {event.id}:") self.logger.error(f"Download failed after 5 attempts, abandoning event {event.id}:")
@@ -221,7 +222,7 @@ class VideoDownloaderExperimental:
""" """
try: try:
downloaded_duration = await get_video_length(video) downloaded_duration = await get_video_length(video)
msg = f" Downloaded video length: {downloaded_duration:.3f}s" f"({downloaded_duration - duration:+.3f}s)" msg = f" Downloaded video length: {downloaded_duration:.3f}s ({downloaded_duration - duration:+.3f}s)"
if downloaded_duration < duration: if downloaded_duration < duration:
self.logger.warning(msg) self.logger.warning(msg)
else: else:

View File

@@ -33,6 +33,7 @@ class EventListener:
detection_types (List[str]): Desired Event detection types to look for detection_types (List[str]): Desired Event detection types to look for
ignore_cameras (List[str]): Cameras IDs to ignore events from ignore_cameras (List[str]): Cameras IDs to ignore events from
cameras (List[str]): Cameras IDs to ONLY include events from cameras (List[str]): Cameras IDs to ONLY include events from
""" """
self._event_queue: asyncio.Queue = event_queue self._event_queue: asyncio.Queue = event_queue
self._protect: ProtectApiClient = protect self._protect: ProtectApiClient = protect
@@ -43,18 +44,19 @@ class EventListener:
self.cameras: List[str] = cameras self.cameras: List[str] = cameras
async def start(self): async def start(self):
"""Main Loop.""" """Run main Loop."""
logger.debug("Subscribed to websocket") logger.debug("Subscribed to websocket")
self._unsub_websocket_state = self._protect.subscribe_websocket_state(self._websocket_state_callback) self._unsub_websocket_state = self._protect.subscribe_websocket_state(self._websocket_state_callback)
self._unsub = self._protect.subscribe_websocket(self._websocket_callback) self._unsub = self._protect.subscribe_websocket(self._websocket_callback)
def _websocket_callback(self, msg: WSSubscriptionMessage) -> None: def _websocket_callback(self, msg: WSSubscriptionMessage) -> None:
"""Callback for "EVENT" websocket messages. """'EVENT' websocket message callback.
Filters the incoming events, and puts completed events onto the download queue Filters the incoming events, and puts completed events onto the download queue
Args: Args:
msg (Event): Incoming event data msg (Event): Incoming event data
""" """
logger.websocket_data(msg) # type: ignore logger.websocket_data(msg) # type: ignore
@@ -107,12 +109,13 @@ class EventListener:
logger.debug(f"Adding event {msg.new_obj.id} to queue (Current download queue={self._event_queue.qsize()})") logger.debug(f"Adding event {msg.new_obj.id} to queue (Current download queue={self._event_queue.qsize()})")
def _websocket_state_callback(self, state: WebsocketState) -> None: def _websocket_state_callback(self, state: WebsocketState) -> None:
"""Callback for websocket state messages. """Websocket state message callback.
Flags the websocket for reconnection Flags the websocket for reconnection
Args: Args:
msg (WebsocketState): new state of the websocket state (WebsocketState): new state of the websocket
""" """
if state == WebsocketState.DISCONNECTED: if state == WebsocketState.DISCONNECTED:
logger.error("Unifi Protect Websocket lost connection. Reconnecting...") logger.error("Unifi Protect Websocket lost connection. Reconnecting...")

View File

@@ -45,6 +45,7 @@ class MissingEventChecker:
ignore_cameras (List[str]): Ignored camera IDs to limit search ignore_cameras (List[str]): Ignored camera IDs to limit search
cameras (List[str]): Included (ONLY) camera IDs to limit search cameras (List[str]): Included (ONLY) camera IDs to limit search
interval (int): How frequently, in seconds, to check for missing events, interval (int): How frequently, in seconds, to check for missing events,
""" """
self._protect: ProtectApiClient = protect self._protect: ProtectApiClient = protect
self._db: aiosqlite.Connection = db self._db: aiosqlite.Connection = db
@@ -111,7 +112,7 @@ class MissingEventChecker:
missing_event_ids = set(unifi_events.keys()) - (db_event_ids | downloading_event_ids | uploading_event_ids) missing_event_ids = set(unifi_events.keys()) - (db_event_ids | downloading_event_ids | uploading_event_ids)
# Exclude events of unwanted types # Exclude events of unwanted types
def wanted_event_type(event_id): def wanted_event_type(event_id, unifi_events=unifi_events):
event = unifi_events[event_id] event = unifi_events[event_id]
if event.start is None or event.end is None: if event.start is None or event.end is None:
return False # This event is still on-going return False # This event is still on-going
@@ -156,7 +157,7 @@ class MissingEventChecker:
await self._db.commit() await self._db.commit()
async def start(self): async def start(self):
"""Main loop.""" """Run main loop."""
logger.info("Starting Missing Event Checker") logger.info("Starting Missing Event Checker")
while True: while True:
try: try:

View File

@@ -13,14 +13,14 @@ logger = logging.getLogger(__name__)
async def delete_file(file_path, rclone_purge_args): async def delete_file(file_path, rclone_purge_args):
"""Deletes `file_path` via rclone.""" """Delete `file_path` via rclone."""
returncode, stdout, stderr = await run_command(f'rclone delete -vv "{file_path}" {rclone_purge_args}') returncode, stdout, stderr = await run_command(f'rclone delete -vv "{file_path}" {rclone_purge_args}')
if returncode != 0: if returncode != 0:
logger.error(f" Failed to delete file: '{file_path}'") logger.error(f" Failed to delete file: '{file_path}'")
async def tidy_empty_dirs(base_dir_path): async def tidy_empty_dirs(base_dir_path):
"""Deletes any empty directories in `base_dir_path` via rclone.""" """Delete any empty directories in `base_dir_path` via rclone."""
returncode, stdout, stderr = await run_command(f'rclone rmdirs -vv --ignore-errors --leave-root "{base_dir_path}"') returncode, stdout, stderr = await run_command(f'rclone rmdirs -vv --ignore-errors --leave-root "{base_dir_path}"')
if returncode != 0: if returncode != 0:
logger.error(" Failed to tidy empty dirs") logger.error(" Failed to tidy empty dirs")
@@ -34,7 +34,7 @@ class Purge:
db: aiosqlite.Connection, db: aiosqlite.Connection,
retention: relativedelta, retention: relativedelta,
rclone_destination: str, rclone_destination: str,
interval: relativedelta = relativedelta(days=1), interval: relativedelta | None,
rclone_purge_args: str = "", rclone_purge_args: str = "",
): ):
"""Init. """Init.
@@ -45,15 +45,16 @@ class Purge:
rclone_destination (str): What rclone destination the clips are stored in rclone_destination (str): What rclone destination the clips are stored in
interval (relativedelta): How often to purge old clips interval (relativedelta): How often to purge old clips
rclone_purge_args (str): Optional extra arguments to pass to `rclone delete` directly. rclone_purge_args (str): Optional extra arguments to pass to `rclone delete` directly.
""" """
self._db: aiosqlite.Connection = db self._db: aiosqlite.Connection = db
self.retention: relativedelta = retention self.retention: relativedelta = retention
self.rclone_destination: str = rclone_destination self.rclone_destination: str = rclone_destination
self.interval: relativedelta = interval self.interval: relativedelta = interval if interval is not None else relativedelta(days=1)
self.rclone_purge_args: str = rclone_purge_args self.rclone_purge_args: str = rclone_purge_args
async def start(self): async def start(self):
"""Main loop - runs forever.""" """Run main loop."""
while True: while True:
try: try:
deleted_a_file = False deleted_a_file = False
@@ -63,7 +64,7 @@ class Purge:
async with self._db.execute( async with self._db.execute(
f"SELECT * FROM events WHERE end < {retention_oldest_time}" f"SELECT * FROM events WHERE end < {retention_oldest_time}"
) as event_cursor: ) as event_cursor:
async for event_id, event_type, camera_id, event_start, event_end in event_cursor: async for event_id, event_type, camera_id, event_start, event_end in event_cursor: # noqa: B007
logger.info(f"Purging event: {event_id}.") logger.info(f"Purging event: {event_id}.")
# For every backup for this event # For every backup for this event

View File

@@ -1,3 +1,5 @@
"""Monkey patch new download method into uiprotect till PR is merged."""
import enum import enum
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -10,13 +12,15 @@ from uiprotect.exceptions import BadRequest
from uiprotect.utils import to_js_time from uiprotect.utils import to_js_time
# First, let's add the new VideoExportType enum
class VideoExportType(str, enum.Enum): class VideoExportType(str, enum.Enum):
"""Unifi Protect video export types."""
TIMELAPSE = "timelapse" TIMELAPSE = "timelapse"
ROTATING = "rotating" ROTATING = "rotating"
def monkey_patch_experimental_downloader(): def monkey_patch_experimental_downloader():
"""Apply patches to uiprotect to add new download method."""
from uiprotect.api import ProtectApiClient from uiprotect.api import ProtectApiClient
# Add the version constant # Add the version constant

View File

@@ -43,7 +43,7 @@ monkey_patch_experimental_downloader()
async def create_database(path: str): async def create_database(path: str):
"""Creates sqlite database and creates the events abd backups tables.""" """Create sqlite database and creates the events abd backups tables."""
db = await aiosqlite.connect(path) db = await aiosqlite.connect(path)
await db.execute("CREATE TABLE events(id PRIMARY KEY, type, camera_id, start REAL, end REAL)") await db.execute("CREATE TABLE events(id PRIMARY KEY, type, camera_id, start REAL, end REAL)")
await db.execute( await db.execute(
@@ -117,7 +117,8 @@ class UnifiProtectBackup:
color_logging (bool): Whether to add color to logging output or not color_logging (bool): Whether to add color to logging output or not
download_rate_limit (float): Limit how events can be downloaded in one minute. Disabled by default", download_rate_limit (float): Limit how events can be downloaded in one minute. Disabled by default",
max_event_length (int): Maximum length in seconds for an event to be considered valid and downloaded max_event_length (int): Maximum length in seconds for an event to be considered valid and downloaded
use_experimental_downloader (bool): Use the new experimental downloader (the same method as used by the webUI) use_experimental_downloader (bool): Use the new experimental downloader (the same method as used by the
webUI)
parallel_uploads (int): Max number of parallel uploads to allow parallel_uploads (int): Max number of parallel uploads to allow
""" """
self.color_logging = color_logging self.color_logging = color_logging
@@ -216,7 +217,7 @@ class UnifiProtectBackup:
delay = 5 # Start with a 5 second delay delay = 5 # Start with a 5 second delay
max_delay = 3600 # 1 hour in seconds max_delay = 3600 # 1 hour in seconds
for attempts in range(20): for _ in range(20):
try: try:
await self._protect.update() await self._protect.update()
break break
@@ -279,7 +280,7 @@ class UnifiProtectBackup:
# Create upload tasks # Create upload tasks
# This will upload the videos in the downloader's buffer to the rclone remotes and log it in the database # This will upload the videos in the downloader's buffer to the rclone remotes and log it in the database
uploaders = [] uploaders = []
for i in range(self._parallel_uploads): for _ in range(self._parallel_uploads):
uploader = VideoUploader( uploader = VideoUploader(
self._protect, self._protect,
upload_queue, upload_queue,

View File

@@ -59,7 +59,7 @@ class VideoUploader:
self.logger = logging.LoggerAdapter(self.base_logger, {"event": ""}) self.logger = logging.LoggerAdapter(self.base_logger, {"event": ""})
async def start(self): async def start(self):
"""Main loop. """Run main loop.
Runs forever looking for video data in the video queue and then uploads it Runs forever looking for video data in the video queue and then uploads it
using rclone, finally it updates the database using rclone, finally it updates the database
@@ -106,6 +106,7 @@ class VideoUploader:
Raises: Raises:
RuntimeError: If rclone returns a non-zero exit code RuntimeError: If rclone returns a non-zero exit code
""" """
returncode, stdout, stderr = await run_command(f'rclone rcat -vv {rclone_args} "{destination}"', video) returncode, stdout, stderr = await run_command(f'rclone rcat -vv {rclone_args} "{destination}"', video)
if returncode != 0: if returncode != 0:
@@ -131,7 +132,7 @@ class VideoUploader:
await self._db.commit() await self._db.commit()
async def _generate_file_path(self, event: Event) -> pathlib.Path: async def _generate_file_path(self, event: Event) -> pathlib.Path:
"""Generates the rclone destination path for the provided event. """Generate the rclone destination path for the provided event.
Generates rclone destination path for the given even based upon the format string Generates rclone destination path for the given even based upon the format string
in `self.file_structure_format`. in `self.file_structure_format`.

View File

@@ -4,7 +4,7 @@ import asyncio
import logging import logging
import re import re
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import Optional
from apprise import NotifyType from apprise import NotifyType
from async_lru import alru_cache from async_lru import alru_cache
@@ -109,6 +109,9 @@ class AppriseStreamHandler(logging.StreamHandler):
Args: Args:
color_logging (bool): If true logging levels will be colorized color_logging (bool): If true logging levels will be colorized
*args (): Positional arguments to pass to StreamHandler
**kwargs: Keyword arguments to pass to StreamHandler
""" """
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.color_logging = color_logging self.color_logging = color_logging
@@ -172,7 +175,7 @@ class AppriseStreamHandler(logging.StreamHandler):
def create_logging_handler(format, color_logging): def create_logging_handler(format, color_logging):
"""Constructs apprise logging handler for the given format.""" """Construct apprise logging handler for the given format."""
date_format = "%Y-%m-%d %H:%M:%S" date_format = "%Y-%m-%d %H:%M:%S"
style = "{" style = "{"
@@ -182,8 +185,8 @@ def create_logging_handler(format, color_logging):
return sh return sh
def setup_logging(verbosity: int, color_logging: bool = False, apprise_notifiers: List[str] = []) -> None: def setup_logging(verbosity: int, color_logging: bool = False) -> None:
"""Configures loggers to provided the desired level of verbosity. """Configure loggers to provided the desired level of verbosity.
Verbosity 0: Only log info messages created by `unifi-protect-backup`, and all warnings Verbosity 0: Only log info messages created by `unifi-protect-backup`, and all warnings
verbosity 1: Only log info & debug messages created by `unifi-protect-backup`, and all warnings verbosity 1: Only log info & debug messages created by `unifi-protect-backup`, and all warnings
@@ -199,7 +202,6 @@ def setup_logging(verbosity: int, color_logging: bool = False, apprise_notifiers
Args: Args:
verbosity (int): The desired level of verbosity verbosity (int): The desired level of verbosity
color_logging (bool): If colors should be used in the log (default=False) color_logging (bool): If colors should be used in the log (default=False)
apprise_notifiers (List[str]): Notification services to hook into the logger
""" """
add_logging_level( add_logging_level(
@@ -242,7 +244,7 @@ _initialized_loggers = []
def setup_event_logger(logger, color_logging): def setup_event_logger(logger, color_logging):
"""Sets up a logger that also displays the event ID currently being processed.""" """Set up a logger that also displays the event ID currently being processed."""
global _initialized_loggers global _initialized_loggers
if logger not in _initialized_loggers: if logger not in _initialized_loggers:
format = "{asctime} [{levelname:^11s}] {name:<42} :{event} {message}" format = "{asctime} [{levelname:^11s}] {name:<42} :{event} {message}"
@@ -256,12 +258,13 @@ _suffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]
def human_readable_size(num: float): def human_readable_size(num: float):
"""Turns a number into a human readable number with ISO/IEC 80000 binary prefixes. """Turn a number into a human readable number with ISO/IEC 80000 binary prefixes.
Based on: https://stackoverflow.com/a/1094933 Based on: https://stackoverflow.com/a/1094933
Args: Args:
num (int): The number to be converted into human readable format num (int): The number to be converted into human readable format
""" """
for unit in _suffixes: for unit in _suffixes:
if abs(num) < 1024.0: if abs(num) < 1024.0:
@@ -271,7 +274,7 @@ def human_readable_size(num: float):
def human_readable_to_float(num: str): def human_readable_to_float(num: str):
"""Turns a human readable ISO/IEC 80000 suffix value to its full float value.""" """Turn a human readable ISO/IEC 80000 suffix value to its full float value."""
pattern = r"([\d.]+)(" + "|".join(_suffixes) + ")" pattern = r"([\d.]+)(" + "|".join(_suffixes) + ")"
result = re.match(pattern, num) result = re.match(pattern, num)
if result is None: if result is None:
@@ -287,7 +290,7 @@ def human_readable_to_float(num: str):
# No max size, and a 6 hour ttl # No max size, and a 6 hour ttl
@alru_cache(None, ttl=60 * 60 * 6) @alru_cache(None, ttl=60 * 60 * 6)
async def get_camera_name(protect: ProtectApiClient, id: str): async def get_camera_name(protect: ProtectApiClient, id: str):
"""Returns the name for the camera with the given ID. """Return the name for the camera with the given ID.
If the camera ID is not know, it tries refreshing the cached data If the camera ID is not know, it tries refreshing the cached data
""" """
@@ -322,6 +325,7 @@ class SubprocessException(Exception):
stdout (str): What rclone output to stdout stdout (str): What rclone output to stdout
stderr (str): What rclone output to stderr stderr (str): What rclone output to stderr
returncode (str): The return code of the rclone process returncode (str): The return code of the rclone process
""" """
super().__init__() super().__init__()
self.stdout: str = stdout self.stdout: str = stdout
@@ -329,12 +333,12 @@ class SubprocessException(Exception):
self.returncode: int = returncode self.returncode: int = returncode
def __str__(self): def __str__(self):
"""Turns exception into a human readable form.""" """Turn exception into a human readable form."""
return f"Return Code: {self.returncode}\nStdout:\n{self.stdout}\nStderr:\n{self.stderr}" return f"Return Code: {self.returncode}\nStdout:\n{self.stdout}\nStderr:\n{self.stderr}"
async def run_command(cmd: str, data=None): async def run_command(cmd: str, data=None):
"""Runs the given command returning the exit code, stdout and stderr.""" """Run the given command returning the exit code, stdout and stderr."""
proc = await asyncio.create_subprocess_shell( proc = await asyncio.create_subprocess_shell(
cmd, cmd,
stdin=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
@@ -367,11 +371,11 @@ class VideoQueue(asyncio.Queue):
self._bytes_sum = 0 self._bytes_sum = 0
def qsize(self): def qsize(self):
"""Number of items in the queue.""" """Get number of items in the queue."""
return self._bytes_sum return self._bytes_sum
def qsize_files(self): def qsize_files(self):
"""Number of items in the queue.""" """Get number of items in the queue."""
return super().qsize() return super().qsize()
def _get(self): def _get(self):