diff --git a/README.md b/README.md index d40dd71..1c2c504 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The reason I created this is that I wanted more granular control of how my setup - Allows you to abort execution if configurable thresholds are broken - Allows you to `scrub` after `sync` - Logs the raw snapraid output as well as formatted text -- Creates a nicely formatted report and sends it via email or discord +- Creates a nicely formatted report and sends it via email, Discord, or [Apprise](https://github.com/caronc/apprise) - Provides live insight into the sync/scrub process in Discord - Spin down selected hard drives after script completion diff --git a/config.json.example b/config.json.example index ee9483a..5efdd4a 100644 --- a/config.json.example +++ b/config.json.example @@ -34,6 +34,11 @@ "enabled": false, "webhook_id": "", "webhook_token": "" + }, + "apprise": { + "enabled": false, + "binary": "/usr/bin/apprise", + "config": "/var/lib/apprise/config.yml" } }, "logs": { diff --git a/config.schema.json b/config.schema.json index 5c0ee7e..57c9709 100644 --- a/config.schema.json +++ b/config.schema.json @@ -230,15 +230,37 @@ "required": [ "enabled", "webhook_id", - "webhook_token" - ] + "webhook_token"] + }, + "apprise": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "examples": [ + true + ], + "description": "Whether or not to send notifications and reports to Apprise." + }, + "binary": { + "type": "string", + "examples": [ + "/usr/bin/apprise" + ], + "description": "The location of `apprise`." + }, + "config": { + "type": "string", + "examples": ["/etc/apprise.yml"], + "description": "Location of the apprise configuration yml." + } + }, + "additionalProperties": false, + "required": ["enabled", "binary", "config"] } }, "additionalProperties": false, - "required": [ - "email", - "discord" - ] + "required": ["email", "discord", "apprise"] }, "logs": { "type": "object", diff --git a/reports/apprise_report.py b/reports/apprise_report.py new file mode 100644 index 0000000..6b2574d --- /dev/null +++ b/reports/apprise_report.py @@ -0,0 +1,83 @@ +from operator import itemgetter + + +def create_apprise_report(report_data): + sync_job_ran, scrub_job_ran, sync_job_time, scrub_job_time, diff_data, zero_subsecond_count, \ + scrub_stats, drive_stats, smart_drive_data, global_fp, total_time = itemgetter( + 'sync_job_ran', + 'scrub_job_ran', + 'sync_job_time', + 'scrub_job_time', + 'diff_data', + 'zero_subsecond_count', + 'scrub_stats', + 'drive_stats', + 'smart_drive_data', + 'global_fp', + 'total_time')(report_data) + + # + # Create email report + + sync_report = f'**Sync Job**' + + if sync_job_ran: + sync_report = sync_report + f''' + Job finished successfully in {sync_job_time}. + File diff summary as follows: + - {diff_data["added"]} added + - {diff_data["removed"]} removed + - {diff_data["updated"]} updated + - {diff_data["moved"]} moved + - {diff_data["copied"]} copied + - {diff_data["restored"]} restored + ''' + else: + sync_report = sync_report + 'Sync job did **not** run.' + + touch_report = '**Touch job**' + + if zero_subsecond_count > 0: + touch_report = touch_report + f''' + 'A total of {zero_subsecond_count} file(s) had their sub-second value fixed. + ''' + else: + touch_report = touch_report + 'No zero sub-second files were found.' + + scrub_report = '**Scrub Job**' + + if scrub_job_ran: + scrub_report = scrub_report + f''' + Job finished successfully in {scrub_job_time}. + {scrub_stats["unscrubbed"]}% of the array has not been scrubbed, with the oldest block at {scrub_stats["scrub_age"]} day(s). + ''' + # , the median at {scrub_stats["median"]} day(s), and the newest at {scrub_stats["newest"]} day(s). + else: + scrub_report = scrub_report + 'Scrub Job did **not** run.' + + # Check if any drive had an error count + drives_with_errors = [] + for drive in smart_drive_data: + error_count = drive.get("error_count") + if isinstance(error_count, str) and error_count.isdigit(): + error_count = int(error_count) + if isinstance(error_count, int) and error_count > 0: + drive["error_count"] = error_count + drives_with_errors.append(drive) + + # Summarize SMART drive report + smart_summary = f'**SMART Summary**\nFailure Probability: {global_fp}%' + + if drives_with_errors: + smart_summary += "\nDrives with errors:" + for drive in drives_with_errors: + smart_summary += f"\n- {drive['disk']} ({drive['device']}) - Error Count: {drive['error_count']}" + + email_report = f'''SnapRAID job completed successfully in {total_time} + {touch_report} + {sync_report} + {scrub_report} + {smart_summary} + ''' + + return email_report diff --git a/snapper.py b/snapper.py index dd7f3f2..5561d30 100644 --- a/snapper.py +++ b/snapper.py @@ -20,6 +20,7 @@ from reports.discord_report import create_discord_report from reports.email_report import create_email_report +from reports.apprise_report import create_apprise_report from utils import format_delta, get_relative_path, human_readable_size, run_script # @@ -101,7 +102,8 @@ def notify_and_handle_error(message, error): def notify_warning(message, embeds=None): - return send_discord(f':warning: [**WARNING!**] {message}', embeds=embeds) + send_apprise(':warning: [**WARNING!**]', message) + send_discord(f':warning: [**WARNING!**] {message}', embeds=embeds) def notify_info(message, embeds=None, message_id=None): @@ -179,6 +181,32 @@ def send_email(subject, message): log.debug(f'Successfully sent email to {to_email}') +def send_apprise(subject, message): + log.debug('Attempting to send apprise notification...') + + is_enabled, apprise_bin, config_loc = itemgetter( + 'enabled', 'binary', 'config')(config['notifications']['apprise']) + + if not is_enabled: + return + + if not os.path.isfile(apprise_bin): + raise FileNotFoundError('Unable to find apprise executable', apprise_bin) + + result = subprocess.run([ + apprise_bin, + '-vv', + '-t', subject, + '-b', message, + '--config=' + config_loc + ], capture_output=True, text=True) + + if result.stderr: + raise ConnectionError('Unable to send notification', result.stderr) + + log.debug(f'Successfully sent apprise notification') + + # # Snapraid Helpers @@ -535,6 +563,7 @@ def get_snapraid_config(): with open(config_file, 'r') as file: snapraid_config = file.read() + #Split parity handling file_regex = re.compile(r'^(content|(?:\d+-)?parity) +(.+/\w+.(?:content|(?:\d+-)?parity)) *$', flags=re.MULTILINE) parity_files = [] @@ -544,7 +573,8 @@ def get_snapraid_config(): if m[1] == 'content': content_files.append(m[2]) else: - parity_files.append(m[2]) + for p in m[2].split(','): + parity_files.append(p) return content_files, parity_files @@ -678,9 +708,13 @@ def main(): 'total_time': total_time } - email_report = create_email_report(report_data) + if config['notifications']['email']['enabled']: + email_report = create_email_report(report_data) + send_email('SnapRAID Job Completed Successfully', email_report) - send_email('SnapRAID Job Completed Successfully', email_report) + if config['notifications']['apprise']['enabled']: + apprise_report = create_apprise_report(report_data) + send_apprise('SnapRAID Job Completed Successfully', apprise_report) if config['notifications']['discord']['enabled']: (discord_message, embeds) = create_discord_report(report_data)