Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions overseerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1770,6 +1770,26 @@ components:
type: number
name:
type: string
DeclineReason:
type: object
properties:
id:
type: integer
example: 1
readOnly: true
reason:
type: string
example: 'Content not suitable for our server'
createdAt:
type: string
format: date-time
example: '2024-01-15T09:45:00.000Z'
readOnly: true
updatedAt:
type: string
format: date-time
example: '2024-01-15T09:45:00.000Z'
readOnly: true
Issue:
type: object
properties:
Expand Down Expand Up @@ -3256,6 +3276,67 @@ paths:
responses:
'204':
description: All sliders reset to defaults
/settings/decline-reasons:
get:
summary: Get all custom decline reasons
description: Returns all custom decline reasons in a JSON array.
tags:
- settings
responses:
'200':
description: Custom decline reasons returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DeclineReason'
post:
summary: Create a new custom decline reason
description: Creates a new custom decline reason. Requires the `ADMIN` permission.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
reason:
type: string
example: 'Content not suitable for our server'
required:
- reason
responses:
'201':
description: Custom decline reason created
content:
application/json:
schema:
$ref: '#/components/schemas/DeclineReason'
'400':
description: Bad request - reason is required
'409':
description: Conflict - decline reason already exists
/settings/decline-reasons/{reasonId}:
delete:
summary: Delete a custom decline reason
description: Deletes a custom decline reason by ID. Requires the `ADMIN` permission.
tags:
- settings
parameters:
- in: path
name: reasonId
required: true
schema:
type: number
example: 1
responses:
'204':
description: Custom decline reason deleted
'404':
description: Decline reason not found
/settings/about:
get:
summary: Get server stats
Expand Down
28 changes: 28 additions & 0 deletions server/entity/DeclineReason.ts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this Entity? Can we not just add this directly to the MediaRequest entity

Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';

@Entity()
export class DeclineReason {
@PrimaryGeneratedColumn()
public id: number;

@Column({ type: 'text' })
public reason: string;

@CreateDateColumn()
public createdAt: Date;

@UpdateDateColumn()
public updatedAt: Date;

constructor(init?: Partial<DeclineReason>) {
Object.assign(this, init);
}
}

export default DeclineReason;
65 changes: 47 additions & 18 deletions server/entity/MediaRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,9 @@ export class MediaRequest {
@Column({ default: false })
public isAutoRequest: boolean;

@Column({ nullable: true, type: 'text' })
public declineReason?: string;

constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init);
}
Expand Down Expand Up @@ -594,6 +597,18 @@ export class MediaRequest {

if (entity.type === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: media.tmdbId });

// For declined requests, include the decline reason in the message
let notificationMessage = truncate(movie.overview, {
length: 500,
separator: /\s/,
omission: '…',
});

if (type === Notification.MEDIA_DECLINED && entity.declineReason) {
notificationMessage = `Decline reason: ${entity.declineReason}\n\n${notificationMessage}`;
}

notificationManager.sendNotification(type, {
media,
request: entity,
Expand All @@ -604,15 +619,40 @@ export class MediaRequest {
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`,
message: truncate(movie.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
message: notificationMessage,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
});
} else if (entity.type === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });

// For declined requests, include the decline reason in the message
let notificationMessage = truncate(tv.overview, {
length: 500,
separator: /\s/,
omission: '…',
});

if (type === Notification.MEDIA_DECLINED && entity.declineReason) {
notificationMessage = `Decline reason: ${entity.declineReason}\n\n${notificationMessage}`;
}

const extraFields = [
{
name: 'Requested Seasons',
value: entity.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
];

// Add decline reason as an extra field for declined requests
if (type === Notification.MEDIA_DECLINED && entity.declineReason) {
extraFields.unshift({
name: 'Decline Reason',
value: entity.declineReason,
});
}

notificationManager.sendNotification(type, {
media,
request: entity,
Expand All @@ -623,20 +663,9 @@ export class MediaRequest {
subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`,
message: truncate(tv.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
message: notificationMessage,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
extra: [
{
name: 'Requested Seasons',
value: entity.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
extra: extraFields,
});
}
} catch (e) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddDeclineReasonToMediaRequest1740717744279
implements MigrationInterface
{
name = 'AddDeclineReasonToMediaRequest1740717744279';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "media_request" ADD "declineReason" text`
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "media_request" DROP COLUMN "declineReason"`
);
}
}
28 changes: 28 additions & 0 deletions server/migration/1740717744280-CreateDeclineReasonTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateDeclineReasonTable1740717744280
implements MigrationInterface
{
name = 'CreateDeclineReasonTable1740717744280';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "decline_reason" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "reason" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
);

// Insert default decline reasons
await queryRunner.query(`
INSERT INTO "decline_reason" ("reason") VALUES
('Inappropriate content'),
('Low quality content'),
('Not available - too niche'),
('Please request only a few seasons at a time'),
('Available on YouTube'),
('No reality TV sorry')
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "decline_reason"`);
}
}
6 changes: 6 additions & 0 deletions server/routes/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,12 @@ requestRoutes.post<{

request.status = newStatus;
request.modifiedBy = req.user;

// If declining with a reason, save the decline reason
if (newStatus === MediaRequestStatus.DECLINED && req.body.reason) {
request.declineReason = req.body.reason;
}

await requestRepository.save(request);

return res.status(200).json(request);
Expand Down
101 changes: 101 additions & 0 deletions server/routes/settings/declineReasons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { getRepository } from '@server/datasource';
import DeclineReason from '@server/entity/DeclineReason';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';

const declineReasonsRoutes = Router();

// Get all custom decline reasons
declineReasonsRoutes.get('/', async (_req, res, next) => {
try {
const declineReasonRepository = getRepository(DeclineReason);
const reasons = await declineReasonRepository.find({
order: { createdAt: 'ASC' },
});

return res.status(200).json(reasons);
} catch (e) {
logger.error('Something went wrong retrieving decline reasons', {
label: 'API',
errorMessage: e.message,
});
next({ status: 500, message: 'Unable to retrieve decline reasons.' });
}
});

// Create a new custom decline reason
declineReasonsRoutes.post<never, DeclineReason, { reason: string }>(
'/',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
try {
const { reason } = req.body;

if (!reason || !reason.trim()) {
return next({ status: 400, message: 'Reason is required.' });
}

const declineReasonRepository = getRepository(DeclineReason);

// Check if reason already exists
const existingReason = await declineReasonRepository.findOne({
where: { reason: reason.trim() },
});

if (existingReason) {
return next({
status: 409,
message: 'This decline reason already exists.',
});
}

const newReason = new DeclineReason({
reason: reason.trim(),
});

await declineReasonRepository.save(newReason);

return res.status(201).json(newReason);
} catch (e) {
logger.error('Something went wrong creating decline reason', {
label: 'API',
errorMessage: e.message,
});
next({ status: 500, message: 'Unable to create decline reason.' });
}
}
);

// Delete a custom decline reason
declineReasonsRoutes.delete<{ reasonId: string }>(
'/:reasonId',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
try {
const declineReasonRepository = getRepository(DeclineReason);
const reasonId = Number(req.params.reasonId);

const reason = await declineReasonRepository.findOne({
where: { id: reasonId },
});

if (!reason) {
return next({ status: 404, message: 'Decline reason not found.' });
}

await declineReasonRepository.remove(reason);

return res.status(204).send();
} catch (e) {
logger.error('Something went wrong deleting decline reason', {
label: 'API',
errorMessage: e.message,
});
next({ status: 500, message: 'Unable to delete decline reason.' });
}
}
);

export default declineReasonsRoutes;
Loading