Add the ability to send logging output to apprise

This commit is contained in:
Sebastian Goscik
2023-02-25 20:42:23 +00:00
parent 6b60fac3c1
commit ce34afaf06
7 changed files with 222 additions and 76 deletions

117
README.md
View File

@@ -112,80 +112,77 @@ Usage: unifi-protect-backup [OPTIONS]
Options:
--version Show the version and exit.
--address TEXT Address of Unifi Protect instance
[required]
--port INTEGER Port of Unifi Protect instance [default:
443]
--username TEXT Username to login to Unifi Protect instance
[required]
--address TEXT Address of Unifi Protect instance [required]
--port INTEGER Port of Unifi Protect instance [default: 443]
--username TEXT Username to login to Unifi Protect instance [required]
--password TEXT Password for Unifi Protect user [required]
--verify-ssl / --no-verify-ssl Set if you do not have a valid HTTPS
Certificate for your instance [default:
verify-ssl]
--rclone-destination TEXT `rclone` destination path in the format
{rclone remote}:{path on remote}. E.g.
`gdrive:/backups/unifi_protect` [required]
--retention TEXT How long should event clips be backed up
for. Format as per the `--max-age` argument
of `rclone`
(https://rclone.org/filtering/#max-age-don-
t-transfer-any-file-older-than-this)
[default: 7d]
--rclone-args TEXT Optional extra arguments to pass to `rclone
rcat` directly. Common usage for this would
be to set a bandwidth limit, for example.
--detection-types TEXT A comma separated list of which types of
detections to backup. Valid options are:
`motion`, `person`, `vehicle`, `ring`
--verify-ssl / --no-verify-ssl Set if you do not have a valid HTTPS Certificate for your
instance [default: verify-ssl]
--rclone-destination TEXT `rclone` destination path in the format {rclone remote}:{path on
remote}. E.g. `gdrive:/backups/unifi_protect` [required]
--retention TEXT How long should event clips be backed up for. Format as per the
`--max-age` argument of `rclone`
(https://rclone.org/filtering/#max-age-don-t-transfer-any-file-
older-than-this) [default: 7d]
--rclone-args TEXT Optional extra arguments to pass to `rclone rcat` directly.
Common usage for this would be to set a bandwidth limit, for
example.
--detection-types TEXT A comma separated list of which types of detections to backup.
Valid options are: `motion`, `person`, `vehicle`, `ring`
[default: motion,person,vehicle,ring]
--ignore-camera TEXT IDs of cameras for which events should not
be backed up. Use multiple times to ignore
multiple IDs. If being set as an environment
variable the IDs should be separated by
whitespace.
--file-structure-format TEXT A Python format string used to generate the
file structure/name on the rclone remote.For
details of the fields available, see the
projects `README.md` file. [default: {camer
a_name}/{event.start:%Y-%m-%d}/{event.end:%Y
-%m-%dT%H-%M-%S} {detection_type}.mp4]
--ignore-camera TEXT IDs of cameras for which events should not be backed up. Use
multiple times to ignore multiple IDs. If being set as an
environment variable the IDs should be separated by whitespace.
--file-structure-format TEXT A Python format string used to generate the file structure/name
on the rclone remote.For details of the fields available, see
the projects `README.md` file. [default: {camera_name}/{event.s
tart:%Y-%m-%d}/{event.end:%Y-%m-%dT%H-%M-%S}
{detection_type}.mp4]
-v, --verbose How verbose the logging output should be.
None: Only log info messages created by
`unifi-protect-backup`, and all warnings
None: Only log info messages created by `unifi-protect-
backup`, and all warnings
-v: Only log info & debug messages
created by `unifi-protect-backup`, and
all warnings
-v: Only log info & debug messages created by `unifi-
protect-backup`, and all warnings
-vv: Log info & debug messages created
by `unifi-protect-backup`, command
output, and all warnings
-vv: Log info & debug messages created by `unifi-protect-
backup`, command output, and all warnings
-vvv Log debug messages created by
`unifi-protect-backup`, command output,
all info messages, and all warnings
-vvv Log debug messages created by `unifi-protect-backup`,
command output, all info messages, and all warnings
-vvvv: Log debug messages created by
`unifi-protect-backup` command output,
all info messages, all warnings, and
-vvvv: Log debug messages created by `unifi-protect-backup`
command output, all info messages, all warnings, and
websocket data
-vvvvv: Log websocket data, command
output, all debug messages, all info
messages and all warnings [x>=0]
-vvvvv: Log websocket data, command output, all debug
messages, all info messages and all warnings [x>=0]
--sqlite_path TEXT Path to the SQLite database to use/create
--color-logging / --plain-logging
Set if you want to use color in logging
output [default: plain-logging]
--download-buffer-size TEXT How big the download buffer should be (you
can use suffixes like "B", "KiB", "MiB",
"GiB") [default: 512MiB]
Set if you want to use color in logging output [default: plain-
logging]
--download-buffer-size TEXT How big the download buffer should be (you can use suffixes like
"B", "KiB", "MiB", "GiB") [default: 512MiB]
--purge_interval TEXT How frequently to check for file to purge.
NOTE: Can create a lot of API calls, so be
careful if your cloud provider charges you per
api call [default: 1d]
NOTE: Can create a lot of API calls, so be careful if your cloud
provider charges you per api call [default: 1d]
--apprise-notifier TEXT Apprise URL for sending notifications.
E.g: ERROR,WARNING=tgram://[BOT KEY]/[CHAT ID]
You can use this parameter multiple times to use more than one
notification platform.
The following notification tags are available (corresponding to
the respective logging levels):
ERROR, WARNING, INFO, DEBUG, EXTRA_DEBUG, WEBSOCKET_DATA
If no tags are specified, it defaults to ERROR
More details about supported platforms can be found here:
https://github.com/caronc/apprise
--help Show this message and exit.
```

89
poetry.lock generated
View File

@@ -75,9 +75,25 @@ category = "main"
optional = true
python-versions = "*"
[[package]]
name = "apprise"
version = "1.3.0"
description = "Push Notifications that work with just about every platform!"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
certifi = "*"
click = ">=5.0"
markdown = "*"
PyYAML = "*"
requests = "*"
requests-oauthlib = "*"
[[package]]
name = "astroid"
version = "2.12.13"
version = "2.14.2"
description = "An abstract syntax tree for Python with inference support."
category = "main"
optional = false
@@ -85,7 +101,7 @@ python-versions = ">=3.7.2"
[package.dependencies]
lazy-object-proxy = ">=1.4.0"
typing-extensions = {version = ">=3.10", markers = "python_version < \"3.10\""}
typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""}
wrapt = [
{version = ">=1.11,<2", markers = "python_version < \"3.11\""},
{version = ">=1.14,<2", markers = "python_version >= \"3.11\""},
@@ -194,7 +210,7 @@ name = "certifi"
version = "2022.5.18.1"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = true
optional = false
python-versions = ">=3.6"
[[package]]
@@ -388,7 +404,7 @@ name = "importlib-metadata"
version = "4.11.4"
description = "Read metadata from Python packages"
category = "main"
optional = true
optional = false
python-versions = ">=3.7"
[package.dependencies]
@@ -522,6 +538,20 @@ category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "markdown"
version = "3.4.1"
description = "Python implementation of Markdown."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""}
[package.extras]
testing = ["coverage", "pyyaml"]
[[package]]
name = "matplotlib-inline"
version = "0.1.3"
@@ -582,6 +612,19 @@ category = "main"
optional = true
python-versions = "*"
[[package]]
name = "oauthlib"
version = "3.2.2"
description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic"
category = "main"
optional = false
python-versions = ">=3.6"
[package.extras]
rsa = ["cryptography (>=3.0.0)"]
signals = ["blinker (>=1.4.0)"]
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]]
name = "orjson"
version = "3.7.10"
@@ -818,16 +861,19 @@ tests = ["pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)"]
[[package]]
name = "pylint"
version = "2.15.7"
version = "2.16.2"
description = "python code static checker"
category = "main"
optional = false
python-versions = ">=3.7.2"
[package.dependencies]
astroid = ">=2.12.13,<=2.14.0-dev0"
astroid = ">=2.14.2,<=2.16.0-dev0"
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
dill = ">=0.2"
dill = [
{version = ">=0.2", markers = "python_version < \"3.11\""},
{version = ">=0.3.6", markers = "python_version >= \"3.11\""},
]
isort = ">=4.2.5,<6"
mccabe = ">=0.6,<0.8"
platformdirs = ">=2.2.0"
@@ -943,7 +989,7 @@ name = "pyyaml"
version = "6.0"
description = "YAML parser and emitter for Python"
category = "main"
optional = true
optional = false
python-versions = ">=3.6"
[[package]]
@@ -967,7 +1013,7 @@ name = "requests"
version = "2.27.1"
description = "Python HTTP for Humans."
category = "main"
optional = true
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[package.dependencies]
@@ -980,6 +1026,21 @@ urllib3 = ">=1.21.1,<1.27"
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
[[package]]
name = "requests-oauthlib"
version = "1.3.1"
description = "OAuthlib authentication support for Requests."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.dependencies]
oauthlib = ">=3.0.0"
requests = ">=2.0.0"
[package.extras]
rsa = ["oauthlib[signedtoken] (>=3.0.0)"]
[[package]]
name = "requests-toolbelt"
version = "0.9.1"
@@ -1200,7 +1261,7 @@ name = "urllib3"
version = "1.26.9"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = true
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
[package.extras]
@@ -1267,7 +1328,7 @@ name = "zipp"
version = "3.8.0"
description = "Backport of pathlib-compatible object wrapper for zip files"
category = "main"
optional = true
optional = false
python-versions = ">=3.7"
[package.extras]
@@ -1281,7 +1342,7 @@ test = ["pytest", "black", "isort", "mypy", "flake8", "flake8-docstrings", "pyte
[metadata]
lock-version = "1.1"
python-versions = ">=3.9.0,<4.0"
content-hash = "c4291adb62da91a97e4d6d5a4aac0be648838b95f96ed93228fd8aacdfce48b0"
content-hash = "f2500345f68039f4afa452e6ead4c286dba8c1df2578c62bc570ce2b0130c606"
[metadata.files]
aiofiles = [
@@ -1376,6 +1437,7 @@ appnope = [
{file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"},
{file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"},
]
apprise = []
astroid = []
asttokens = [
{file = "asttokens-2.0.5-py2.py3-none-any.whl", hash = "sha256:0844691e88552595a6f4a4281a9f7f79b8dd45ca4ccea82e5e05b4bbdb76705c"},
@@ -1672,6 +1734,7 @@ keyring = [
{file = "keyring-23.5.1.tar.gz", hash = "sha256:dee502cdf18a98211bef428eea11456a33c00718b2f08524fd5727c7f424bffd"},
]
lazy-object-proxy = []
markdown = []
matplotlib-inline = [
{file = "matplotlib-inline-0.1.3.tar.gz", hash = "sha256:a04bfba22e0d1395479f866853ec1ee28eea1485c1d69a6faf00dc3e24ff34ee"},
{file = "matplotlib_inline-0.1.3-py3-none-any.whl", hash = "sha256:aed605ba3b72462d64d475a21a9296f400a19c4f74a31b59103d2a99ffd5aa5c"},
@@ -1774,6 +1837,7 @@ nodeenv = [
{file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"},
{file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"},
]
oauthlib = []
orjson = []
packaging = [
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
@@ -1997,6 +2061,7 @@ requests = [
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
{file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
]
requests-oauthlib = []
requests-toolbelt = [
{file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"},
{file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"},

View File

@@ -47,6 +47,7 @@ aiosqlite = "^0.17.0"
python-dateutil = "^2.8.2"
aiorun = "^2022.11.1"
pylint = {version = "^2.15.6", extras = ["dev"]}
apprise = "^1.3.0"
[tool.poetry.extras]
test = [

View File

@@ -24,7 +24,7 @@ def _parse_detection_types(ctx, param, value):
return types
@click.command()
@click.command(context_settings=dict(max_content_width=100))
@click.version_option(__version__)
@click.option('--address', required=True, envvar='UFP_ADDRESS', help='Address of Unifi Protect instance')
@click.option('--port', default=443, envvar='UFP_PORT', show_default=True, help='Port of Unifi Protect instance')
@@ -134,6 +134,25 @@ all warnings, and websocket data
help="How frequently to check for file to purge.\n\nNOTE: Can create a lot of API calls, so be careful if "
"your cloud provider charges you per api call",
)
@click.option(
'--apprise-notifier',
'apprise_notifiers',
multiple=True,
envvar="APPRISE_NOTIFIERS",
help="""\b
Apprise URL for sending notifications.
E.g: ERROR,WARNING=tgram://[BOT KEY]/[CHAT ID]
You can use this parameter multiple times to use more than one notification platform.
The following notification tags are available (corresponding to the respective logging levels):
ERROR, WARNING, INFO, DEBUG, EXTRA_DEBUG, WEBSOCKET_DATA
If no tags are specified, it defaults to ERROR
More details about supported platforms can be found here: https://github.com/caronc/apprise""",
)
def main(**kwargs):
"""A Python based tool for backing up Unifi Protect event clips as they occur."""
event_listener = UnifiProtectBackup(**kwargs)

View File

@@ -0,0 +1,15 @@
import apprise
notifier = apprise.Apprise()
def add_notification_service(url):
config = apprise.AppriseConfig()
config.add_config(url, format='text')
# If not tags are specified, default to errors otherwise ALL logging will
# be spammed to the notification service
if not config.servers()[0].tags:
config.servers()[0].tags = {'ERROR'}
notifier.add(config)

View File

@@ -28,6 +28,7 @@ from unifi_protect_backup.utils import (
human_readable_size,
VideoQueue,
)
from unifi_protect_backup.notifications import notifier
logger = logging.getLogger(__name__)
@@ -67,6 +68,7 @@ class UnifiProtectBackup:
verbose: int,
download_buffer_size: int,
purge_interval: str,
apprise_notifiers: str,
sqlite_path: str = "events.sqlite",
color_logging=False,
port: int = 443,
@@ -94,7 +96,7 @@ class UnifiProtectBackup:
sqlite_path (str): Path where to find/create sqlite database
purge_interval (str): How often to check for files to delete
"""
setup_logging(verbose, color_logging)
setup_logging(verbose, color_logging, apprise_notifiers)
logger.debug("Config:")
logger.debug(f" {address=}")
@@ -116,6 +118,7 @@ class UnifiProtectBackup:
logger.debug(f" {sqlite_path=}")
logger.debug(f" download_buffer_size={human_readable_size(download_buffer_size)}")
logger.debug(f" {purge_interval=}")
logger.debug(f" {apprise_notifiers=}")
self.rclone_destination = rclone_destination
self.retention = parse_rclone_retention(retention)
@@ -154,6 +157,7 @@ class UnifiProtectBackup:
"""
try:
logger.info("Starting...")
await notifier.async_notify("Starting UniFi Protect Backup")
# Ensure `rclone` is installed and properly configured
logger.info("Checking rclone configuration...")

View File

@@ -1,12 +1,15 @@
import logging
import re
import asyncio
from typing import Optional
from typing import Optional, List
from datetime import datetime
from dateutil.relativedelta import relativedelta
from pyunifiprotect import ProtectApiClient
from apprise import NotifyType
from unifi_protect_backup import notifications
logger = logging.getLogger(__name__)
@@ -118,7 +121,41 @@ def create_logging_handler(format):
return sh
def setup_logging(verbosity: int, color_logging: bool = False) -> None:
def patch_logger_notifications(logger):
"""
Patches the core logging function to also send apprise notifications
"""
original_log = logger._log
logging_map = {
logging.ERROR: NotifyType.FAILURE,
logging.WARNING: NotifyType.WARNING,
logging.INFO: NotifyType.INFO,
logging.DEBUG: NotifyType.INFO,
logging.EXTRA_DEBUG: NotifyType.INFO,
logging.WEBSOCKET_DATA: NotifyType.INFO,
}
def new_log(self, level, msg, *args, **kwargs):
original_log(level, msg, *args, **kwargs)
loop = asyncio.get_event_loop()
if not loop.is_closed():
level_name = logging.getLevelName(level)
coro = notifications.notifier.async_notify(
body=msg, title=level_name, notify_type=logging_map[level], tag=[level_name]
)
if loop.is_running():
asyncio.create_task(coro)
else:
loop.run_until_complete(coro)
logger.__class__._log = new_log
def setup_logging(verbosity: int, color_logging: bool = False, apprise_notifiers: List[str] = []) -> None:
"""Configures loggers to provided the desired level of verbosity.
Verbosity 0: Only log info messages created by `unifi-protect-backup`, and all warnings
@@ -135,6 +172,7 @@ def setup_logging(verbosity: int, color_logging: bool = False) -> None:
Args:
verbosity (int): The desired level of verbosity
color_logging (bool): If colors should be used in the log (default=False)
apprise_notifiers (List[str]): Notification services to hook into the logger
"""
globals()['color_logging'] = color_logging
@@ -174,6 +212,13 @@ def setup_logging(verbosity: int, color_logging: bool = False) -> None:
logging.basicConfig(level=logging.DEBUG, handlers=[sh])
logger.setLevel(logging.WEBSOCKET_DATA) # type: ignore
for notifier in apprise_notifiers:
notifications.add_notification_service(notifier)
# Only send logs to notification service if it is enabled
if notifications.notifier.servers:
patch_logger_notifications(logger)
def setup_event_logger(logger):
format = "{asctime} [{levelname:^11s}] {name:<42} :{event} {message}"