diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 617203447f..d49a92e031 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -18,6 +18,7 @@ export interface PlexLibraryItem { guid: string; parentGuid?: string; grandparentGuid?: string; + editionTitle?: string; addedAt: number; updatedAt: number; Guid?: { @@ -53,6 +54,7 @@ export interface PlexMetadata { guid: string; type: 'movie' | 'show' | 'season'; title: string; + editionTitle?: string; Guid: { id: string; }[]; diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index c6c09cb553..f8ab5e159a 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -16,6 +16,7 @@ import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { getHostname } from '@server/utils/getHostname'; +import { isIgnoredEdition } from '@server/utils/plexFilter'; class AvailabilitySync { public running = false; @@ -878,6 +879,12 @@ class AvailabilitySync { } } + if (plexMedia && media.mediaType === 'movie') { + if (isIgnoredEdition(plexMedia.editionTitle)) { + plexMedia = undefined; + } + } + if (plexMedia) { existsInPlex = true; } diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index b464008e89..530421e1d5 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -20,6 +20,7 @@ import type { import BaseScanner from '@server/lib/scanners/baseScanner'; import type { Library } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; +import { isIgnoredEdition, isIgnoredEpisode } from '@server/utils/plexFilter'; import { uniqWith } from 'lodash'; const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/); @@ -225,6 +226,13 @@ class PlexScanner } private async processPlexMovie(plexitem: PlexLibraryItem) { + if (isIgnoredEdition(plexitem.editionTitle)) { + this.log( + `Skipping movie ${plexitem.title} with ignored edition: ${plexitem.editionTitle}` + ); + return; + } + const mediaIds = await this.getMediaIds(plexitem); const has4k = plexitem.Media.some( @@ -243,6 +251,13 @@ class PlexScanner plexitem: PlexMetadata, tmdbId: number ) { + if (isIgnoredEdition(plexitem.editionTitle)) { + this.log( + `Skipping movie ${plexitem.title} with ignored edition: ${plexitem.editionTitle}` + ); + return; + } + const has4k = plexitem.Media.some( (media) => media.videoResolution === '4k' ); @@ -334,9 +349,19 @@ class PlexScanner if (matchedPlexSeason) { // If we have a matched Plex season, get its children metadata so we can check details - const episodes = await this.plexClient.getChildrenMetadata( + const allEpisodes = await this.plexClient.getChildrenMetadata( matchedPlexSeason.ratingKey ); + + const episodes = allEpisodes.filter( + (episode) => + !isIgnoredEpisode( + episode.title, + season.season_number, + episode.index + ) + ); + // Total episodes that are in standard definition (not 4k) const totalStandard = episodes.filter((episode) => !this.enable4kShow @@ -367,17 +392,18 @@ class PlexScanner } } - if (mediaIds.tvdbId) { - await this.processShow( - mediaIds.tmdbId, - mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id, - processableSeasons, - { - mediaAddedAt: new Date(metadata.addedAt * 1000), - ratingKey: ratingKey, - title: metadata.title, - } - ); + const hasAnyEpisodes = processableSeasons.some( + (s) => s.episodes > 0 || s.episodes4k > 0 + ); + + const tvdbId = mediaIds.tvdbId ?? tvShow.external_ids?.tvdb_id; + + if (tvdbId && hasAnyEpisodes) { + await this.processShow(mediaIds.tmdbId, tvdbId, processableSeasons, { + mediaAddedAt: new Date(metadata.addedAt * 1000), + ratingKey: ratingKey, + title: metadata.title, + }); } } @@ -577,6 +603,9 @@ class PlexScanner ); if (episodes) { for (const episode of episodes) { + if (isIgnoredEpisode(episode.title, 0, episode.index)) { + continue; + } const special = animeList.getSpecialEpisode(tvdbId, episode.index); if (special) { if (special.tmdbId) { diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 7057cf2b01..00e5df4707 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -35,6 +35,9 @@ export interface PlexSettings { useSsl?: boolean; libraries: Library[]; webAppUrl?: string; + ignoredEditions: string[]; + ignoredEpisodeTitles: string[]; + ignoredEpisodeFilterMode: 'season' | 'seasonAndEpisode' | 'any'; } export interface JellyfinSettings { @@ -414,6 +417,9 @@ class Settings { port: 32400, useSsl: false, libraries: [], + ignoredEditions: [], + ignoredEpisodeTitles: [], + ignoredEpisodeFilterMode: 'season', }, jellyfin: { name: '', diff --git a/server/utils/plexFilter.ts b/server/utils/plexFilter.ts new file mode 100644 index 0000000000..4e14c4da06 --- /dev/null +++ b/server/utils/plexFilter.ts @@ -0,0 +1,34 @@ +import { getSettings } from '@server/lib/settings'; + +export function isIgnoredEdition(editionTitle?: string): boolean { + if (!editionTitle) return false; + + const ignoredEditions = getSettings().plex.ignoredEditions ?? []; + return ignoredEditions.some( + (e) => editionTitle.toLowerCase().trim() === e.toLowerCase().trim() + ); +} + +export function isIgnoredEpisode( + episodeTitle: string | undefined, + seasonNumber: number, + episodeNumber: number +): boolean { + const settings = getSettings(); + const ignoredTitles = settings.plex.ignoredEpisodeTitles ?? []; + const filterMode = settings.plex.ignoredEpisodeFilterMode ?? 'season'; + + if (ignoredTitles.length === 0) return false; + + const titleMatch = ignoredTitles.some( + (t) => episodeTitle?.toLowerCase().trim() === t.toLowerCase().trim() + ); + if (!titleMatch) return false; + + if (filterMode === 'any') return true; + if (filterMode === 'season') return seasonNumber === 0; + if (filterMode === 'seasonAndEpisode') + return seasonNumber === 0 && episodeNumber === 0; + + return false; +} diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 0cb1c11987..e6e6d1d76b 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -22,10 +22,16 @@ import { Field, Formik } from 'formik'; import { orderBy } from 'lodash'; import { useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; +import CreatableSelect from 'react-select/creatable'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import * as Yup from 'yup'; +type TagOption = { + label: string; + value: string; +}; + const messages = defineMessages('components.Settings', { plex: 'Plex', plexsettings: 'Plex Settings', @@ -67,6 +73,23 @@ const messages = defineMessages('components.Settings', { webAppUrl: 'Web App URL', webAppUrlTip: 'Optionally direct users to the web app on your server instead of the "hosted" web app', + contentFiltering: 'Content Filtering', + contentFilteringDescription: + 'Configure rules to exclude specific media from being detected during Plex library scans.', + ignoredEditions: 'Ignored Editions', + ignoredEditionsTip: + 'Movies with these Plex edition tags will be ignored during library scans and not marked as available (e.g., Trailer)', + ignoredEditionsPlaceholder: 'Type an edition name and press Enter…', + ignoredEpisodeTitles: 'Ignored Episode Titles', + ignoredEpisodeTitlesTip: + 'TV episodes with these titles will be ignored during Plex library scans (used to filter placeholders like trailers)', + ignoredEpisodeTitlesPlaceholder: 'Type an episode title and press Enter…', + ignoredEpisodeFilterMode: 'Episode Filter Mode', + ignoredEpisodeFilterModeTip: + 'Controls which episodes are filtered when their title matches the ignored list above', + ignoredEpisodeFilterModeSeason: 'Season 0', + ignoredEpisodeFilterModeSeasonAndEpisode: 'Season 0 + Episode 0', + ignoredEpisodeFilterModeAny: 'Any Season or Episode', tautulliSettings: 'Tautulli Settings', tautulliSettingsDescription: 'Optionally configure the settings for your Tautulli server. Seerr fetches watch history data for your Plex media from Tautulli.', @@ -375,6 +398,9 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { useSsl: data?.useSsl, selectedPreset: undefined, webAppUrl: data?.webAppUrl, + ignoredEditions: data?.ignoredEditions ?? [], + ignoredEpisodeTitles: data?.ignoredEpisodeTitles ?? [], + ignoredEpisodeFilterMode: data?.ignoredEpisodeFilterMode ?? 'season', }} validationSchema={PlexSettingsSchema} validateOnMount={true} @@ -391,11 +417,24 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { toastId = id; } ); + const dedupe = (arr: string[]) => [ + ...new Map(arr.map((s) => [s, s])).values(), + ]; + const normalize = (arr?: string[]) => + dedupe( + (arr ?? []) + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length > 0) + ); + await axios.post('/api/v1/settings/plex', { ip: values.hostname, port: Number(values.port), useSsl: values.useSsl, webAppUrl: values.webAppUrl, + ignoredEditions: normalize(values.ignoredEditions), + ignoredEpisodeTitles: normalize(values.ignoredEpisodeTitles), + ignoredEpisodeFilterMode: values.ignoredEpisodeFilterMode, } as PlexSettings); syncLibraries(); @@ -601,6 +640,130 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { )} +
+

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

+

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

+
+
+ +
+
+ + components={{ + DropdownIndicator: null, + }} + isClearable + isMulti + noOptionsMessage={() => null} + className="react-select-container" + classNamePrefix="react-select" + placeholder={intl.formatMessage( + messages.ignoredEditionsPlaceholder + )} + value={(values.ignoredEditions ?? []).map((e) => ({ + label: e, + value: e, + }))} + onChange={(newValue) => { + setFieldValue( + 'ignoredEditions', + newValue.map((v) => v.value) + ); + }} + /> +
+
+
+
+ +
+
+ + components={{ + DropdownIndicator: null, + }} + isClearable + isMulti + noOptionsMessage={() => null} + className="react-select-container" + classNamePrefix="react-select" + placeholder={intl.formatMessage( + messages.ignoredEpisodeTitlesPlaceholder + )} + value={(values.ignoredEpisodeTitles ?? []).map((e) => ({ + label: e, + value: e, + }))} + onChange={(newValue) => { + setFieldValue( + 'ignoredEpisodeTitles', + newValue.map((v) => v.value) + ); + }} + /> +
+
+
+
+ +
+
+ +
+
+
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 06ad3fecbd..6ecc42a534 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1135,6 +1135,8 @@ "components.Settings.clearBlocklistedTagsConfirm": "Are you sure you want to clear the blocklisted tags?", "components.Settings.clickTest": "Click on the \"Test\" button to check connectivity with metadata providers", "components.Settings.connectionTestFailed": "Connection test failed", + "components.Settings.contentFiltering": "Content Filtering", + "components.Settings.contentFilteringDescription": "Configure rules to exclude specific media from being detected during Plex library scans.", "components.Settings.copyBlocklistedTags": "Copied blocklisted tags to clipboard.", "components.Settings.copyBlocklistedTagsEmpty": "Nothing to copy", "components.Settings.copyBlocklistedTagsTip": "Copy blocklisted tag configuration", @@ -1152,6 +1154,17 @@ "components.Settings.general": "General", "components.Settings.hostname": "Hostname or IP Address", "components.Settings.importBlocklistedTagsTip": "Import blocklisted tag configuration", + "components.Settings.ignoredEditions": "Ignored Editions", + "components.Settings.ignoredEditionsPlaceholder": "Type an edition name and press Enter…", + "components.Settings.ignoredEditionsTip": "Movies with these Plex edition tags will be ignored during library scans and not marked as available (e.g., Trailer)", + "components.Settings.ignoredEpisodeFilterMode": "Episode Filter Mode", + "components.Settings.ignoredEpisodeFilterModeAny": "Any Season or Episode", + "components.Settings.ignoredEpisodeFilterModeSeason": "Season 0", + "components.Settings.ignoredEpisodeFilterModeSeasonAndEpisode": "Season 0 + Episode 0", + "components.Settings.ignoredEpisodeFilterModeTip": "Controls which episodes are filtered when their title matches the ignored list above", + "components.Settings.ignoredEpisodeTitles": "Ignored Episode Titles", + "components.Settings.ignoredEpisodeTitlesPlaceholder": "Type an episode title and press Enter…", + "components.Settings.ignoredEpisodeTitlesTip": "TV episodes with these titles will be ignored during Plex library scans (used to filter placeholders like trailers)", "components.Settings.invalidKeyword": "{keywordId} is not a TMDB keyword.", "components.Settings.invalidurlerror": "Unable to connect to {mediaServerName} server.", "components.Settings.is4k": "4K",