diff --git a/README.md b/README.md index 0a767bb..8e7611d 100644 --- a/README.md +++ b/README.md @@ -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. ``` diff --git a/poetry.lock b/poetry.lock index 60514b3..50b3ad8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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"}, diff --git a/pyproject.toml b/pyproject.toml index 23084e2..2cf3d84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/unifi_protect_backup/cli.py b/unifi_protect_backup/cli.py index d8ecb7d..8c2ce44 100644 --- a/unifi_protect_backup/cli.py +++ b/unifi_protect_backup/cli.py @@ -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) diff --git a/unifi_protect_backup/notifications.py b/unifi_protect_backup/notifications.py new file mode 100644 index 0000000..60efd8f --- /dev/null +++ b/unifi_protect_backup/notifications.py @@ -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) diff --git a/unifi_protect_backup/unifi_protect_backup_core.py b/unifi_protect_backup/unifi_protect_backup_core.py index fdd1347..7820075 100644 --- a/unifi_protect_backup/unifi_protect_backup_core.py +++ b/unifi_protect_backup/unifi_protect_backup_core.py @@ -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...") diff --git a/unifi_protect_backup/utils.py b/unifi_protect_backup/utils.py index 5b3dda5..faeb728 100644 --- a/unifi_protect_backup/utils.py +++ b/unifi_protect_backup/utils.py @@ -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}"