diff --git a/.all-contributorsrc b/.all-contributorsrc index 8df4a8c55..d8cbe98d9 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -189,6 +189,33 @@ "contributions": [ "translation" ] + }, + { + "login": "danshilm", + "name": "Danshil Mungur", + "avatar_url": "https://avatars2.githubusercontent.com/u/20923978?v=4", + "profile": "https://github.com/danshilm", + "contributions": [ + "code" + ] + }, + { + "login": "doob187", + "name": "doob187", + "avatar_url": "https://avatars1.githubusercontent.com/u/60312740?v=4", + "profile": "https://github.com/doob187", + "contributions": [ + "infra" + ] + }, + { + "login": "johnpyp", + "name": "johnpyp", + "avatar_url": "https://avatars2.githubusercontent.com/u/20625636?v=4", + "profile": "https://github.com/johnpyp", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/.dockerignore b/.dockerignore index 6e5894b74..4d49270ea 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,7 +4,12 @@ node_modules .gitconfig .gitignore .github +.all-contributorsrc +.editorconfig +.prettierignore **/README.md **/.vscode config/db/db.sqlite3 config/db/logs/overseerr.log +Dockerfil** +**.md diff --git a/README.md b/README.md index 801338b90..bc4946125 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Language grade: JavaScript GitHub -All Contributors +All Contributors

@@ -123,6 +123,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
Paul Hagedorn

🌍
Shagon94

🌍
sebstrgg

🌍 +
Danshil Mungur

💻 + + +
doob187

🚇 +
johnpyp

💻 diff --git a/ormconfig.js b/ormconfig.js index 2c0afb735..2cc4533b2 100644 --- a/ormconfig.js +++ b/ormconfig.js @@ -2,6 +2,7 @@ const devConfig = { type: 'sqlite', database: 'config/db/db.sqlite3', synchronize: true, + migrationsRun: false, logging: false, entities: ['server/entity/**/*.ts'], migrations: ['server/migration/**/*.ts'], @@ -19,7 +20,7 @@ const prodConfig = { logging: false, entities: ['dist/entity/**/*.js'], migrations: ['dist/migration/**/*.js'], - migrationsRun: true, + migrationsRun: false, subscribers: ['dist/subscriber/**/*.js'], cli: { entitiesDir: 'dist/entity', diff --git a/overseerr-api.yml b/overseerr-api.yml index 209017974..268fa896a 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -58,6 +58,9 @@ components: applicationUrl: type: string example: https://os.example.com + defaultPermissions: + type: number + example: 32 PlexLibrary: type: object properties: @@ -1488,6 +1491,21 @@ paths: application/json: schema: $ref: '#/components/schemas/NotificationEmailSettings' + /settings/notifications/email/test: + post: + summary: Test the provided email settings + description: Sends a test notification to the email agent + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + responses: + '204': + description: Test notification attempted /settings/notifications/discord: get: summary: Return current discord notification settings @@ -1519,6 +1537,21 @@ paths: application/json: schema: $ref: '#/components/schemas/DiscordSettings' + /settings/notifications/discord/test: + post: + summary: Test the provided discord settings + description: Sends a test notification to the discord agent + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + responses: + '204': + description: Test notification attempted /settings/about: get: summary: Return current about stats @@ -1640,6 +1673,24 @@ paths: application/json: schema: $ref: '#/components/schemas/User' + /user/import-from-plex: + post: + summary: Imports all users from Plex + description: | + Requests users from the Plex Server and creates a new user for each of them + + Requires the `MANAGE_USERS` permission. + tags: + - users + responses: + '201': + description: A list of the newly created users + content: + application/json: + schema: + type: array + $ref: '#/components/schemas/User' + /user/{userId}: get: summary: Retrieve a user by ID diff --git a/package.json b/package.json index e346acbb3..5de8bf866 100644 --- a/package.json +++ b/package.json @@ -25,21 +25,21 @@ "cookie-parser": "^1.4.5", "email-templates": "^8.0.2", "express": "^4.17.1", - "express-openapi-validator": "^4.8.0", + "express-openapi-validator": "^4.9.4", "express-session": "^1.17.1", - "formik": "^2.2.5", + "formik": "^2.2.6", "intl": "^1.2.5", "lodash": "^4.17.20", "next": "^10.0.3", "node-schedule": "^1.3.2", - "nodemailer": "^6.4.16", + "nodemailer": "^6.4.17", "nookies": "^2.5.0", "plex-api": "^5.3.1", "pug": "^3.0.0", "react": "17.0.1", "react-dom": "17.0.1", "react-intersection-observer": "^8.31.0", - "react-intl": "^5.10.6", + "react-intl": "^5.10.9", "react-markdown": "^5.0.3", "react-spring": "^8.0.27", "react-toast-notifications": "^2.4.0", @@ -48,7 +48,7 @@ "reflect-metadata": "^0.1.13", "sqlite3": "^5.0.0", "swagger-ui-express": "^4.1.5", - "swr": "^0.3.9", + "swr": "^0.3.11", "typeorm": "^0.2.29", "uuid": "^8.3.2", "winston": "^3.3.3", @@ -57,7 +57,7 @@ "yup": "^0.32.8" }, "devDependencies": { - "@babel/cli": "^7.12.8", + "@babel/cli": "^7.12.10", "@commitlint/cli": "^11.0.0", "@commitlint/config-conventional": "^11.0.0", "@semantic-release/changelog": "^5.0.1", @@ -73,7 +73,7 @@ "@types/express": "^4.17.9", "@types/express-session": "^1.17.0", "@types/lodash": "^4.14.165", - "@types/node": "^14.14.11", + "@types/node": "^14.14.14", "@types/node-schedule": "^1.3.1", "@types/nodemailer": "^6.4.0", "@types/react": "^17.0.0", @@ -84,24 +84,24 @@ "@types/uuid": "^8.3.0", "@types/xml2js": "^0.4.7", "@types/yamljs": "^0.2.31", - "@types/yup": "^0.29.10", - "@typescript-eslint/eslint-plugin": "^4.9.1", - "@typescript-eslint/parser": "^4.9.1", + "@types/yup": "^0.29.11", + "@typescript-eslint/eslint-plugin": "^4.10.0", + "@typescript-eslint/parser": "^4.10.0", "autoprefixer": "^9", - "babel-plugin-react-intl": "^8.2.21", + "babel-plugin-react-intl": "^8.2.22", "babel-plugin-react-intl-auto": "^3.3.0", "commitizen": "^4.2.2", "copyfiles": "^2.4.1", "cz-conventional-changelog": "^3.3.0", - "eslint": "^7.15.0", - "eslint-config-prettier": "^7.0.0", - "eslint-plugin-formatjs": "^2.9.10", + "eslint": "^7.16.0", + "eslint-config-prettier": "^7.1.0", + "eslint-plugin-formatjs": "^2.9.11", "eslint-plugin-jsx-a11y": "^6.4.1", - "eslint-plugin-prettier": "^3.2.0", + "eslint-plugin-prettier": "^3.3.0", "eslint-plugin-react": "^7.21.5", "eslint-plugin-react-hooks": "^4.2.0", "extract-react-intl-messages": "^4.1.1", - "husky": "^4.3.5", + "husky": "^4.3.6", "lint-staged": "^10.5.3", "nodemon": "^2.0.6", "postcss": "^7", @@ -111,7 +111,7 @@ "semantic-release-docker": "^2.2.0", "tailwindcss": "npm:@tailwindcss/postcss7-compat", "ts-node": "^9.1.1", - "typescript": "^4.1.2" + "typescript": "^4.1.3" }, "config": { "commitizen": { diff --git a/public/site.webmanifest b/public/site.webmanifest index 3f47bc7cb..38af1e93f 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -1 +1,20 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#1e2937","display":"standalone"} +{ + "name": "Overseerr", + "short_name": "Overseerr", + "start_url": "./", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#1e2937", + "display": "standalone" +} diff --git a/server/api/plextv.ts b/server/api/plextv.ts index a1152ada5..e3e40c73e 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -56,6 +56,21 @@ interface FriendResponse { }; } +interface UsersResponse { + MediaContainer: { + User: { + $: { + id: string; + title: string; + username: string; + email: string; + thumb: string; + }; + Server: ServerResponse[]; + }[]; + }; +} + class PlexTvAPI { private authToken: string; private axios: AxiosInstance; @@ -129,6 +144,18 @@ class PlexTvAPI { return false; } } + + public async getUsers(): Promise { + const response = await this.axios.get('/api/users', { + transformResponse: [], + responseType: 'text', + }); + + const parsedXml = (await xml2js.parseStringPromise( + response.data + )) as UsersResponse; + return parsedXml; + } } export default PlexTvAPI; diff --git a/server/api/radarr.ts b/server/api/radarr.ts index 4797ef5d7..968cb21da 100644 --- a/server/api/radarr.ts +++ b/server/api/radarr.ts @@ -78,7 +78,7 @@ class RadarrAPI { public addMovie = async (options: RadarrMovieOptions): Promise => { try { - await this.axios.post(`/movie`, { + const response = await this.axios.post(`/movie`, { title: options.title, qualityProfileId: options.qualityProfileId, profileId: options.profileId, @@ -92,6 +92,19 @@ class RadarrAPI { searchForMovie: options.searchNow, }, }); + + if (response.data.id) { + logger.info('Radarr accepted request', { label: 'Radarr' }); + logger.debug('Radarr add details', { + label: 'Radarr', + movie: response.data, + }); + } else { + logger.error('Failed to add movie to Radarr', { + label: 'Radarr', + options, + }); + } } catch (e) { logger.error( 'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.', diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index 903cd4cc6..a49378765 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -126,7 +126,7 @@ class SonarrAPI { series.addOptions = { ignoreEpisodesWithFiles: true, - searchForMissingEpisodes: true, + searchForMissingEpisodes: options.searchNow, }; const newSeriesResponse = await this.axios.put( @@ -134,6 +134,21 @@ class SonarrAPI { series ); + if (newSeriesResponse.data.id) { + logger.info('Sonarr accepted request. Updated existing series', { + label: 'Sonarr', + }); + logger.debug('Sonarr add details', { + label: 'Sonarr', + movie: newSeriesResponse.data, + }); + } else { + logger.error('Failed to add movie to Sonarr', { + label: 'Sonarr', + options, + }); + } + return newSeriesResponse.data; } @@ -162,6 +177,19 @@ class SonarrAPI { } as Partial ); + if (createdSeriesResponse.data.id) { + logger.info('Sonarr accepted request', { label: 'Sonarr' }); + logger.debug('Sonarr add details', { + label: 'Sonarr', + movie: createdSeriesResponse.data, + }); + } else { + logger.error('Failed to add movie to Sonarr', { + label: 'Sonarr', + options, + }); + } + return createdSeriesResponse.data; } catch (e) { logger.error('Something went wrong adding a series to Sonarr', { diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts index 6f87823b6..0f8763964 100644 --- a/server/api/themoviedb.ts +++ b/server/api/themoviedb.ts @@ -374,7 +374,12 @@ class TheMovieDb { return response.data; } catch (e) { - throw new Error(`[TMDB] Failed to search multi: ${e.message}`); + return { + page: 1, + results: [], + total_pages: 1, + total_results: 0, + }; } }; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 0222e1043..7723fb9c9 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -92,6 +92,9 @@ class Media { @UpdateDateColumn() public updatedAt: Date; + @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + public lastSeasonChange: Date; + constructor(init?: Partial) { Object.assign(this, init); } diff --git a/server/index.ts b/server/index.ts index 87066254d..857232d07 100644 --- a/server/index.ts +++ b/server/index.ts @@ -29,7 +29,15 @@ const handle = app.getRequestHandler(); app .prepare() .then(async () => { - await createConnection(); + const dbConnection = await createConnection(); + + // Run migrations in production + if (process.env.NODE_ENV === 'production') { + await dbConnection.query('PRAGMA foreign_keys=OFF'); + await dbConnection.runMigrations(); + await dbConnection.query('PRAGMA foreign_keys=ON'); + } + // Load Settings const settings = getSettings().load(); diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index c38197ffa..31880e197 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -1,7 +1,10 @@ import { getRepository } from 'typeorm'; import { User } from '../../entity/User'; import PlexAPI, { PlexLibraryItem } from '../../api/plexapi'; -import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb'; +import TheMovieDb, { + TmdbMovieDetails, + TmdbTvDetails, +} from '../../api/themoviedb'; import Media from '../../entity/Media'; import { MediaStatus, MediaType } from '../../constants/media'; import logger from '../../logger'; @@ -93,40 +96,58 @@ class JobPlexSync { this.log(`Saved ${plexitem.title}`); } } else { - const matchedid = plexitem.guid.match(/imdb:\/\/(tt[0-9]+)/); + let tmdbMovieId: number | undefined; + let tmdbMovie: TmdbMovieDetails | undefined; - if (matchedid?.[1]) { - const tmdbMovie = await this.tmdb.getMovieByImdbId({ - imdbId: matchedid[1], + const imdbMatch = plexitem.guid.match(imdbRegex); + const tmdbMatch = plexitem.guid.match(tmdbRegex); + + if (imdbMatch) { + tmdbMovie = await this.tmdb.getMovieByImdbId({ + imdbId: imdbMatch[1], }); + tmdbMovieId = tmdbMovie.id; + } else if (tmdbMatch) { + tmdbMovieId = Number(tmdbMatch[1]); + } - const existing = await this.getExisting(tmdbMovie.id); - if (existing && existing.status === MediaStatus.AVAILABLE) { - this.log(`Title exists and is already available ${plexitem.title}`); - } else if (existing && existing.status !== MediaStatus.AVAILABLE) { - existing.status = MediaStatus.AVAILABLE; - await mediaRepository.save(existing); - this.log( - `Request for ${plexitem.title} exists. Setting status AVAILABLE`, - 'info' - ); - } else if (tmdbMovie) { - const newMedia = new Media(); - newMedia.imdbId = tmdbMovie.external_ids.imdb_id; - newMedia.tmdbId = tmdbMovie.id; - newMedia.status = MediaStatus.AVAILABLE; - newMedia.mediaType = MediaType.MOVIE; - await mediaRepository.save(newMedia); - this.log(`Saved ${tmdbMovie.title}`); + if (!tmdbMovieId) { + throw new Error('Unable to find TMDB ID'); + } + + const existing = await this.getExisting(tmdbMovieId); + if (existing && existing.status === MediaStatus.AVAILABLE) { + this.log(`Title exists and is already available ${plexitem.title}`); + } else if (existing && existing.status !== MediaStatus.AVAILABLE) { + existing.status = MediaStatus.AVAILABLE; + await mediaRepository.save(existing); + this.log( + `Request for ${plexitem.title} exists. Setting status AVAILABLE`, + 'info' + ); + } else { + // If we have a tmdb movie guid but it didn't already exist, only then + // do we request the movie from tmdb (to reduce api requests) + if (!tmdbMovie) { + tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId }); } + const newMedia = new Media(); + newMedia.imdbId = tmdbMovie.external_ids.imdb_id; + newMedia.tmdbId = tmdbMovie.id; + newMedia.status = MediaStatus.AVAILABLE; + newMedia.mediaType = MediaType.MOVIE; + await mediaRepository.save(newMedia); + this.log(`Saved ${tmdbMovie.title}`); } } } catch (e) { this.log( - `Failed to process plex item. ratingKey: ${ - plexitem.parentRatingKey ?? plexitem.ratingKey - }`, - 'error' + `Failed to process plex item. ratingKey: ${plexitem.ratingKey}`, + 'error', + { + errorMessage: e.message, + plexitem, + } ); } } @@ -169,6 +190,12 @@ class JobPlexSync { const newSeasons: Season[] = []; + const currentSeasonAvailable = ( + media?.seasons.filter( + (season) => season.status === MediaStatus.AVAILABLE + ) ?? [] + ).length; + seasons.forEach((season) => { const matchedPlexSeason = metadata.Children?.Metadata.find( (md) => Number(md.index) === season.season_number @@ -219,6 +246,25 @@ class JobPlexSync { if (media) { // Update existing media.seasons = [...media.seasons, ...newSeasons]; + + const newSeasonAvailable = ( + media.seasons.filter( + (season) => season.status === MediaStatus.AVAILABLE + ) ?? [] + ).length; + + // If at least one new season has become available, update + // the lastSeasonChange field so we can trigger notifications + if (newSeasonAvailable > currentSeasonAvailable) { + this.log( + `Detected ${ + newSeasonAvailable - currentSeasonAvailable + } new season(s) for ${tvShow.name}`, + 'debug' + ); + media.lastSeasonChange = new Date(); + } + media.status = isAllSeasons ? MediaStatus.AVAILABLE : MediaStatus.PARTIALLY_AVAILABLE; diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index 15d57bca4..d04cabf08 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -1,5 +1,6 @@ import { Notification } from '..'; import { User } from '../../../entity/User'; +import { NotificationAgentConfig } from '../../settings'; export interface NotificationPayload { subject: string; @@ -9,6 +10,15 @@ export interface NotificationPayload { extra?: { name: string; value: string }[]; } +export abstract class BaseAgent { + protected settings?: T; + public constructor(settings?: T) { + this.settings = settings; + } + + protected abstract getSettings(): T; +} + export interface NotificationAgent { shouldSend(type: Notification): boolean; send(type: Notification, payload: NotificationPayload): Promise; diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 008e91490..08239980e 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -1,8 +1,8 @@ import axios from 'axios'; import { Notification } from '..'; import logger from '../../../logger'; -import { getSettings } from '../../settings'; -import type { NotificationAgent, NotificationPayload } from './agent'; +import { getSettings, NotificationAgentDiscord } from '../../settings'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; enum EmbedColors { DEFAULT = 0, @@ -37,6 +37,11 @@ interface DiscordImageEmbed { width?: number; } +interface Field { + name: string; + value: string; + inline?: boolean; +} interface DiscordRichEmbed { title?: string; type?: 'rich'; // Always rich for webhooks @@ -61,11 +66,7 @@ interface DiscordRichEmbed { icon_url?: string; proxy_icon_url?: string; }; - fields?: { - name: string; - value: string; - inline?: boolean; - }[]; + fields?: Field[]; } interface DiscordWebhookPayload { @@ -75,26 +76,72 @@ interface DiscordWebhookPayload { tts: boolean; } -class DiscordAgent implements NotificationAgent { +class DiscordAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentDiscord { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.discord; + } + public buildEmbed( type: Notification, payload: NotificationPayload ): DiscordRichEmbed { let color = EmbedColors.DEFAULT; - let status = 'Unknown'; + + const fields: Field[] = []; switch (type) { case Notification.MEDIA_PENDING: color = EmbedColors.ORANGE; - status = 'Pending Approval'; + fields.push( + { + name: 'Requested By', + value: payload.notifyUser.username ?? '', + inline: true, + }, + { + name: 'Status', + value: 'Pending Approval', + inline: true, + } + ); break; case Notification.MEDIA_APPROVED: color = EmbedColors.PURPLE; - status = 'Processing Request'; + fields.push( + { + name: 'Requested By', + value: payload.notifyUser.username ?? '', + inline: true, + }, + { + name: 'Status', + value: 'Processing Request', + inline: true, + } + ); break; case Notification.MEDIA_AVAILABLE: color = EmbedColors.GREEN; - status = 'Available'; + fields.push( + { + name: 'Requested By', + value: payload.notifyUser.username ?? '', + inline: true, + }, + { + name: 'Status', + value: 'Available', + inline: true, + } + ); break; } @@ -105,16 +152,7 @@ class DiscordAgent implements NotificationAgent { timestamp: new Date().toISOString(), author: { name: 'Overseerr' }, fields: [ - { - name: 'Requested By', - value: payload.notifyUser.username ?? '', - inline: true, - }, - { - name: 'Status', - value: status, - inline: true, - }, + ...fields, // If we have extra data, map it to fields for discord notifications ...(payload.extra ?? []).map((extra) => ({ name: extra.name, @@ -130,12 +168,7 @@ class DiscordAgent implements NotificationAgent { // TODO: Add checking for type here once we add notification type filters for agents // eslint-disable-next-line @typescript-eslint/no-unused-vars public shouldSend(_type: Notification): boolean { - const settings = getSettings(); - - if ( - settings.notifications.agents.discord?.enabled && - settings.notifications.agents.discord?.options?.webhookUrl - ) { + if (this.getSettings().enabled && this.getSettings().options.webhookUrl) { return true; } @@ -146,11 +179,9 @@ class DiscordAgent implements NotificationAgent { type: Notification, payload: NotificationPayload ): Promise { - const settings = getSettings(); logger.debug('Sending discord notification', { label: 'Notifications' }); try { - const webhookUrl = - settings.notifications.agents.discord?.options?.webhookUrl; + const webhookUrl = this.getSettings().options.webhookUrl; if (!webhookUrl) { return false; diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 185525251..354a5150e 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -1,7 +1,7 @@ -import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; import { Notification } from '..'; import path from 'path'; -import { getSettings } from '../../settings'; +import { getSettings, NotificationAgentEmail } from '../../settings'; import nodemailer from 'nodemailer'; import Email from 'email-templates'; import logger from '../../../logger'; @@ -9,13 +9,25 @@ import { getRepository } from 'typeorm'; import { User } from '../../../entity/User'; import { Permission } from '../../permissions'; -class EmailAgent implements NotificationAgent { +class EmailAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentEmail { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.email; + } + // TODO: Add checking for type here once we add notification type filters for agents // eslint-disable-next-line @typescript-eslint/no-unused-vars public shouldSend(_type: Notification): boolean { - const settings = getSettings(); + const settings = this.getSettings(); - if (settings.notifications.agents.email.enabled) { + if (settings.enabled) { return true; } @@ -23,7 +35,7 @@ class EmailAgent implements NotificationAgent { } private getSmtpTransport() { - const emailSettings = getSettings().notifications.agents.email.options; + const emailSettings = this.getSettings().options; return nodemailer.createTransport({ host: emailSettings.smtpHost, @@ -40,7 +52,7 @@ class EmailAgent implements NotificationAgent { } private getNewEmail() { - const settings = getSettings().notifications.agents.email; + const settings = this.getSettings(); return new Email({ message: { from: settings.options.emailFrom, @@ -51,7 +63,8 @@ class EmailAgent implements NotificationAgent { } private async sendMediaRequestEmail(payload: NotificationPayload) { - const settings = getSettings().main; + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; try { const userRepository = getRepository(User); const users = await userRepository.find(); @@ -76,7 +89,7 @@ class EmailAgent implements NotificationAgent { imageUrl: payload.image, timestamp: new Date().toTimeString(), requestedBy: payload.notifyUser.username, - actionUrl: settings.applicationUrl, + actionUrl: applicationUrl, requestType: 'New Request', }, }); @@ -92,7 +105,8 @@ class EmailAgent implements NotificationAgent { } private async sendMediaApprovedEmail(payload: NotificationPayload) { - const settings = getSettings().main; + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; try { const email = this.getNewEmail(); @@ -110,7 +124,7 @@ class EmailAgent implements NotificationAgent { imageUrl: payload.image, timestamp: new Date().toTimeString(), requestedBy: payload.notifyUser.username, - actionUrl: settings.applicationUrl, + actionUrl: applicationUrl, requestType: 'Request Approved', }, }); @@ -125,7 +139,8 @@ class EmailAgent implements NotificationAgent { } private async sendMediaAvailableEmail(payload: NotificationPayload) { - const settings = getSettings().main; + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; try { const email = this.getNewEmail(); @@ -143,7 +158,7 @@ class EmailAgent implements NotificationAgent { imageUrl: payload.image, timestamp: new Date().toTimeString(), requestedBy: payload.notifyUser.username, - actionUrl: settings.applicationUrl, + actionUrl: applicationUrl, requestType: 'Now Available', }, }); @@ -157,6 +172,32 @@ class EmailAgent implements NotificationAgent { } } + private async sendTestEmail(payload: NotificationPayload) { + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; + try { + const email = this.getNewEmail(); + + email.send({ + template: path.join(__dirname, '../../../templates/email/test-email'), + message: { + to: payload.notifyUser.email, + }, + locals: { + body: payload.message, + actionUrl: applicationUrl, + }, + }); + return true; + } catch (e) { + logger.error('Mail notification failed to send', { + label: 'Notifications', + message: e.message, + }); + return false; + } + } + public async send( type: Notification, payload: NotificationPayload @@ -173,6 +214,9 @@ class EmailAgent implements NotificationAgent { case Notification.MEDIA_AVAILABLE: this.sendMediaAvailableEmail(payload); break; + case Notification.TEST_NOTIFICATION: + this.sendTestEmail(payload); + break; } return true; diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index 91be4c5d8..c826bfeb5 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -5,6 +5,7 @@ export enum Notification { MEDIA_PENDING = 2, MEDIA_APPROVED = 4, MEDIA_AVAILABLE = 8, + TEST_NOTIFICATION = 16, } class NotificationManager { diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 327563631..4b075af85 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { merge } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; +import { Permission } from './permissions'; export interface Library { id: string; @@ -47,24 +48,25 @@ export interface SonarrSettings extends DVRSettings { export interface MainSettings { apiKey: string; applicationUrl: string; + defaultPermissions: number; } interface PublicSettings { initialized: boolean; } -interface NotificationAgent { +export interface NotificationAgentConfig { enabled: boolean; types: number; options: Record; } -interface NotificationAgentDiscord extends NotificationAgent { +export interface NotificationAgentDiscord extends NotificationAgentConfig { options: { webhookUrl: string; }; } -interface NotificationAgentEmail extends NotificationAgent { +export interface NotificationAgentEmail extends NotificationAgentConfig { options: { emailFrom: string; smtpHost: string; @@ -105,6 +107,7 @@ class Settings { main: { apiKey: '', applicationUrl: '', + defaultPermissions: Permission.REQUEST, }, plex: { name: '', diff --git a/server/migration/1608477467935-AddLastSeasonChangeMedia.ts b/server/migration/1608477467935-AddLastSeasonChangeMedia.ts new file mode 100644 index 000000000..b1d3968a2 --- /dev/null +++ b/server/migration/1608477467935-AddLastSeasonChangeMedia.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLastSeasonChangeMedia1608477467935 + implements MigrationInterface { + name = 'AddLastSeasonChangeMedia1608477467935'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query( + `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"), CONSTRAINT "UQ_b4e05e8b45c9cc64e047db95463" UNIQUE ("imdbId"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"` + ); + await queryRunner.query(`DROP TABLE "media"`); + await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query( + `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt" FROM "temporary_media"` + ); + await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + } +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index c734e7020..b1fb4bf86 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -5,6 +5,7 @@ import PlexTvAPI from '../api/plextv'; import { isAuthenticated } from '../middleware/auth'; import { Permission } from '../lib/permissions'; import logger from '../logger'; +import { getSettings } from '../lib/settings'; const authRoutes = Router(); @@ -25,6 +26,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => { }); authRoutes.post('/login', async (req, res, next) => { + const settings = getSettings(); const userRepository = getRepository(User); const body = req.body as { authToken?: string }; @@ -69,44 +71,48 @@ authRoutes.post('/login', async (req, res, next) => { await userRepository.save(user); } - // If we get to this point, the user does not already exist so we need to create the - // user _assuming_ they have access to the plex server - const mainUser = await userRepository.findOneOrFail({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, - }); - const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); - if (await mainPlexTv.checkUserAccess(account)) { - user = new User({ - email: account.email, - username: account.username, - plexId: account.id, - plexToken: account.authToken, - permissions: Permission.REQUEST, - avatar: account.thumb, - }); - await userRepository.save(user); - } else { - logger.info( - 'Failed login attempt from user without access to plex server', - { - label: 'Auth', - account: { - ...account, - authentication_token: '__REDACTED__', - authToken: '__REDACTED__', - }, - } - ); - return next({ - status: 403, - message: 'You do not have access to this Plex server', + // Double check that we didn't create the first admin user before running this + if (!user) { + // If we get to this point, the user does not already exist so we need to create the + // user _assuming_ they have access to the plex server + const mainUser = await userRepository.findOneOrFail({ + select: ['id', 'plexToken'], + order: { id: 'ASC' }, }); + const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); + + if (await mainPlexTv.checkUserAccess(account)) { + user = new User({ + email: account.email, + username: account.username, + plexId: account.id, + plexToken: account.authToken, + permissions: settings.main.defaultPermissions, + avatar: account.thumb, + }); + await userRepository.save(user); + } else { + logger.info( + 'Failed login attempt from user without access to plex server', + { + label: 'Auth', + account: { + ...account, + authentication_token: '__REDACTED__', + authToken: '__REDACTED__', + }, + } + ); + return next({ + status: 403, + message: 'You do not have access to this Plex server', + }); + } } } // Set logged in session - if (req.session && user) { + if (req.session) { req.session.userId = user.id; } diff --git a/server/routes/settings.ts b/server/routes/settings.ts index 8f72e588a..a01722210 100644 --- a/server/routes/settings.ts +++ b/server/routes/settings.ts @@ -16,11 +16,14 @@ import logger from '../logger'; import { scheduledJobs } from '../job/schedule'; import { Permission } from '../lib/permissions'; import { isAuthenticated } from '../middleware/auth'; -import { merge } from 'lodash'; +import { merge, omit } from 'lodash'; import Media from '../entity/Media'; import { MediaRequest } from '../entity/MediaRequest'; import { getAppVersion } from '../utils/appVersion'; import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces'; +import { Notification } from '../lib/notifications'; +import DiscordAgent from '../lib/notifications/agents/discord'; +import EmailAgent from '../lib/notifications/agents/email'; const settingsRoutes = Router(); @@ -29,9 +32,7 @@ const filteredMainSettings = ( main: MainSettings ): Partial => { if (!user?.hasPermission(Permission.ADMIN)) { - return { - applicationUrl: main.applicationUrl, - }; + return omit(main, 'apiKey'); } return main; @@ -448,6 +449,25 @@ settingsRoutes.post('/notifications/discord', (req, res) => { res.status(200).json(settings.notifications.agents.discord); }); +settingsRoutes.post('/notifications/discord/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const discordAgent = new DiscordAgent(req.body); + discordAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + settingsRoutes.get('/notifications/email', (_req, res) => { const settings = getSettings(); @@ -463,6 +483,25 @@ settingsRoutes.post('/notifications/email', (req, res) => { res.status(200).json(settings.notifications.agents.email); }); +settingsRoutes.post('/notifications/email/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const emailAgent = new EmailAgent(req.body); + emailAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + settingsRoutes.get('/about', async (req, res) => { const mediaRepository = getRepository(Media); const mediaRequestRepository = getRepository(MediaRequest); diff --git a/server/routes/user.ts b/server/routes/user.ts index e6dd136a5..acbdfdb30 100644 --- a/server/routes/user.ts +++ b/server/routes/user.ts @@ -1,8 +1,10 @@ import { Router } from 'express'; import { getRepository } from 'typeorm'; +import PlexTvAPI from '../api/plextv'; import { MediaRequest } from '../entity/MediaRequest'; import { User } from '../entity/User'; import { hasPermission, Permission } from '../lib/permissions'; +import { getSettings } from '../lib/settings'; import logger from '../logger'; const router = Router(); @@ -142,4 +144,51 @@ router.delete<{ id: string }>('/:id', async (req, res, next) => { } }); +router.post('/import-from-plex', async (req, res, next) => { + try { + const settings = getSettings(); + const userRepository = getRepository(User); + + // taken from auth.ts + const mainUser = await userRepository.findOneOrFail({ + select: ['id', 'plexToken'], + order: { id: 'ASC' }, + }); + const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); + + const plexUsersResponse = await mainPlexTv.getUsers(); + const createdUsers: User[] = []; + for (const rawUser of plexUsersResponse.MediaContainer.User) { + const account = rawUser.$; + const user = await userRepository.findOne({ + where: { plexId: account.id }, + }); + if (user) { + // Update the users avatar with their plex thumbnail (incase it changed) + user.avatar = account.thumb; + user.email = account.email; + user.username = account.username; + await userRepository.save(user); + } else { + // Check to make sure it's a real account + if (account.email && account.username) { + const newUser = new User({ + username: account.username, + email: account.email, + permissions: settings.main.defaultPermissions, + plexId: parseInt(account.id), + plexToken: '', + avatar: account.thumb, + }); + await userRepository.save(newUser); + createdUsers.push(newUser); + } + } + } + return res.status(201).json(User.filterMany(createdUsers)); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + export default router; diff --git a/server/templates/email/test-email/html.pug b/server/templates/email/test-email/html.pug new file mode 100644 index 000000000..46f4ca2cb --- /dev/null +++ b/server/templates/email/test-email/html.pug @@ -0,0 +1,96 @@ +doctype html +head + meta(charset='utf-8') + meta(name='x-apple-disable-message-reformatting') + meta(http-equiv='x-ua-compatible' content='ie=edge') + meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') + link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap' rel='stylesheet' media='screen') + //if mso + xml + o:officedocumentsettings + o:pixelsperinch 96 + style. + td, + th, + div, + p, + a, + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: 'Segoe UI', sans-serif; + mso-line-height-rule: exactly; + } + style. + @media (max-width: 600px) { + .sm-w-full { + width: 100% !important; + } + } +div(role='article' aria-roledescription='email' aria-label='' lang='en') + table(style="\ + background-color: #f2f4f6;\ + font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\ + width: 100%;\ + " width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center') + table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center' style='\ + font-size: 16px;\ + padding-top: 25px;\ + padding-bottom: 25px;\ + text-align: center;\ + ') + a(href=actionUrl style='\ + text-shadow: 0 1px 0 #ffffff;\ + font-weight: 700;\ + font-size: 16px;\ + color: #a8aaaf;\ + text-decoration: none;\ + ') + | Overseerr + tr + td(style='width: 100%' width='100%') + table.sm-w-full(align='center' style='\ + background-color: #ffffff;\ + margin-left: auto;\ + margin-right: auto;\ + width: 570px;\ + ' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation') + tr + td(style='padding: 45px') + div(style='font-size: 16px') + | #{body} + p(style='\ + font-size: 13px;\ + line-height: 24px;\ + margin-top: 6px;\ + margin-bottom: 20px;\ + color: #51545e;\ + ') + a(href=actionUrl style='color: #3869d4') Open Overseerr +tr + td + table.sm-w-full(align='center' style='\ + margin-left: auto;\ + margin-right: auto;\ + text-align: center;\ + width: 570px;\ + ' width='570' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center' style='font-size: 16px; padding: 45px') + p(style='\ + font-size: 13px;\ + line-height: 24px;\ + margin-top: 6px;\ + margin-bottom: 20px;\ + text-align: center;\ + color: #a8aaaf;\ + ') + | Overseerr. diff --git a/server/templates/email/test-email/subject.pug b/server/templates/email/test-email/subject.pug new file mode 100644 index 000000000..6e50c1b5c --- /dev/null +++ b/server/templates/email/test-email/subject.pug @@ -0,0 +1 @@ += `Test Notification - Overseerr` diff --git a/src/components/Common/SlideOver/index.tsx b/src/components/Common/SlideOver/index.tsx index 97ee1a3f3..eaa7c1e64 100644 --- a/src/components/Common/SlideOver/index.tsx +++ b/src/components/Common/SlideOver/index.tsx @@ -1,4 +1,3 @@ -/* eslint-disable jsx-a11y/no-static-element-interactions */ import React, { useState, useEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; import Transition from '../../Transition'; @@ -49,7 +48,7 @@ const SlideOver: React.FC = ({ className={`z-50 fixed inset-0 overflow-hidden bg-opacity-50 bg-gray-800`} >
-
+
= ({ leaveTo="translate-x-full" >
-
-
+
+
-

+

{title}

-
+
)}
-
+
{children}
diff --git a/src/components/MovieDetails/MovieCast/index.tsx b/src/components/MovieDetails/MovieCast/index.tsx index 1adcb3366..744b5efb8 100644 --- a/src/components/MovieDetails/MovieCast/index.tsx +++ b/src/components/MovieDetails/MovieCast/index.tsx @@ -42,11 +42,11 @@ const MovieCast: React.FC = () => { {intl.formatMessage(messages.fullcast)}