From faeed44cb94a7394d56522720b522bcc5f79326b Mon Sep 17 00:00:00 2001 From: Ruben Date: Sat, 28 Feb 2026 14:42:09 +0800 Subject: [PATCH 1/5] feat(plex): add ignored editions filter for Plex movie scanning --- server/api/plexapi.ts | 2 + server/lib/availabilitySync.ts | 14 +++++++ server/lib/scanners/plex/index.ts | 23 ++++++++++++ server/lib/settings/index.ts | 2 + src/components/Settings/SettingsPlex.tsx | 48 ++++++++++++++++++++++++ src/i18n/locale/en.json | 3 ++ 6 files changed, 92 insertions(+) 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..50e2bb46cc 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -878,6 +878,20 @@ class AvailabilitySync { } } + if (plexMedia && media.mediaType === 'movie') { + const ignoredEditions = getSettings().plex.ignoredEditions ?? []; + if ( + plexMedia.editionTitle && + ignoredEditions.some( + (e) => + plexMedia!.editionTitle!.toLowerCase().trim() === + e.toLowerCase().trim() + ) + ) { + plexMedia = undefined; + } + } + if (plexMedia) { existsInPlex = true; } diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index b464008e89..63b8122298 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -224,7 +224,23 @@ class PlexScanner } } + private isIgnoredEdition(editionTitle?: string): boolean { + if (!editionTitle) return false; + + const ignoredEditions = getSettings().plex.ignoredEditions ?? []; + return ignoredEditions.some( + (e) => editionTitle.toLowerCase().trim() === e.toLowerCase().trim() + ); + } + private async processPlexMovie(plexitem: PlexLibraryItem) { + if (this.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 +259,13 @@ class PlexScanner plexitem: PlexMetadata, tmdbId: number ) { + if (this.isIgnoredEdition(plexitem.editionTitle)) { + this.log( + `Skipping movie ${plexitem.title} with ignored edition: ${plexitem.editionTitle}` + ); + return; + } + const has4k = plexitem.Media.some( (media) => media.videoResolution === '4k' ); diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 7057cf2b01..b442ee72dd 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -35,6 +35,7 @@ export interface PlexSettings { useSsl?: boolean; libraries: Library[]; webAppUrl?: string; + ignoredEditions: string[]; } export interface JellyfinSettings { @@ -414,6 +415,7 @@ class Settings { port: 32400, useSsl: false, libraries: [], + ignoredEditions: [], }, jellyfin: { name: '', diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 0cb1c11987..455677e4aa 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 EditionOption = { + label: string; + value: string; +}; + const messages = defineMessages('components.Settings', { plex: 'Plex', plexsettings: 'Plex Settings', @@ -67,6 +73,10 @@ 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', + 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…', 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 +385,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { useSsl: data?.useSsl, selectedPreset: undefined, webAppUrl: data?.webAppUrl, + ignoredEditions: data?.ignoredEditions ?? [], }} validationSchema={PlexSettingsSchema} validateOnMount={true} @@ -396,6 +407,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { port: Number(values.port), useSsl: values.useSsl, webAppUrl: values.webAppUrl, + ignoredEditions: values.ignoredEditions, } as PlexSettings); syncLibraries(); @@ -601,6 +613,42 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { )} +
+ +
+
+ + 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) + ); + }} + /> +
+
+
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 06ad3fecbd..24ca6c2791 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1152,6 +1152,9 @@ "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.invalidKeyword": "{keywordId} is not a TMDB keyword.", "components.Settings.invalidurlerror": "Unable to connect to {mediaServerName} server.", "components.Settings.is4k": "4K", From ded7150ba8cb2c821ec8e388b39d4cf4fdcc2839 Mon Sep 17 00:00:00 2001 From: Ruben Date: Sat, 28 Feb 2026 17:42:51 +0800 Subject: [PATCH 2/5] feat(plex): add ignored episode titles filter and extract shared helpers --- server/lib/availabilitySync.ts | 11 +-- server/lib/scanners/plex/index.ts | 67 +++++++++++++---- server/lib/settings/index.ts | 4 + src/components/Settings/SettingsPlex.tsx | 95 +++++++++++++++++++++++- src/i18n/locale/en.json | 8 ++ 5 files changed, 161 insertions(+), 24 deletions(-) diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 50e2bb46cc..fc1078bf19 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -12,6 +12,7 @@ import Media from '@server/entity/Media'; import MediaRequest from '@server/entity/MediaRequest'; import type Season from '@server/entity/Season'; import { User } from '@server/entity/User'; +import { isIgnoredEdition } from '@server/lib/scanners/plex'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -879,15 +880,7 @@ class AvailabilitySync { } if (plexMedia && media.mediaType === 'movie') { - const ignoredEditions = getSettings().plex.ignoredEditions ?? []; - if ( - plexMedia.editionTitle && - ignoredEditions.some( - (e) => - plexMedia!.editionTitle!.toLowerCase().trim() === - e.toLowerCase().trim() - ) - ) { + if (isIgnoredEdition(plexMedia.editionTitle)) { plexMedia = undefined; } } diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index 63b8122298..4205798bbd 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -33,6 +33,39 @@ const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/); const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/); const HAMA_AGENT = 'com.plexapp.agents.hama'; +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; +} + type SyncStatus = StatusBase & { currentLibrary: Library; libraries: Library[]; @@ -224,17 +257,8 @@ class PlexScanner } } - private isIgnoredEdition(editionTitle?: string): boolean { - if (!editionTitle) return false; - - const ignoredEditions = getSettings().plex.ignoredEditions ?? []; - return ignoredEditions.some( - (e) => editionTitle.toLowerCase().trim() === e.toLowerCase().trim() - ); - } - private async processPlexMovie(plexitem: PlexLibraryItem) { - if (this.isIgnoredEdition(plexitem.editionTitle)) { + if (isIgnoredEdition(plexitem.editionTitle)) { this.log( `Skipping movie ${plexitem.title} with ignored edition: ${plexitem.editionTitle}` ); @@ -259,7 +283,7 @@ class PlexScanner plexitem: PlexMetadata, tmdbId: number ) { - if (this.isIgnoredEdition(plexitem.editionTitle)) { + if (isIgnoredEdition(plexitem.editionTitle)) { this.log( `Skipping movie ${plexitem.title} with ignored edition: ${plexitem.editionTitle}` ); @@ -357,9 +381,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 @@ -390,7 +424,11 @@ class PlexScanner } } - if (mediaIds.tvdbId) { + const hasAnyEpisodes = processableSeasons.some( + (s) => s.episodes > 0 || s.episodes4k > 0 + ); + + if (mediaIds.tvdbId && hasAnyEpisodes) { await this.processShow( mediaIds.tmdbId, mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id, @@ -600,6 +638,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 b442ee72dd..00e5df4707 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -36,6 +36,8 @@ export interface PlexSettings { libraries: Library[]; webAppUrl?: string; ignoredEditions: string[]; + ignoredEpisodeTitles: string[]; + ignoredEpisodeFilterMode: 'season' | 'seasonAndEpisode' | 'any'; } export interface JellyfinSettings { @@ -416,6 +418,8 @@ class Settings { useSsl: false, libraries: [], ignoredEditions: [], + ignoredEpisodeTitles: [], + ignoredEpisodeFilterMode: 'season', }, jellyfin: { name: '', diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 455677e4aa..1a1d656db8 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -27,7 +27,7 @@ import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import * as Yup from 'yup'; -type EditionOption = { +type TagOption = { label: string; value: string; }; @@ -77,6 +77,16 @@ const messages = defineMessages('components.Settings', { 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 how strictly episode titles are matched when filtering', + 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.', @@ -386,6 +396,9 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { selectedPreset: undefined, webAppUrl: data?.webAppUrl, ignoredEditions: data?.ignoredEditions ?? [], + ignoredEpisodeTitles: data?.ignoredEpisodeTitles ?? [], + ignoredEpisodeFilterMode: + data?.ignoredEpisodeFilterMode ?? 'season', }} validationSchema={PlexSettingsSchema} validateOnMount={true} @@ -408,6 +421,8 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { useSsl: values.useSsl, webAppUrl: values.webAppUrl, ignoredEditions: values.ignoredEditions, + ignoredEpisodeTitles: values.ignoredEpisodeTitles, + ignoredEpisodeFilterMode: values.ignoredEpisodeFilterMode, } as PlexSettings); syncLibraries(); @@ -623,7 +638,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
- + components={{ DropdownIndicator: null, }} @@ -649,6 +664,82 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
+
+ +
+
+ + 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 24ca6c2791..5a970a9dcc 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1155,6 +1155,14 @@ "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.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.ignoredEpisodeFilterMode": "Episode Filter Mode", + "components.Settings.ignoredEpisodeFilterModeTip": "Controls how strictly episode titles are matched when filtering", + "components.Settings.ignoredEpisodeFilterModeSeason": "Season 0", + "components.Settings.ignoredEpisodeFilterModeSeasonAndEpisode": "Season 0 + Episode 0", + "components.Settings.ignoredEpisodeFilterModeAny": "Any Season or Episode", "components.Settings.invalidKeyword": "{keywordId} is not a TMDB keyword.", "components.Settings.invalidurlerror": "Unable to connect to {mediaServerName} server.", "components.Settings.is4k": "4K", From 3e371d5ea5d122332bfebb2cf6eb9351a1e9ebc0 Mon Sep 17 00:00:00 2001 From: Ruben Date: Sat, 28 Feb 2026 17:55:17 +0800 Subject: [PATCH 3/5] feat(plex): add content filtering section heading to Plex settings --- src/components/Settings/SettingsPlex.tsx | 11 +++++++++++ src/i18n/locale/en.json | 2 ++ 2 files changed, 13 insertions(+) diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 1a1d656db8..4dec9a854d 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -73,6 +73,9 @@ 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)', @@ -628,6 +631,14 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { )}
+
+

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

+

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

+
-