mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: notifications for media_available and media_approved
This commit is contained in:
@@ -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;
|
||||||
|
@@ -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(', '),
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
||||||
|
@@ -146,31 +146,43 @@ 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) {
|
||||||
new Season({
|
existingSeason.status = MediaStatus.AVAILABLE;
|
||||||
seasonNumber: season.season_number,
|
} else {
|
||||||
status: MediaStatus.AVAILABLE,
|
newSeasons.push(
|
||||||
})
|
new Season({
|
||||||
);
|
seasonNumber: season.season_number,
|
||||||
|
status: MediaStatus.AVAILABLE,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (matchedPlexSeason) {
|
} else if (matchedPlexSeason) {
|
||||||
availableSeasons.push(
|
if (existingSeason) {
|
||||||
new Season({
|
existingSeason.status = MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
seasonNumber: season.season_number,
|
} else {
|
||||||
status: MediaStatus.PARTIALLY_AVAILABLE,
|
newSeasons.push(
|
||||||
})
|
new Season({
|
||||||
);
|
seasonNumber: season.season_number,
|
||||||
|
status: MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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;
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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 {
|
||||||
|
Reference in New Issue
Block a user