From 3451038a9643da22e2b30856590608dc435b07aa Mon Sep 17 00:00:00 2001 From: JustMichael Date: Wed, 3 Apr 2024 17:35:22 +0000 Subject: [PATCH] refactor(requests): cleaned up requests so they are faster and more readable --- pkg/api/src/api/routes/request.ts | 50 ++- pkg/api/src/models/request.ts | 24 +- pkg/api/src/services/requests/display.ts | 424 ++++++++++++---------- pkg/api/src/services/requests/types.ts | 131 +++++++ pkg/api/src/services/requests/utils.ts | 26 ++ pkg/frontend/src/services/user.service.js | 5 +- 6 files changed, 457 insertions(+), 203 deletions(-) create mode 100644 pkg/api/src/services/requests/types.ts create mode 100644 pkg/api/src/services/requests/utils.ts diff --git a/pkg/api/src/api/routes/request.ts b/pkg/api/src/api/routes/request.ts index 648e6e6de..42869cc1d 100644 --- a/pkg/api/src/api/routes/request.ts +++ b/pkg/api/src/api/routes/request.ts @@ -11,7 +11,7 @@ import Radarr from '@/services/downloaders/radarr'; import Sonarr from '@/services/downloaders/sonarr'; import Mailer from '@/services/mail/mailer'; import { getArchive } from '@/services/requests/archive'; -import { getRequests } from '@/services/requests/display'; +import { getAllUserRequests, getRequests } from '@/services/requests/display'; import ProcessRequest from '@/services/requests/process'; const listRequests = async (ctx: Context) => { @@ -20,14 +20,26 @@ const listRequests = async (ctx: Context) => { }; const getUserRequests = async (ctx: Context) => { - const userId = ctx.state.user.id; - if (!userId) { - ctx.status = StatusCodes.NOT_FOUND; - ctx.body = {}; + try { + const userId = ctx.state.user.id; + if (!userId) { + ctx.error({ + statusCode: StatusCodes.BAD_REQUEST, + code: 'USER_NOT_FOUND', + message: 'User not found', + }); + return; + } + const requests = await getAllUserRequests(userId); + ctx.ok(requests); + } catch (err) { + logger.error(`Error getting user requests`, err); + ctx.error({ + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal Server Error', + }); } - - ctx.status = StatusCodes.OK; - ctx.body = await getRequests(userId, false); }; const getRequestMinified = async (ctx: Context) => { @@ -67,8 +79,11 @@ const getRequestMinified = async (ctx: Context) => { ); } catch (err) { logger.error(`ROUTE: Error getting requests`, err); - ctx.status = StatusCodes.INTERNAL_SERVER_ERROR; - ctx.body = {}; + ctx.error({ + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal Server Error', + }); return; } @@ -85,8 +100,11 @@ const addRequest = async (ctx: Context) => { ctx.body = await new ProcessRequest(request, ctx.state.user).new(); } catch (err) { logger.error(`ROUTE: Error adding request`, err); - ctx.status = StatusCodes.INTERNAL_SERVER_ERROR; - ctx.body = {}; + ctx.error({ + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal Server Error', + }); } }; @@ -211,9 +229,11 @@ const updateRequest = async (ctx: Context) => { ctx.body = {}; } catch (err) { logger.error(`ROUTE: Error updating requests`, err); - - ctx.status = StatusCodes.INTERNAL_SERVER_ERROR; - ctx.body = {}; + ctx.error({ + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal Server Error', + }); } }; diff --git a/pkg/api/src/models/request.ts b/pkg/api/src/models/request.ts index 2d1f96e06..95c1a3fcf 100644 --- a/pkg/api/src/models/request.ts +++ b/pkg/api/src/models/request.ts @@ -1,6 +1,26 @@ import mongoose from 'mongoose'; +import { z } from 'zod'; -const RequestSchema = new mongoose.Schema({ +const RequestZodSchema = z.object({ + requestId: z.string(), + type: z.string(), + title: z.string(), + thumb: z.string(), + imdb_id: z.string(), + tmdb_id: z.string(), + tvdb_id: z.string(), + users: z.array(z.string()), + sonarrId: z.array(z.string()), + radarrId: z.array(z.string()), + approved: z.boolean(), + manualStatus: z.number(), + pendingDefault: z.unknown(), + seasons: z.unknown(), + timeStamp: z.date(), +}); +export type IRequest = z.infer; + +const RequestSchema = new mongoose.Schema({ requestId: String, type: String, title: String, @@ -18,4 +38,4 @@ const RequestSchema = new mongoose.Schema({ timeStamp: Date, }); -export default mongoose.model('Request', RequestSchema); +export default mongoose.model('Request', RequestSchema); diff --git a/pkg/api/src/services/requests/display.ts b/pkg/api/src/services/requests/display.ts index 259be078d..69eed1189 100644 --- a/pkg/api/src/services/requests/display.ts +++ b/pkg/api/src/services/requests/display.ts @@ -1,147 +1,21 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable import/prefer-default-export */ import Bluebird from 'bluebird'; -import { config } from '@/config/index'; +import { RequestChildren, RequestOutput, RequestState, isDownloaderSeries, isDownloaderMovie } from './types'; +import { calcDate, cinemaWindow } from "./utils"; +import { Movie } from "@/infra/arr/radarr/v4/movie"; +import { Series } from "@/infra/arr/sonarr/v3/series"; import logger from '@/loaders/logger'; -import { DownloaderType, GetAllDownloaders } from '@/models/downloaders'; -import Request from '@/models/request'; +import { DownloaderType, GetAllDownloaders, IDownloader } from '@/models/downloaders'; +import Request, { IRequest } from '@/models/request'; import Radarr from '@/services/downloaders/radarr'; import Sonarr from '@/services/downloaders/sonarr'; import { movieLookup } from '@/services/tmdb/movie'; import { showLookup } from '@/services/tmdb/show'; -export const getRequests = async (user = false, all = false) => { - let data = {}; - try { - const requests = await Request.find().exec(); - - const instances = await GetAllDownloaders(); - const sonarrs = instances.filter((i) => i.type === DownloaderType.Sonarr); - const radarrs = instances.filter((i) => i.type === DownloaderType.Radarr); - - const [sonarrQ, radarrQ] = await Bluebird.all([ - Bluebird.map(sonarrs, async (i) => new Sonarr(i).queue()), - Bluebird.map(radarrs, async (i) => new Radarr(i).queue()), - ]); - - data = {}; - - await Bluebird.all( - Bluebird.map( - requests, - async (request: any, _i) => { - const children: any = []; - let media: any = []; - if (request.users.includes(user) || all) { - if (request.type === 'movie' && request.radarrId.length > 0) { - for (let i = 0; i < Object.keys(request.radarrId).length; i++) { - const radarrIds = request.radarrId[i]; - const rId = parseInt(radarrIds[Object.keys(radarrIds)[0]], 10); - const serverUuid = Object.keys(radarrIds)[0]; - - const instance = instances.find((i) => i.id === serverUuid); - if (!instance) { - return; - } - - const server = new Radarr(instance); - children[i] = {}; - children[i].id = rId; - try { - children[i].info = await server.getClient().GetMovie(rId); - children[i].info.serverName = server.instance.name; - } catch { - children[i].info = { message: 'NotFound' }; - } - children[i].status = []; - if (radarrQ[serverUuid] && radarrQ[serverUuid].records) { - for (const element of radarrQ[serverUuid].records) { - if (element.movieId === rId) { - children[i].status.push(element); - } - } - } - } - } - - if (request.type === 'tv' && request.sonarrId.length > 0) { - for (let i = 0; i < Object.keys(request.sonarrId).length; i++) { - const sonarrIds = request.sonarrId[i]; - const sId = parseInt(sonarrIds[Object.keys(sonarrIds)[0]], 10); - const serverUuid = Object.keys(sonarrIds)[0]; - - const instance = instances.find((i) => i.id === serverUuid); - if (!instance) { - continue; - } - - const server = new Sonarr(instance); - children[i] = {}; - children[i].id = sId; - try { - children[i].info = await server - .getClient() - .GetSeriesById(sId); - children[i].info.serverName = server.instance.name; - } catch (e) { - children[i].info = { message: 'NotFound', error: e }; - } - children[i].status = []; - if (sonarrQ[serverUuid] && sonarrQ[serverUuid].records) { - for (const element of sonarrQ[serverUuid].records) { - if (element.seriesId === sId) { - children[i].status.push(element); - } - } - } - } - } - - if (request.type === 'movie') { - media = await movieLookup(request.requestId, true); - } else if (request.type === 'tv') { - media = await showLookup(request.requestId, true); - } - - data[request.requestId] = { - title: request.title, - children, - requestId: request.requestId, - type: request.type, - thumb: request.thumb, - imdb_id: request.imdb_id, - tmdb_id: request.tmdb_id, - tvdb_id: request.tvdb_id, - users: request.users, - sonarrId: request.sonarrId, - radarrId: request.radarrId, - media, - approved: request.approved, - manualStatus: request.manualStatus, - process_stage: reqState(request, children), - defaults: request.pendingDefault, - }; - - if (request.type === 'tv') { - data[request.requestId].seasons = request.seasons; - } - } - }, - { concurrency: config.get('general.concurrency') }, - ), - ); - } catch (err) { - logger.error(err.stack); - logger.error(`ROUTE: Error getting requests display`, { - label: 'requests.display', - }); - logger.error(err, { label: 'requests.display' }); - data = requests; - } - return data; -}; - -function reqState(req, children) { - let diff; +function reqState(req: IRequest, children: RequestChildren[]): RequestState { + let diff: number; if (!req.approved) { return { status: 'pending', @@ -160,15 +34,7 @@ function reqState(req, children) { }; } - if (element.info.downloaded || element.info.movieFile) { - return { - status: 'good', - message: 'Downloaded', - step: 4, - }; - } - - if (element.info.message === 'NotFound') { + if ('message' in element.info && element.info.message === 'Not Found') { return { status: 'bad', message: 'Removed', @@ -176,10 +42,11 @@ function reqState(req, children) { }; } - if (req.type === 'tv' && element.info) { + if (req.type === 'tv' && isDownloaderSeries(element.info)) { + const data = element.info as Series; if ( - element.info.episodeCount === element.info.episodeFileCount && - element.info.episodeCount > 0 + data.statistics.episodeCount === data.statistics.episodeFileCount && + data.statistics.episodeCount > 0 ) { return { status: 'good', @@ -188,9 +55,9 @@ function reqState(req, children) { }; } - if (element.info.seasons) { + if (data.seasons) { let missing = false; - for (const season of element.info.seasons) { + for (const season of data.seasons) { if (season.monitored) { if ( season.statistics && @@ -200,14 +67,14 @@ function reqState(req, children) { } } - if (!missing && element.info.statistics.totalEpisodeCount > 0) { + if (!missing && data.statistics.totalEpisodeCount > 0) { return { status: 'good', message: 'Downloaded', step: 4, }; } - const airDate = element.info.firstAired; + const airDate = data.firstAired; if (!airDate) return { status: 'blue', @@ -224,21 +91,27 @@ function reqState(req, children) { step: 3, }; } - if (element.info.episodeFileCount > 0) { + if (data.statistics.episodeFileCount > 0) { return { status: 'blue', message: 'Partially Downloaded', step: 3, }; } - - } } - if (req.type === 'movie' && element.info) { - if (element.info.inCinemas || element.info.digitalRelease) { - if (element.info.inCinemas) { + if (req.type === 'movie' && isDownloaderMovie(element.info)) { + const data = element.info as Movie; + if (data.hasFile) { + return { + status: 'good', + message: 'Downloaded', + step: 4, + }; + } + if (data.inCinemas || data.digitalRelease) { + if (data.inCinemas) { diff = Math.ceil( new Date(element.info.inCinemas).getTime() - new Date().getTime(), @@ -251,8 +124,8 @@ function reqState(req, children) { }; } } - if (element.info.digitalRelease) { - const digitalDate = new Date(element.info.digitalRelease); + if (data.digitalRelease) { + const digitalDate = new Date(data.digitalRelease); if (new Date().getTime() - digitalDate.getTime() < 0) { return { status: 'cinema', @@ -260,10 +133,10 @@ function reqState(req, children) { step: 3, }; } - } else if (element.info.inCinemas) { + } else if (data.inCinemas) { diff = Math.ceil( new Date().getTime() - - new Date(element.info.inCinemas).getTime(), + new Date(data.inCinemas).getTime(), ); if (cinemaWindow(diff)) { return { @@ -275,7 +148,7 @@ function reqState(req, children) { } } - if (element.info.status === 'announced') { + if (data.status === 'announced') { return { status: 'blue', message: 'Awaiting Info', @@ -294,24 +167,34 @@ function reqState(req, children) { if (req.manualStatus) { switch (req.manualStatus) { - case 3: + case 3: { return { status: 'orange', message: 'Processing', step: 3, }; - case 4: + } + case 4: { return { status: 'good', message: 'Finalising', step: 4, }; - case 5: + } + case 5: { return { status: 'good', message: 'Complete', step: 5, }; + } + default: { + return { + status: 'error', + message: 'No Status', + step: -1, + }; + } } } @@ -322,29 +205,200 @@ function reqState(req, children) { }; } -function calcDate(diff) { - const day = 1000 * 60 * 60 * 24; +function makeOutput(request: IRequest): RequestOutput { + return { + [request.requestId]: { + title: request.title, + children: [], + requestId: request.requestId, + type: request.type, + thumb: request.thumb, + imdb_id: request.imdb_id, + tmdb_id: request.tmdb_id, + tvdb_id: request.tvdb_id, + users: request.users, + sonarrId: request.sonarrId, + radarrId: request.radarrId, + media: {}, + approved: request.approved, + manualStatus: request.manualStatus, + process_stage: reqState(request, []), + defaults: request.pendingDefault, + seasons: request.type === 'tv' ? request.seasons : undefined, + } + }; +} + +async function getRequestsForMovies(radarrs: IDownloader[], requests: IRequest[]) { + const results = await Bluebird.map(requests, async (request) => { + const output = makeOutput(request); + try { + const lookup = await movieLookup(request.requestId, true); + const instance = radarrs.find((r) => request.radarrId.includes(r.id)); + if (instance) { + try { + const client = new Radarr(instance).getClient(); + const movieId = parseInt(request.radarrId[0], 10); - let days = Math.ceil(diff / day); - let months = Math.floor(days / 31); - const years = Math.floor(months / 12); - days -= months * 31; - months -= years * 12; + const [movie, queue] = await Promise.all([ + client.GetMovie(movieId), + client.GetQueue(), + ]); - let message = '~'; - message += years ? `${years}y ` : ''; - message += months ? `${months}m ` : ''; - message += days ? `${days}d` : ''; - if (years) message = '> 1y'; + const status = queue.items + .filter((q) => q.id === movieId); - return message; + output[request.requestId].children.push({ + id: movieId, + info: { + ...movie, + serverName: instance.name, + }, + status, + }); + output[request.requestId].process_stage = reqState(request, output[request.requestId].children); + } catch (error) { + logger.error(`failed to get movie ${request.radarrId[0]} from ${instance.name}`, error); + output[request.requestId].children.push({ + id: -1, + info: { + message: 'Not Found', + }, + status: [], + }); + } + } + output[request.requestId].media = lookup; + return output; + } catch (error) { + logger.error(`failed to get movie ${request.requestId}`, error); + return output; + } + }); + if (results.length > 0) { + return results.reduce((acc, curr) => ({ ...acc, ...curr })); + } + return results; } -function cinemaWindow(diff) { - const day = 1000 * 60 * 60 * 24; - const days = Math.ceil(diff / day); - if (days >= 62) { - return false; +async function getRequestsForShows(sonarrs: IDownloader[], requests: IRequest[]) { + const results = await Bluebird.map(requests, async (request) => { + const output = makeOutput(request); + try { + const lookup = await showLookup(request.requestId, true); + const instance = sonarrs.find((s) => request.sonarrId.includes(s.id)); + if (instance) { + try { + const client = new Sonarr(instance).getClient(); + const seriesId = parseInt(request.sonarrId[0], 10); + + const [series, queue] = await Promise.all([ + client.GetSeriesById(seriesId), + client.GetQueue(), + ]); + + const status = queue.items + .filter((q) => q.id === seriesId); + + output[request.requestId].children.push({ + id: seriesId, + info: { + ...series, + serverName: instance.name, + }, + status, + }); + output[request.requestId].process_stage = reqState(request, output[request.requestId].children); + } catch (error) { + logger.error(`failed to get show ${request.sonarrId[0]} from ${instance.name}`, error); + output[request.requestId].children.push({ + id: -1, + info: { + message: 'Not Found', + }, + status: [], + }); + } + } + output[request.requestId].media = lookup; + return output; + } catch (error) { + logger.error(`failed to get show ${request.requestId}`, error); + return output; + } + }); + if (results.length > 0) { + return results.reduce((acc, curr) => ({ ...acc, ...curr })); + } + return results; +} + +export async function getAllRequests() { + try { + const [requests, instances] = await Promise.all([ + Request.find({}), + GetAllDownloaders(), + ]); + + if (requests.length === 0) { + return {}; + } + + const sonarrs = instances.filter((instance) => instance.type === DownloaderType.Sonarr); + const radarrs = instances.filter((instance) => instance.type === DownloaderType.Radarr); + + const movieRequests = requests.filter((request) => request.type === 'movie'); + const tvRequests = requests.filter((request) => request.type === 'tv'); + + const [movieStatus, tvStatus] = await Promise.all([ + getRequestsForMovies(radarrs, movieRequests), + getRequestsForShows(sonarrs, tvRequests), + ]); + + if (Object.keys(movieStatus).length === 0 && Object.keys(tvStatus).length === 0) { + return requests.map((request) => makeOutput(request)); + } + + return { ...movieStatus, ...tvStatus }; + } catch (error) { + logger.error('failed to get requests', error); + return {}; + } +} + +export async function getAllUserRequests(userId: string) { + try { + const [requests, instances] = await Promise.all([ + Request.find({ + users: { + $in: [userId], + }, + }), + GetAllDownloaders(), + ]); + + if (requests.length === 0) { + return {}; + } + + const sonarrs = instances.filter((instance) => instance.type === DownloaderType.Sonarr); + const radarrs = instances.filter((instance) => instance.type === DownloaderType.Radarr); + + const movieRequests = requests.filter((request) => request.type === 'movie'); + const tvRequests = requests.filter((request) => request.type === 'tv'); + + const [movieStatus, tvStatus] = await Promise.all([ + getRequestsForMovies(radarrs, movieRequests), + getRequestsForShows(sonarrs, tvRequests), + ]); + + if (Object.keys(movieStatus).length === 0 && Object.keys(tvStatus).length === 0) { + return requests.map((request) => makeOutput(request)); + } + + return { ...movieStatus, ...tvStatus }; + } catch (error) { + logger.error('failed to get user requests', error); + return {}; } - return true; } diff --git a/pkg/api/src/services/requests/types.ts b/pkg/api/src/services/requests/types.ts new file mode 100644 index 000000000..e2d6bac5c --- /dev/null +++ b/pkg/api/src/services/requests/types.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; +import { Movie } from "@/infra/arr/radarr/v4/movie"; +import { Series } from "@/infra/arr/sonarr/v3/series"; + +// { +// '634492': { +// title: 'Madame Web', +// children: [], +// requestId: '634492', +// type: 'movie', +// thumb: '/rULWuutDcN5NvtiZi4FRPzRYWSh.jpg', +// imdb_id: 'tt11057302', +// tmdb_id: '634492', +// tvdb_id: 'n/a', +// users: [ '660854db9eedb831193b6438' ], +// sonarrId: [], +// radarrId: [], +// media: { +// backdrop_path: '/zAepSrO99owYwQqi0QG2AS0dHXw.jpg', +// budget: 80000000, +// id: 634492, +// imdb_id: 'tt11057302', +// poster_path: '/rULWuutDcN5NvtiZi4FRPzRYWSh.jpg', +// production_companies: [Array], +// release_date: '2024-02-14', +// title: 'Madame Web', +// video: false, +// videos: [Object], +// keywords: [Array], +// timestamp: 2024-03-31T19:50:37.038Z, +// age_rating: '', +// on_server: false, +// available_resolutions: [], +// imdb_data: false, +// reviews: undefined, +// collection: false +// }, +// approved: false, +// manualStatus: undefined, +// process_stage: { status: 'pending', message: 'Pending', step: 2 }, +// defaults: undefined +// } +// } +export const RequestsSchema = z.object({ + title: z.string(), + children: z.array(z.unknown()), + requestId: z.string(), + type: z.string(), + thumb: z.string(), + imdb_id: z.string(), + tmdb_id: z.string(), + tvdb_id: z.string(), + users: z.array(z.string()), + sonarrId: z.array(z.unknown()), + radarrId: z.array(z.unknown()), + media: z.object({ + backdrop_path: z.string(), + budget: z.number(), + id: z.number(), + imdb_id: z.string(), + poster_path: z.string(), + production_companies: z.array(z.unknown()), + release_date: z.string(), + title: z.string(), + video: z.boolean(), + videos: z.unknown(), + keywords: z.array(z.unknown()), + timestamp: z.date(), + age_rating: z.string(), + on_server: z.boolean(), + available_resolutions: z.array(z.unknown()), + imdb_data: z.boolean(), + reviews: z.unknown().optional(), + collection: z.boolean() + }), + approved: z.boolean(), + manualStatus: z.unknown().optional(), + process_stage: z.object({ + status: z.string(), + message: z.string(), + step: z.number() + }), + defaults: z.unknown().optional() +}); +export type Requests = z.infer; + +export type RequestChildren = { + id: number, + info: Movie | Series & { + serverName: string, + } | { + message: string, + }, + status: any[], +}; + +export type RequestState = { + status: string; + message: string; + step: number; +}; + +export type RequestOutput = { + [requestId: string]: { + title: string; + children: any[]; + requestId: string; + type: string; + thumb: string; + imdb_id: string; + tmdb_id: string; + tvdb_id: string; + users: string[]; + sonarrId: any[]; + radarrId: any[]; + media: any; + approved: boolean; + manualStatus: any; + process_stage: RequestState; + defaults: any; + seasons?: any; + }; +}; + +export function isDownloaderMovie(data: any): data is Movie { + return 'movieFile' in data; +} + +export function isDownloaderSeries(data: any): data is Series { + return 'seasons' in data; +} diff --git a/pkg/api/src/services/requests/utils.ts b/pkg/api/src/services/requests/utils.ts new file mode 100644 index 000000000..7c5aa2627 --- /dev/null +++ b/pkg/api/src/services/requests/utils.ts @@ -0,0 +1,26 @@ +export function calcDate(diff) { + const day = 1000 * 60 * 60 * 24; + + let days = Math.ceil(diff / day); + let months = Math.floor(days / 31); + const years = Math.floor(months / 12); + days -= months * 31; + months -= years * 12; + + let message = '~'; + message += years ? `${years}y ` : ''; + message += months ? `${months}m ` : ''; + message += days ? `${days}d` : ''; + if (years) message = '> 1y'; + + return message; +} + +export function cinemaWindow(diff) { + const day = 1000 * 60 * 60 * 24; + const days = Math.ceil(diff / day); + if (days >= 62) { + return false; + } + return true; +} diff --git a/pkg/frontend/src/services/user.service.js b/pkg/frontend/src/services/user.service.js index 4e14396bc..55eb9bc7a 100644 --- a/pkg/frontend/src/services/user.service.js +++ b/pkg/frontend/src/services/user.service.js @@ -46,7 +46,10 @@ export async function getRequests(min = true) { export async function myRequests() { try { const data = await get('/request/me'); - updateStore({ type: 'user/my-requests', requests: data }); + if (data.status !== 'success') { + return; + } + updateStore({ type: 'user/my-requests', requests: data.data }); return data; } catch (e) { console.log(e);