Skip to content

Commit

Permalink
Add better live status handling and notification improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
micthiesen committed Oct 26, 2024
1 parent edb16cf commit 875fe2b
Show file tree
Hide file tree
Showing 7 changed files with 118 additions and 32 deletions.
5 changes: 5 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"formatter": {
"lineWidth": 88
}
}
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"docker:dev": "docker run -p 3000:3000 --rm --name my-nodejs-project my-nodejs-project"
},
"dependencies": {
"date-fns": "^4.1.0",
"dotenv": "^16.4.5",
"html-entities": "^2.5.2",
"node-cron": "^3.0.3",
Expand All @@ -26,9 +27,9 @@
"@biomejs/biome": "^1.8.3",
"@types/node": "20.14.12",
"@types/node-cron": "3.0.11",
"typescript": "^5.5.4",
"vite-node": "^2.0.4",
"vitest": "^2.0.4",
"typescript": "^5.5.4"
"vitest": "^2.0.4"
},
"engines": {
"node": "20.16.0",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 86 additions & 21 deletions src/tasks/LiveCheckTask.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,38 @@
import { formatDistance, formatDistanceToNow } from "date-fns";
import BetterMap from "../utils/BetterMap.js";
import type Logger from "../utils/Logger.js";
import { sendNotification } from "../utils/notifications.js";
import { checkYouTubeLiveStatus, getYouTubeLiveUrl } from "../utils/youtube.js";
import {
type LiveStatusLive,
type LiveStatusOffline,
checkYouTubeLiveStatus,
getYouTubeLiveUrl,
} from "../utils/youtube.js";
import { Task } from "./types.js";

type ChannelStatusLive = {
isLive: true;
title: string;
startedAt: Date;
};
type ChannelStatusOffline =
| {
isLive: false;
lastEndedAt?: undefined;
lastStartedAt?: undefined;
}
| {
isLive: false;
lastEndedAt: Date;
lastStartedAt: Date;
};
type ChannelStatus = ChannelStatusLive | ChannelStatusOffline;

export default class LiveCheckTask extends Task {
public name = "YT Live Check";

private logger: Logger;
private previousStatuses = new Map<string, boolean>();
private statuses: BetterMap<string, ChannelStatus>;
private numPreviousFailures = 0;

public constructor(
Expand All @@ -16,43 +41,83 @@ export default class LiveCheckTask extends Task {
) {
super();
this.logger = parentLogger.extend("LiveCheckTask");
this.statuses = new BetterMap(channelNames.map((n) => [n, { isLive: false }]));
}

public async run() {
const results = await Promise.allSettled(
this.channelNames.map(async (username) => {
const result = await checkYouTubeLiveStatus({ username });
this.logger.debug(`${username} is ${result.isLive ? "" : "NOT "}live`);

const isLivePrevious = this.previousStatuses.get(username) ?? false;
if (result.isLive && !isLivePrevious) {
this.logger.info(`${username} is live: sending notification`);
await sendNotification({
title: `${username} is LIVE on YouTube!`,
message: result.title,
url: getYouTubeLiveUrl(username),
});
} else if (!result.isLive && isLivePrevious) {
this.logger.info(`${username} is no longer live on YouTube`);
}
const currentStatus = await checkYouTubeLiveStatus({ username });
const previousStatus = this.statuses.getOrThrow(username);
this.logger.debug(`${username} is ${currentStatus.isLive ? "" : "NOT "}live`);

this.previousStatuses.set(username, result.isLive);
if (currentStatus.isLive && !previousStatus.isLive) {
await this.handleLiveEvent(username, currentStatus, previousStatus);
} else if (!currentStatus.isLive && previousStatus.isLive) {
await this.handleOfflineEvent(username, currentStatus, previousStatus);
}
}),
);

const failures = results.filter((r) => r.status === "rejected");
this.handleFailures(failures);
}

private async handleLiveEvent(
username: string,
{ title }: LiveStatusLive,
{ lastEndedAt, lastStartedAt }: ChannelStatusOffline,
) {
this.logger.info(`${username} is live: sending notification`);

const lastLiveMessage = (() => {
if (!lastEndedAt) return null;
const ago = formatDistanceToNow(lastEndedAt);
const duration = formatDistance(lastEndedAt, lastStartedAt);
return `Last live ${ago} ago for ${duration}`;
})();
const message = (() => {
if (!lastLiveMessage) return title;
return `${title}\n\n${lastLiveMessage}`;
})();

await sendNotification({
title: `${username} is LIVE on YouTube!`,
message,
url: getYouTubeLiveUrl(username),
url_title: "Watch on YouTube",
});

this.statuses.set(username, { isLive: true, startedAt: new Date(), title });
}

private async handleOfflineEvent(
username: string,
_: LiveStatusOffline,
{ startedAt }: ChannelStatusLive,
) {
const lastEndedAt = new Date();
this.logger.info(`${username} is no longer live: sending notification`);

const duration = formatDistance(lastEndedAt, startedAt);
await sendNotification({
title: `${username} is now offline`,
message: `Streamed for ${duration}`,
});

this.statuses.set(username, {
isLive: false,
lastEndedAt,
lastStartedAt: startedAt,
});
}

private handleFailures(failures: PromiseRejectedResult[]) {
if (failures.length > 0) {
// Don't sent a notification if there's an occasional error
const loggerMethod = this.numPreviousFailures >= 2 ? "error" : "warn";
for (const result of failures) {
this.logger[loggerMethod](
"Failed to check live status:",
result.reason,
);
this.logger[loggerMethod]("Failed to check live status:", result.reason);
}
this.numPreviousFailures += 1;
} else {
Expand Down
9 changes: 9 additions & 0 deletions src/utils/BetterMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default class BetterMap<K, V> extends Map<K, V> {
public getOrThrow(key: K): V {
const value = this.get(key);
if (value === undefined) {
throw new Error(`Key not found: ${key}`);
}
return value;
}
}
8 changes: 2 additions & 6 deletions src/utils/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ interface PushoverMessage {
timestamp?: number;
}

export async function sendNotification(
message: PushoverMessage,
): Promise<void> {
export async function sendNotification(message: PushoverMessage): Promise<void> {
return new Promise((resolve, reject) => {
const params = new URLSearchParams({
token: config.PUSHOVER_APP_TOKEN,
Expand Down Expand Up @@ -53,9 +51,7 @@ export async function sendNotification(
resolve();
} else {
reject(
new Error(
`Pushover API returned status code ${res.statusCode}: ${data}`,
),
new Error(`Pushover API returned status code ${res.statusCode}: ${data}`),
);
}
});
Expand Down
8 changes: 5 additions & 3 deletions src/utils/youtube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { decode } from "html-entities";

const TIMEOUT_MS = 10 * 1000;

export type IsLiveResult = { isLive: true; title: string } | { isLive: false };
export type LiveStatusLive = { isLive: true; title: string };
export type LiveStatusOffline = { isLive: false };
export type LiveStatus = LiveStatusLive | LiveStatusOffline;

export async function checkYouTubeLiveStatus({
username,
}: { username: string }): Promise<IsLiveResult> {
}: { username: string }): Promise<LiveStatus> {
const url = getYouTubeLiveUrl(username);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
Expand All @@ -33,7 +35,7 @@ export async function checkYouTubeLiveStatus({
}
}

export function extractMetaTitleContent(html: string): IsLiveResult {
export function extractMetaTitleContent(html: string): LiveStatus {
const metaTagRegex = /<meta\s+name="title"\s+content="([^"]*)"\s*\/?>/i;
const match = metaTagRegex.exec(html);
return match ? { isLive: true, title: decode(match[1]) } : { isLive: false };
Expand Down

0 comments on commit 875fe2b

Please sign in to comment.