feat: notifications for media_available and media_approved

This commit is contained in:
sct
2020-11-23 10:34:53 +00:00
parent d8e542e5fe
commit a6c5e65bbf
8 changed files with 234 additions and 29 deletions

View File

@@ -8,11 +8,14 @@ import {
UpdateDateColumn, UpdateDateColumn,
getRepository, getRepository,
In, In,
AfterUpdate,
} from 'typeorm'; } from 'typeorm';
import { MediaRequest } from './MediaRequest'; import { MediaRequest } from './MediaRequest';
import { MediaStatus, MediaType } from '../constants/media'; import { MediaStatus, MediaType } from '../constants/media';
import logger from '../logger'; import logger from '../logger';
import Season from './Season'; import Season from './Season';
import notificationManager, { Notification } from '../lib/notifications';
import TheMovieDb from '../api/themoviedb';
@Entity() @Entity()
class Media { class Media {
@@ -95,6 +98,32 @@ class Media {
constructor(init?: Partial<Media>) { constructor(init?: Partial<Media>) {
Object.assign(this, init); Object.assign(this, init);
} }
@AfterUpdate()
private async notifyAvailable() {
if (this.status === MediaStatus.AVAILABLE) {
if (this.mediaType === MediaType.MOVIE) {
const requestRepository = getRepository(MediaRequest);
const relatedRequests = await requestRepository.find({
where: { media: this },
});
if (relatedRequests.length > 0) {
const tmdb = new TheMovieDb();
const movie = await tmdb.getMovie({ movieId: this.tmdbId });
relatedRequests.forEach((request) => {
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
notifyUser: request.requestedBy,
subject: movie.title,
message: movie.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
});
});
}
}
}
}
} }
export default Media; export default Media;

View File

@@ -64,14 +64,86 @@ export class MediaRequest {
@AfterInsert() @AfterInsert()
private async notifyNewRequest() { private async notifyNewRequest() {
if (this.status === MediaRequestStatus.PENDING) { if (this.status === MediaRequestStatus.PENDING) {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
logger.error('No parent media!', { label: 'Media Request' });
return;
}
const tmdb = new TheMovieDb();
if (this.type === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
subject: movie.title,
message: movie.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyUser: this.requestedBy,
});
}
if (this.type === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
subject: tv.name,
message: tv.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyUser: this.requestedBy,
extra: [
{
name: 'Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
});
}
}
}
/**
* Notification for approval
*
* We only check on AfterUpdate as to not trigger this for
* auto approved content
*/
@AfterUpdate()
private async notifyApproved() {
if (this.status === MediaRequestStatus.APPROVED) {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
logger.error('No parent media!', { label: 'Media Request' });
return;
}
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
if (this.media.mediaType === MediaType.MOVIE) { if (this.media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_ADDED, { notificationManager.sendNotification(Notification.MEDIA_APPROVED, {
subject: `New Request: ${movie.title}`, subject: movie.title,
message: movie.overview, message: movie.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
username: this.requestedBy.username, notifyUser: this.requestedBy,
});
} else if (this.media.mediaType === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_APPROVED, {
subject: tv.name,
message: tv.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyUser: this.requestedBy,
extra: [
{
name: 'Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
}); });
} }
} }

View File

@@ -5,9 +5,16 @@ import {
ManyToOne, ManyToOne,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
AfterInsert,
AfterUpdate,
getRepository,
RelationId,
} from 'typeorm'; } from 'typeorm';
import { MediaStatus } from '../constants/media'; import { MediaStatus } from '../constants/media';
import Media from './Media'; import Media from './Media';
import logger from '../logger';
import TheMovieDb from '../api/themoviedb';
import notificationManager, { Notification } from '../lib/notifications';
@Entity() @Entity()
class Season { class Season {
@@ -20,8 +27,8 @@ class Season {
@Column({ type: 'int', default: MediaStatus.UNKNOWN }) @Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status: MediaStatus; public status: MediaStatus;
@ManyToOne(() => Media, (media) => media.seasons) @ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' })
public media: Media; public media: Promise<Media>;
@CreateDateColumn() @CreateDateColumn()
public createdAt: Date; public createdAt: Date;
@@ -32,6 +39,60 @@ class Season {
constructor(init?: Partial<Season>) { constructor(init?: Partial<Season>) {
Object.assign(this, init); Object.assign(this, init);
} }
@AfterInsert()
@AfterUpdate()
private async sendSeasonAvailableNotification() {
if (this.status === MediaStatus.AVAILABLE) {
try {
const lazyMedia = await this.media;
const tmdb = new TheMovieDb();
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOneOrFail({
where: { id: lazyMedia.id },
relations: ['requests'],
});
const availableSeasons = media.seasons.map(
(season) => season.seasonNumber
);
const request = media.requests.find(
(request) =>
// Check if the season is complete AND it contains the current season that was just marked available
request.seasons.every((season) =>
availableSeasons.includes(season.seasonNumber)
) &&
request.seasons.some(
(season) => season.seasonNumber === this.seasonNumber
)
);
if (request) {
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
subject: tv.name,
message: tv.overview,
notifyUser: request.requestedBy,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
extra: [
{
name: 'Seasons',
value: request.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
});
}
} catch (e) {
logger.error('Something went wrong sending season available notice', {
label: 'Notifications',
message: e.message,
});
}
}
}
} }
export default Season; export default Season;

View File

@@ -146,32 +146,44 @@ class JobPlexSync {
where: { tmdbId: tvShow.id, mediaType: MediaType.TV }, where: { tmdbId: tvShow.id, mediaType: MediaType.TV },
}); });
const availableSeasons: Season[] = []; const newSeasons: Season[] = [];
seasons.forEach((season) => { seasons.forEach((season) => {
const matchedPlexSeason = metadata.Children?.Metadata.find( const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number (md) => Number(md.index) === season.season_number
); );
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.season_number
);
// Check if we found the matching season and it has all the available episodes // Check if we found the matching season and it has all the available episodes
if ( if (
matchedPlexSeason && matchedPlexSeason &&
Number(matchedPlexSeason.leafCount) === season.episode_count Number(matchedPlexSeason.leafCount) === season.episode_count
) { ) {
availableSeasons.push( if (existingSeason) {
existingSeason.status = MediaStatus.AVAILABLE;
} else {
newSeasons.push(
new Season({ new Season({
seasonNumber: season.season_number, seasonNumber: season.season_number,
status: MediaStatus.AVAILABLE, status: MediaStatus.AVAILABLE,
}) })
); );
}
} else if (matchedPlexSeason) { } else if (matchedPlexSeason) {
availableSeasons.push( if (existingSeason) {
existingSeason.status = MediaStatus.PARTIALLY_AVAILABLE;
} else {
newSeasons.push(
new Season({ new Season({
seasonNumber: season.season_number, seasonNumber: season.season_number,
status: MediaStatus.PARTIALLY_AVAILABLE, status: MediaStatus.PARTIALLY_AVAILABLE,
}) })
); );
} }
}
}); });
// Remove extras season. We dont count it for determining availability // Remove extras season. We dont count it for determining availability
@@ -179,11 +191,13 @@ class JobPlexSync {
(season) => season.season_number !== 0 (season) => season.season_number !== 0
); );
const isAllSeasons = availableSeasons.length >= filteredSeasons.length; const isAllSeasons =
newSeasons.length + (media?.seasons.length ?? 0) >=
filteredSeasons.length;
if (media) { if (media) {
// Update existing // Update existing
media.seasons = availableSeasons; media.seasons = [...media.seasons, ...newSeasons];
media.status = isAllSeasons media.status = isAllSeasons
? MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE
: MediaStatus.PARTIALLY_AVAILABLE; : MediaStatus.PARTIALLY_AVAILABLE;
@@ -192,7 +206,7 @@ class JobPlexSync {
} else { } else {
const newMedia = new Media({ const newMedia = new Media({
mediaType: MediaType.TV, mediaType: MediaType.TV,
seasons: availableSeasons, seasons: newSeasons,
tmdbId: tvShow.id, tmdbId: tvShow.id,
tvdbId: tvShow.external_ids.tvdb_id, tvdbId: tvShow.external_ids.tvdb_id,
status: isAllSeasons status: isAllSeasons

View File

@@ -1,10 +1,12 @@
import { Notification } from '..'; import { Notification } from '..';
import { User } from '../../../entity/User';
export interface NotificationPayload { export interface NotificationPayload {
subject: string; subject: string;
username?: string; notifyUser: User;
image?: string; image?: string;
message?: string; message?: string;
extra?: { name: string; value: string }[];
} }
export interface NotificationAgent { export interface NotificationAgent {

View File

@@ -81,10 +81,21 @@ class DiscordAgent implements NotificationAgent {
payload: NotificationPayload payload: NotificationPayload
): DiscordRichEmbed { ): DiscordRichEmbed {
let color = EmbedColors.DEFAULT; let color = EmbedColors.DEFAULT;
let status = 'Unknown';
switch (type) { switch (type) {
case Notification.MEDIA_ADDED: case Notification.MEDIA_PENDING:
color = EmbedColors.ORANGE; color = EmbedColors.ORANGE;
status = 'Pending Approval';
break;
case Notification.MEDIA_APPROVED:
color = EmbedColors.PURPLE;
status = 'Processing Request';
break;
case Notification.MEDIA_AVAILABLE:
color = EmbedColors.GREEN;
status = 'Available';
break;
} }
return { return {
@@ -96,14 +107,19 @@ class DiscordAgent implements NotificationAgent {
fields: [ fields: [
{ {
name: 'Requested By', name: 'Requested By',
value: payload.username ?? '', value: payload.notifyUser.username ?? '',
inline: true, inline: true,
}, },
{ {
name: 'Status', name: 'Status',
value: 'Pending Approval', value: status,
inline: true, inline: true,
}, },
// If we have extra data, map it to fields for discord notifications
...(payload.extra ?? []).map((extra) => ({
name: extra.name,
value: extra.value,
})),
], ],
thumbnail: { thumbnail: {
url: payload.image, url: payload.image,
@@ -131,8 +147,8 @@ class DiscordAgent implements NotificationAgent {
const settings = getSettings(); const settings = getSettings();
logger.debug('Sending discord notification', { label: 'Notifications' }); logger.debug('Sending discord notification', { label: 'Notifications' });
try { try {
const webhookUrl = settings.notifications.agents.discord?.options const webhookUrl =
?.webhookUrl as string; settings.notifications.agents.discord?.options?.webhookUrl;
if (!webhookUrl) { if (!webhookUrl) {
return false; return false;

View File

@@ -2,7 +2,9 @@ import logger from '../../logger';
import type { NotificationAgent, NotificationPayload } from './agents/agent'; import type { NotificationAgent, NotificationPayload } from './agents/agent';
export enum Notification { export enum Notification {
MEDIA_ADDED = 2, MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
} }
class NotificationManager { class NotificationManager {

View File

@@ -55,9 +55,18 @@ interface NotificationAgent {
types: number; types: number;
options: Record<string, unknown>; options: Record<string, unknown>;
} }
interface NotificationAgentDiscord extends NotificationAgent {
options: {
webhookUrl: string;
};
}
interface NotificationAgents {
discord: NotificationAgentDiscord;
}
interface NotificationSettings { interface NotificationSettings {
agents: Record<string, NotificationAgent>; agents: NotificationAgents;
} }
interface AllSettings { interface AllSettings {