mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat(api): request api (#80)
This commit is contained in:
@@ -99,7 +99,7 @@ interface TmdbSearchTvResponse extends TmdbPaginatedResponse {
|
|||||||
results: TmdbTvResult[];
|
results: TmdbTvResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TmdbMovieDetails {
|
export interface TmdbMovieDetails {
|
||||||
id: number;
|
id: number;
|
||||||
imdb_id?: string;
|
imdb_id?: string;
|
||||||
adult: boolean;
|
adult: boolean;
|
||||||
@@ -154,7 +154,7 @@ interface TmdbTvEpisodeDetails {
|
|||||||
vote_cuont: number;
|
vote_cuont: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TmdbTvDetails {
|
export interface TmdbTvDetails {
|
||||||
id: number;
|
id: number;
|
||||||
backdrop_path?: string;
|
backdrop_path?: string;
|
||||||
created_by: {
|
created_by: {
|
||||||
|
@@ -7,10 +7,11 @@ import {
|
|||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
getRepository,
|
getRepository,
|
||||||
In,
|
In,
|
||||||
|
Index,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|
||||||
export enum Status {
|
export enum MediaRequestStatus {
|
||||||
PENDING,
|
PENDING,
|
||||||
APPROVED,
|
APPROVED,
|
||||||
DECLINED,
|
DECLINED,
|
||||||
@@ -44,14 +45,15 @@ export class MediaRequest {
|
|||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
public id: number;
|
public id: number;
|
||||||
|
|
||||||
@Column()
|
@Column({ unique: true })
|
||||||
|
@Index()
|
||||||
public mediaId: number;
|
public mediaId: number;
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
public mediaType: 'movie' | 'tv';
|
public mediaType: 'movie' | 'tv';
|
||||||
|
|
||||||
@Column({ type: 'integer' })
|
@Column({ type: 'integer' })
|
||||||
public status: Status;
|
public status: MediaRequestStatus;
|
||||||
|
|
||||||
@ManyToOne(() => User, (user) => user.requests, { eager: true })
|
@ManyToOne(() => User, (user) => user.requests, { eager: true })
|
||||||
public requestedBy: User;
|
public requestedBy: User;
|
||||||
@@ -65,7 +67,7 @@ export class MediaRequest {
|
|||||||
@UpdateDateColumn()
|
@UpdateDateColumn()
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<User>) {
|
constructor(init?: Partial<MediaRequest>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,6 +6,7 @@ export enum Permission {
|
|||||||
MANAGE_REQUESTS = 16,
|
MANAGE_REQUESTS = 16,
|
||||||
REQUEST = 32,
|
REQUEST = 32,
|
||||||
VOTE = 64,
|
VOTE = 64,
|
||||||
|
AUTO_APPROVE = 128,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -3,7 +3,7 @@ import type {
|
|||||||
TmdbPersonResult,
|
TmdbPersonResult,
|
||||||
TmdbTvResult,
|
TmdbTvResult,
|
||||||
} from '../api/themoviedb';
|
} from '../api/themoviedb';
|
||||||
import { MediaRequest } from '../entity/MediaRequest';
|
import type { MediaRequest } from '../entity/MediaRequest';
|
||||||
|
|
||||||
export type MediaType = 'tv' | 'movie' | 'person';
|
export type MediaType = 'tv' | 'movie' | 'person';
|
||||||
|
|
||||||
|
@@ -356,8 +356,10 @@ components:
|
|||||||
$ref: '#/components/schemas/User'
|
$ref: '#/components/schemas/User'
|
||||||
readOnly: true
|
readOnly: true
|
||||||
modifiedBy:
|
modifiedBy:
|
||||||
$ref: '#/components/schemas/User'
|
anyOf:
|
||||||
readOnly: true
|
- $ref: '#/components/schemas/User'
|
||||||
|
- type: string
|
||||||
|
nullable: true
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- mediaId
|
- mediaId
|
||||||
@@ -872,6 +874,123 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/TvResult'
|
$ref: '#/components/schemas/TvResult'
|
||||||
|
/request:
|
||||||
|
get:
|
||||||
|
summary: Get all requests
|
||||||
|
description: |
|
||||||
|
Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged in users requests are returned.
|
||||||
|
tags:
|
||||||
|
- request
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Requests returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/MediaRequest'
|
||||||
|
post:
|
||||||
|
summary: Create a new request
|
||||||
|
description: |
|
||||||
|
Creates a new request with the provided media id and type. The `REQUEST` permission is required.
|
||||||
|
|
||||||
|
If the user has the `ADMIN` or `AUTO_APPROVE` permissions, their request will be auomatically approved.
|
||||||
|
tags:
|
||||||
|
- request
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
mediaType:
|
||||||
|
type: string
|
||||||
|
enum: [movie, tv]
|
||||||
|
example: movie
|
||||||
|
mediaId:
|
||||||
|
type: number
|
||||||
|
example: 123
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Succesfully created the request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MediaRequest'
|
||||||
|
/request/{requestId}:
|
||||||
|
get:
|
||||||
|
summary: Requests a specific MediaRequest
|
||||||
|
description: Returns a MediaRequest in JSON format
|
||||||
|
tags:
|
||||||
|
- request
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: requestId
|
||||||
|
description: Request ID
|
||||||
|
required: true
|
||||||
|
example: 1
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Succesfully returns request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MediaRequest'
|
||||||
|
delete:
|
||||||
|
summary: Delete a request
|
||||||
|
description: Removes a request. If the user has the `MANAGE_REQUESTS` permission, then any request can be removed. Otherwise, only pending requests can be removed.
|
||||||
|
tags:
|
||||||
|
- request
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: requestId
|
||||||
|
description: Request ID
|
||||||
|
required: true
|
||||||
|
example: 1
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Succesfully removed request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MediaRequest'
|
||||||
|
/request/{requestId}/{status}:
|
||||||
|
get:
|
||||||
|
summary: Update a requests status
|
||||||
|
description: |
|
||||||
|
Updates a requests status to approved or declined. Also returns the request in JSON format
|
||||||
|
|
||||||
|
Requires the `MANAGE_REQUESTS` permission or `ADMIN`
|
||||||
|
tags:
|
||||||
|
- request
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: requestId
|
||||||
|
description: Request ID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 1
|
||||||
|
- in: path
|
||||||
|
name: status
|
||||||
|
description: New status
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [pending, approve, decline, available]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Request status changed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MediaRequest'
|
||||||
|
|
||||||
security:
|
security:
|
||||||
- cookieAuth: []
|
- cookieAuth: []
|
||||||
|
@@ -7,6 +7,7 @@ import { Permission } from '../lib/permissions';
|
|||||||
import { getSettings } from '../lib/settings';
|
import { getSettings } from '../lib/settings';
|
||||||
import searchRoutes from './search';
|
import searchRoutes from './search';
|
||||||
import discoverRoutes from './discover';
|
import discoverRoutes from './discover';
|
||||||
|
import requestRoutes from './request';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ router.use(
|
|||||||
);
|
);
|
||||||
router.use('/search', isAuthenticated(), searchRoutes);
|
router.use('/search', isAuthenticated(), searchRoutes);
|
||||||
router.use('/discover', isAuthenticated(), discoverRoutes);
|
router.use('/discover', isAuthenticated(), discoverRoutes);
|
||||||
|
router.use('/request', isAuthenticated(), requestRoutes);
|
||||||
router.use('/auth', authRoutes);
|
router.use('/auth', authRoutes);
|
||||||
|
|
||||||
router.get('/settings/public', (_req, res) => {
|
router.get('/settings/public', (_req, res) => {
|
||||||
|
140
server/routes/request.ts
Normal file
140
server/routes/request.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { isAuthenticated } from '../middleware/auth';
|
||||||
|
import { Permission } from '../lib/permissions';
|
||||||
|
import { getRepository } from 'typeorm';
|
||||||
|
import { MediaRequest, MediaRequestStatus } from '../entity/MediaRequest';
|
||||||
|
import TheMovieDb from '../api/themoviedb';
|
||||||
|
|
||||||
|
const requestRoutes = Router();
|
||||||
|
|
||||||
|
requestRoutes.get('/', async (req, res, next) => {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
try {
|
||||||
|
const requests = req.user?.hasPermission(Permission.MANAGE_REQUESTS)
|
||||||
|
? await requestRepository.find()
|
||||||
|
: await requestRepository.find({
|
||||||
|
where: { requestedBy: { id: req.user?.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json(requests);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 500, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
requestRoutes.post(
|
||||||
|
'/',
|
||||||
|
isAuthenticated(Permission.REQUEST),
|
||||||
|
async (req, res, next) => {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const media =
|
||||||
|
req.body.mediaType === 'movie'
|
||||||
|
? await tmdb.getMovie({ movieId: req.body.mediaId })
|
||||||
|
: await tmdb.getTvShow({ tvId: req.body.mediaId });
|
||||||
|
const request = new MediaRequest({
|
||||||
|
mediaId: media.id,
|
||||||
|
mediaType: req.body.mediaType,
|
||||||
|
requestedBy: req.user,
|
||||||
|
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||||
|
status: req.user?.hasPermission(Permission.AUTO_APPROVE)
|
||||||
|
? MediaRequestStatus.APPROVED
|
||||||
|
: MediaRequestStatus.PENDING,
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestRepository.save(request);
|
||||||
|
|
||||||
|
return res.status(201).json(request);
|
||||||
|
} catch (e) {
|
||||||
|
next({ message: e.message, status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
requestRoutes.get('/:requestId', async (req, res, next) => {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = await requestRepository.findOneOrFail({
|
||||||
|
where: { id: Number(req.params.requestId) },
|
||||||
|
relations: ['requestedBy', 'modifiedBy'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json(request);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: 'Request not found' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
requestRoutes.delete('/:requestId', async (req, res, next) => {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = await requestRepository.findOneOrFail({
|
||||||
|
where: { id: Number(req.params.requestId) },
|
||||||
|
relations: ['requestedBy', 'modifiedBy'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!req.user?.hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||||
|
(request.requestedBy.id !== req.user?.id || request.status > 0)
|
||||||
|
) {
|
||||||
|
return next({
|
||||||
|
status: 401,
|
||||||
|
message: 'You do not have permission to remove this request',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
requestRepository.delete(request.id);
|
||||||
|
|
||||||
|
return res.status(200).json(request);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: 'Request not found' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
requestRoutes.get<{
|
||||||
|
requestId: string;
|
||||||
|
status: 'pending' | 'approve' | 'decline' | 'available';
|
||||||
|
}>(
|
||||||
|
'/:requestId/:status',
|
||||||
|
isAuthenticated(Permission.MANAGE_REQUESTS),
|
||||||
|
async (req, res, next) => {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = await requestRepository.findOneOrFail({
|
||||||
|
where: { id: Number(req.params.requestId) },
|
||||||
|
relations: ['requestedBy', 'modifiedBy'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let newStatus: MediaRequestStatus;
|
||||||
|
|
||||||
|
switch (req.params.status) {
|
||||||
|
case 'pending':
|
||||||
|
newStatus = MediaRequestStatus.PENDING;
|
||||||
|
break;
|
||||||
|
case 'approve':
|
||||||
|
newStatus = MediaRequestStatus.APPROVED;
|
||||||
|
break;
|
||||||
|
case 'decline':
|
||||||
|
newStatus = MediaRequestStatus.DECLINED;
|
||||||
|
break;
|
||||||
|
case 'available':
|
||||||
|
newStatus = MediaRequestStatus.AVAILABLE;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.status = newStatus;
|
||||||
|
await requestRepository.save(request);
|
||||||
|
|
||||||
|
return res.status(200).json(request);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: 'Request not found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default requestRoutes;
|
@@ -3,10 +3,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"outDir": "../dist",
|
"outDir": "../dist",
|
||||||
"noEmit": false,
|
"noEmit": false
|
||||||
"strictPropertyInitialization": false,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"emitDecoratorMetadata": true
|
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"]
|
"include": ["**/*.ts"]
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user