diff --git a/src/app/components/modals/MovieModal.vue b/src/app/components/modals/MovieModal.vue index 27fee9d..37083b4 100644 --- a/src/app/components/modals/MovieModal.vue +++ b/src/app/components/modals/MovieModal.vue @@ -155,8 +155,7 @@ export default defineComponent({ return; } this.isRequesting = true; - // await new Promise((resolve) => setTimeout(resolve, 3000)); - let result = await this.$store.dispatch(Action.RequestMovie, this.movie.imdb_id); + let ticketId = await this.$store.dispatch(Action.RequestMovie, this.movieId); this.isRequested = true; this.isRequesting = false; } diff --git a/src/app/store/actions.ts b/src/app/store/actions.ts index c018993..b15f1c6 100644 --- a/src/app/store/actions.ts +++ b/src/app/store/actions.ts @@ -35,7 +35,7 @@ export type ActionsTypes = { // RESTful [Action.AuthFetch] : (payload: IAuthFetchPayload) => Promise, - [Action.RequestMovie]: (imdbId: string) => Promise + [Action.RequestMovie]: (tmdbId: number | string) => Promise } /** @@ -125,8 +125,11 @@ export const actions: Actions { + let response = await authFetch(`/api/movie/request/create/tmdb/${tmdbId}`); + if (response.status != 200) { + throw new Error("Movie request failed"); + } + return (await response.json()).data.ticket_id; } }; diff --git a/src/common/api_schema.ts b/src/common/api_schema.ts index ef1d42c..f820856 100644 --- a/src/common/api_schema.ts +++ b/src/common/api_schema.ts @@ -1,5 +1,8 @@ import { IMovieDetails } from "@lib/tmdb/schema"; +/** + * The JWT auth token structure + */ export interface ITokenSchema { id : number, name : string, @@ -8,17 +11,30 @@ export interface ITokenSchema { exp : number } +/** + * The general API response structure + */ export interface IApiResponse { status: string } + +/** + * A generic data response from the API + */ export interface IApiDataResponse extends IApiResponse { data: T } +/** + * Movie detail data + */ export interface IApiMovieDetails extends Pick { is_requested: boolean } +/** + * + */ export type IApiMovieDetailsResponse = IApiDataResponse; diff --git a/src/server/database/entities/MovieInfo.ts b/src/server/database/entities/MovieInfo.ts new file mode 100644 index 0000000..4f4f5f3 --- /dev/null +++ b/src/server/database/entities/MovieInfo.ts @@ -0,0 +1,26 @@ +import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from "typeorm"; + +@Entity() +export class MovieInfo extends BaseEntity +{ + @PrimaryGeneratedColumn() + id!: number; + + @Column({ unique: true }) + tmdbId!: number; + + @Column({ type: "text", nullable: true }) + overview!: string | null; + + @Column({ type: "int", nullable: true }) + runtime!: number | null; + + @Column({ type: "char", length: 10, nullable: true }) + releaseDate!: string | null; + + @Column({ type: "varchar", length: 32, nullable: true }) + backdropPath!: string | null; + + @Column({ type: "varchar", length: 32, nullable: true }) + posterPath!: string | null; +} diff --git a/src/server/database/entities/MovieTicket.ts b/src/server/database/entities/MovieTicket.ts index fa92ede..24db1ec 100644 --- a/src/server/database/entities/MovieTicket.ts +++ b/src/server/database/entities/MovieTicket.ts @@ -1,4 +1,6 @@ -import { Entity, PrimaryGeneratedColumn, Column, BaseEntity, ManyToOne, OneToMany } from "typeorm"; +import { IApiMovieDetails } from "@common/api_schema"; +import { Entity, PrimaryGeneratedColumn, Column, BaseEntity, ManyToOne, OneToMany, OneToOne, JoinColumn } from "typeorm"; +import { MovieInfo } from "./MovieInfo"; import { MovieTorrent } from "./MovieTorrent"; import { User } from "./User"; @@ -8,12 +10,54 @@ export class MovieTicket extends BaseEntity @PrimaryGeneratedColumn() id!: number; - @Column({ length: 27 }) - imdbId!: string; + @Column({ type: "varchar", length: 27, unique: true, nullable: true }) + imdbId!: string | null; + + @Column({ type: "varchar", nullable: true }) + title!: string | null; + + @Column({ type: "year", nullable: true }) + year!: number | null; @ManyToOne(() => User, user => user.movieTickets) user!: User; @OneToMany(() => MovieTorrent, torrent => torrent.movieTicket) torrents!: MovieTorrent[]; + + @OneToOne(() => MovieInfo, { nullable: true }) + @JoinColumn() + info!: MovieInfo; + + /** + * Insert a request via IMDb movie details + */ + public static async requestImdb(user: User, imdbId: string, title: string | null = null) { + let ticket = new MovieTicket(); + ticket.imdbId = imdbId; + ticket.title = title; + ticket.user = user; + return await ticket.save(); + } + + /** + * Insert a rquest via TMDb movie details + */ + public static async requestTmdb(user: User, tmdbId: number, movie: IApiMovieDetails) { + let info = new MovieInfo(); + info.overview = movie.overview; + info.posterPath = movie.poster_path; + info.backdropPath = movie.backdrop_path; + info.releaseDate = movie.release_date; + info.runtime = movie.runtime; + info.tmdbId = tmdbId; + await info.save(); + + let ticket = new MovieTicket(); + ticket.imdbId = movie.imdb_id; + ticket.title = movie.title; + ticket.user = user; + ticket.info = info; + return await ticket.save(); + } } diff --git a/src/server/database/entities/index.ts b/src/server/database/entities/index.ts index daedf6a..b85cb59 100644 --- a/src/server/database/entities/index.ts +++ b/src/server/database/entities/index.ts @@ -1,3 +1,4 @@ +export * from "./MovieInfo"; export * from "./MovieTicket"; export * from "./MovieTorrent"; export * from "./RegisterToken"; diff --git a/src/server/services/MovieSearch.ts b/src/server/services/MovieSearch.ts index 9b3cfa2..36e9a61 100644 --- a/src/server/services/MovieSearch.ts +++ b/src/server/services/MovieSearch.ts @@ -3,11 +3,11 @@ import TheMovieDb from "@lib/tmdb"; import { env, secret } from "@server/util"; import { readFile } from "fs/promises"; import TVDB from "tvdb-v4"; -import { request, Agent } from "https"; +import { request } from "https"; import Service from "./Service"; import TvDb from "./TvDb"; import { IApiMovieDetails } from "@common/api_schema"; -import { MovieTicket } from "@server/database/entities"; +import { MovieInfo, MovieTicket } from "@server/database/entities"; const CACHE_CLEAR_INTERVAL = 1000*10; // 60 seconds @@ -21,9 +21,9 @@ export default class MovieSearch extends Service protected tvdb!: TvDb; /** - * Hold a cache of recently fetch movie IMDB ids to speed up request times + * Hold a cache of recently fetched movies to speed up request times */ - protected imdbCache: { [imdbId: string]: number }; + protected movieCache: { [tmdbId: number]: { timestamp: number, movie: IApiMovieDetails } }; /** * Hold the clear cache interval reference @@ -35,7 +35,7 @@ export default class MovieSearch extends Service */ public constructor(app: Application) { super("Movie Search", app); - this.imdbCache = {}; + this.movieCache = {}; this.__clearCacheInterval = null; } @@ -64,22 +64,23 @@ export default class MovieSearch extends Service /** * Store an IMDb ID in cache */ - protected cacheImdbId(id: string) { - this.imdbCache[id] = Date.now() + CACHE_CLEAR_INTERVAL; + protected cacheMovie(tmdbId: number, movie: IApiMovieDetails) { + this.movieCache[tmdbId] = { movie, timestamp: Date.now() + CACHE_CLEAR_INTERVAL }; if (this.__clearCacheInterval === null) { - this.__clearCacheInterval = setInterval(() => this.cleanImdbCache(), CACHE_CLEAR_INTERVAL); + this.__clearCacheInterval = setInterval(() => this.cleanMovieCache(), CACHE_CLEAR_INTERVAL); } + return this.movieCache[tmdbId]; } /** * Clean the IMDb cache */ - protected cleanImdbCache() { + protected cleanMovieCache() { let now = Date.now(); let remaining = 0; - for (let key in this.imdbCache) { - if (now > this.imdbCache[key]) { - delete this.imdbCache[key]; + for (let key in this.movieCache) { + if (now > this.movieCache[key].timestamp) { + delete this.movieCache[key]; } else { remaining++; } @@ -98,9 +99,9 @@ export default class MovieSearch extends Service public verifyImdbId(id: string) { return new Promise((resolve, reject) => { // If the ID is cached, no need to fetch it - if (id in this.imdbCache) { - resolve(true); - } + // if (id in this.imdbCache) { + // resolve(true); + // } // Verify the movie exists on IMDb by checking for a 404 let req = request({ method: "HEAD", host: "www.imdb.com", path: `/title/${id}/` }, (response) => { response.resume(); @@ -120,12 +121,15 @@ export default class MovieSearch extends Service */ public async details(id: number) { let isRequested = false; - let movie = await this.tmdb.movie(id); - if (movie.imdb_id != null) { - this.cacheImdbId(movie.imdb_id); - isRequested = Boolean(await MovieTicket.findOne({imdbId: movie.imdb_id})); + if (id in this.movieCache) { + if (!this.movieCache[id].movie.is_requested) { + isRequested = await MovieInfo.count({tmdbId: id}) > 0; + this.movieCache[id].movie.is_requested = isRequested; + } + return this.movieCache[id].movie; } - return { + let movie = await this.tmdb.movie(id); + let result: IApiMovieDetails = { backdrop_path: movie.backdrop_path, imdb_id : movie.imdb_id, overview : movie.overview, @@ -135,6 +139,7 @@ export default class MovieSearch extends Service title : movie.title, is_requested : isRequested }; + return this.cacheMovie(id, result).movie; } /** diff --git a/src/server/services/WebServer/middleware/auth.ts b/src/server/services/WebServer/middleware/auth.ts index 318a5fb..ffa9985 100644 --- a/src/server/services/WebServer/middleware/auth.ts +++ b/src/server/services/WebServer/middleware/auth.ts @@ -2,22 +2,22 @@ import { FastifyReply, FastifyRequest } from "fastify"; import Application from "@server/Application"; import jwt from "jsonwebtoken"; import { IteratorNext, MiddlewareRequest } from "."; -import { IUser } from "@app/store/schema"; import { ITokenSchema } from "@common/api_schema"; +import { User } from "@server/database/entities"; /** * Attempt to authenticate a client's JWT token */ -function authenticateJwtToken(request: FastifyRequest, reply: FastifyReply): ITokenSchema | undefined { +async function authenticateJwtToken(request: FastifyRequest, reply: FastifyReply): Promise { // Verify headers if (!request.headers["authorization"]) { reply.status(401); - reply.send(); + reply.send({ status: "Unauthorized" }); return; } if (!request.headers["authorization"].startsWith("Bearer ")) { reply.status(400); - reply.send(); + reply.send({ status: "Bad request" }); return; } // Construct the token string @@ -26,34 +26,38 @@ function authenticateJwtToken(request: FastifyRequest, reply: FastifyReply): ITo token += '.' + (request.cookies.jwt_signature ?? "").trim(); } // Decode the token - let decoded: ITokenSchema; + let user: User; try { - decoded = jwt.verify(token, Application.instance().APP_KEY); + let decoded = jwt.verify(token, Application.instance().APP_KEY); + user = await User.findOneOrFail(decoded.id); } catch(e) { reply.status(401); - reply.send(); + reply.send({ status: "Unauthorized" }); return; } - return decoded; + return user; } /** * The parameter types for the auth middleware */ export interface IAuthMiddlewareParams { - auth: ITokenSchema + auth: { + user: User + } } /** * Ensure that a valid authentication token is provided */ -export function auth(request: MiddlewareRequest, reply: FastifyReply, next: IteratorNext) { - let decoded = authenticateJwtToken(request, reply); - if (decoded === undefined) { +export async function auth(request: MiddlewareRequest, reply: FastifyReply, next: IteratorNext) { + let user = await authenticateJwtToken(request, reply); + if (user === undefined) { + // The authenticateJwtToken function sends out the response return; } request.middlewareParams = { - auth: decoded + auth: { user } }; next(); } diff --git a/src/server/services/WebServer/requests/RequestMovieRequest.ts b/src/server/services/WebServer/requests/RequestImdbMovieRequest.ts similarity index 87% rename from src/server/services/WebServer/requests/RequestMovieRequest.ts rename to src/server/services/WebServer/requests/RequestImdbMovieRequest.ts index 11eba29..d286889 100644 --- a/src/server/services/WebServer/requests/RequestMovieRequest.ts +++ b/src/server/services/WebServer/requests/RequestImdbMovieRequest.ts @@ -3,7 +3,7 @@ import validate from "validate.js"; import Request from "./Request"; -export default class MovieSearchRequest extends Request +export default class RequestImdbMovieRequest extends Request { public validate(request: FastifyRequest) { return validate.async(request.params, { diff --git a/src/server/services/WebServer/requests/RequestTmdbMovieRequest.ts b/src/server/services/WebServer/requests/RequestTmdbMovieRequest.ts new file mode 100644 index 0000000..3a8e001 --- /dev/null +++ b/src/server/services/WebServer/requests/RequestTmdbMovieRequest.ts @@ -0,0 +1,24 @@ +import { FastifyRequest } from "fastify"; +import validate from "validate.js"; +import Request from "./Request"; + + +export default class RequestTmdbMovieRequest extends Request +{ + public validate(request: FastifyRequest) { + return validate.async(request.params, { + tmdb_id: { + presence: { + allowEmpty: false, + message: "TMDb ID is required" + }, + numericality: { + onlyInteger: true, + greaterThanOrEqualTo: 0, + notInteger: "Invalid TMDb ID", + notGreaterThanOrEqualTo: "Invalid TMDb ID" + } + } + },{ fullMessages: false }); + } +} diff --git a/src/server/services/WebServer/routes/api.ts b/src/server/services/WebServer/routes/api.ts index 00658f2..ee407ea 100644 --- a/src/server/services/WebServer/routes/api.ts +++ b/src/server/services/WebServer/routes/api.ts @@ -1,10 +1,11 @@ import Application from "@server/Application"; import MovieSearch from "@server/services/MovieSearch"; -import RequestMovieRequest from "../requests/RequestMovieRequest"; +import RequestImdbMovieRequest from "../requests/RequestImdbMovieRequest"; import { auth } from "../middleware/auth"; import RouteRegisterFactory from "./RouteRegisterFactory"; import handle from "../requests"; -import { MovieTicket, User } from "@server/database/entities"; +import { MovieInfo, MovieTicket } from "@server/database/entities"; +import RequestTmdbMovieRequest from "../requests/RequestTmdbMovieRequest"; /** * Register API routes @@ -37,36 +38,80 @@ export default function register(factory: RouteRegisterFactory, app: Application reply.send({ status: "success", data: results}); }); - /** - * Request a movie to download - */ - factory.get("/movie/request/:imdb_id", handle([RequestMovieRequest], async (request, reply) => { - // Verify that the ID has not yet been requested - let imdbId = (request.params)["imdb_id"]; - if (undefined !== await MovieTicket.findOne({imdbId})) { - reply.status(409); - reply.send({ status: "Conflict" }); - return; - } - // Verify that the IMDb ID exists - if (!await app.service("Movie Search").verifyImdbId(imdbId)) { - reply.status(404); - reply.send({ satus: "Not found" }); - return; - } - // Grab the user - let user = await User.findOne({id: request.middlewareParams.auth.id}); - if (user === undefined) { - reply.status(401); - reply.send({ status: "Unauthorized" }); - return; - } - // Create the movie request ticket - let ticket = new MovieTicket(); - ticket.imdbId = imdbId; - ticket.user = user; - await ticket.save(); - return reply.send({ status: "success" }); - })); + // Movie Request --------------------------------------------------------------------------- + + factory.prefix("/movie/request", [], (factory, app) => { + /** + * Request a movie to download + */ + factory.get("/create/tmdb/:tmdb_id", handle([RequestTmdbMovieRequest], async (request, reply) => { + // Verify that the ID has not yet been requested + let tmdbId = (request.params)["tmdb_id"]; + if (0 != await MovieInfo.count({tmdbId})) { + reply.status(409); + reply.send({ status: "Conflict" }); + return; + } + // Verify that the IMDb ID exists + let movieDetails = await app.service("Movie Search").details(tmdbId); + if (!movieDetails) { // @TODO This isn't correct I don't think + reply.status(404); + reply.send({ satus: "Not found" }); + return; + } + // Create the movie request ticket + let user = request.middlewareParams.auth.user; + let ticket = await MovieTicket.requestTmdb(user, tmdbId, movieDetails); + return reply.send({ status: "success", data: { ticket_id: ticket.id }}); + })); + + /** + * Request a movie to download + */ + factory.get("/create/imdb/:imdb_id", handle([RequestImdbMovieRequest], async (request, reply) => { + // Verify that the ID has not yet been requested + let imdbId = (request.params)["imdb_id"]; + let title = (request.query)["title"] || null; + if (0 != await MovieTicket.count({imdbId})) { + reply.status(409); + reply.send({ status: "Conflict" }); + return; + } + // Verify that the IMDb ID exists + if (!await app.service("Movie Search").verifyImdbId(imdbId)) { + reply.status(404); + reply.send({ satus: "Not found" }); + return; + } + // Create the movie request ticket + let user = request.middlewareParams.auth.user; + let ticket = await MovieTicket.requestImdb(user, imdbId, title); + return reply.send({ status: "success", data: { ticket_id: ticket.id }}); + })); + + /** + * Remove/cancel a request + */ + // factory.get("/cancel/:ticket_id", async (request, reply) => { + // // Verify that the ticket exists + // let ticketId = parseInt((request.params)["ticket_id"]); + // if (ticketId === NaN) { + // reply.status(400); + // reply.send({ status: "Bad request" }); + // return; + // } + // let user = request.middlewareParams.auth.user; + // let ticket = await MovieTicket.findOne({ id: ticketId, user}); + // if (ticket === ) { + // reply.status(404); + // reply.send({ status: "Not found" }); + // return; + // } + // // Create the movie request ticket + // let user = + // let ticket = await MovieTicket.requestTmdb(user, tmdbId, movieDetails); + // return reply.send({ status: "success", data: { ticket_id: ticket.id }}); + // }); + }); }); }