diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 0222e1043..435cfb0a2 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -8,11 +8,14 @@ import { UpdateDateColumn, getRepository, In, + AfterUpdate, } from 'typeorm'; import { MediaRequest } from './MediaRequest'; import { MediaStatus, MediaType } from '../constants/media'; import logger from '../logger'; import Season from './Season'; +import notificationManager, { Notification } from '../lib/notifications'; +import TheMovieDb from '../api/themoviedb'; @Entity() class Media { @@ -95,6 +98,32 @@ class Media { constructor(init?: Partial) { 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; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 6c59adbe7..8ae56dbfe 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -64,14 +64,86 @@ export class MediaRequest { @AfterInsert() private async notifyNewRequest() { 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(); if (this.media.mediaType === MediaType.MOVIE) { const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); - notificationManager.sendNotification(Notification.MEDIA_ADDED, { - subject: `New Request: ${movie.title}`, + notificationManager.sendNotification(Notification.MEDIA_APPROVED, { + subject: movie.title, message: movie.overview, 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(', '), + }, + ], }); } } diff --git a/server/entity/Season.ts b/server/entity/Season.ts index 3f18ed08f..0a5562ea6 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -5,9 +5,16 @@ import { ManyToOne, CreateDateColumn, UpdateDateColumn, + AfterInsert, + AfterUpdate, + getRepository, + RelationId, } from 'typeorm'; import { MediaStatus } from '../constants/media'; import Media from './Media'; +import logger from '../logger'; +import TheMovieDb from '../api/themoviedb'; +import notificationManager, { Notification } from '../lib/notifications'; @Entity() class Season { @@ -20,8 +27,8 @@ class Season { @Column({ type: 'int', default: MediaStatus.UNKNOWN }) public status: MediaStatus; - @ManyToOne(() => Media, (media) => media.seasons) - public media: Media; + @ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' }) + public media: Promise; @CreateDateColumn() public createdAt: Date; @@ -32,6 +39,60 @@ class Season { constructor(init?: Partial) { 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; diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index ee70f3b90..edcec9481 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -146,31 +146,43 @@ class JobPlexSync { where: { tmdbId: tvShow.id, mediaType: MediaType.TV }, }); - const availableSeasons: Season[] = []; + const newSeasons: Season[] = []; seasons.forEach((season) => { const matchedPlexSeason = metadata.Children?.Metadata.find( (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 if ( matchedPlexSeason && Number(matchedPlexSeason.leafCount) === season.episode_count ) { - availableSeasons.push( - new Season({ - seasonNumber: season.season_number, - status: MediaStatus.AVAILABLE, - }) - ); + if (existingSeason) { + existingSeason.status = MediaStatus.AVAILABLE; + } else { + newSeasons.push( + new Season({ + seasonNumber: season.season_number, + status: MediaStatus.AVAILABLE, + }) + ); + } } else if (matchedPlexSeason) { - availableSeasons.push( - new Season({ - seasonNumber: season.season_number, - status: MediaStatus.PARTIALLY_AVAILABLE, - }) - ); + if (existingSeason) { + existingSeason.status = MediaStatus.PARTIALLY_AVAILABLE; + } else { + newSeasons.push( + new Season({ + seasonNumber: season.season_number, + status: MediaStatus.PARTIALLY_AVAILABLE, + }) + ); + } } }); @@ -179,11 +191,13 @@ class JobPlexSync { (season) => season.season_number !== 0 ); - const isAllSeasons = availableSeasons.length >= filteredSeasons.length; + const isAllSeasons = + newSeasons.length + (media?.seasons.length ?? 0) >= + filteredSeasons.length; if (media) { // Update existing - media.seasons = availableSeasons; + media.seasons = [...media.seasons, ...newSeasons]; media.status = isAllSeasons ? MediaStatus.AVAILABLE : MediaStatus.PARTIALLY_AVAILABLE; @@ -192,7 +206,7 @@ class JobPlexSync { } else { const newMedia = new Media({ mediaType: MediaType.TV, - seasons: availableSeasons, + seasons: newSeasons, tmdbId: tvShow.id, tvdbId: tvShow.external_ids.tvdb_id, status: isAllSeasons diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index 26fd7639e..15d57bca4 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -1,10 +1,12 @@ import { Notification } from '..'; +import { User } from '../../../entity/User'; export interface NotificationPayload { subject: string; - username?: string; + notifyUser: User; image?: string; message?: string; + extra?: { name: string; value: string }[]; } export interface NotificationAgent { diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 02540c1dc..c293f4184 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -81,10 +81,21 @@ class DiscordAgent implements NotificationAgent { payload: NotificationPayload ): DiscordRichEmbed { let color = EmbedColors.DEFAULT; + let status = 'Unknown'; switch (type) { - case Notification.MEDIA_ADDED: + case Notification.MEDIA_PENDING: 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 { @@ -96,14 +107,19 @@ class DiscordAgent implements NotificationAgent { fields: [ { name: 'Requested By', - value: payload.username ?? '', + value: payload.notifyUser.username ?? '', inline: true, }, { name: 'Status', - value: 'Pending Approval', + value: status, 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: { url: payload.image, @@ -131,8 +147,8 @@ class DiscordAgent implements NotificationAgent { const settings = getSettings(); logger.debug('Sending discord notification', { label: 'Notifications' }); try { - const webhookUrl = settings.notifications.agents.discord?.options - ?.webhookUrl as string; + const webhookUrl = + settings.notifications.agents.discord?.options?.webhookUrl; if (!webhookUrl) { return false; diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index 05dc2aa64..91be4c5d8 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -2,7 +2,9 @@ import logger from '../../logger'; import type { NotificationAgent, NotificationPayload } from './agents/agent'; export enum Notification { - MEDIA_ADDED = 2, + MEDIA_PENDING = 2, + MEDIA_APPROVED = 4, + MEDIA_AVAILABLE = 8, } class NotificationManager { diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 72d65dfe4..39aad9008 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -55,9 +55,18 @@ interface NotificationAgent { types: number; options: Record; } +interface NotificationAgentDiscord extends NotificationAgent { + options: { + webhookUrl: string; + }; +} + +interface NotificationAgents { + discord: NotificationAgentDiscord; +} interface NotificationSettings { - agents: Record; + agents: NotificationAgents; } interface AllSettings {