[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: Implement notifications via Apprise

FoxxMD (Apr 5, 2024, 12:07 PM EDT) 5967e3df 6b1ee836

+307 -9
+1 -1
README.md
··· 28 28 * [Maloja](/docsite/docs/configuration/configuration.md#maloja) 29 29 * [Last.fm](/docsite/docs/configuration/configuration.md#lastfm) 30 30 * [ListenBrainz](/docsite/docs/configuration/configuration.md#listenbrainz) 31 - * Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](/docsite/docs/configuration/configuration.md#webhook-configurations) or [healthcheck endpoint](/docsite/docs/configuration/configuration.md#health-endpoint) 31 + * Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](/docsite/docs/configuration/configuration.md#webhook-configurations) or [healthcheck endpoint](/docsite/docs/configuration/configuration.md#health-endpoint) 32 32 * Supports configuring for single or multiple users (scrobbling for your friends and family!) 33 33 * Web server interface for stats, basic control, and detailed logs 34 34 * Graceful network and client failure handling (queued scrobbles that auto-retry)
+19
docsite/docs/configuration/configuration.md
··· 983 983 } 984 984 ``` 985 985 986 + ### [Apprise](https://github.com/caronc/apprise-api) 987 + 988 + Refer to the [config schema for AppriseConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FAppriseConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json) 989 + 990 + multi-scrobbler supports [stateless](https://github.com/caronc/apprise-api?tab=readme-ov-file#stateless-solution) and [persistent storage](https://github.com/caronc/apprise-api?tab=readme-ov-file#persistent-storage-solution) endpoints as well as [tags](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging)/ 991 + 992 + EX 993 + 994 + ```json5 995 + { 996 + "type": "apprise", 997 + "name": "MyAppriseFriendlyNameForLogs", 998 + "host": "http://192.168.0.100:8080", 999 + "urls": ["gotify://192.168.0.101:8070/MyToken"], // stateless endpoints 1000 + "keys": ["e90b20526808373353afad7fb98a201198c0c3e0555bea19f182df3388af7b17"], //persistent storage endpoints 1001 + "tags": ["my","optional","tags"] 1002 + } 1003 + ``` 1004 + 986 1005 ## Health Endpoint 987 1006 988 1007 An endpoint for monitoring the health of sources/clients is available at GET `http://YourMultiScrobblerDomain/health`
+1 -1
docsite/src/pages/index.mdx
··· 31 31 * [Maloja](docs/configuration#maloja) 32 32 * [Last.fm](docs/configuration#lastfm) 33 33 * [ListenBrainz](docs/configuration#listenbrainz) 34 - * Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint) 34 + * Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint) 35 35 * Supports configuring for single or multiple users (scrobbling for your friends and family!) 36 36 * Web server interface for stats, basic control, and detailed logs 37 37 * Graceful network and client failure handling (queued scrobbles that auto-retry)
+8 -3
src/backend/notifier/AbstractWebhookNotifier.ts
··· 1 1 import { childLogger, Logger } from "@foxxmd/logging"; 2 - import { GotifyConfig, NtfyConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js"; 2 + import { 3 + AppriseConfig, 4 + GotifyConfig, 5 + NtfyConfig, 6 + WebhookPayload 7 + } from "../common/infrastructure/config/health/webhooks.js"; 3 8 4 9 export abstract class AbstractWebhookNotifier { 5 10 6 - config: GotifyConfig | NtfyConfig 11 + config: GotifyConfig | NtfyConfig | AppriseConfig 7 12 logger: Logger; 8 13 9 14 initialized: boolean = false; 10 15 requiresAuth: boolean = false; 11 16 authed: boolean = false; 12 17 13 - protected constructor(type: string, defaultName: string, config: GotifyConfig | NtfyConfig, logger: Logger) { 18 + protected constructor(type: string, defaultName: string, config: GotifyConfig | NtfyConfig | AppriseConfig, logger: Logger) { 14 19 this.config = config; 15 20 const label = `${type} - ${config.name ?? defaultName}` 16 21 this.logger = childLogger(logger, label);
+155
src/backend/notifier/AppriseWebhookNotifier.ts
··· 1 + import { Logger } from "@foxxmd/logging"; 2 + import request, { Request } from "superagent"; 3 + import { truncateStringToLength } from "../../core/StringUtils.js"; 4 + import { isSuperAgentResponseError } from "../common/errors/ErrorUtils.js"; 5 + import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 6 + import { UpstreamError } from "../common/errors/UpstreamError.js"; 7 + import { 8 + AppriseConfig, 9 + PrioritiesConfig, 10 + Priority, 11 + WebhookPayload 12 + } from "../common/infrastructure/config/health/webhooks.js"; 13 + import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js"; 14 + 15 + const shortKey = truncateStringToLength(10); 16 + 17 + export class AppriseWebhookNotifier extends AbstractWebhookNotifier { 18 + 19 + declare config: AppriseConfig; 20 + 21 + priorities: PrioritiesConfig; 22 + 23 + urls: string[]; 24 + keys: string[]; 25 + 26 + constructor(defaultName: string, config: AppriseConfig, logger: Logger) { 27 + super('Apprise', defaultName, config, logger); 28 + const { 29 + urls = [], 30 + keys = [], 31 + host, 32 + } = this.config; 33 + if (host === undefined) { 34 + throw new Error(`'host' must be defined in configuration for this notification`); 35 + } 36 + this.urls = Array.isArray(urls) ? urls : [urls]; 37 + this.keys = Array.isArray(keys) ? keys : [keys]; 38 + 39 + if (this.urls.length === 0 && this.keys.length === 0) { 40 + this.logger.warn(`No 'urls' or 'keys' were defined! Will assume stateless (POST ${host}/notify) and that you have the ENV 'APPRISE_STATELESS_URLS' set on your Apprise instance`); 41 + } 42 + } 43 + 44 + initialize = async () => { 45 + // check url is correct 46 + try { 47 + await request.get(this.config.host); 48 + } catch (e) { 49 + this.logger.error(new Error('Failed to contact Apprise server', {cause: e})); 50 + } 51 + 52 + if (this.keys.length > 0) { 53 + let anyOk = false; 54 + for (const key of this.keys) { 55 + try { 56 + const resp = await request.get(`${this.config.host}/json/urls/${key}`); 57 + if (resp.statusCode === 204) { 58 + this.logger.warn(`Details for Config ${shortKey(key)} returned no content. Double check the key is set correctly or that the apprise Config is not empty.`); 59 + } else { 60 + anyOk = true; 61 + } 62 + } catch (e) { 63 + this.logger.warn(new Error(`Failed to get details for Config ${shortKey(key)}`, {cause: e})); 64 + } 65 + } 66 + if (!anyOk) { 67 + this.logger.error('No Apprise Configs were valid!'); 68 + this.initialized = false; 69 + return; 70 + } 71 + } 72 + this.initialized = true; 73 + } 74 + 75 + doNotify = async (payload: WebhookPayload) => { 76 + const body: Record<string, any> = { 77 + title: payload.title, 78 + body: payload.message, 79 + type: convertPriorityToType(payload.priority) 80 + } 81 + 82 + let anyOk = false; 83 + if (this.keys.length > 0) { 84 + for (const key of this.keys) { 85 + try { 86 + const resp = await this.callApi(request.post(`${this.config.host}/notify/${key}`) 87 + .type('json') 88 + .send(body)); 89 + anyOk = true; 90 + this.logger.debug(`Pushed notification to Config ${shortKey(key)}`); 91 + } catch (e: any) { 92 + this.logger.warn(new Error(`Failed to push notification for '${payload.title}' to Config ${shortKey(key)}`, {cause: e})); 93 + } 94 + } 95 + } 96 + 97 + if (this.urls.length > 0 || this.keys.length === 0) { 98 + if (this.urls.length > 0) { 99 + body.urls = this.urls.join(',') 100 + } 101 + try { 102 + const resp = await this.callApi(request.post(`${this.config.host}/notify`) 103 + .type('json') 104 + .send(body)); 105 + anyOk = true; 106 + this.logger.debug(`Pushed notification to URLs`); 107 + } catch (e: any) { 108 + this.logger.warn(`Failed to push notification for '${payload.title}' to URLs`, {cause: e}); 109 + } 110 + } 111 + 112 + if (!anyOk) { 113 + this.logger.error(`Failed to push any notifications!`) 114 + } 115 + } 116 + 117 + callApi = async <T = unknown>(req: Request, retries = 0): Promise<T> => { 118 + try { 119 + return await req as T; 120 + } catch (e) { 121 + if (isNodeNetworkException(e) || isSuperAgentResponseError(e) && e.timeout) { 122 + throw new UpstreamError('Request failed to due a network issue', {cause: e}); 123 + } else if (isSuperAgentResponseError(e)) { 124 + const { 125 + message, 126 + status, 127 + response: { 128 + body: jsonBody = undefined, 129 + text = undefined, 130 + } = {} 131 + } = e; 132 + const errorMsgs = [message]; 133 + if (typeof jsonBody === 'object' && jsonBody.error !== undefined) { 134 + errorMsgs.push(jsonBody.error); 135 + } 136 + throw new UpstreamError(`Apprise API Request failed => (${status}) ${errorMsgs.join(' => ')}`, {response: e.response}); 137 + } else { 138 + throw new Error('Non API Request error encountered', {cause: e}); 139 + } 140 + } 141 + } 142 + } 143 + 144 + const convertPriorityToType = (priority?: Priority): 'info' | 'success' | 'warning' | 'failure' => { 145 + switch (priority) { 146 + case 'info': 147 + return 'info'; 148 + case 'warn': 149 + return 'warning'; 150 + case 'error': 151 + return 'failure'; 152 + default: 153 + return 'info'; 154 + } 155 + }
+6 -1
src/backend/notifier/Notifiers.ts
··· 1 1 import { childLogger, Logger } from '@foxxmd/logging'; 2 2 import { EventEmitter } from "events"; 3 3 import { 4 + AppriseConfig, 4 5 GotifyConfig, 5 6 NtfyConfig, 6 7 WebhookConfig, 7 8 WebhookPayload 8 9 } from "../common/infrastructure/config/health/webhooks.js"; 9 10 import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js"; 11 + import { AppriseWebhookNotifier } from "./AppriseWebhookNotifier.js"; 10 12 import { GotifyWebhookNotifier } from "./GotifyWebhookNotifier.js"; 11 13 import { NtfyWebhookNotifier } from "./NtfyWebhookNotifier.js"; 12 14 ··· 26 28 this.clientEmitter = clientEmitter; 27 29 this.sourceEmitter = sourceEmitter; 28 30 29 - this.logger = childLogger(parentLogger, 'Notifiers'); // winston.loggers.get('app').child({labels: ['Notifiers']}, mergeArr); 31 + this.logger = childLogger(parentLogger, 'Notifiers'); 30 32 31 33 this.sourceEmitter.on('notify', async (payload: WebhookPayload) => { 32 34 await this.notify(payload); ··· 43 45 break; 44 46 case 'ntfy': 45 47 webhook = new NtfyWebhookNotifier(defaultName, config as NtfyConfig, this.logger); 48 + break; 49 + case 'apprise': 50 + webhook = new AppriseWebhookNotifier(defaultName, config as AppriseConfig, this.logger); 46 51 break; 47 52 default: 48 53 this.logger.error(`'${config.type}' is not a valid webhook type`);
+86
src/backend/common/schema/aio.json
··· 1 1 { 2 2 "$schema": "http://json-schema.org/draft-07/schema#", 3 3 "definitions": { 4 + "AppriseConfig": { 5 + "properties": { 6 + "host": { 7 + "description": "The URL of the apprise-api server", 8 + "examples": [ 9 + "http://192.168.0.100:8078" 10 + ], 11 + "title": "host", 12 + "type": "string" 13 + }, 14 + "keys": { 15 + "anyOf": [ 16 + { 17 + "items": { 18 + "type": "string" 19 + }, 20 + "type": "array" 21 + }, 22 + { 23 + "type": "string" 24 + } 25 + ], 26 + "description": "If using [Persistent Store Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#persistent-storage-solution) the Configuration ID(s) to send to\n\nNote: If multiple keys are defined then MS will attempt to POST to each one individually", 27 + "title": "keys" 28 + }, 29 + "name": { 30 + "description": "A friendly name used to identify webhook config in logs", 31 + "title": "name", 32 + "type": "string" 33 + }, 34 + "tags": { 35 + "anyOf": [ 36 + { 37 + "items": { 38 + "type": "string" 39 + }, 40 + "type": "array" 41 + }, 42 + { 43 + "type": "string" 44 + } 45 + ], 46 + "description": "Optional [tag(s)](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging) to send in the notification payload", 47 + "title": "tags" 48 + }, 49 + "type": { 50 + "description": "Webhook type. Valid values are:\n\n* gotify\n* ntfy", 51 + "enum": [ 52 + "apprise", 53 + "gotify", 54 + "ntfy" 55 + ], 56 + "examples": [ 57 + "gotify" 58 + ], 59 + "title": "type", 60 + "type": "string" 61 + }, 62 + "urls": { 63 + "anyOf": [ 64 + { 65 + "items": { 66 + "type": "string" 67 + }, 68 + "type": "array" 69 + }, 70 + { 71 + "type": "string" 72 + } 73 + ], 74 + "description": "If using [Stateless Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#stateless-solution) the Apprise config URL(s) to send", 75 + "title": "urls" 76 + } 77 + }, 78 + "required": [ 79 + "host", 80 + "type" 81 + ], 82 + "title": "AppriseConfig", 83 + "type": "object" 84 + }, 4 85 "ChromecastData": { 5 86 "properties": { 6 87 "allowUnknownMedia": { ··· 602 683 "type": { 603 684 "description": "Webhook type. Valid values are:\n\n* gotify\n* ntfy", 604 685 "enum": [ 686 + "apprise", 605 687 "gotify", 606 688 "ntfy" 607 689 ], ··· 2170 2252 "type": { 2171 2253 "description": "Webhook type. Valid values are:\n\n* gotify\n* ntfy", 2172 2254 "enum": [ 2255 + "apprise", 2173 2256 "gotify", 2174 2257 "ntfy" 2175 2258 ], ··· 3070 3153 }, 3071 3154 { 3072 3155 "$ref": "#/definitions/NtfyConfig" 3156 + }, 3157 + { 3158 + "$ref": "#/definitions/AppriseConfig" 3073 3159 } 3074 3160 ], 3075 3161 "title": "WebhookConfig"
+31 -3
src/backend/common/infrastructure/config/health/webhooks.ts
··· 1 1 export interface WebhookPayload { 2 2 title?: string 3 3 message: string 4 - priority: 'info' | 'warn' | 'error' 4 + priority: Priority 5 5 } 6 + 7 + export type Priority = 'info' | 'warn' | 'error'; 6 8 7 9 export interface PrioritiesConfig { 8 10 /** ··· 28 30 * 29 31 * @examples ["gotify"] 30 32 * */ 31 - type: 'gotify' | 'ntfy' 33 + type: 'gotify' | 'ntfy' | 'apprise' 32 34 /** 33 35 * A friendly name used to identify webhook config in logs 34 36 * */ ··· 90 92 priorities?: PrioritiesConfig 91 93 } 92 94 93 - export type WebhookConfig = GotifyConfig | NtfyConfig; 95 + export interface AppriseConfig extends CommonWebhookConfig { 96 + /** 97 + * The URL of the apprise-api server 98 + * 99 + * @examples ["http://192.168.0.100:8078"] 100 + * */ 101 + host: string 102 + 103 + /** 104 + * If using [Stateless Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#stateless-solution) the Apprise config URL(s) to send 105 + * */ 106 + urls?: string | string[] 107 + 108 + /** 109 + * If using [Persistent Store Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#persistent-storage-solution) the Configuration ID(s) to send to 110 + * 111 + * Note: If multiple keys are defined then MS will attempt to POST to each one individually 112 + * */ 113 + keys?: string | string[] 114 + 115 + /** 116 + * Optional [tag(s)](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging) to send in the notification payload 117 + * */ 118 + tags?: string | string[] 119 + } 120 + 121 + export type WebhookConfig = GotifyConfig | NtfyConfig | AppriseConfig;