From edbbccf3ae623430294f1a5c3fd2728dbd42e555 Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 14 Dec 2020 08:01:33 +0000 Subject: [PATCH 1/6] fix(plex sync): catch errors that occur during processMovie this also removes the unique constraint on imdbId re #244 #246 #250 --- package.json | 1 + server/job/plexsync/index.ts | 103 ++++++++++-------- .../1607928251245-DropImdbIdConstraint.ts | 20 ++++ 3 files changed, 81 insertions(+), 43 deletions(-) create mode 100644 server/migration/1607928251245-DropImdbIdConstraint.ts diff --git a/package.json b/package.json index b053dcefc..f8926e9cf 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start": "NODE_ENV=production node dist/index.js", "i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault false './src/**/!(*.test).{ts,tsx}'", "migration:generate": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:generate", + "migration:create": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:create", "migration:run": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:run", "format": "prettier --write ." }, diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index c2c95face..cf1b3823b 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -51,64 +51,81 @@ class JobPlexSync { private async processMovie(plexitem: PlexLibraryItem) { const mediaRepository = getRepository(Media); - if (plexitem.guid.match(plexRegex)) { - const metadata = await this.plexClient.getMetadata(plexitem.ratingKey); - const newMedia = new Media(); - - metadata.Guid.forEach((ref) => { - if (ref.id.match(imdbRegex)) { - newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined; - } else if (ref.id.match(tmdbRegex)) { - const tmdbMatch = ref.id.match(tmdbRegex)?.[1]; - newMedia.tmdbId = Number(tmdbMatch); + try { + if (plexitem.guid.match(plexRegex)) { + const metadata = await this.plexClient.getMetadata(plexitem.ratingKey); + const newMedia = new Media(); + + if (!metadata.Guid) { + logger.debug('No Guid metadata for this title. Skipping', { + label: 'Plex Sync', + ratingKey: plexitem.ratingKey, + }); + return; } - }); - - const existing = await this.getExisting(newMedia.tmdbId); - - if (existing && existing.status === MediaStatus.AVAILABLE) { - this.log(`Title exists and is already available ${metadata.title}`); - } else if (existing && existing.status !== MediaStatus.AVAILABLE) { - existing.status = MediaStatus.AVAILABLE; - mediaRepository.save(existing); - this.log( - `Request for ${metadata.title} exists. Setting status AVAILABLE`, - 'info' - ); - } else { - newMedia.status = MediaStatus.AVAILABLE; - newMedia.mediaType = MediaType.MOVIE; - await mediaRepository.save(newMedia); - this.log(`Saved ${plexitem.title}`); - } - } else { - const matchedid = plexitem.guid.match(/imdb:\/\/(tt[0-9]+)/); - if (matchedid?.[1]) { - const tmdbMovie = await this.tmdb.getMovieByImdbId({ - imdbId: matchedid[1], + metadata.Guid.forEach((ref) => { + if (ref.id.match(imdbRegex)) { + newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined; + } else if (ref.id.match(tmdbRegex)) { + const tmdbMatch = ref.id.match(tmdbRegex)?.[1]; + newMedia.tmdbId = Number(tmdbMatch); + } }); - const existing = await this.getExisting(tmdbMovie.id); + const existing = await this.getExisting(newMedia.tmdbId); + if (existing && existing.status === MediaStatus.AVAILABLE) { - this.log(`Title exists and is already available ${plexitem.title}`); + this.log(`Title exists and is already available ${metadata.title}`); } else if (existing && existing.status !== MediaStatus.AVAILABLE) { existing.status = MediaStatus.AVAILABLE; - await mediaRepository.save(existing); + mediaRepository.save(existing); this.log( - `Request for ${plexitem.title} exists. Setting status AVAILABLE`, + `Request for ${metadata.title} exists. Setting status AVAILABLE`, 'info' ); - } else if (tmdbMovie) { - const newMedia = new Media(); - newMedia.imdbId = tmdbMovie.external_ids.imdb_id; - newMedia.tmdbId = tmdbMovie.id; + } else { newMedia.status = MediaStatus.AVAILABLE; newMedia.mediaType = MediaType.MOVIE; await mediaRepository.save(newMedia); - this.log(`Saved ${tmdbMovie.title}`); + this.log(`Saved ${plexitem.title}`); + } + } else { + const matchedid = plexitem.guid.match(/imdb:\/\/(tt[0-9]+)/); + + if (matchedid?.[1]) { + const tmdbMovie = await this.tmdb.getMovieByImdbId({ + imdbId: matchedid[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}`); + } } } + } catch (e) { + this.log( + `Failed to process plex item. ratingKey: ${ + plexitem.parentRatingKey ?? plexitem.ratingKey + }`, + 'error' + ); } } diff --git a/server/migration/1607928251245-DropImdbIdConstraint.ts b/server/migration/1607928251245-DropImdbIdConstraint.ts new file mode 100644 index 000000000..97baa861a --- /dev/null +++ b/server/migration/1607928251245-DropImdbIdConstraint.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner, TableUnique } from 'typeorm'; + +export class DropImdbIdConstraint1607928251245 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.dropUniqueConstraint( + 'media', + 'UQ_7ff2d11f6a83cb52386eaebe74b' + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.createUniqueConstraint( + 'media', + new TableUnique({ + name: 'UQ_7ff2d11f6a83cb52386eaebe74b', + columnNames: ['imdbId'], + }) + ); + } +} From 0658b7943e1ab25816db9da34d4c9ea808d9203d Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 14 Dec 2020 08:14:08 +0000 Subject: [PATCH 2/6] fix(services): radarr/sonarr will use the correct default server --- server/entity/MediaRequest.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index b00ddd9c8..dc20cad74 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -234,8 +234,19 @@ export class MediaRequest { return; } + const radarrSettings = settings.radarr.find( + (radarr) => radarr.isDefault && !radarr.is4k + ); + + if (!radarrSettings) { + logger.info( + 'There is no default radarr configured. Did you set any of your Radarr servers as default?', + { label: 'Media Request' } + ); + return; + } + const tmdb = new TheMovieDb(); - const radarrSettings = settings.radarr[0]; const radarr = new RadarrAPI({ apiKey: radarrSettings.apiKey, url: `${radarrSettings.useSsl ? 'https' : 'http'}://${ @@ -283,6 +294,18 @@ export class MediaRequest { return; } + const sonarrSettings = settings.sonarr.find( + (sonarr) => sonarr.isDefault && !sonarr.is4k + ); + + if (!sonarrSettings) { + logger.info( + 'There is no default sonarr configured. Did you set any of your Sonarr servers as default?', + { label: 'Media Request' } + ); + return; + } + const media = await mediaRepository.findOne({ where: { id: this.media.id }, relations: ['requests'], @@ -293,7 +316,6 @@ export class MediaRequest { } const tmdb = new TheMovieDb(); - const sonarrSettings = settings.sonarr[0]; const sonarr = new SonarrAPI({ apiKey: sonarrSettings.apiKey, url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${ From 6c1ee830a183f89bb1fe96a181a7d61684e23b22 Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 14 Dec 2020 08:17:38 +0000 Subject: [PATCH 3/6] fix(services): improve logging for adding movies to Radarr --- server/api/radarr.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/api/radarr.ts b/server/api/radarr.ts index e75e7c521..fe22e72c9 100644 --- a/server/api/radarr.ts +++ b/server/api/radarr.ts @@ -1,4 +1,5 @@ import Axios, { AxiosInstance } from 'axios'; +import logger from '../logger'; interface RadarrMovieOptions { title: string; @@ -96,6 +97,11 @@ class RadarrAPI { 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}`); } }; From 2098a2d3d2981fd2ae54392aec3ef81327f2858e Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 14 Dec 2020 09:00:10 +0000 Subject: [PATCH 4/6] fix(frontend): converts email smtp port to a number before posting to the api - also adds toast notifications to both email/discord notifications pages to know when sucesses or failures occur re #251 --- .../Notifications/NotificationsDiscord.tsx | 13 ++++++++++++- .../Settings/Notifications/NotificationsEmail.tsx | 15 +++++++++++++-- src/i18n/locale/en.json | 4 ++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/components/Settings/Notifications/NotificationsDiscord.tsx b/src/components/Settings/Notifications/NotificationsDiscord.tsx index 4eae75a6a..2f70269ce 100644 --- a/src/components/Settings/Notifications/NotificationsDiscord.tsx +++ b/src/components/Settings/Notifications/NotificationsDiscord.tsx @@ -6,6 +6,7 @@ import Button from '../../Common/Button'; import { defineMessages, useIntl } from 'react-intl'; import Axios from 'axios'; import * as Yup from 'yup'; +import { useToasts } from 'react-toast-notifications'; const messages = defineMessages({ save: 'Save Changes', @@ -14,10 +15,13 @@ const messages = defineMessages({ webhookUrl: 'Webhook URL', validationWebhookUrlRequired: 'You must provide a webhook URL', webhookUrlPlaceholder: 'Server Settings -> Integrations -> Webhooks', + discordsettingssaved: 'Discord notification settings saved!', + discordsettingsfailed: 'Discord notification settings failed to save.', }); const NotificationsDiscord: React.FC = () => { const intl = useIntl(); + const { addToast } = useToasts(); const { data, error, revalidate } = useSWR( '/api/v1/settings/notifications/discord' ); @@ -49,8 +53,15 @@ const NotificationsDiscord: React.FC = () => { webhookUrl: values.webhookUrl, }, }); + addToast(intl.formatMessage(messages.discordsettingssaved), { + appearance: 'success', + autoDismiss: true, + }); } catch (e) { - // TODO show error + addToast(intl.formatMessage(messages.discordsettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); } finally { revalidate(); } diff --git a/src/components/Settings/Notifications/NotificationsEmail.tsx b/src/components/Settings/Notifications/NotificationsEmail.tsx index 02cf0d917..fa23f245a 100644 --- a/src/components/Settings/Notifications/NotificationsEmail.tsx +++ b/src/components/Settings/Notifications/NotificationsEmail.tsx @@ -6,6 +6,7 @@ import Button from '../../Common/Button'; import { defineMessages, useIntl } from 'react-intl'; import Axios from 'axios'; import * as Yup from 'yup'; +import { useToasts } from 'react-toast-notifications'; const messages = defineMessages({ save: 'Save Changes', @@ -20,10 +21,13 @@ const messages = defineMessages({ enableSsl: 'Enable SSL', authUser: 'Auth User', authPass: 'Auth Pass', + emailsettingssaved: 'Email notification settings saved!', + emailsettingsfailed: 'Email notification settings failed to save.', }); const NotificationsEmail: React.FC = () => { const intl = useIntl(); + const { addToast } = useToasts(); const { data, error, revalidate } = useSWR( '/api/v1/settings/notifications/email' ); @@ -65,14 +69,21 @@ const NotificationsEmail: React.FC = () => { options: { emailFrom: values.emailFrom, smtpHost: values.smtpHost, - smtpPort: values.smtpPort, + smtpPort: Number(values.smtpPort), secure: values.secure, authUser: values.authUser, authPass: values.authPass, }, }); + addToast(intl.formatMessage(messages.emailsettingssaved), { + appearance: 'success', + autoDismiss: true, + }); } catch (e) { - // TODO show error + addToast(intl.formatMessage(messages.emailsettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); } finally { revalidate(); } diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 88da4d24e..a9d4c7aed 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -91,7 +91,11 @@ "components.Settings.Notifications.agentenabled": "Agent Enabled", "components.Settings.Notifications.authPass": "Auth Pass", "components.Settings.Notifications.authUser": "Auth User", + "components.Settings.Notifications.discordsettingsfailed": "Discord notification settings failed to save.", + "components.Settings.Notifications.discordsettingssaved": "Discord notification settings saved!", "components.Settings.Notifications.emailsender": "Email Sender Address", + "components.Settings.Notifications.emailsettingsfailed": "Email notification settings failed to save.", + "components.Settings.Notifications.emailsettingssaved": "Email notification settings saved!", "components.Settings.Notifications.enableSsl": "Enable SSL", "components.Settings.Notifications.save": "Save Changes", "components.Settings.Notifications.saving": "Saving...", From 8cb05c413a15a4b74e37ece5e24367d115995b32 Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 14 Dec 2020 09:13:59 +0000 Subject: [PATCH 5/6] fix(frontend): convert plex port to a number before posting to the api --- src/components/Settings/SettingsPlex.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 87d1fe39a..d9d97c5d8 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -78,7 +78,7 @@ const SettingsPlex: React.FC = ({ onComplete }) => { try { await axios.post('/api/v1/settings/plex', { ip: values.hostname, - port: values.port, + port: Number(values.port), } as PlexSettings); revalidate(); From 15013d6c5dbff15704c7c30d261d68a265e7f2d7 Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 14 Dec 2020 09:38:38 +0000 Subject: [PATCH 6/6] fix(frontend): encode special characters in search input to prevent crashing router re #252 --- src/hooks/useSearchInput.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/hooks/useSearchInput.ts b/src/hooks/useSearchInput.ts index 61df92e0a..b761928dd 100644 --- a/src/hooks/useSearchInput.ts +++ b/src/hooks/useSearchInput.ts @@ -7,6 +7,10 @@ import type { Nullable } from '../utils/typeHelpers'; type Url = string | UrlObject; +const encodeURIExtraParams = (string: string): string => { + return encodeURIComponent(string).replace(/!/g, '%21'); +}; + interface SearchObject { searchValue: string; searchOpen: boolean; @@ -35,14 +39,17 @@ const useSearchInput = (): SearchObject => { if (router.pathname.startsWith('/search')) { router.replace({ pathname: router.pathname, - query: { ...router.query, query: debouncedValue }, + query: { + ...router.query, + query: encodeURIExtraParams(debouncedValue), + }, }); } else { setLastRoute(router.asPath); router .push({ pathname: '/search', - query: { query: debouncedValue }, + query: { query: encodeURIExtraParams(debouncedValue) }, }) .then(() => window.scrollTo(0, 0)); } @@ -85,8 +92,12 @@ const useSearchInput = (): SearchObject => { * is on /search */ useEffect(() => { - if (router.query.query !== debouncedValue) { - setSearchValue((router.query.query as string) ?? ''); + if (router.query.query !== encodeURIExtraParams(debouncedValue)) { + setSearchValue( + router.query.query + ? decodeURIComponent(router.query.query as string) + : '' + ); if (!router.pathname.startsWith('/search') && !router.query.query) { setIsOpen(false);