diff --git a/.all-contributorsrc b/.all-contributorsrc index 31a4cc63b..378c8ee28 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -124,6 +124,26 @@ "contributions": [ "code" ] + }, + { + "login": "samwiseg0", + "name": "samwiseg0", + "avatar_url": "https://avatars1.githubusercontent.com/u/2241731?v=4", + "profile": "https://github.com/samwiseg0", + "contributions": [ + "question", + "infra" + ] + }, + { + "login": "ecelebi29", + "name": "ecelebi29", + "avatar_url": "https://avatars2.githubusercontent.com/u/8337120?v=4", + "profile": "https://github.com/ecelebi29", + "contributions": [ + "code", + "doc" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6267abc3..66d98b66e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ You can also run the development environment in [Docker](https://www.docker.com/ - PRs with commits not following this standard will not be merged. - Please make meaningful commits, or squash them - Always rebase your commit to the latest `develop` branch. Do not merge develop into your branch. -- It is your responsbility to keep your branch up to date. It will not be merged unless its rebased off the latest develop branch. +- It is your responsibility to keep your branch up to date. It will not be merged unless its rebased off the latest develop branch. - You can create a Draft pull request early to get feedback on your work. - Your code must be formatted correctly or the tests will fail. - We use Prettier to format our codebase. It should auto run with a git hook, but its recommended to have a Prettier extension installed in your editor and have it format on save. diff --git a/README.md b/README.md index 6c52d0153..03d6e5276 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Language grade: JavaScript GitHub -All Contributors +All Contributors

@@ -44,7 +44,7 @@ - More notification types (Slack/Telegram/etc.). - Issues system. This will allow users to report issues with content on your media server. - Local user system (for those who don't use Plex). -- Compatiblity APIs (to work with existing tools in your system). +- Compatibility APIs (to work with existing tools in your system). ## Running Overseerr @@ -114,10 +114,13 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
Shutruk

🌍
Krystian Charubin

🎨
Kieron Boswell

đź’» +
samwiseg0

💬 🚇 + + +
ecelebi29

đź’» đź“– - diff --git a/ormconfig.js b/ormconfig.js index 93da376bc..2c0afb735 100644 --- a/ormconfig.js +++ b/ormconfig.js @@ -5,6 +5,7 @@ const devConfig = { logging: false, entities: ['server/entity/**/*.ts'], migrations: ['server/migration/**/*.ts'], + subscribers: ['server/subscriber/**/*.ts'], cli: { entitiesDir: 'server/entity', migrationsDir: 'server/migration', @@ -19,6 +20,7 @@ const prodConfig = { entities: ['dist/entity/**/*.js'], migrations: ['dist/migration/**/*.js'], migrationsRun: true, + subscribers: ['dist/subscriber/**/*.js'], cli: { entitiesDir: 'dist/entity', migrationsDir: 'dist/migration', diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index 72a2de6b4..4a86b68bb 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -76,6 +76,7 @@ interface AddSeriesOptions { title: string; profileId: number; seasons: number[]; + seasonFolder: boolean; rootFolderPath: string; monitored?: boolean; searchNow?: boolean; @@ -149,6 +150,7 @@ class SonarrAPI { monitored: false, })) ), + seasonFolder: options.seasonFolder, monitored: options.monitored, rootFolderPath: options.rootFolderPath, addOptions: { diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 8f2f8ff6d..0222e1043 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -8,14 +8,11 @@ import { UpdateDateColumn, getRepository, In, - AfterUpdate, } from 'typeorm'; import { MediaRequest } from './MediaRequest'; import { MediaStatus, MediaType } from '../constants/media'; import logger from '../logger'; import Season from './Season'; -import notificationManager, { Notification } from '../lib/notifications'; -import TheMovieDb from '../api/themoviedb'; @Entity() class Media { @@ -98,32 +95,6 @@ class Media { constructor(init?: Partial) { Object.assign(this, init); } - - @AfterUpdate() - private async _notifyAvailable() { - if (this.status === MediaStatus.AVAILABLE) { - if (this.mediaType === MediaType.MOVIE) { - const requestRepository = getRepository(MediaRequest); - const relatedRequests = await requestRepository.find({ - where: { media: this }, - }); - - if (relatedRequests.length > 0) { - const tmdb = new TheMovieDb(); - const movie = await tmdb.getMovie({ movieId: this.tmdbId }); - - relatedRequests.forEach((request) => { - notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { - notifyUser: request.requestedBy, - subject: movie.title, - message: movie.overview, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, - }); - }); - } - } - } - } } export default Media; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 1bfe23741..e584dc55c 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -335,6 +335,7 @@ export class MediaRequest { title: series.name, tvdbid: series.external_ids.tvdb_id, seasons: this.seasons.map((season) => season.seasonNumber), + seasonFolder: sonarrSettings.enableSeasonFolders, monitored: true, searchNow: true, }); diff --git a/server/entity/Season.ts b/server/entity/Season.ts index a591c3ca7..d66805cbd 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -5,15 +5,9 @@ import { ManyToOne, CreateDateColumn, UpdateDateColumn, - AfterInsert, - AfterUpdate, - getRepository, } from 'typeorm'; import { MediaStatus } from '../constants/media'; import Media from './Media'; -import logger from '../logger'; -import TheMovieDb from '../api/themoviedb'; -import notificationManager, { Notification } from '../lib/notifications'; @Entity() class Season { @@ -38,60 +32,6 @@ class Season { constructor(init?: Partial) { Object.assign(this, init); } - - @AfterInsert() - @AfterUpdate() - private async _sendSeasonAvailableNotification() { - if (this.status === MediaStatus.AVAILABLE) { - try { - const lazyMedia = await this.media; - const tmdb = new TheMovieDb(); - const mediaRepository = getRepository(Media); - const media = await mediaRepository.findOneOrFail({ - where: { id: lazyMedia.id }, - relations: ['requests'], - }); - - const availableSeasons = media.seasons.map( - (season) => season.seasonNumber - ); - - const request = media.requests.find( - (request) => - // Check if the season is complete AND it contains the current season that was just marked available - request.seasons.every((season) => - availableSeasons.includes(season.seasonNumber) - ) && - request.seasons.some( - (season) => season.seasonNumber === this.seasonNumber - ) - ); - - if (request) { - const tv = await tmdb.getTvShow({ tvId: media.tmdbId }); - notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { - subject: tv.name, - message: tv.overview, - notifyUser: request.requestedBy, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, - extra: [ - { - name: 'Seasons', - value: request.seasons - .map((season) => season.seasonNumber) - .join(', '), - }, - ], - }); - } - } catch (e) { - logger.error('Something went wrong sending season available notice', { - label: 'Notifications', - message: e.message, - }); - } - } - } } export default Season; diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index 87e078d40..c38197ffa 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -7,6 +7,7 @@ import { MediaStatus, MediaType } from '../../constants/media'; import logger from '../../logger'; import { getSettings, Library } from '../../lib/settings'; import Season from '../../entity/Season'; +import { uniqWith } from 'lodash'; const BUNDLE_SIZE = 20; const UPDATE_RATE = 4 * 1000; @@ -326,7 +327,25 @@ class JobPlexSync { `Beginning to process recently added for library: ${library.name}`, 'info' ); - this.items = await this.plexClient.getRecentlyAdded(library.id); + const libraryItems = await this.plexClient.getRecentlyAdded( + library.id + ); + + // Bundle items up by rating keys + this.items = uniqWith(libraryItems, (mediaA, mediaB) => { + if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) { + return ( + mediaA.grandparentRatingKey === mediaB.grandparentRatingKey + ); + } + + if (mediaA.parentRatingKey && mediaB.parentRatingKey) { + return mediaA.parentRatingKey === mediaB.parentRatingKey; + } + + return mediaA.ratingKey === mediaB.ratingKey; + }); + await this.loop(); } } else { diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 6ba96f608..185525251 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -29,10 +29,13 @@ class EmailAgent implements NotificationAgent { host: emailSettings.smtpHost, port: emailSettings.smtpPort, secure: emailSettings.secure, - auth: { - user: emailSettings.authUser, - pass: emailSettings.authPass, - }, + auth: + emailSettings.authUser && emailSettings.authPass + ? { + user: emailSettings.authUser, + pass: emailSettings.authPass, + } + : undefined, }); } diff --git a/server/models/Tv.ts b/server/models/Tv.ts index f303c95c2..f1e8f7797 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -56,7 +56,7 @@ export interface TvDetails { profilePath?: string; }[]; episodeRunTime: number[]; - firstAirDate: string; + firstAirDate?: string; genres: Genre[]; homepage: string; inProduction: boolean; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 29314d337..50a5f2201 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -24,7 +24,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => { return res.status(200).json(user.filter()); }); -authRoutes.post('/login', async (req, res) => { +authRoutes.post('/login', async (req, res, next) => { const userRepository = getRepository(User); const body = req.body as { authToken?: string }; @@ -86,6 +86,22 @@ authRoutes.post('/login', async (req, res) => { 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', + }); } } @@ -97,9 +113,10 @@ authRoutes.post('/login', async (req, res) => { return res.status(200).json(user?.filter() ?? {}); } catch (e) { logger.error(e.message, { label: 'Auth' }); - res - .status(500) - .json({ error: 'Something went wrong. Is your auth token valid?' }); + return next({ + status: 500, + message: 'Something went wrong. Is your auth token valid?', + }); } }); diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts new file mode 100644 index 000000000..f63b14f64 --- /dev/null +++ b/server/subscriber/MediaSubscriber.ts @@ -0,0 +1,112 @@ +import { + EntitySubscriberInterface, + EventSubscriber, + getRepository, + UpdateEvent, +} from 'typeorm'; +import TheMovieDb from '../api/themoviedb'; +import { MediaStatus, MediaType } from '../constants/media'; +import Media from '../entity/Media'; +import { MediaRequest } from '../entity/MediaRequest'; +import notificationManager, { Notification } from '../lib/notifications'; + +@EventSubscriber() +export class MediaSubscriber implements EntitySubscriberInterface { + private async notifyAvailableMovie(entity: Media) { + if (entity.status === MediaStatus.AVAILABLE) { + if (entity.mediaType === MediaType.MOVIE) { + const requestRepository = getRepository(MediaRequest); + const relatedRequests = await requestRepository.find({ + where: { media: entity }, + }); + + if (relatedRequests.length > 0) { + const tmdb = new TheMovieDb(); + const movie = await tmdb.getMovie({ movieId: entity.tmdbId }); + + relatedRequests.forEach((request) => { + notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { + notifyUser: request.requestedBy, + subject: movie.title, + message: movie.overview, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, + }); + }); + } + } + } + } + + private async notifyAvailableSeries(entity: Media, dbEntity: Media) { + const newAvailableSeasons = entity.seasons + .filter((season) => season.status === MediaStatus.AVAILABLE) + .map((season) => season.seasonNumber); + const oldAvailableSeasons = dbEntity.seasons + .filter((season) => season.status === MediaStatus.AVAILABLE) + .map((season) => season.seasonNumber); + + const changedSeasons = newAvailableSeasons.filter( + (seasonNumber) => !oldAvailableSeasons.includes(seasonNumber) + ); + + if (changedSeasons.length > 0) { + const tmdb = new TheMovieDb(); + const requestRepository = getRepository(MediaRequest); + const processedSeasons: number[] = []; + + for (const changedSeasonNumber of changedSeasons) { + const requests = await requestRepository.find({ + where: { media: entity }, + }); + const request = requests.find( + (request) => + // Check if the season is complete AND it contains the current season that was just marked available + request.seasons.every((season) => + newAvailableSeasons.includes(season.seasonNumber) + ) && + request.seasons.some( + (season) => season.seasonNumber === changedSeasonNumber + ) + ); + + if (request && !processedSeasons.includes(changedSeasonNumber)) { + processedSeasons.push( + ...request.seasons.map((season) => season.seasonNumber) + ); + const tv = await tmdb.getTvShow({ tvId: entity.tmdbId }); + notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { + subject: tv.name, + message: tv.overview, + notifyUser: request.requestedBy, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, + extra: [ + { + name: 'Seasons', + value: request.seasons + .map((season) => season.seasonNumber) + .join(', '), + }, + ], + }); + } + } + } + } + + public beforeUpdate(event: UpdateEvent): void { + if ( + event.entity.mediaType === MediaType.MOVIE && + event.entity.status === MediaStatus.AVAILABLE + ) { + this.notifyAvailableMovie(event.entity); + } + + if ( + event.entity.mediaType === MediaType.TV && + (event.entity.status === MediaStatus.AVAILABLE || + event.entity.status === MediaStatus.PARTIALLY_AVAILABLE) + ) { + this.notifyAvailableSeries(event.entity, event.databaseEntity); + } + } +} diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 484010a2b..87095b7bf 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -5,12 +5,15 @@ import axios from 'axios'; import { useRouter } from 'next/dist/client/router'; import ImageFader from '../Common/ImageFader'; import { defineMessages, FormattedMessage } from 'react-intl'; +import Transition from '../Transition'; const messages = defineMessages({ signinplex: 'Sign in to continue', }); const Login: React.FC = () => { + const [error, setError] = useState(''); + const [isProcessing, setProcessing] = useState(false); const [authToken, setAuthToken] = useState(undefined); const { user, revalidate } = useUser(); const router = useRouter(); @@ -20,10 +23,17 @@ const Login: React.FC = () => { // ask swr to revalidate the user which _shouid_ come back with a valid user. useEffect(() => { const login = async () => { - const response = await axios.post('/api/v1/auth/login', { authToken }); + setProcessing(true); + try { + const response = await axios.post('/api/v1/auth/login', { authToken }); - if (response.data?.email) { - revalidate(); + if (response.data?.email) { + revalidate(); + } + } catch (e) { + setError(e.response.data.message); + setAuthToken(undefined); + setProcessing(false); } }; if (authToken) { @@ -64,7 +74,40 @@ const Login: React.FC = () => { className="bg-gray-800 bg-opacity-50 py-8 px-4 shadow sm:rounded-lg sm:px-10" style={{ backdropFilter: 'blur(5px)' }} > + +
+
+
+ +
+
+

{error}

+
+
+
+
setAuthToken(authToken)} /> diff --git a/src/components/PlexLoginButton/index.tsx b/src/components/PlexLoginButton/index.tsx index 56883fb39..3c58e2336 100644 --- a/src/components/PlexLoginButton/index.tsx +++ b/src/components/PlexLoginButton/index.tsx @@ -12,23 +12,23 @@ const plexOAuth = new PlexOAuth(); interface PlexLoginButtonProps { onAuthToken: (authToken: string) => void; + isProcessing?: boolean; onError?: (message: string) => void; } const PlexLoginButton: React.FC = ({ onAuthToken, onError, + isProcessing, }) => { const intl = useIntl(); const [loading, setLoading] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); const getPlexLogin = async () => { setLoading(true); try { const authToken = await plexOAuth.login(); setLoading(false); - setIsProcessing(true); onAuthToken(authToken); } catch (e) { if (onError) { diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index f7d80081f..f89439544 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -17,6 +17,7 @@ const messages = defineMessages({ validationApiKeyRequired: 'You must provide an API key', validationRootFolderRequired: 'You must select a root folder', validationProfileRequired: 'You must select a profile', + validationMinimumAvailabilityRequired: 'You must select minimum availability', toastRadarrTestSuccess: 'Radarr connection established!', toastRadarrTestFailure: 'Failed to connect to Radarr Server', saving: 'Saving...', @@ -41,6 +42,10 @@ const messages = defineMessages({ selectQualityProfile: 'Select a Quality Profile', selectRootFolder: 'Select a Root Folder', selectMinimumAvailability: 'Select minimum availability', + loadingprofiles: 'Loading quality profiles…', + testFirstQualityProfiles: 'Test your connection to load quality profiles', + loadingrootfolders: 'Loading root folders…', + testFirstRootFolders: 'Test your connection to load root folders', }); interface TestResponse { @@ -85,10 +90,15 @@ const RadarrModal: React.FC = ({ intl.formatMessage(messages.validationPortRequired) ), apiKey: Yup.string().required(intl.formatMessage(messages.apiKey)), - rootFolder: Yup.string().required(intl.formatMessage(messages.rootfolder)), + rootFolder: Yup.string().required( + intl.formatMessage(messages.validationRootFolderRequired) + ), activeProfileId: Yup.string().required( intl.formatMessage(messages.validationProfileRequired) ), + minimumAvailability: Yup.string().required( + intl.formatMessage(messages.validationMinimumAvailabilityRequired) + ), }); const testConnection = useCallback( @@ -175,7 +185,7 @@ const RadarrModal: React.FC = ({ baseUrl: radarr?.baseUrl, activeProfileId: radarr?.activeProfileId, rootFolder: radarr?.activeDirectory, - minimumAvailability: radarr?.minimumAvailability, + minimumAvailability: radarr?.minimumAvailability ?? 'released', isDefault: radarr?.isDefault ?? false, is4k: radarr?.is4k ?? false, }} @@ -222,6 +232,7 @@ const RadarrModal: React.FC = ({ handleSubmit, setFieldValue, isSubmitting, + isValid, }) => { return ( = ({ secondaryDisabled={ !values.apiKey || !values.hostname || !values.port || isTesting } - okDisabled={!isValidated || isSubmitting || isTesting} + okDisabled={!isValidated || isSubmitting || isTesting || !isValid} onOk={() => handleSubmit()} title={ !radarr @@ -316,6 +327,9 @@ const RadarrModal: React.FC = ({
+ + {values.ssl ? 'https://' : 'http://'} + = ({ setIsValidated(false); setFieldValue('hostname', e.target.value); }} - className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" + className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" />
{errors.hostname && touched.hostname && ( @@ -446,10 +460,17 @@ const RadarrModal: React.FC = ({ as="select" id="activeProfileId" name="activeProfileId" - className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5" + disabled={!isValidated || isTesting} + className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50" > {testResponse.profiles.length > 0 && testResponse.profiles.map((profile) => ( @@ -482,10 +503,15 @@ const RadarrModal: React.FC = ({ as="select" id="rootFolder" name="rootFolder" - className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5" + disabled={!isValidated || isTesting} + className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50" > {testResponse.rootFolders.length > 0 && testResponse.rootFolders.map((folder) => ( @@ -520,17 +546,18 @@ const RadarrModal: React.FC = ({ name="minimumAvailability" className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5" > -
+ {errors.minimumAvailability && + touched.minimumAvailability && ( +
+ {errors.minimumAvailability} +
+ )}
diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index d4fa052ed..0fa23ad3b 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -224,12 +224,15 @@ const SettingsPlex: React.FC = ({ onComplete }) => {
+ + {values.useSsl ? 'https://' : 'http://'} +
{errors.hostname && touched.hostname && ( diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index 07ccafd3a..5a3b1e8c5 100644 --- a/src/components/Settings/SettingsServices.tsx +++ b/src/components/Settings/SettingsServices.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import Badge from '../Common/Badge'; import Button from '../Common/Button'; import useSWR from 'swr'; @@ -31,8 +31,48 @@ const messages = defineMessages({ activeProfile: 'Active Profile', addradarr: 'Add Radarr Server', addsonarr: 'Add Sonarr Server', + nodefault: 'No default server selected!', + nodefaultdescription: + 'At least one server must be marked as default before any requests will make it to your services.', + no4kimplemented: '(Default 4K servers are not currently implemented)', }); +const NoDefaultAlert: React.FC = () => { + const intl = useIntl(); + return ( +
+
+
+ +
+
+

+ {intl.formatMessage(messages.nodefault)} +

+
+

{intl.formatMessage(messages.nodefaultdescription)}

+

+ {intl.formatMessage(messages.no4kimplemented)} +

+
+
+
+
+ ); +}; + interface ServerInstanceProps { name: string; isDefault?: boolean; @@ -249,51 +289,57 @@ const SettingsServices: React.FC = () => {
{!radarrData && !radarrError && } {radarrData && !radarrError && ( -
    - {radarrData.map((radarr) => ( - setEditRadarrModal({ open: true, radarr })} - onDelete={() => - setDeleteServerModal({ - open: true, - serverId: radarr.id, - type: 'radarr', - }) - } - /> - ))} -
  • -
    - -
    -
  • -
+ + + + + +
+ + + )}
@@ -307,52 +353,58 @@ const SettingsServices: React.FC = () => {
{!sonarrData && !sonarrError && } {sonarrData && !sonarrError && ( -
    - {sonarrData.map((sonarr) => ( - setEditSonarrModal({ open: true, sonarr })} - onDelete={() => - setDeleteServerModal({ - open: true, - serverId: sonarr.id, - type: 'sonarr', - }) - } - /> - ))} -
  • -
    - -
    -
  • -
+ + + + + +
+ + + )}
diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index a98c64ed0..ad6fee594 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -40,6 +40,10 @@ const messages = defineMessages({ server4k: '4K Server', selectQualityProfile: 'Select a Quality Profile', selectRootFolder: 'Select a Root Folder', + loadingprofiles: 'Loading quality profiles…', + testFirstQualityProfiles: 'Test your connection to load quality profiles', + loadingrootfolders: 'Loading root folders…', + testFirstRootFolders: 'Test your connection to load root folders', }); interface TestResponse { @@ -225,6 +229,7 @@ const SonarrModal: React.FC = ({ handleSubmit, setFieldValue, isSubmitting, + isValid, }) => { return ( = ({ secondaryDisabled={ !values.apiKey || !values.hostname || !values.port || isTesting } - okDisabled={!isValidated || isSubmitting || isTesting} + okDisabled={!isValidated || isSubmitting || isTesting || !isValid} onOk={() => handleSubmit()} title={ !sonarr @@ -319,6 +324,9 @@ const SonarrModal: React.FC = ({
+ + {values.ssl ? 'https://' : 'http://'} + = ({ setIsValidated(false); setFieldValue('hostname', e.target.value); }} - className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" + className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500" />
{errors.hostname && touched.hostname && ( @@ -449,10 +457,17 @@ const SonarrModal: React.FC = ({ as="select" id="activeProfileId" name="activeProfileId" - className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5" + disabled={!isValidated || isTesting} + className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50" > {testResponse.profiles.length > 0 && testResponse.profiles.map((profile) => ( @@ -485,10 +500,15 @@ const SonarrModal: React.FC = ({ as="select" id="rootFolder" name="rootFolder" - className="mt-1 form-select block rounded-md w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5" + disabled={!isValidated || isTesting} + className="mt-1 form-select block rounded-md w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50" > {testResponse.rootFolders.length > 0 && testResponse.rootFolders.map((folder) => ( diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 443f06a1b..c460a9f90 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -21,7 +21,7 @@ interface TitleCardProps { id: number; image?: string; summary?: string; - year: string; + year?: string; title: string; userScore: number; mediaType: MediaType; @@ -169,7 +169,7 @@ const TitleCard: React.FC = ({ >
-
{year}
+ {year &&
{year}
}

{title} diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 451a119f8..84e5fc156 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -227,8 +227,12 @@ const TvDetails: React.FC = ({ tv }) => { )}

- {data.name}{' '} - ({data.firstAirDate.slice(0, 4)}) + {data.name} + {data.firstAirDate && ( + + ({data.firstAirDate.slice(0, 4)}) + + )}

{data.genres.map((g) => g.name).join(', ')} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 8b1b27e39..f08ebcd5e 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -116,6 +116,8 @@ "components.Settings.RadarrModal.defaultserver": "Default Server", "components.Settings.RadarrModal.editradarr": "Edit Radarr Server", "components.Settings.RadarrModal.hostname": "Hostname", + "components.Settings.RadarrModal.loadingprofiles": "Loading quality profiles…", + "components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…", "components.Settings.RadarrModal.minimumAvailability": "Minimum Availability", "components.Settings.RadarrModal.port": "Port", "components.Settings.RadarrModal.qualityprofile": "Quality Profile", @@ -130,11 +132,14 @@ "components.Settings.RadarrModal.servernamePlaceholder": "A Radarr Server", "components.Settings.RadarrModal.ssl": "SSL", "components.Settings.RadarrModal.test": "Test", + "components.Settings.RadarrModal.testFirstQualityProfiles": "Test your connection to load quality profiles", + "components.Settings.RadarrModal.testFirstRootFolders": "Test your connection to load root folders", "components.Settings.RadarrModal.testing": "Testing...", "components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr Server", "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.validationMinimumAvailabilityRequired": "You must select minimum availability", "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", @@ -155,6 +160,8 @@ "components.Settings.SonarrModal.defaultserver": "Default Server", "components.Settings.SonarrModal.editsonarr": "Edit Sonarr Server", "components.Settings.SonarrModal.hostname": "Hostname", + "components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…", + "components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…", "components.Settings.SonarrModal.port": "Port", "components.Settings.SonarrModal.qualityprofile": "Quality Profile", "components.Settings.SonarrModal.rootfolder": "Root Folder", @@ -168,6 +175,8 @@ "components.Settings.SonarrModal.servernamePlaceholder": "A Sonarr Server", "components.Settings.SonarrModal.ssl": "SSL", "components.Settings.SonarrModal.test": "Test", + "components.Settings.SonarrModal.testFirstQualityProfiles": "Test your connection to load quality profiles", + "components.Settings.SonarrModal.testFirstRootFolders": "Test your connection to load root folders", "components.Settings.SonarrModal.testing": "Testing...", "components.Settings.SonarrModal.toastRadarrTestFailure": "Could not connect to Sonarr Server", "components.Settings.SonarrModal.toastRadarrTestSuccess": "Sonarr connection established!", @@ -206,6 +215,9 @@ "components.Settings.menuPlexSettings": "Plex", "components.Settings.menuServices": "Services", "components.Settings.nextexecution": "Next Execution", + "components.Settings.no4kimplemented": "(Default 4K servers are not currently implemented)", + "components.Settings.nodefault": "No default server selected!", + "components.Settings.nodefaultdescription": "At least one server must be marked as default before any requests will make it to your services.", "components.Settings.notificationsettings": "Notification Settings", "components.Settings.notificationsettingsDescription": "Here you can pick and choose what types of notifications to send and through what types of services.", "components.Settings.notrunning": "Not Running", diff --git a/src/styles/globals.css b/src/styles/globals.css index 18c2b0ff0..9df59f095 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -7,7 +7,7 @@ body { } .plex-button { - @apply w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 transition ease-in-out duration-150 text-center; + @apply w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 transition ease-in-out duration-150 text-center disabled:opacity-50; background-color: #cc7b19; }