Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions server/api/plexapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface PlexLibraryItem {
guid: string;
parentGuid?: string;
grandparentGuid?: string;
editionTitle?: string;
addedAt: number;
updatedAt: number;
Guid?: {
Expand Down Expand Up @@ -53,6 +54,7 @@ export interface PlexMetadata {
guid: string;
type: 'movie' | 'show' | 'season';
title: string;
editionTitle?: string;
Guid: {
id: string;
}[];
Expand Down
7 changes: 7 additions & 0 deletions server/lib/availabilitySync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -878,6 +879,12 @@ class AvailabilitySync {
}
}

if (plexMedia && media.mediaType === 'movie') {
if (isIgnoredEdition(plexMedia.editionTitle)) {
plexMedia = undefined;
}
}

if (plexMedia) {
existsInPlex = true;
}
Expand Down
53 changes: 41 additions & 12 deletions server/lib/scanners/plex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]+)/);
Expand Down Expand Up @@ -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(
Expand All @@ -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'
);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
});
}
Comment on lines +395 to 407
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "index.ts" -path "*/scanners/plex/*" | head -1

Repository: seerr-team/seerr

Length of output: 95


🏁 Script executed:

find . -name "baseScanner.ts" -path "*/scanners/*" | head -1

Repository: seerr-team/seerr

Length of output: 96


🏁 Script executed:

wc -l server/lib/scanners/plex/index.ts

Repository: seerr-team/seerr

Length of output: 97


🏁 Script executed:

sed -n '390,415p' server/lib/scanners/plex/index.ts

Repository: seerr-team/seerr

Length of output: 835


🏁 Script executed:

wc -l server/lib/scanners/baseScanner.ts

Repository: seerr-team/seerr

Length of output: 98


🏁 Script executed:

sed -n '240,310p' server/lib/scanners/baseScanner.ts

Repository: seerr-team/seerr

Length of output: 2265


🏁 Script executed:

sed -n '1,50p' server/lib/scanners/baseScanner.ts

Repository: seerr-team/seerr

Length of output: 1274


🏁 Script executed:

sed -n '310,390p' server/lib/scanners/baseScanner.ts

Repository: seerr-team/seerr

Length of output: 3360


🏁 Script executed:

rg "getExisting" server/lib/scanners/baseScanner.ts -A 5 -B 2

Repository: seerr-team/seerr

Length of output: 807


🏁 Script executed:

rg "availability" server/lib/scanners/ -i --max-count=20

Repository: seerr-team/seerr

Length of output: 239


🏁 Script executed:

sed -n '376,420p' server/lib/scanners/baseScanner.ts

Repository: seerr-team/seerr

Length of output: 1544


🏁 Script executed:

sed -n '370,390p' server/lib/scanners/plex/index.ts

Repository: seerr-team/seerr

Length of output: 710


🏁 Script executed:

rg "processShow|processMedia" server/lib/scanners/plex/index.ts -B 5 -A 2

Repository: seerr-team/seerr

Length of output: 335


🏁 Script executed:

sed -n '410,460p' server/lib/scanners/baseScanner.ts

Repository: seerr-team/seerr

Length of output: 1652


🏁 Script executed:

rg "mediaRepository.save|mediaRepository.update" server/lib/scanners/baseScanner.ts -B 2 -A 2

Repository: seerr-team/seerr

Length of output: 712


🏁 Script executed:

sed -n '350,410p' server/lib/scanners/plex/index.ts

Repository: seerr-team/seerr

Length of output: 1956


🏁 Script executed:

rg "ignored|filter|delete.*season|availability.*sync" server/lib/scanners/ -i -B 2 -A 2 | head -50

Repository: seerr-team/seerr

Length of output: 3072


🏁 Script executed:

grep -rn "MediaStatus.UNKNOWN\|MediaStatus.DELETED" server/lib/scanners/baseScanner.ts | head -20

Repository: seerr-team/seerr

Length of output: 1136


🏁 Script executed:

sed -n '455,480p' server/lib/scanners/baseScanner.ts

Repository: seerr-team/seerr

Length of output: 1159


🏁 Script executed:

rg "MediaStatus.UNKNOWN|refresh|cleanup|sync.*availability" server/lib -i -B 2 -A 2 | head -60

Repository: seerr-team/seerr

Length of output: 3025


🏁 Script executed:

rg "this\.getExisting" server/lib/scanners/ -B 2 -A 2

Repository: seerr-team/seerr

Length of output: 727


🏁 Script executed:

sed -n '1,100p' server/lib/scanners/plex/index.ts | head -50

Repository: seerr-team/seerr

Length of output: 1946


🏁 Script executed:

grep -A 20 "class PlexScanner" server/lib/scanners/plex/index.ts

Repository: seerr-team/seerr

Length of output: 637


🏁 Script executed:

rg "availability-sync" server/lib -B 5 -A 10

Repository: seerr-team/seerr

Length of output: 1807


🏁 Script executed:

grep -n "private\|protected\|public" server/lib/scanners/baseScanner.ts | grep -i "getexisting\|processshow"

Repository: seerr-team/seerr

Length of output: 165


🏁 Script executed:

find server -name "*availability*" -o -name "*sync*" | grep -i availability

Repository: seerr-team/seerr

Length of output: 90


🏁 Script executed:

rg "availability.sync|availabilitySync" server/ -B 5 -A 15

Repository: seerr-team/seerr

Length of output: 10499


🏁 Script executed:

grep -rn "MediaStatus.UNKNOWN" server/lib/scanners/baseScanner.ts | grep -i "season\|status ="

Repository: seerr-team/seerr

Length of output: 42


🏁 Script executed:

wc -l server/lib/availabilitySync.ts

Repository: seerr-team/seerr

Length of output: 95


🏁 Script executed:

sed -n '1,100p' server/lib/availabilitySync.ts

Repository: seerr-team/seerr

Length of output: 3779


🏁 Script executed:

rg "MediaStatus\." server/lib/availabilitySync.ts -B 2 -A 2

Repository: seerr-team/seerr

Length of output: 4020


🏁 Script executed:

sed -n '200,350p' server/lib/availabilitySync.ts | grep -A 30 "showExists"

Repository: seerr-team/seerr

Length of output: 3798


🏁 Script executed:

sed -n '300,450p' server/lib/availabilitySync.ts

Repository: seerr-team/seerr

Length of output: 4859


🏁 Script executed:

sed -n '600,700p' server/lib/availabilitySync.ts | head -50

Repository: seerr-team/seerr

Length of output: 1423


🏁 Script executed:

rg "mediaExistsInPlex" server/lib/availabilitySync.ts -A 20 | head -40

Repository: seerr-team/seerr

Length of output: 1546


🏁 Script executed:

git log --oneline -p server/lib/scanners/plex/index.ts | grep -B 20 -A 5 "hasAnyEpisodes" | head -50

Repository: seerr-team/seerr

Length of output: 1043


🏁 Script executed:

rg "processShow" server/lib/scanners/ -B 3 -A 1 | head -60

Repository: seerr-team/seerr

Length of output: 1798


Risk: Skipping processShow when all episodes are filtered leaves stale TV availability in the database.

When a show already exists in the database marked as AVAILABLE and a subsequent scan finds hasAnyEpisodes === false (all episodes filtered by the ignored titles setting), processShow is skipped entirely. This means the show's season statuses and overall availability status are never updated in the database. Since processShow is where season availability is recalculated and media.status is refreshed (lines 455–495 in baseScanner.ts), skipping it allows stale AVAILABLE/PARTIALLY_AVAILABLE records to persist indefinitely.

The availability-sync job (scheduled daily) does not address this scenario—it only marks shows as DELETED if they're genuinely missing from the external source, not if all episodes are filtered by user settings.

Note: The Sonarr and Jellyfin scanners do not have this guard and always call processShow, creating inconsistent behavior across scanners.

Recommended action: Consider updating the show if it already exists in the database, even when hasAnyEpisodes === false, so that season and media statuses reflect the current filtered state. The getExisting method is protected in BaseScanner; confirm the preferred approach within this codebase's patterns.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/lib/scanners/plex/index.ts` around lines 395 - 407, The current guard
skips calling processShow when hasAnyEpisodes is false, which leaves existing
media availability stale; always invoke processShow for the show when it already
exists so season availability and media.status are recalculated even if all
episodes were filtered. Specifically, remove or bypass the hasAnyEpisodes-only
condition around the call to this.processShow (referencing mediaIds.tmdbId,
tvdbId, hasAnyEpisodes) and ensure you check for an existing show via the
BaseScanner.getExisting pattern (or call getExisting first) and then call
this.processShow with the same payload ({ mediaAddedAt: new
Date(metadata.addedAt * 1000), ratingKey, title: metadata.title }) so the DB
seasons/status are updated even when no episodes survive filtering. Ensure you
preserve creating new shows only when appropriate and follow existing scanner
semantics used by Sonarr/Jellyfin.

}

Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions server/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export interface PlexSettings {
useSsl?: boolean;
libraries: Library[];
webAppUrl?: string;
ignoredEditions: string[];
ignoredEpisodeTitles: string[];
ignoredEpisodeFilterMode: 'season' | 'seasonAndEpisode' | 'any';
}

export interface JellyfinSettings {
Expand Down Expand Up @@ -414,6 +417,9 @@ class Settings {
port: 32400,
useSsl: false,
libraries: [],
ignoredEditions: [],
ignoredEpisodeTitles: [],
ignoredEpisodeFilterMode: 'season',
},
jellyfin: {
name: '',
Expand Down
34 changes: 34 additions & 0 deletions server/utils/plexFilter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
163 changes: 163 additions & 0 deletions src/components/Settings/SettingsPlex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -67,6 +73,23 @@ const messages = defineMessages('components.Settings', {
webAppUrl: '<WebAppLink>Web App</WebAppLink> 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.',
Expand Down Expand Up @@ -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}
Expand All @@ -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();
Expand Down Expand Up @@ -601,6 +640,130 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
)}
</div>
</div>
<div className="mt-6">
<h3 className="heading">
{intl.formatMessage(messages.contentFiltering)}
</h3>
<p className="description">
{intl.formatMessage(messages.contentFilteringDescription)}
</p>
</div>
<div className="form-row">
<label htmlFor="ignoredEditions" className="text-label">
{intl.formatMessage(messages.ignoredEditions)}
<SettingsBadge badgeType="advanced" className="ml-2" />
<span className="label-tip">
{intl.formatMessage(messages.ignoredEditionsTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field relative">
<CreatableSelect<TagOption, true>
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)
);
}}
/>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="ignoredEpisodeTitles" className="text-label">
{intl.formatMessage(messages.ignoredEpisodeTitles)}
<SettingsBadge badgeType="advanced" className="ml-2" />
<span className="label-tip">
{intl.formatMessage(messages.ignoredEpisodeTitlesTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field relative">
<CreatableSelect<TagOption, true>
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)
);
}}
/>
</div>
</div>
</div>
<div className="form-row">
<label
htmlFor="ignoredEpisodeFilterMode"
className="text-label"
>
{intl.formatMessage(messages.ignoredEpisodeFilterMode)}
<SettingsBadge badgeType="advanced" className="ml-2" />
<span className="label-tip">
{intl.formatMessage(messages.ignoredEpisodeFilterModeTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<select
id="ignoredEpisodeFilterMode"
name="ignoredEpisodeFilterMode"
disabled={!values.ignoredEpisodeTitles?.length}
value={values.ignoredEpisodeFilterMode}
onChange={(e) => {
setFieldValue(
'ignoredEpisodeFilterMode',
e.target.value
);
}}
>
<option value="season">
{intl.formatMessage(
messages.ignoredEpisodeFilterModeSeason
)}
</option>
<option value="seasonAndEpisode">
{intl.formatMessage(
messages.ignoredEpisodeFilterModeSeasonAndEpisode
)}
</option>
<option value="any">
{intl.formatMessage(
messages.ignoredEpisodeFilterModeAny
)}
</option>
</select>
</div>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
Expand Down
Loading
Loading