diff --git a/.all-contributorsrc b/.all-contributorsrc index 56e321b3..fac1d1f0 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -88,6 +88,15 @@ "contributions": [ "code" ] + }, + { + "login": "saltydk", + "name": "salty", + "avatar_url": "https://avatars1.githubusercontent.com/u/6587950?v=4", + "profile": "https://github.com/saltydk", + "contributions": [ + "infra" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5629d453..8d401771 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,4 +37,5 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} repository: sctx/overseerr + build_args: COMMIT_TAG=${{ github.sha }} tags: develop diff --git a/Dockerfile b/Dockerfile index e5198a4b..35fb08b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,9 @@ RUN yarn cache clean FROM node:12.18-alpine +ARG COMMIT_TAG +ENV COMMIT_TAG=${COMMIT_TAG} + COPY . /app WORKDIR /app @@ -21,3 +24,5 @@ COPY --from=BUILD_IMAGE /app/.next ./.next COPY --from=BUILD_IMAGE /app/node_modules ./node_modules CMD yarn start + +EXPOSE 3000 diff --git a/README.md b/README.md index 4a19cfaf..544b2cd6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Language grade: JavaScript GitHub -All Contributors +All Contributors

@@ -104,6 +104,7 @@ Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_COND
jvennik

🌍
darknessgp

💻 +
salty

🚇 diff --git a/overseerr-api.yml b/overseerr-api.yml index 4b92a9ee..07da768a 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1478,6 +1478,29 @@ paths: application/json: schema: $ref: '#/components/schemas/DiscordSettings' + /settings/about: + get: + summary: Return current about stats + description: Returns current server stats in JSON format + tags: + - settings + responses: + '200': + description: Returned about settings + content: + application/json: + schema: + type: object + properties: + version: + type: string + example: '1.0.0' + totalRequests: + type: number + example: 100 + totalMediaItems: + type: number + example: 100 /auth/me: get: summary: Returns the currently logged in user diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index a3abf052..c8e12371 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -62,6 +62,7 @@ class PlexAPI { this.plexClient = new NodePlexAPI({ hostname: settings.plex.ip, port: settings.plex.port, + https: settings.plex.useSsl, token: plexToken, authenticator: { authenticate: ( diff --git a/server/api/radarr.ts b/server/api/radarr.ts index fe22e72c..4797ef5d 100644 --- a/server/api/radarr.ts +++ b/server/api/radarr.ts @@ -76,11 +76,9 @@ class RadarrAPI { } }; - public addMovie = async ( - options: RadarrMovieOptions - ): Promise => { + public addMovie = async (options: RadarrMovieOptions): Promise => { try { - const response = await this.axios.post(`/movie`, { + await this.axios.post(`/movie`, { title: options.title, qualityProfileId: options.qualityProfileId, profileId: options.profileId, @@ -94,15 +92,15 @@ class RadarrAPI { searchForMovie: options.searchNow, }, }); - - return response.data; } catch (e) { - logger.error('Something went wrong adding a movie to Radarr', { - label: 'Radarr', - message: e.message, - options, - }); - throw new Error(`[Radarr] Failed to add movie: ${e.message}`); + 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.', + { + label: 'Radarr', + errorMessage: e.message, + options, + } + ); } }; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts new file mode 100644 index 00000000..bc6dcf02 --- /dev/null +++ b/server/interfaces/api/settingsInterfaces.ts @@ -0,0 +1,5 @@ +export interface SettingsAboutResponse { + version: string; + totalRequests: number; + totalMediaItems: number; +} diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 1f33f3be..f618615c 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -14,6 +14,7 @@ export interface PlexSettings { machineId?: string; ip: string; port: number; + useSsl?: boolean; libraries: Library[]; } @@ -109,6 +110,7 @@ class Settings { name: '', ip: '127.0.0.1', port: 32400, + useSsl: false, libraries: [], }, radarr: [], diff --git a/server/routes/settings.ts b/server/routes/settings.ts index 422510f2..fee4e5c7 100644 --- a/server/routes/settings.ts +++ b/server/routes/settings.ts @@ -17,6 +17,8 @@ import { scheduledJobs } from '../job/schedule'; import { Permission } from '../lib/permissions'; import { isAuthenticated } from '../middleware/auth'; import { merge } from 'lodash'; +import Media from '../entity/Media'; +import { MediaRequest } from '../entity/MediaRequest'; const settingsRoutes = Router(); @@ -431,4 +433,26 @@ settingsRoutes.post('/notifications/email', (req, res) => { res.status(200).json(settings.notifications.agents.email); }); +settingsRoutes.get('/about', async (req, res) => { + const mediaRepository = getRepository(Media); + const mediaRequestRepository = getRepository(MediaRequest); + + const totalMediaItems = await mediaRepository.count(); + const totalRequests = await mediaRequestRepository.count(); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { version } = require('../../package.json'); + + let finalVersion = version; + + if (version === '0.1.0') { + finalVersion = `develop-${process.env.COMMIT_TAG ?? 'local'}`; + } + return res.status(200).json({ + version: finalVersion, + totalMediaItems, + totalRequests, + }); +}); + export default settingsRoutes; diff --git a/server/types/plex-api.d.ts b/server/types/plex-api.d.ts index fd6db2dd..9222faaf 100644 --- a/server/types/plex-api.d.ts +++ b/server/types/plex-api.d.ts @@ -4,6 +4,7 @@ declare module 'plex-api' { hostname: string; port: number; token?: string; + https?: boolean; authenticator: { authenticate: ( _plexApi: PlexAPI, diff --git a/src/components/Common/List/index.tsx b/src/components/Common/List/index.tsx new file mode 100644 index 00000000..462f6ce8 --- /dev/null +++ b/src/components/Common/List/index.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { withProperties } from '../../../utils/typeHelpers'; + +interface ListItemProps { + title: string; +} + +const ListItem: React.FC = ({ title, children }) => { + return ( +
+
{title}
+
+ {children} +
+
+ ); +}; + +interface ListProps { + title: string; + subTitle?: string; +} + +const List: React.FC = ({ title, subTitle, children }) => { + return ( + <> +
+

{title}

+ {subTitle && ( +

{subTitle}

+ )} +
+
+
{children}
+
+ + ); +}; + +export default withProperties(List, { Item: ListItem }); diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index a581b667..799a3257 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -6,6 +6,7 @@ import Notifications from './Notifications'; import LanguagePicker from './LanguagePicker'; import { useRouter } from 'next/router'; import { defineMessages, FormattedMessage } from 'react-intl'; +import { Permission, useUser } from '../../hooks/useUser'; const messages = defineMessages({ alphawarning: @@ -14,6 +15,7 @@ const messages = defineMessages({ const Layout: React.FC = ({ children }) => { const [isSidebarOpen, setSidebarOpen] = useState(false); + const { hasPermission } = useUser(); const router = useRouter(); return ( @@ -57,7 +59,7 @@ const Layout: React.FC = ({ children }) => { >
- {router.pathname === '/' && ( + {router.pathname === '/' && hasPermission(Permission.ADMIN) && (
@@ -85,7 +87,7 @@ const Layout: React.FC = ({ children }) => { target="_blank" rel="noreferrer" > - Github → + GitHub →

diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index cce4c37b..f7d80081 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -11,6 +11,7 @@ import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ createradarr: 'Create New Radarr Server', editradarr: 'Edit Radarr Server', + validationNameRequired: 'You must provide a server name', validationHostnameRequired: 'You must provide a hostname/IP', validationPortRequired: 'You must provide a port', validationApiKeyRequired: 'You must provide an API key', @@ -74,6 +75,9 @@ const RadarrModal: React.FC = ({ rootFolders: [], }); const RadarrSettingsSchema = Yup.object().shape({ + name: Yup.string().required( + intl.formatMessage(messages.validationNameRequired) + ), hostname: Yup.string().required( intl.formatMessage(messages.validationHostnameRequired) ), diff --git a/src/components/Settings/SettingsAbout/index.tsx b/src/components/Settings/SettingsAbout/index.tsx new file mode 100644 index 00000000..7925e193 --- /dev/null +++ b/src/components/Settings/SettingsAbout/index.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import useSWR from 'swr'; +import Error from '../../../pages/_error'; +import List from '../../Common/List'; +import LoadingSpinner from '../../Common/LoadingSpinner'; +import { SettingsAboutResponse } from '../../../../server/interfaces/api/settingsInterfaces'; +import { defineMessages, FormattedNumber, useIntl } from 'react-intl'; + +const messages = defineMessages({ + overseerrinformation: 'Overseerr Information', + version: 'Version', + totalmedia: 'Total Media', + totalrequests: 'Total Requests', + gettingsupport: 'Getting Support', + githubdiscussions: 'GitHub Discussions', + clickheretojoindiscord: 'Click here to join our Discord server.', +}); + +const SettingsAbout: React.FC = () => { + const intl = useIntl(); + const { data, error } = useSWR( + '/api/v1/settings/about' + ); + + if (error) { + return ; + } + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + return ( + <> +
+ + + {data.version} + + + + + + + + +
+ + + ); +}; + +export default SettingsAbout; diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index d9d97c5d..d4fa052e 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -2,21 +2,23 @@ import React, { useState } from 'react'; import LoadingSpinner from '../Common/LoadingSpinner'; import type { PlexSettings } from '../../../server/lib/settings'; import useSWR from 'swr'; -import { useFormik } from 'formik'; +import { Formik, Field } from 'formik'; import Button from '../Common/Button'; import axios from 'axios'; import LibraryItem from './LibraryItem'; import Badge from '../Common/Badge'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import * as Yup from 'yup'; const messages = defineMessages({ plexsettings: 'Plex Settings', plexsettingsDescription: 'Configure the settings for your Plex server. Overseerr uses your Plex server to scan your library at an interval and see what content is available.', - servername: 'Server Name (Automatically Set)', + servername: 'Server Name (Automatically set after you save)', servernamePlaceholder: 'Plex Server Name', hostname: 'Hostname/IP', port: 'Port', + ssl: 'SSL', save: 'Save Changes', saving: 'Saving...', plexlibraries: 'Plex Libraries', @@ -32,6 +34,8 @@ const messages = defineMessages({ librariesRemaining: 'Libraries Remaining: {count}', startscan: 'Start Scan', cancelscan: 'Cancel Scan', + validationHostnameRequired: 'You must provide a hostname/IP', + validationPortRequired: 'You must provide a port', }); interface Library { @@ -64,33 +68,15 @@ const SettingsPlex: React.FC = ({ onComplete }) => { } ); const [isSyncing, setIsSyncing] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); const [submitError, setSubmitError] = useState(null); - const formik = useFormik({ - initialValues: { - hostname: data?.ip, - port: data?.port, - }, - enableReinitialize: true, - onSubmit: async (values) => { - setSubmitError(null); - setIsUpdating(true); - try { - await axios.post('/api/v1/settings/plex', { - ip: values.hostname, - port: Number(values.port), - } as PlexSettings); - revalidate(); - if (onComplete) { - onComplete(); - } - } catch (e) { - setSubmitError(e.response.data.message); - } finally { - setIsUpdating(false); - } - }, + const PlexSettingsSchema = Yup.object().shape({ + hostname: Yup.string().required( + intl.formatMessage(messages.validationHostnameRequired) + ), + port: Yup.number().required( + intl.formatMessage(messages.validationPortRequired) + ), }); const activeLibraries = @@ -164,91 +150,154 @@ const SettingsPlex: React.FC = ({ onComplete }) => {

-
-
- {submitError && ( -
- {submitError} -
- )} -
- -
-
- + { + setSubmitError(null); + try { + await axios.post('/api/v1/settings/plex', { + ip: values.hostname, + port: Number(values.port), + useSsl: values.useSsl, + } as PlexSettings); + + revalidate(); + if (onComplete) { + onComplete(); + } + } catch (e) { + setSubmitError(e.response.data.message); + } + }} + > + {({ + errors, + touched, + values, + handleSubmit, + setFieldValue, + isSubmitting, + }) => { + return ( + +
+ {submitError && ( +
+ {submitError} +
+ )} +
+ +
+
+ +
+
+
+
+ +
+
+ +
+ {errors.hostname && touched.hostname && ( +
{errors.hostname}
+ )} +
+
+
+ +
+
+ +
+ {errors.port && touched.port && ( +
{errors.port}
+ )} +
+
-
-
-
- -
-
- +
+ +
+ { + setFieldValue('useSsl', !values.useSsl); + }} + className="form-checkbox h-6 w-6 rounded-md text-indigo-600 transition duration-150 ease-in-out" + /> +
-
-
-
- -
-
- +
+
+ + + +
-
-
-
-
-
- - - -
-
- + + ); + }} +

diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index 248006dd..a98c64ed 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -11,6 +11,7 @@ import { useIntl, defineMessages } from 'react-intl'; const messages = defineMessages({ createsonarr: 'Create New Sonarr Server', editsonarr: 'Edit Sonarr Server', + validationNameRequired: 'You must provide a server name', validationHostnameRequired: 'You must provide a hostname/IP', validationPortRequired: 'You must provide a port', validationApiKeyRequired: 'You must provide an API key', @@ -73,6 +74,9 @@ const SonarrModal: React.FC = ({ rootFolders: [], }); const SonarrSettingsSchema = Yup.object().shape({ + name: Yup.string().required( + intl.formatMessage(messages.validationNameRequired) + ), hostname: Yup.string().required( intl.formatMessage(messages.validationHostnameRequired) ), diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index a9d4c7ae..f598cae4 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -135,9 +135,17 @@ "components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr connection established!", "components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key", "components.Settings.RadarrModal.validationHostnameRequired": "You must provide a hostname/IP", + "components.Settings.RadarrModal.validationNameRequired": "You must provide a server name", "components.Settings.RadarrModal.validationPortRequired": "You must provide a port", "components.Settings.RadarrModal.validationProfileRequired": "You must select a profile", "components.Settings.RadarrModal.validationRootFolderRequired": "You must select a root folder", + "components.Settings.SettingsAbout.clickheretojoindiscord": "Click here to join our Discord server.", + "components.Settings.SettingsAbout.gettingsupport": "Getting Support", + "components.Settings.SettingsAbout.githubdiscussions": "GitHub Discussions", + "components.Settings.SettingsAbout.overseerrinformation": "Overseerr Information", + "components.Settings.SettingsAbout.totalmedia": "Total Media", + "components.Settings.SettingsAbout.totalrequests": "Total Requests", + "components.Settings.SettingsAbout.version": "Version", "components.Settings.SonarrModal.add": "Add Server", "components.Settings.SonarrModal.apiKey": "API Key", "components.Settings.SonarrModal.apiKeyPlaceholder": "Your Sonarr API Key", @@ -165,6 +173,7 @@ "components.Settings.SonarrModal.toastRadarrTestSuccess": "Sonarr connection established!", "components.Settings.SonarrModal.validationApiKeyRequired": "You must provide an API key", "components.Settings.SonarrModal.validationHostnameRequired": "You must provide a hostname/IP", + "components.Settings.SonarrModal.validationNameRequired": "You must provide a server name", "components.Settings.SonarrModal.validationPortRequired": "You must provide a port", "components.Settings.SonarrModal.validationProfileRequired": "You must select a profile", "components.Settings.SonarrModal.validationRootFolderRequired": "You must select a root folder", @@ -210,7 +219,7 @@ "components.Settings.runnow": "Run Now", "components.Settings.save": "Save Changes", "components.Settings.saving": "Saving...", - "components.Settings.servername": "Server Name (Automatically Set)", + "components.Settings.servername": "Server Name (Automatically set after you save)", "components.Settings.servernamePlaceholder": "Plex Server Name", "components.Settings.sonarrSettingsDescription": "Set up your Sonarr connection below. You can have multiple, but only two active as defaults at any time (one for standard HD and one for 4K). Administrators can override which server is used for new requests.", "components.Settings.sonarrsettings": "Sonarr Settings", @@ -218,6 +227,8 @@ "components.Settings.startscan": "Start Scan", "components.Settings.sync": "Sync Plex Libraries", "components.Settings.syncing": "Syncing…", + "components.Settings.validationHostnameRequired": "You must provide a hostname/IP", + "components.Settings.validationPortRequired": "You must provide a port", "components.Setup.configureplex": "Configure Plex", "components.Setup.configureservices": "Configure Services", "components.Setup.continue": "Continue", diff --git a/src/pages/settings/about.tsx b/src/pages/settings/about.tsx new file mode 100644 index 00000000..442669d9 --- /dev/null +++ b/src/pages/settings/about.tsx @@ -0,0 +1,17 @@ +import { NextPage } from 'next'; +import React from 'react'; +import SettingsAbout from '../../components/Settings/SettingsAbout'; +import SettingsLayout from '../../components/Settings/SettingsLayout'; +import useRouteGuard from '../../hooks/useRouteGuard'; +import { Permission } from '../../hooks/useUser'; + +const SettingsAboutPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_SETTINGS); + return ( + + + + ); +}; + +export default SettingsAboutPage;