mirror of
https://github.com/ep1cman/unifi-protect-backup.git
synced 2025-12-05 23:53:30 +00:00
flake8 & mypy fixes
This commit is contained in:
14
poetry.lock
generated
14
poetry.lock
generated
@@ -2169,6 +2169,18 @@ files = [
|
||||
{file = "types_cryptography-3.3.23.2-py3-none-any.whl", hash = "sha256:b965d548f148f8e87f353ccf2b7bd92719fdf6c845ff7cedf2abb393a0643e4f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-python-dateutil"
|
||||
version = "2.8.19.10"
|
||||
description = "Typing stubs for python-dateutil"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "types-python-dateutil-2.8.19.10.tar.gz", hash = "sha256:c640f2eb71b4b94a9d3bfda4c04250d29a24e51b8bad6e12fddec0cf6e96f7a3"},
|
||||
{file = "types_python_dateutil-2.8.19.10-py3-none-any.whl", hash = "sha256:fbecd02c19cac383bf4a16248d45ffcff17c93a04c0794be5f95d42c6aa5de39"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-pytz"
|
||||
version = "2021.3.8"
|
||||
@@ -2382,4 +2394,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = ">=3.9.0,<4.0"
|
||||
content-hash = "9025e6deb7dca5f2c5cbf1f5545dcf82789c5190634993b8b88899865d86208c"
|
||||
content-hash = "403929920e1f93f1267d3ffc57b3586451d6c880407c2e399be10f8f028315dd"
|
||||
|
||||
@@ -44,6 +44,7 @@ types-cryptography = "^3.3.18"
|
||||
twine = "^3.3.0"
|
||||
bump2version = "^1.0.1"
|
||||
pre-commit = "^2.12.0"
|
||||
types-python-dateutil = "^2.8.19.10"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
@@ -88,6 +89,9 @@ skip_gitignore = true
|
||||
# you can skip files as below
|
||||
#skip_glob = docs/conf.py
|
||||
|
||||
[tool.mypy]
|
||||
allow_redefinition=true
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
"""Tests for `unifi_protect_backup` package."""
|
||||
|
||||
import pytest
|
||||
import pytest # type: ignore
|
||||
|
||||
# from click.testing import CliRunner
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Console script for unifi_protect_backup."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import click
|
||||
from aiorun import run
|
||||
from aiorun import run # type: ignore
|
||||
|
||||
from unifi_protect_backup import __version__
|
||||
from unifi_protect_backup.unifi_protect_backup_core import UnifiProtectBackup
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# noqa: D100
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import pytz
|
||||
from aiohttp.client_exceptions import ClientPayloadError
|
||||
@@ -21,7 +24,7 @@ from unifi_protect_backup.utils import (
|
||||
|
||||
|
||||
async def get_video_length(video: bytes) -> float:
|
||||
"""Uses ffprobe to get the length of the video file passed in as a byte stream"""
|
||||
"""Uses ffprobe to get the length of the video file passed in as a byte stream."""
|
||||
returncode, stdout, stderr = await run_command(
|
||||
'ffprobe -v quiet -show_streams -select_streams v:0 -of json -', video
|
||||
)
|
||||
@@ -34,11 +37,19 @@ async def get_video_length(video: bytes) -> float:
|
||||
|
||||
|
||||
class VideoDownloader:
|
||||
"""Downloads event video clips from Unifi Protect"""
|
||||
"""Downloads event video clips from Unifi Protect."""
|
||||
|
||||
def __init__(
|
||||
self, protect: ProtectApiClient, download_queue: asyncio.Queue, upload_queue: VideoQueue, color_logging: bool
|
||||
):
|
||||
"""Init.
|
||||
|
||||
Args:
|
||||
protect (ProtectApiClient): UniFi Protect API client to use
|
||||
download_queue (asyncio.Queue): Queue to get event details from
|
||||
upload_queue (VideoQueue): Queue to place downloaded videos on
|
||||
color_logging (bool): Whether or not to add color to logging output
|
||||
"""
|
||||
self._protect: ProtectApiClient = protect
|
||||
self.download_queue: asyncio.Queue = download_queue
|
||||
self.upload_queue: VideoQueue = upload_queue
|
||||
@@ -57,7 +68,7 @@ class VideoDownloader:
|
||||
self._has_ffprobe = False
|
||||
|
||||
async def start(self):
|
||||
"""Main loop"""
|
||||
"""Main loop."""
|
||||
self.logger.info("Starting Downloader")
|
||||
while True:
|
||||
try:
|
||||
@@ -113,11 +124,14 @@ class VideoDownloader:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unexpected exception occurred, abandoning event {event.id}:", exc_info=e)
|
||||
|
||||
async def _download(self, event: Event) -> bytes:
|
||||
"""Downloads the video clip for the given event"""
|
||||
async def _download(self, event: Event) -> Optional[bytes]:
|
||||
"""Downloads the video clip for the given event."""
|
||||
self.logger.debug(" Downloading video...")
|
||||
for x in range(5):
|
||||
try:
|
||||
assert isinstance(event.camera_id, str)
|
||||
assert isinstance(event.start, datetime)
|
||||
assert isinstance(event.end, datetime)
|
||||
video = await self._protect.get_camera_video(event.camera_id, event.start, event.end)
|
||||
assert isinstance(video, bytes)
|
||||
break
|
||||
@@ -126,15 +140,16 @@ class VideoDownloader:
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
self.logger.error(f"Download failed after 5 attempts, abandoning event {event.id}:")
|
||||
return
|
||||
return None
|
||||
|
||||
self.logger.debug(f" Downloaded video size: {human_readable_size(len(video))}s")
|
||||
return video
|
||||
|
||||
async def _check_video_length(self, video, duration):
|
||||
"""Check if the downloaded event is at least the length of the event, warn otherwise
|
||||
"""Check if the downloaded event is at least the length of the event, warn otherwise.
|
||||
|
||||
It is expected for events to regularly be slightly longer than the event specified"""
|
||||
It is expected for events to regularly be slightly longer than the event specified
|
||||
"""
|
||||
try:
|
||||
downloaded_duration = await get_video_length(video)
|
||||
msg = f" Downloaded video length: {downloaded_duration:.3f}s" f"({downloaded_duration - duration:+.3f}s)"
|
||||
@@ -143,4 +158,4 @@ class VideoDownloader:
|
||||
else:
|
||||
self.logger.debug(msg)
|
||||
except SubprocessException as e:
|
||||
self.logger.warning(" `ffprobe` failed")
|
||||
self.logger.warning(" `ffprobe` failed", exc_info=e)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# noqa: D100
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from time import sleep
|
||||
@@ -12,7 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventListener:
|
||||
"""Listens to the unifi protect websocket for new events to backup"""
|
||||
"""Listens to the unifi protect websocket for new events to backup."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -21,6 +23,14 @@ class EventListener:
|
||||
detection_types: List[str],
|
||||
ignore_cameras: List[str],
|
||||
):
|
||||
"""Init.
|
||||
|
||||
Args:
|
||||
event_queue (asyncio.Queue): Queue to place events to backup on
|
||||
protect (ProtectApiClient): UniFI Protect API client to use
|
||||
detection_types (List[str]): Desired Event detection types to look for
|
||||
ignore_cameras (List[str]): Cameras IDs to ignore events from
|
||||
"""
|
||||
self._event_queue: asyncio.Queue = event_queue
|
||||
self._protect: ProtectApiClient = protect
|
||||
self._unsub = None
|
||||
@@ -28,7 +38,7 @@ class EventListener:
|
||||
self.ignore_cameras: List[str] = ignore_cameras
|
||||
|
||||
async def start(self):
|
||||
"""Main Loop"""
|
||||
"""Main Loop."""
|
||||
logger.debug("Subscribed to websocket")
|
||||
self._unsub = self._protect.subscribe_websocket(self._websocket_callback)
|
||||
|
||||
@@ -71,7 +81,7 @@ class EventListener:
|
||||
|
||||
# TODO: Will this even work? I think it will block the async loop
|
||||
while self._event_queue.full():
|
||||
logger.extra_debug("Event queue full, waiting 1s...")
|
||||
logger.extra_debug("Event queue full, waiting 1s...") # type: ignore
|
||||
sleep(1)
|
||||
|
||||
self._event_queue.put_nowait(msg.new_obj)
|
||||
@@ -85,8 +95,7 @@ class EventListener:
|
||||
logger.debug(f"Adding event {msg.new_obj.id} to queue (Current download queue={self._event_queue.qsize()})")
|
||||
|
||||
async def _check_websocket_and_reconnect(self):
|
||||
"""Checks for websocket disconnect and triggers a reconnect"""
|
||||
|
||||
"""Checks for websocket disconnect and triggers a reconnect."""
|
||||
logger.extra_debug("Checking the status of the websocket...")
|
||||
if self._protect.check_ws():
|
||||
logger.extra_debug("Websocket is connected.")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# noqa: D100
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
@@ -14,7 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MissingEventChecker:
|
||||
"""Periodically checks if any unifi protect events exist within the retention period that are not backed up"""
|
||||
"""Periodically checks if any unifi protect events exist within the retention period that are not backed up."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -28,6 +30,19 @@ class MissingEventChecker:
|
||||
ignore_cameras: List[str],
|
||||
interval: int = 60 * 5,
|
||||
) -> None:
|
||||
"""Init.
|
||||
|
||||
Args:
|
||||
protect (ProtectApiClient): UniFi Protect API client to use
|
||||
db (aiosqlite.Connection): Async SQLite database to check for missing events
|
||||
download_queue (asyncio.Queue): Download queue to check for on-going downloads
|
||||
downloader (VideoDownloader): Downloader to check for on-going downloads
|
||||
uploader (VideoUploader): Uploader to check for on-going uploads
|
||||
retention (relativedelta): Retention period to limit search window
|
||||
detection_types (List[str]): Detection types wanted to limit search
|
||||
ignore_cameras (List[str]): Ignored camera IDs to limit search
|
||||
interval (int): How frequently, in seconds, to check for missing events,
|
||||
"""
|
||||
self._protect: ProtectApiClient = protect
|
||||
self._db: aiosqlite.Connection = db
|
||||
self._download_queue: asyncio.Queue = download_queue
|
||||
@@ -39,7 +54,7 @@ class MissingEventChecker:
|
||||
self.interval: int = interval
|
||||
|
||||
async def start(self):
|
||||
"""main loop"""
|
||||
"""Main loop."""
|
||||
logger.info("Starting Missing Event Checker")
|
||||
while True:
|
||||
try:
|
||||
@@ -102,15 +117,19 @@ class MissingEventChecker:
|
||||
event = unifi_events[event_id]
|
||||
if event.type != EventType.SMART_DETECT:
|
||||
missing_logger(
|
||||
f" Adding missing event to backup queue: {event.id} ({event.type}) ({event.start.strftime('%Y-%m-%dT%H-%M-%S')} - {event.end.strftime('%Y-%m-%dT%H-%M-%S')})"
|
||||
f" Adding missing event to backup queue: {event.id} ({event.type})"
|
||||
f" ({event.start.strftime('%Y-%m-%dT%H-%M-%S')} -"
|
||||
f" {event.end.strftime('%Y-%m-%dT%H-%M-%S')})"
|
||||
)
|
||||
else:
|
||||
missing_logger(
|
||||
f" Adding missing event to backup queue: {event.id} ({', '.join(event.smart_detect_types)}) ({event.start.strftime('%Y-%m-%dT%H-%M-%S')} - {event.end.strftime('%Y-%m-%dT%H-%M-%S')})"
|
||||
f" Adding missing event to backup queue: {event.id} ({', '.join(event.smart_detect_types)})"
|
||||
f" ({event.start.strftime('%Y-%m-%dT%H-%M-%S')} -"
|
||||
f" {event.end.strftime('%Y-%m-%dT%H-%M-%S')})"
|
||||
)
|
||||
await self._download_queue.put(event)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected exception occurred during missing event check:", exc_info=e)
|
||||
logger.error("Unexpected exception occurred during missing event check:", exc_info=e)
|
||||
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""A 'singleton' module for registering apprise notifiers."""
|
||||
|
||||
import apprise
|
||||
|
||||
notifier = apprise.Apprise()
|
||||
|
||||
|
||||
def add_notification_service(url):
|
||||
"""Add apprise URI with support for tags e.g. TAG1,TAG2=PROTOCOL://settings."""
|
||||
config = apprise.AppriseConfig()
|
||||
config.add_config(url, format='text')
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
# noqa: D100
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
@@ -12,34 +13,44 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def delete_file(file_path):
|
||||
"""Deletes `file_path` via rclone."""
|
||||
returncode, stdout, stderr = await run_command(f'rclone delete -vv "{file_path}"')
|
||||
if returncode != 0:
|
||||
logger.error(f" Failed to delete file: '{file_path}'")
|
||||
|
||||
|
||||
async def tidy_empty_dirs(base_dir_path):
|
||||
"""Deletes 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}"')
|
||||
if returncode != 0:
|
||||
logger.error(f" Failed to tidy empty dirs")
|
||||
logger.error(" Failed to tidy empty dirs")
|
||||
|
||||
|
||||
class Purge:
|
||||
"""Deletes old files from rclone remotes"""
|
||||
"""Deletes old files from rclone remotes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: aiosqlite.Connection,
|
||||
retention: relativedelta,
|
||||
rclone_destination: str,
|
||||
interval: relativedelta(days=1),
|
||||
interval: relativedelta = relativedelta(days=1),
|
||||
):
|
||||
"""Init.
|
||||
|
||||
Args:
|
||||
db (aiosqlite.Connection): Async SQlite database connection to purge clips from
|
||||
retention (relativedelta): How long clips should be kept
|
||||
rclone_destination (str): What rclone destination the clips are stored in
|
||||
interval (relativedelta): How often to purge old clips
|
||||
"""
|
||||
self._db: aiosqlite.Connection = db
|
||||
self.retention: relativedelta = retention
|
||||
self.rclone_destination: str = rclone_destination
|
||||
self.interval: relativedelta = interval
|
||||
|
||||
async def start(self):
|
||||
"""Main loop - runs forever"""
|
||||
"""Main loop - runs forever."""
|
||||
while True:
|
||||
try:
|
||||
deleted_a_file = False
|
||||
@@ -69,7 +80,7 @@ class Purge:
|
||||
await tidy_empty_dirs(self.rclone_destination)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected exception occurred during purge:", exc_info=e)
|
||||
logger.error("Unexpected exception occurred during purge:", exc_info=e)
|
||||
|
||||
next_purge_time = datetime.now() + self.interval
|
||||
logger.extra_debug(f'sleeping until {next_purge_time}')
|
||||
|
||||
@@ -3,9 +3,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from cmath import log
|
||||
from datetime import datetime, timezone
|
||||
from time import sleep
|
||||
from typing import Callable, List
|
||||
|
||||
import aiosqlite
|
||||
@@ -35,7 +33,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_database(path: str):
|
||||
"""Creates sqlite database and creates the events abd backups tables"""
|
||||
"""Creates sqlite database and creates the events abd backups tables."""
|
||||
db = await aiosqlite.connect(path)
|
||||
await db.execute("CREATE TABLE events(id PRIMARY KEY, type, camera_id, start REAL, end REAL)")
|
||||
await db.execute(
|
||||
@@ -92,8 +90,11 @@ class UnifiProtectBackup:
|
||||
ignore_cameras (List[str]): List of camera IDs for which to not backup events.
|
||||
file_structure_format (str): A Python format string for output file path.
|
||||
verbose (int): How verbose to setup logging, see :func:`setup_logging` for details.
|
||||
sqlite_path (str): Path where to find/create sqlite database
|
||||
download_buffer_size (int): How many bytes big the download buffer should be
|
||||
purge_interval (str): How often to check for files to delete
|
||||
apprise_notifiers (str): Apprise URIs for notifications
|
||||
sqlite_path (str): Path where to find/create sqlite database
|
||||
color_logging (bool): Whether to add color to logging output or not
|
||||
"""
|
||||
for notifier in apprise_notifiers:
|
||||
notifications.add_notification_service(notifier)
|
||||
@@ -256,7 +257,7 @@ class UnifiProtectBackup:
|
||||
await self._db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected exception occurred in main loop:", exc_info=e)
|
||||
logger.error("Unexpected exception occurred in main loop:", exc_info=e)
|
||||
await asyncio.sleep(10) # Give remaining tasks a chance to complete e.g sending notifications
|
||||
raise
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
# noqa: D100
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import re
|
||||
@@ -12,7 +13,7 @@ from unifi_protect_backup.utils import VideoQueue, get_camera_name, human_readab
|
||||
|
||||
|
||||
class VideoUploader:
|
||||
"""Uploads videos from the video_queue to the provided rclone destination
|
||||
"""Uploads videos from the video_queue to the provided rclone destination.
|
||||
|
||||
Keeps a log of what its uploaded in `db`
|
||||
"""
|
||||
@@ -27,6 +28,17 @@ class VideoUploader:
|
||||
db: aiosqlite.Connection,
|
||||
color_logging: bool,
|
||||
):
|
||||
"""Init.
|
||||
|
||||
Args:
|
||||
protect (ProtectApiClient): UniFi Protect API client to use
|
||||
upload_queue (VideoQueue): Queue to get video files from
|
||||
rclone_destination (str): rclone file destination URI
|
||||
rclone_args (str): arguments to pass to the rclone command
|
||||
file_structure_format (str): format string for how to structure the uploaded files
|
||||
db (aiosqlite.Connection): Async SQlite database connection
|
||||
color_logging (bool): Whether or not to add color to logging output
|
||||
"""
|
||||
self._protect: ProtectApiClient = protect
|
||||
self.upload_queue: VideoQueue = upload_queue
|
||||
self._rclone_destination: str = rclone_destination
|
||||
@@ -40,11 +52,11 @@ class VideoUploader:
|
||||
self.logger = logging.LoggerAdapter(self.base_logger, {'event': ''})
|
||||
|
||||
async def start(self):
|
||||
"""Main loop
|
||||
"""Main loop.
|
||||
|
||||
Runs forever looking for video data in the video queue and then uploads it using rclone, finally it updates the database
|
||||
Runs forever looking for video data in the video queue and then uploads it
|
||||
using rclone, finally it updates the database
|
||||
"""
|
||||
|
||||
self.logger.info("Starting Uploader")
|
||||
while True:
|
||||
try:
|
||||
@@ -55,7 +67,8 @@ class VideoUploader:
|
||||
|
||||
self.logger.info(f"Uploading event: {event.id}")
|
||||
self.logger.debug(
|
||||
f" Remaining Upload Queue: {self.upload_queue.qsize_files()} ({human_readable_size(self.upload_queue.qsize())})"
|
||||
f" Remaining Upload Queue: {self.upload_queue.qsize_files()}"
|
||||
f" ({human_readable_size(self.upload_queue.qsize())})"
|
||||
)
|
||||
|
||||
destination = await self._generate_file_path(event)
|
||||
@@ -64,7 +77,7 @@ class VideoUploader:
|
||||
await self._upload_video(video, destination, self._rclone_args)
|
||||
await self._update_database(event, destination)
|
||||
|
||||
self.logger.debug(f"Uploaded")
|
||||
self.logger.debug("Uploaded")
|
||||
self.current_event = None
|
||||
|
||||
except Exception as e:
|
||||
@@ -89,13 +102,13 @@ class VideoUploader:
|
||||
self.logger.error(f" Failed to upload file: '{destination}'")
|
||||
|
||||
async def _update_database(self, event: Event, destination: str):
|
||||
"""
|
||||
Add the backed up event to the database along with where it was backed up to
|
||||
"""
|
||||
"""Add the backed up event to the database along with where it was backed up to."""
|
||||
assert isinstance(event.start, datetime)
|
||||
assert isinstance(event.end, datetime)
|
||||
await self._db.execute(
|
||||
f"""INSERT INTO events VALUES
|
||||
('{event.id}', '{event.type}', '{event.camera_id}', '{event.start.timestamp()}', '{event.end.timestamp()}')
|
||||
"""
|
||||
"INSERT INTO events VALUES "
|
||||
f"('{event.id}', '{event.type}', '{event.camera_id}',"
|
||||
f"'{event.start.timestamp()}', '{event.end.timestamp()}')"
|
||||
)
|
||||
|
||||
remote, file_path = str(destination).split(":")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Utility functions used throughout the code, kept here to allow re use and/or minimize clutter elsewhere."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
@@ -7,6 +9,7 @@ from typing import List, Optional
|
||||
from apprise import NotifyType
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from pyunifiprotect import ProtectApiClient
|
||||
from pyunifiprotect.data.nvr import Event
|
||||
|
||||
from unifi_protect_backup import notifications
|
||||
|
||||
@@ -64,9 +67,7 @@ def add_logging_level(levelName: str, levelNum: int, methodName: Optional[str] =
|
||||
logging.log(levelNum, message, *args, **kwargs)
|
||||
|
||||
def adapterLog(self, msg, *args, **kwargs):
|
||||
"""
|
||||
Delegate an error call to the underlying logger.
|
||||
"""
|
||||
"""Delegate an error call to the underlying logger."""
|
||||
self.log(levelNum, msg, *args, **kwargs)
|
||||
|
||||
logging.addLevelName(levelNum, levelName)
|
||||
@@ -80,6 +81,7 @@ color_logging = False
|
||||
|
||||
|
||||
def add_color_to_record_levelname(record):
|
||||
"""Colorizes logging level names."""
|
||||
levelno = record.levelno
|
||||
if levelno >= logging.CRITICAL:
|
||||
color = '\x1b[31;1m' # RED
|
||||
@@ -100,11 +102,18 @@ def add_color_to_record_levelname(record):
|
||||
|
||||
|
||||
class AppriseStreamHandler(logging.StreamHandler):
|
||||
def __init__(self, color_logging, *args, **kwargs):
|
||||
"""Logging handler that also sends logging output to configured Apprise notifiers."""
|
||||
|
||||
def __init__(self, color_logging: bool, *args, **kwargs):
|
||||
"""Init.
|
||||
|
||||
Args:
|
||||
color_logging (bool): If true logging levels will be colorized
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.color_logging = color_logging
|
||||
|
||||
def emit_apprise(self, record):
|
||||
def _emit_apprise(self, record):
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
@@ -134,7 +143,7 @@ class AppriseStreamHandler(logging.StreamHandler):
|
||||
else:
|
||||
loop.run_until_complete(notify)
|
||||
|
||||
def emit_stream(self, record):
|
||||
def _emit_stream(self, record):
|
||||
record.levelname = f"{record.levelname:^11s}" # Pad level name to max width
|
||||
if self.color_logging:
|
||||
record.levelname = add_color_to_record_levelname(record)
|
||||
@@ -146,15 +155,16 @@ class AppriseStreamHandler(logging.StreamHandler):
|
||||
self.flush()
|
||||
|
||||
def emit(self, record):
|
||||
"""Emit log to stdout and apprise."""
|
||||
try:
|
||||
self.emit_apprise(record)
|
||||
self._emit_apprise(record)
|
||||
except RecursionError: # See issue 36272
|
||||
raise
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
try:
|
||||
self.emit_stream(record)
|
||||
self._emit_stream(record)
|
||||
except RecursionError: # See issue 36272
|
||||
raise
|
||||
except Exception:
|
||||
@@ -162,6 +172,7 @@ class AppriseStreamHandler(logging.StreamHandler):
|
||||
|
||||
|
||||
def create_logging_handler(format, color_logging):
|
||||
"""Constructs apprise logging handler for the given format."""
|
||||
date_format = "%Y-%m-%d %H:%M:%S"
|
||||
style = '{'
|
||||
|
||||
@@ -228,6 +239,7 @@ def setup_logging(verbosity: int, color_logging: bool = False, apprise_notifiers
|
||||
|
||||
|
||||
def setup_event_logger(logger, color_logging):
|
||||
"""Sets up a logger that also displays the event ID currently being processed."""
|
||||
format = "{asctime} [{levelname:^11s}] {name:<42} :{event} {message}"
|
||||
sh = create_logging_handler(format, color_logging)
|
||||
logger.addHandler(sh)
|
||||
@@ -253,6 +265,7 @@ def human_readable_size(num: float):
|
||||
|
||||
|
||||
def human_readable_to_float(num: str):
|
||||
"""Turns a human readable ISO/IEC 80000 suffix value to its full float value."""
|
||||
pattern = r"([\d.]+)(" + "|".join(_suffixes) + ")"
|
||||
result = re.match(pattern, num)
|
||||
if result is None:
|
||||
@@ -265,8 +278,7 @@ def human_readable_to_float(num: str):
|
||||
|
||||
|
||||
async def get_camera_name(protect: ProtectApiClient, id: str):
|
||||
"""
|
||||
Returns the name for the camera with the given ID
|
||||
"""Returns the name for the camera with the given ID.
|
||||
|
||||
If the camera ID is not know, it tries refreshing the cached data
|
||||
"""
|
||||
@@ -289,6 +301,8 @@ async def get_camera_name(protect: ProtectApiClient, id: str):
|
||||
|
||||
|
||||
class SubprocessException(Exception):
|
||||
"""Class to capture: stdout, stderr, and return code of Subprocess errors."""
|
||||
|
||||
def __init__(self, stdout, stderr, returncode):
|
||||
"""Exception class for when rclone does not exit with `0`.
|
||||
|
||||
@@ -308,11 +322,7 @@ class SubprocessException(Exception):
|
||||
|
||||
|
||||
def parse_rclone_retention(retention: str) -> relativedelta:
|
||||
"""
|
||||
Parses the rclone `retention` parameter into a relativedelta which can then be used
|
||||
to calculate datetimes
|
||||
"""
|
||||
|
||||
"""Parses 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)}
|
||||
return relativedelta(
|
||||
microseconds=matches.get("ms", 0) * 1000,
|
||||
@@ -327,9 +337,7 @@ def parse_rclone_retention(retention: str) -> relativedelta:
|
||||
|
||||
|
||||
async def run_command(cmd: str, data=None):
|
||||
"""
|
||||
Runs the given command returning the exit code, stdout and stderr
|
||||
"""
|
||||
"""Runs the given command returning the exit code, stdout and stderr."""
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
@@ -347,16 +355,17 @@ async def run_command(cmd: str, data=None):
|
||||
logger.error(f"stdout:\n{stdout_indented}")
|
||||
logger.error(f"stderr:\n{stderr_indented}")
|
||||
else:
|
||||
logger.extra_debug(f"stdout:\n{stdout_indented}")
|
||||
logger.extra_debug(f"stderr:\n{stderr_indented}")
|
||||
logger.extra_debug(f"stdout:\n{stdout_indented}") # type: ignore
|
||||
logger.extra_debug(f"stderr:\n{stderr_indented}") # type: ignore
|
||||
|
||||
return proc.returncode, stdout, stderr
|
||||
|
||||
|
||||
class VideoQueue(asyncio.Queue):
|
||||
"""A queue that limits the number of bytes it can store rather than discrete entries"""
|
||||
"""A queue that limits the number of bytes it can store rather than discrete entries."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Init."""
|
||||
super().__init__(*args, **kwargs)
|
||||
self._bytes_sum = 0
|
||||
|
||||
@@ -373,11 +382,11 @@ class VideoQueue(asyncio.Queue):
|
||||
self._bytes_sum -= len(data[1])
|
||||
return data
|
||||
|
||||
def _put(self, item: bytes):
|
||||
self._queue.append(item)
|
||||
def _put(self, item: tuple[Event, bytes]):
|
||||
self._queue.append(item) # type: ignore
|
||||
self._bytes_sum += len(item[1])
|
||||
|
||||
def full(self, item: bytes = None):
|
||||
def full(self, item: tuple[Event, bytes] = None):
|
||||
"""Return True if there are maxsize bytes in the queue.
|
||||
|
||||
optionally if `item` is provided, it will return False if there is enough space to
|
||||
@@ -386,35 +395,36 @@ class VideoQueue(asyncio.Queue):
|
||||
Note: if the Queue was initialized with maxsize=0 (the default),
|
||||
then full() is never True.
|
||||
"""
|
||||
if self._maxsize <= 0:
|
||||
if self._maxsize <= 0: # type: ignore
|
||||
return False
|
||||
else:
|
||||
if item is None:
|
||||
return self.qsize() >= self._maxsize
|
||||
return self.qsize() >= self._maxsize # type: ignore
|
||||
else:
|
||||
return self.qsize() + len(item[1]) >= self._maxsize
|
||||
return self.qsize() + len(item[1]) >= self._maxsize # type: ignore
|
||||
|
||||
async def put(self, item: bytes):
|
||||
async def put(self, item: tuple[Event, bytes]):
|
||||
"""Put an item into the queue.
|
||||
|
||||
Put an item into the queue. If the queue is full, wait until a free
|
||||
slot is available before adding item.
|
||||
"""
|
||||
if len(item[1]) > self._maxsize:
|
||||
if len(item[1]) > self._maxsize: # type: ignore
|
||||
raise ValueError(
|
||||
f"Item is larger ({human_readable_size(len(item[1]))}) than the size of the buffer ({human_readable_size(self._maxsize)})"
|
||||
f"Item is larger ({human_readable_size(len(item[1]))}) "
|
||||
f"than the size of the buffer ({human_readable_size(self._maxsize)})" # type: ignore
|
||||
)
|
||||
|
||||
while self.full(item):
|
||||
putter = self._loop.create_future()
|
||||
self._putters.append(putter)
|
||||
putter = self._loop.create_future() # type: ignore
|
||||
self._putters.append(putter) # type: ignore
|
||||
try:
|
||||
await putter
|
||||
except:
|
||||
except: # noqa: E722
|
||||
putter.cancel() # Just in case putter is not done yet.
|
||||
try:
|
||||
# Clean self._putters from canceled putters.
|
||||
self._putters.remove(putter)
|
||||
self._putters.remove(putter) # type: ignore
|
||||
except ValueError:
|
||||
# The putter could be removed from self._putters by a
|
||||
# previous get_nowait call.
|
||||
@@ -422,11 +432,11 @@ class VideoQueue(asyncio.Queue):
|
||||
if not self.full(item) and not putter.cancelled():
|
||||
# We were woken up by get_nowait(), but can't take
|
||||
# the call. Wake up the next in line.
|
||||
self._wakeup_next(self._putters)
|
||||
self._wakeup_next(self._putters) # type: ignore
|
||||
raise
|
||||
return self.put_nowait(item)
|
||||
|
||||
def put_nowait(self, item: bytes):
|
||||
def put_nowait(self, item: tuple[Event, bytes]):
|
||||
"""Put an item into the queue without blocking.
|
||||
|
||||
If no free slot is immediately available, raise QueueFull.
|
||||
@@ -434,12 +444,12 @@ class VideoQueue(asyncio.Queue):
|
||||
if self.full(item):
|
||||
raise asyncio.QueueFull
|
||||
self._put(item)
|
||||
self._unfinished_tasks += 1
|
||||
self._finished.clear()
|
||||
self._wakeup_next(self._getters)
|
||||
self._unfinished_tasks += 1 # type: ignore
|
||||
self._finished.clear() # type: ignore
|
||||
self._wakeup_next(self._getters) # type: ignore
|
||||
|
||||
|
||||
async def wait_until(dt):
|
||||
# sleep until the specified datetime
|
||||
"""Sleep until the specified datetime."""
|
||||
now = datetime.now()
|
||||
await asyncio.sleep((dt - now).total_seconds())
|
||||
|
||||
Reference in New Issue
Block a user