diff --git a/.all-contributorsrc b/.all-contributorsrc index 8df4a8c5..d8cbe98d 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 6e5894b7..4d49270e 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 801338b9..bc494612 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 2c0afb73..2cc4533b 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 20901797..268fa896 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 e346acbb..5de8bf86 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 3f47bc7c..38af1e93 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 a1152ada..e3e40c73 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 4797ef5d..968cb21d 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 903cd4cc..a4937876 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 6f87823b..0f876396 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 0222e104..7723fb9c 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 87066254..857232d0 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 c38197ff..31880e19 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 15d57bca..d04cabf0 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 008e9149..08239980 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 18552525..354a5150 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 91be4c5d..c826bfeb 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 32756363..4b075af8 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 00000000..b1d3968a --- /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 c734e702..b1fb4bf8 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 8f72e588..a0172221 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 e6dd136a..acbdfdb3 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 00000000..46f4ca2c --- /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 00000000..6e50c1b5 --- /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 97ee1a3f..eaa7c1e6 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 1adcb336..744b5efb 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)}