Skip to content

Commit

Permalink
feat(notifications): Make init more granular and always complete init
Browse files Browse the repository at this point in the history
* Notifications are not mission critical so its okay if they fail on init because service outage may be transient. Always init notification services and always attempt to push (we catch failures anyway)
* Parse more info from notification URL and test simple reachability of host/port to make troubleshooting easier

Fixes #215
  • Loading branch information
FoxxMD committed Oct 28, 2024
1 parent f308f71 commit a04c1f4
Show file tree
Hide file tree
Showing 16 changed files with 235 additions and 107 deletions.
3 changes: 2 additions & 1 deletion src/backend/common/vendor/LastfmApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import LastFm, {
} from "lastfm-node-client";
import { PlayObject } from "../../../core/Atomic.js";
import { nonEmptyStringOrDefault, splitByFirstFound } from "../../../core/StringUtils.js";
import { joinedUrl, readJson, removeUndefinedKeys, sleep, writeFile } from "../../utils.js";
import { readJson, removeUndefinedKeys, sleep, writeFile } from "../../utils.js";
import { joinedUrl } from "../../utils/NetworkUtils.js";
import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.js";
import { getNodeNetworkException, isNodeNetworkException } from "../errors/NodeErrors.js";
import { UpstreamError } from "../errors/UpstreamError.js";
Expand Down
3 changes: 2 additions & 1 deletion src/backend/ioc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { WildcardEmitter } from "./common/WildcardEmitter.js";
import { Notifiers } from "./notifier/Notifiers.js";
import ScrobbleClients from "./scrobblers/ScrobbleClients.js";
import ScrobbleSources from "./sources/ScrobbleSources.js";
import { generateBaseURL } from "./utils.js";

import { generateBaseURL } from "./utils/NetworkUtils.js";

let version: string = 'unknown';

Expand Down
26 changes: 17 additions & 9 deletions src/backend/notifier/AppriseWebhookNotifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
WebhookPayload
} from "../common/infrastructure/config/health/webhooks.js";
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
import { URLData } from "../../core/Atomic.js";
import { isPortReachable, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js";

const shortKey = truncateStringToLength(10);

Expand All @@ -20,6 +22,8 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {

priorities: PrioritiesConfig;

protected endpoint: URLData;

urls: string[];
keys: string[];

Expand All @@ -42,18 +46,24 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
}

initialize = async () => {
// check url is correct
// check url is correct as a courtesy
this.endpoint = normalizeWebAddress(this.config.host);
this.logger.verbose(`Config URL: '${this.config.host}' => Normalized: '${this.endpoint.normal}'`)

this.initialized = true; // always set as ready to go. Server issues may be transient.

try {
await request.get(this.config.host);
await isPortReachable(this.endpoint.port, { host: this.endpoint.url.hostname });
} catch (e) {
this.logger.error(new Error('Failed to contact Apprise server', {cause: e}));
this.logger.warn(new Error('Unable to detect if server is reachable', { cause: e }));
return;
}

if (this.keys.length > 0) {
let anyOk = false;
for (const key of this.keys) {
try {
const resp = await request.get(`${this.config.host}/json/urls/${key}`);
const resp = await request.get(joinedUrl(this.endpoint.url, `/json/urls/${key}`).toString());
if (resp.statusCode === 204) {
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.`);
} else {
Expand All @@ -64,9 +74,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
}
}
if (!anyOk) {
this.logger.error('No Apprise Configs were valid!');
this.initialized = false;
return;
this.logger.warn('No Apprise Configs were valid!');
}
}
this.initialized = true;
Expand All @@ -83,7 +91,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
if (this.keys.length > 0) {
for (const key of this.keys) {
try {
const resp = await this.callApi(request.post(`${this.config.host}/notify/${key}`)
const resp = await this.callApi(request.post(joinedUrl(this.endpoint.url, `/notify/${key}`).toString())
.type('json')
.send(body));
anyOk = true;
Expand All @@ -99,7 +107,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
body.urls = this.urls.join(',')
}
try {
const resp = await this.callApi(request.post(`${this.config.host}/notify`)
const resp = await this.callApi(request.post(joinedUrl(this.endpoint.url, '/notify').toString())
.type('json')
.send(body));
anyOk = true;
Expand Down
31 changes: 23 additions & 8 deletions src/backend/notifier/GotifyWebhookNotifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import { gotify } from 'gotify';
import request from 'superagent';
import { GotifyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
import { isPortReachable, normalizeWebAddress } from "../utils/NetworkUtils.js";
import { URLData } from "../../core/Atomic.js";

export class GotifyWebhookNotifier extends AbstractWebhookNotifier {

declare config: GotifyConfig;

priorities: PrioritiesConfig;

protected endpoint: URLData;

constructor(defaultName: string, config: GotifyConfig, logger: Logger) {
super('Gotify', defaultName, config, logger);
this.requiresAuth = true;
Expand All @@ -29,14 +33,25 @@ export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
}

initialize = async () => {
// check url is correct
// check url is correct as a courtesy
this.endpoint = normalizeWebAddress(this.config.url);
this.logger.verbose(`Config URL: '${this.config.url}' => Normalized: '${this.endpoint.normal}'`)

this.initialized = true; // always set as ready to go. Server issues may be transient.

try {
await isPortReachable(this.endpoint.port, { host: this.endpoint.url.hostname });
} catch (e) {
this.logger.warn(new Error('Unable to detect if server is reachable', { cause: e }));
return;
}

try {
const url = this.config.url;
const resp = await request.get(`${url}/version`);
this.logger.verbose(`Initialized. Found Server version ${resp.body.version}`);
this.initialized = true;
this.logger.verbose(`Found Server version ${resp.body.version}`);
} catch (e) {
this.logger.error(`Failed to contact server | Error: ${e.message}`);
this.logger.warn(new Error('Server was reachable but could not determine version', { cause: e }));
}
}

Expand All @@ -48,19 +63,19 @@ export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
doNotify = async (payload: WebhookPayload) => {
try {
await gotify({
server: this.config.url,
server: this.endpoint.normal,
app: this.config.token,
message: payload.message,
title: payload.title,
priority: this.priorities[payload.priority]
});
this.logger.verbose(`Pushed notification.`);
} catch (e: any) {
} catch (e) {
if(e instanceof HTTPError && e.response.statusCode === 401) {
this.logger.warn(`Unable to push notification. Error returned with 401 which means the TOKEN provided is probably incorrect. Disabling Notifier | Error => ${e.response.body}`);
this.logger.warn(new Error(`Unable to push notification. Error returned with 401 which means the TOKEN provided is probably incorrect. Disabling Notifier \n Response Error => ${e.response.body}`, {cause: e}));
this.authed = false;
} else {
this.logger.warn(`Failed to push notification | Error => ${e.message}`);
this.logger.warn(new Error('Failed to push notification', {cause: e}));
}
}
}
Expand Down
29 changes: 22 additions & 7 deletions src/backend/notifier/NtfyWebhookNotifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import { Config, publish } from 'ntfy';
import request from "superagent";
import { NtfyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
import { URLData } from "../../core/Atomic.js";
import { isPortReachable, normalizeWebAddress } from "../utils/NetworkUtils.js";

export class NtfyWebhookNotifier extends AbstractWebhookNotifier {

declare config: NtfyConfig;

priorities: PrioritiesConfig;

protected endpoint: URLData;

constructor(defaultName: string, config: NtfyConfig, logger: Logger) {
super('Ntfy', defaultName, config, logger);
const {
Expand All @@ -27,24 +31,35 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
}

initialize = async () => {
// check url is correct
// check url is correct as a courtesy
this.endpoint = normalizeWebAddress(this.config.url);
this.logger.verbose(`Config URL: '${this.config.url}' => Normalized: '${this.endpoint.normal}'`)

this.initialized = true; // always set as ready to go. Server issues may be transient.

try {
await isPortReachable(this.endpoint.port, { host: this.endpoint.url.hostname });
} catch (e) {
this.logger.warn(new Error('Unable to detect if server is reachable', { cause: e }));
return;
}

try {
const url = this.config.url;
const resp = await request.get(`${url}/v1/health`);
if(resp.body !== undefined && typeof resp.body === 'object') {
const {health} = resp.body;
if(health === false) {
this.logger.error('Found Ntfy server but it responded that it was not ready.')
this.logger.warn('Found server but it responded that it was not ready.')
return;
}
} else {
this.logger.error(`Found Ntfy server but expected a response with 'health' in payload. Found => ${resp.body}`);
this.logger.warn(`Found server but expected a response with 'health' in payload. Found => ${resp.body}`);
return;
}
this.logger.info('Initialized. Found Ntfy server');
this.initialized = true;
this.logger.info('Found Ntfy server');
} catch (e) {
this.logger.error(`Failed to contact Ntfy server | Error: ${e.message}`);
this.logger.error(new Error('Failed to contact server', {cause: e}));
}
}

Expand All @@ -54,7 +69,7 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
message: payload.message,
topic: this.config.topic,
title: payload.title,
server: this.config.url,
server: this.endpoint.normal,
priority: this.priorities[payload.priority],
};
if (this.config.username !== undefined) {
Expand Down
3 changes: 2 additions & 1 deletion src/backend/scrobblers/ScrobbleClients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { ListenBrainzClientConfig } from "../common/infrastructure/config/client
import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js";
import { WildcardEmitter } from "../common/WildcardEmitter.js";
import { Notifiers } from "../notifier/Notifiers.js";
import { joinedUrl, readJson, thresholdResultSummary } from "../utils.js";
import { readJson, thresholdResultSummary } from "../utils.js";
import { joinedUrl } from "../utils/NetworkUtils.js";
import { getTypeSchemaFromConfigGenerator } from "../utils/SchemaUtils.js";
import { validateJson } from "../utils/ValidationUtils.js";
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
Expand Down
3 changes: 2 additions & 1 deletion src/backend/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import path from "path";
import ViteExpress from "vite-express";
import { projectDir } from "../common/index.js";
import { getRoot } from "../ioc.js";
import { getAddress, parseBool } from "../utils.js";
import { parseBool } from "../utils.js";
import { getAddress } from "../utils/NetworkUtils.js";
import { setupApi } from "./api.js";

const app = addAsync(express());
Expand Down
3 changes: 2 additions & 1 deletion src/backend/sources/DeezerSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import request from 'superagent';
import { PlayObject } from "../../core/Atomic.js";
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
import { DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
import { joinedUrl, parseRetryAfterSecsFromObj, readJson, sleep, sortByOldestPlayDate, writeFile, } from "../utils.js";
import { parseRetryAfterSecsFromObj, readJson, sleep, sortByOldestPlayDate, writeFile, } from "../utils.js";
import { joinedUrl } from "../utils/NetworkUtils.js";
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js";

export default class DeezerSource extends AbstractSource {
Expand Down
3 changes: 2 additions & 1 deletion src/backend/sources/JellyfinApiSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ import {
PlayPlatformId, REPORTED_PLAYER_STATUSES
} from "../common/infrastructure/Atomic.js";
import { JellyApiSourceConfig } from "../common/infrastructure/config/source/jellyfin.js";
import { combinePartsToString, genGroupIdStr, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js";
import { combinePartsToString, genGroupIdStr, getPlatformIdFromData, parseBool, } from "../utils.js";
import { joinedUrl } from "../utils/NetworkUtils.js";
import { parseArrayFromMaybeString } from "../utils/StringUtils.js";
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";

Expand Down
4 changes: 2 additions & 2 deletions src/backend/sources/PlexApiSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import {
PlayerStateDataMaybePlay,
PlayPlatformId, REPORTED_PLAYER_STATUSES
} from "../common/infrastructure/Atomic.js";
import { combinePartsToString, genGroupIdStr, getFirstNonEmptyString, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js";
import { combinePartsToString, genGroupIdStr, getFirstNonEmptyString, getPlatformIdFromData, parseBool, } from "../utils.js";
import { parseArrayFromMaybeString } from "../utils/StringUtils.js";
import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js";
import { PlexAPI } from "@lukehagar/plexjs";
import {
SDKValidationError,
} from "@lukehagar/plexjs/sdk/models/errors";
import { PlexApiSourceConfig } from "../common/infrastructure/config/source/plex.js";
import { isPortReachable } from '../utils/NetworkUtils.js';
import { isPortReachable, joinedUrl } from '../utils/NetworkUtils.js';
import normalizeUrl from 'normalize-url';
import { GetTokenDetailsResponse, GetTokenDetailsUserPlexAccount } from '@lukehagar/plexjs/sdk/models/operations/gettokendetails.js';
import { parseRegexSingle } from '@foxxmd/regex-buddy-core';
Expand Down
2 changes: 1 addition & 1 deletion src/backend/sources/SpotifySource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ import {
import { SpotifySourceConfig } from "../common/infrastructure/config/source/spotify.js";
import {
combinePartsToString,
joinedUrl,
parseRetryAfterSecsFromObj,
readJson,
sleep,
sortByOldestPlayDate,
writeFile,
} from "../utils.js";
import { findCauseByFunc } from "../utils/ErrorUtils.js";
import { joinedUrl } from "../utils/NetworkUtils.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import AlbumObjectSimplified = SpotifyApi.AlbumObjectSimplified;
import ArtistObjectSimplified = SpotifyApi.ArtistObjectSimplified;
Expand Down
3 changes: 2 additions & 1 deletion src/backend/sources/WebScrobblerSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
WebScrobblerPayload,
WebScrobblerSong
} from "../common/vendor/webscrobbler/interfaces.js";
import { joinedUrl } from "../utils.js";

import { joinedUrl } from "../utils/NetworkUtils.js";
import MemorySource from "./MemorySource.js";

export class WebScrobblerSource extends MemorySource {
Expand Down
63 changes: 56 additions & 7 deletions src/backend/tests/utils/strings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assert, expect } from 'chai';
import { describe, it } from 'mocha';
import { generateBaseURL, intersect, joinedUrl } from "../../utils.js";
import { intersect } from "../../utils.js";
import { generateBaseURL, joinedUrl, normalizeWebAddress } from "../../utils/NetworkUtils.js";
import {
compareNormalizedStrings,
normalizeStr,
Expand Down Expand Up @@ -203,13 +204,61 @@ describe('URL Parsing', function () {
});
});

describe('String Splitting', function() {
describe('Normalizing', function() {

it('should not split string with no delimiter', function() {
const artistName = undefined;
const artist = "Phil Collins";
const artistStrings = splitByFirstFound(artist, [','], [artistName]);
expect(artistStrings).to.eql(['Phil Collins'])
const anIP = '192.168.0.100';

it('Should unwrap a quoted value', function () {
expect(normalizeWebAddress(`"${anIP}"`).url.hostname).to.eq(anIP);
});

it('Should normalize an IP to HTTP protocol', function () {
expect(normalizeWebAddress(anIP).url.protocol).to.eq('http:');
});

it('Should normalize an IP without a port to port 80', function () {
expect(normalizeWebAddress(anIP).port).to.eq(80);
});

it('Should normalize an IP to an HTTP URL', function () {
expect(normalizeWebAddress(anIP).normal).to.eq(`http://${anIP}`);
});

it('Should normalize an IP with port 443 to an HTTPS URL', function () {
expect(normalizeWebAddress(`${anIP}:443`).url.protocol).to.eq(`https:`);
expect(normalizeWebAddress(`${anIP}:443`).url.toString()).to.include(`https:`);
expect(normalizeWebAddress(`${anIP}:443`).normal).to.include(`https:`);
expect(normalizeWebAddress(`${anIP}:443`).port).to.eq(443);
});

it('Should not normalize an IP with port 443 if protocol is specified', function () {
expect(normalizeWebAddress(`http://${anIP}:443`).url.protocol).to.eq(`http:`);
expect(normalizeWebAddress(`http://${anIP}:443`).url.toString()).to.include(`http:`);
expect(normalizeWebAddress(`http://${anIP}:443`).normal).to.include(`http:`);
expect(normalizeWebAddress(`http://${anIP}:443`).port).to.eq(443);
});

it('Should normalize an IP with a port and preserve port', function () {
expect(normalizeWebAddress(`${anIP}:5000`).port).to.eq(5000);
expect(normalizeWebAddress(`${anIP}:5000`).normal).to.eq(`http://${anIP}:5000`);
expect(normalizeWebAddress(`${anIP}:5000`).url.protocol).to.eq('http:');
expect(normalizeWebAddress(`${anIP}:5000`).url.port).to.eq('5000');
});

it('Should remove trailing slash', function () {
expect(normalizeWebAddress(`${anIP}:5000/`).normal).to.eq(`http://${anIP}:5000`);
});
});

});

describe('String Splitting', function() {

it('should not split string with no delimiter', function() {
const artistName = undefined;
const artist = "Phil Collins";
const artistStrings = splitByFirstFound(artist, [','], [artistName]);
expect(artistStrings).to.eql(['Phil Collins'])
});
});

Loading

0 comments on commit a04c1f4

Please sign in to comment.