diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts new file mode 100644 index 000000000..c68c1f0be --- /dev/null +++ b/server/entity/MediaRequest.ts @@ -0,0 +1,71 @@ +import { + Entity, + PrimaryGeneratedColumn, + ManyToOne, + Column, + CreateDateColumn, + UpdateDateColumn, + getRepository, + In, +} from 'typeorm'; +import { User } from './User'; + +export enum Status { + PENDING, + APPROVED, + DECLINED, + AVAILABLE, +} + +@Entity() +export class MediaRequest { + public static async getRelatedRequests( + mediaIds: number | number[] + ): Promise { + const requestRepository = getRepository(MediaRequest); + + try { + let finalIds: number[]; + if (!Array.isArray(mediaIds)) { + finalIds = [mediaIds]; + } else { + finalIds = mediaIds; + } + + const requests = await requestRepository.find({ mediaId: In(finalIds) }); + + return requests; + } catch (e) { + console.error(e.messaage); + return []; + } + } + + @PrimaryGeneratedColumn() + public id: number; + + @Column() + public mediaId: number; + + @Column() + public mediaType: 'movie' | 'tv'; + + @Column({ type: 'integer' }) + public status: Status; + + @ManyToOne(() => User, (user) => user.requests, { eager: true }) + public requestedBy: User; + + @ManyToOne(() => User, { nullable: true }) + public modifiedBy?: User; + + @CreateDateColumn() + public createdAt: Date; + + @UpdateDateColumn() + public updatedAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} diff --git a/server/entity/User.ts b/server/entity/User.ts index 94feccbf7..d2b610d0b 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -4,8 +4,10 @@ import { Column, CreateDateColumn, UpdateDateColumn, + OneToMany, } from 'typeorm'; import { Permission, hasPermission } from '../lib/permissions'; +import { MediaRequest } from './MediaRequest'; @Entity() export class User { @@ -21,7 +23,13 @@ export class User { @Column({ unique: true }) public email: string; - @Column({ nullable: true }) + @Column() + public username: string; + + @Column({ select: false }) + public plexId: number; + + @Column({ nullable: true, select: false }) public plexToken?: string; @Column({ type: 'integer', default: 0 }) @@ -30,6 +38,9 @@ export class User { @Column() public avatar: string; + @OneToMany(() => MediaRequest, (request) => request.requestedBy) + public requests: MediaRequest; + @CreateDateColumn() public createdAt: Date; diff --git a/server/models/Search.ts b/server/models/Search.ts index 8a4b18f61..be2e5c443 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -3,6 +3,7 @@ import type { TmdbPersonResult, TmdbTvResult, } from '../api/themoviedb'; +import { MediaRequest } from '../entity/MediaRequest'; export type MediaType = 'tv' | 'movie' | 'person'; @@ -17,6 +18,7 @@ interface SearchResult { genreIds: number[]; overview: string; originalLanguage: string; + request?: MediaRequest; } export interface MovieResult extends SearchResult { @@ -26,6 +28,7 @@ export interface MovieResult extends SearchResult { releaseDate: string; adult: boolean; video: boolean; + request?: MediaRequest; } export interface TvResult extends SearchResult { @@ -48,7 +51,10 @@ export interface PersonResult { export type Results = MovieResult | TvResult | PersonResult; -export const mapMovieResult = (movieResult: TmdbMovieResult): MovieResult => ({ +export const mapMovieResult = ( + movieResult: TmdbMovieResult, + request?: MediaRequest +): MovieResult => ({ id: movieResult.id, mediaType: 'movie', adult: movieResult.adult, @@ -64,9 +70,13 @@ export const mapMovieResult = (movieResult: TmdbMovieResult): MovieResult => ({ voteCount: movieResult.vote_count, backdropPath: movieResult.backdrop_path, posterPath: movieResult.poster_path, + request, }); -export const mapTvResult = (tvResult: TmdbTvResult): TvResult => ({ +export const mapTvResult = ( + tvResult: TmdbTvResult, + request?: MediaRequest +): TvResult => ({ id: tvResult.id, firstAirDate: tvResult.first_air_Date, genreIds: tvResult.genre_ids, @@ -81,6 +91,7 @@ export const mapTvResult = (tvResult: TmdbTvResult): TvResult => ({ voteCount: tvResult.vote_count, backdropPath: tvResult.backdrop_path, posterPath: tvResult.poster_path, + request, }); export const mapPersonResult = ( @@ -102,14 +113,21 @@ export const mapPersonResult = ( }); export const mapSearchResults = ( - results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] + results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[], + requests?: MediaRequest[] ): Results[] => results.map((result) => { switch (result.media_type) { case 'movie': - return mapMovieResult(result); + return mapMovieResult( + result, + requests?.find((req) => req.mediaId === result.id) + ); case 'tv': - return mapTvResult(result); + return mapTvResult( + result, + requests?.find((req) => req.mediaId === result.id) + ); default: return mapPersonResult(result); } diff --git a/server/overseerr-api.yml b/server/overseerr-api.yml index c5618dea6..a444f68ed 100644 --- a/server/overseerr-api.yml +++ b/server/overseerr-api.yml @@ -260,6 +260,8 @@ components: video: type: boolean example: false + request: + $ref: '#/components/schemas/MediaRequest' TvResult: type: object properties: @@ -301,6 +303,8 @@ components: type: string firstAirDate: type: string + request: + $ref: '#/components/schemas/MediaRequest' PersonResult: type: object properties: @@ -321,6 +325,44 @@ components: oneOf: - $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/TvResult' + MediaRequest: + type: object + properties: + id: + type: number + example: 123 + readOnly: true + mediaId: + type: number + example: 123 + description: TMDB Movie ID + mediaType: + type: string + enum: [movie, tv] + status: + type: number + example: 0 + description: Status of the request. 0 = PENDING APPROVAL, 1 = APPROVED, 2 = DECLINED, 3 = AVAILABLE + readOnly: true + createdAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + requestedBy: + $ref: '#/components/schemas/User' + readOnly: true + modifiedBy: + $ref: '#/components/schemas/User' + readOnly: true + required: + - id + - mediaId + - mediaType + - status securitySchemes: cookieAuth: diff --git a/server/routes/auth.ts b/server/routes/auth.ts index a997a6549..b386f9cbb 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -37,7 +37,7 @@ authRoutes.post('/login', async (req, res) => { // Next let's see if the user already exists let user = await userRepository.findOne({ - where: { email: account.email }, + where: { plexId: account.id }, }); if (user) { @@ -49,6 +49,8 @@ authRoutes.post('/login', async (req, res) => { // Update the users avatar with their plex thumbnail (incase it changed) user.avatar = account.thumb; + user.email = account.email; + user.username = account.username; } else { // Here we check if it's the first user. If it is, we create the user with no check // and give them admin permissions @@ -57,6 +59,8 @@ authRoutes.post('/login', async (req, res) => { if (totalUsers === 0) { user = new User({ email: account.email, + username: account.username, + plexId: account.id, plexToken: account.authToken, permissions: Permission.ADMIN, avatar: account.thumb, diff --git a/server/routes/discover.ts b/server/routes/discover.ts index ab69273bf..a0009ca72 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import TheMovieDb from '../api/themoviedb'; import { mapMovieResult, mapTvResult } from '../models/Search'; +import { MediaRequest } from '../entity/MediaRequest'; const discoverRoutes = Router(); @@ -9,11 +10,20 @@ discoverRoutes.get('/movies', async (req, res) => { const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page) }); + const requests = await MediaRequest.getRelatedRequests( + data.results.map((result) => result.id) + ); + return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - results: data.results.map(mapMovieResult), + results: data.results.map((result) => + mapMovieResult( + result, + requests.find((req) => req.mediaId === result.id) + ) + ), }); }); @@ -22,11 +32,20 @@ discoverRoutes.get('/tv', async (req, res) => { const data = await tmdb.getDiscoverTv({ page: Number(req.query.page) }); + const requests = await MediaRequest.getRelatedRequests( + data.results.map((result) => result.id) + ); + return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - results: data.results.map(mapTvResult), + results: data.results.map((result) => + mapTvResult( + result, + requests.find((req) => req.mediaId === result.id) + ) + ), }); }); diff --git a/server/routes/search.ts b/server/routes/search.ts index e00768210..20aebe4c0 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import TheMovieDb from '../api/themoviedb'; import { mapSearchResults } from '../models/Search'; +import { MediaRequest } from '../entity/MediaRequest'; const searchRoutes = Router(); @@ -12,11 +13,15 @@ searchRoutes.get('/', async (req, res) => { page: Number(req.query.page), }); + const requests = await MediaRequest.getRelatedRequests( + results.results.map((result) => result.id) + ); + return res.status(200).json({ page: results.page, totalPages: results.total_pages, totalResults: results.total_results, - results: mapSearchResults(results.results), + results: mapSearchResults(results.results, requests), }); }); diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 8aba03437..a9b31619c 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -37,10 +37,6 @@ const Discover: React.FC = () => { return
{error}
; } - if (!data && !error) { - return
loading!
; - } - const titles = data?.reduce( (a, v) => [...a, ...v.results], [] as MovieResult[] diff --git a/tsconfig.json b/tsconfig.json index f395aa1d5..5a10e8bfb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,10 @@ "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve" + "jsx": "preserve", + "strictPropertyInitialization": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true }, "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules"]