Skip to content

Commit

Permalink
feat(scanners): remove requests of unmonitored movies/seasons during …
Browse files Browse the repository at this point in the history
…scan

When a movie / show - which was monitored before - is unmonitored, it won't appear as "requested" in
Jellyseerr anymore, if removeUnmonitoredEnabled option is set to true

re Fallenbagel#695
  • Loading branch information
bonswouar committed Dec 8, 2024
1 parent 89831f7 commit caaf13e
Show file tree
Hide file tree
Showing 11 changed files with 131 additions and 11 deletions.
1 change: 1 addition & 0 deletions cypress/config/settings.cypress.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"trustProxy": false,
"mediaServerType": 1,
"partialRequestsEnabled": true,
"removeUnmonitoredEnabled": false,
"locale": "en"
},
"plex": {
Expand Down
3 changes: 3 additions & 0 deletions overseerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ components:
partialRequestsEnabled:
type: boolean
example: false
removeUnmonitoredEnabled:
type: boolean
example: false
localLogin:
type: boolean
example: true
Expand Down
1 change: 1 addition & 0 deletions server/interfaces/api/settingsInterfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface PublicSettingsResponse {
originalLanguage: string;
mediaServerType: number;
partialRequestsEnabled: boolean;
removeUnmonitoredEnabled: boolean;
cacheImages: boolean;
vapidPublic: string;
enablePushRegistration: boolean;
Expand Down
71 changes: 68 additions & 3 deletions server/lib/scanners/baseScanner.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { SonarrSeason } from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import Season from '@server/entity/Season';
import SeasonRequest from '@server/entity/SeasonRequest';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock';
Expand Down Expand Up @@ -48,6 +51,7 @@ export interface ProcessableSeason {
episodes4k: number;
is4kOverride?: boolean;
processing?: boolean;
monitored?: boolean;
}

class BaseScanner<T> {
Expand Down Expand Up @@ -211,7 +215,7 @@ class BaseScanner<T> {

/**
* processShow takes a TMDB ID and an array of ProcessableSeasons, which
* should include the total episodes a sesaon has + the total available
* should include the total episodes a season has + the total available
* episodes that each season currently has. Unlike processMovie, this method
* does not take an `is4k` option. We handle both the 4k _and_ non 4k status
* in one method.
Expand All @@ -234,6 +238,7 @@ class BaseScanner<T> {
}: ProcessOptions = {}
): Promise<void> {
const mediaRepository = getRepository(Media);
const settings = getSettings();

await this.asyncLock.dispatch(tmdbId, async () => {
const media = await this.getExisting(tmdbId, MediaType.TV);
Expand Down Expand Up @@ -277,25 +282,35 @@ class BaseScanner<T> {
// force it to stay available (to avoid competing scanners)
existingSeason.status =
(season.totalEpisodes === season.episodes && season.episodes > 0) ||
existingSeason.status === MediaStatus.AVAILABLE
(existingSeason.status === MediaStatus.AVAILABLE &&
season.episodes > 0)
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: settings.main.removeUnmonitoredEnabled &&
!season.monitored &&
season.episodes == 0
? MediaStatus.UNKNOWN
: existingSeason.status;

// Same thing here, except we only do updates if 4k is enabled
existingSeason.status4k =
(this.enable4kShow &&
season.episodes4k === season.totalEpisodes &&
season.episodes4k > 0) ||
existingSeason.status4k === MediaStatus.AVAILABLE
(existingSeason.status4k === MediaStatus.AVAILABLE &&
season.episodes > 0)
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: settings.main.removeUnmonitoredEnabled &&
!season.monitored &&
season.episodes4k == 0
? MediaStatus.UNKNOWN
: existingSeason.status4k;
} else {
newSeasons.push(
Expand Down Expand Up @@ -618,6 +633,56 @@ class BaseScanner<T> {
get protectedBundleSize(): number {
return this.bundleSize;
}

protected async processUnmonitoredMovie(tmdbId: number): Promise<void> {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(tmdbId, async () => {
const existing = await this.getExisting(tmdbId, MediaType.MOVIE);
if (existing && existing.status === MediaStatus.PROCESSING) {
existing.status = MediaStatus.UNKNOWN;
await mediaRepository.save(existing);
this.log(
`Movie TMDB ID ${tmdbId} unmonitored from Radarr. Media status set to UNKNOWN.`,
'info'
);
}
});
}

protected async processUnmonitoredSeason(
tmdbId: number,
season: SonarrSeason
): Promise<void> {
// Remove unmonitored seasons from Requests
const requestRepository = getRepository(MediaRequest);
const seasonRequestRepository = getRepository(SeasonRequest);

const existingRequests = await requestRepository
.createQueryBuilder('request')
.innerJoinAndSelect('request.media', 'media')
.innerJoinAndSelect('request.seasons', 'seasons')
.where('media.tmdbId = :tmdbId', { tmdbId: tmdbId })
.andWhere('media.mediaType = :mediaType', {
mediaType: MediaType.TV,
})
.andWhere('seasons.seasonNumber = :seasonNumber', {
seasonNumber: season.seasonNumber,
})
.getMany();

if (existingRequests && existingRequests.length > 0) {
for (const existingRequest of existingRequests) {
for (const requestedSeason of existingRequest.seasons) {
if (requestedSeason.seasonNumber === season.seasonNumber) {
this.log(
`Removing request for Season ${season.seasonNumber} of tmdbId ${tmdbId} as it is unmonitored`
);
await seasonRequestRepository.remove(requestedSeason);
}
}
}
}
}
}

export default BaseScanner;
15 changes: 7 additions & 8 deletions server/lib/scanners/radarr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,13 @@ class RadarrScanner
}

private async processRadarrMovie(radarrMovie: RadarrMovie): Promise<void> {
if (!radarrMovie.monitored && !radarrMovie.hasFile) {
this.log(
'Title is unmonitored and has not been downloaded. Skipping item.',
'debug',
{
title: radarrMovie.title,
}
);
const settings = getSettings();
if (
settings.main.removeUnmonitoredEnabled &&
!radarrMovie.monitored &&
!radarrMovie.hasFile
) {
this.processUnmonitoredMovie(radarrMovie.tmdbId);
return;
}

Expand Down
10 changes: 10 additions & 0 deletions server/lib/scanners/sonarr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class SonarrScanner
private async processSonarrSeries(sonarrSeries: SonarrSeries) {
try {
const mediaRepository = getRepository(Media);
const settings = getSettings();
const server4k = this.enable4kShow && this.currentServer.is4k;
const processableSeasons: ProcessableSeason[] = [];
let tvShow: TmdbTvDetails;
Expand All @@ -110,13 +111,22 @@ class SonarrScanner
for (const season of filteredSeasons) {
const totalAvailableEpisodes = season.statistics?.episodeFileCount ?? 0;

if (
settings.main.removeUnmonitoredEnabled &&
season.monitored === false &&
totalAvailableEpisodes === 0
) {
this.processUnmonitoredSeason(tmdbId, season);
}

processableSeasons.push({
seasonNumber: season.seasonNumber,
episodes: !server4k ? totalAvailableEpisodes : 0,
episodes4k: server4k ? totalAvailableEpisodes : 0,
totalEpisodes: season.statistics?.totalEpisodeCount ?? 0,
processing: season.monitored && totalAvailableEpisodes === 0,
is4kOverride: server4k,
monitored: season.monitored,
});
}

Expand Down
4 changes: 4 additions & 0 deletions server/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export interface MainSettings {
trustProxy: boolean;
mediaServerType: number;
partialRequestsEnabled: boolean;
removeUnmonitoredEnabled: boolean;
locale: string;
proxy: ProxySettings;
}
Expand All @@ -153,6 +154,7 @@ interface FullPublicSettings extends PublicSettings {
jellyfinForgotPasswordUrl?: string;
jellyfinServerName?: string;
partialRequestsEnabled: boolean;
removeUnmonitoredEnabled: boolean;
cacheImages: boolean;
vapidPublic: string;
enablePushRegistration: boolean;
Expand Down Expand Up @@ -341,6 +343,7 @@ class Settings {
trustProxy: false,
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
removeUnmonitoredEnabled: false,
locale: 'en',
proxy: {
enabled: false,
Expand Down Expand Up @@ -584,6 +587,7 @@ class Settings {
originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType,
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
removeUnmonitoredEnabled: this.data.main.removeUnmonitoredEnabled,
cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic,
enablePushRegistration: this.data.notifications.agents.webpush.enabled,
Expand Down
34 changes: 34 additions & 0 deletions src/components/Settings/SettingsMain/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ const messages = defineMessages('components.Settings.SettingsMain', {
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
partialRequestsEnabled: 'Allow Partial Series Requests',
removeUnmonitoredEnabled: 'Remove Unmonitored Media',
removeUnmonitoredExplanation:
'Remove Movies/Seasons from Jellyseerr that are not available and have been un-monitored since',
locale: 'Display Language',
proxyEnabled: 'HTTP(S) Proxy',
proxyHostname: 'Proxy Hostname',
Expand Down Expand Up @@ -158,6 +161,7 @@ const SettingsMain = () => {
originalLanguage: data?.originalLanguage,
streamingRegion: data?.streamingRegion,
partialRequestsEnabled: data?.partialRequestsEnabled,
removeUnmonitoredEnabled: data?.removeUnmonitoredEnabled,
trustProxy: data?.trustProxy,
cacheImages: data?.cacheImages,
proxyEnabled: data?.proxy?.enabled,
Expand Down Expand Up @@ -188,6 +192,7 @@ const SettingsMain = () => {
streamingRegion: values.streamingRegion,
originalLanguage: values.originalLanguage,
partialRequestsEnabled: values.partialRequestsEnabled,
removeUnmonitoredEnabled: values.removeUnmonitoredEnabled,
trustProxy: values.trustProxy,
cacheImages: values.cacheImages,
proxy: {
Expand Down Expand Up @@ -498,6 +503,35 @@ const SettingsMain = () => {
/>
</div>
</div>
<div className="form-row">
<label
htmlFor="removeUnmonitoredEnabled"
className="checkbox-label"
>
<span className="mr-2">
{intl.formatMessage(messages.removeUnmonitoredEnabled)}
</span>
<SettingsBadge badgeType="experimental" />
<span className="label-tip">
{intl.formatMessage(
messages.removeUnmonitoredExplanation
)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="removeUnmonitoredEnabled"
name="removeUnmonitoredEnabled"
onChange={() => {
setFieldValue(
'removeUnmonitoredEnabled',
!values.removeUnmonitoredEnabled
);
}}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="proxyEnabled" className="checkbox-label">
<span className="mr-2">
Expand Down
1 change: 1 addition & 0 deletions src/context/SettingsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const defaultSettings = {
originalLanguage: '',
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
removeUnmonitoredEnabled: false,
cacheImages: false,
vapidPublic: '',
enablePushRegistration: false,
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,7 @@
"components.Settings.SettingsMain.proxyUser": "Proxy Username",
"components.Settings.SettingsMain.streamingRegion": "Streaming Region",
"components.Settings.SettingsMain.streamingRegionTip": "Show streaming sites by regional availability",
"components.Settings.SettingsMain.removeUnmonitoredFromRequestsEnabled": "Remove Request for Movies/Seasons that have been un-monitored since",
"components.Settings.SettingsMain.toastApiKeyFailure": "Something went wrong while generating a new API key.",
"components.Settings.SettingsMain.toastApiKeySuccess": "New API key generated successfully!",
"components.Settings.SettingsMain.toastSettingsFailure": "Something went wrong while saving settings.",
Expand Down
1 change: 1 addition & 0 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ CoreApp.getInitialProps = async (initialProps) => {
originalLanguage: '',
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
removeUnmonitoredEnabled: false,
cacheImages: false,
vapidPublic: '',
enablePushRegistration: false,
Expand Down

0 comments on commit caaf13e

Please sign in to comment.