diff --git a/docs/using-seerr/settings/general.md b/docs/using-seerr/settings/general.md index 65b337495f..bcdca21f26 100644 --- a/docs/using-seerr/settings/general.md +++ b/docs/using-seerr/settings/general.md @@ -65,6 +65,14 @@ Available media will still appear in search results, however, so it is possible This setting is **disabled** by default. +## Hide Requested Media + +When enabled, movies and series with at least one pending or approved request will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages. This includes partially requested series (e.g. when only some seasons have been requested) and 4K requests. Media with only declined, failed, or completed requests is not hidden. + +Requested media will still appear in search results, however, so it is possible to locate and view hidden items by searching for them by title. + +This setting is **disabled** by default. + ## Hide Blocklisted Items When enabled, media that has been blocklisted will not appear on the "Discover" home page, for all administrators. This can be useful to hide content that you don't want to see, such as content with specific tags or content that has been manually blocklisted when you have the "Manage Blocklist" permission. diff --git a/seerr-api.yml b/seerr-api.yml index cd0012b679..f0e5601137 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -232,6 +232,9 @@ components: hideAvailable: type: boolean example: false + hideRequested: + type: boolean + example: false partialRequestsEnabled: type: boolean example: false @@ -1296,6 +1299,11 @@ components: readOnly: true items: $ref: '#/components/schemas/MediaRequest' + hasActiveRequest: + type: boolean + readOnly: true + nullable: true + description: Only populated when the "Hide Requested Media" setting is enabled. True if at least one request is pending or approved. createdAt: type: string example: '2020-09-12T10:00:27.000Z' diff --git a/server/entity/Media.ts b/server/entity/Media.ts index a63003df66..6ad0499198 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -1,6 +1,10 @@ import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; -import { MediaStatus, MediaType } from '@server/constants/media'; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; import { Blocklist } from '@server/entity/Blocklist'; @@ -53,9 +57,35 @@ class Media { .where(' media.tmdbId in (:...finalIds)', { finalIds }) .getMany(); - return media.filter((m) => + const relatedMedia = media.filter((m) => items.some((i) => i.tmdbId === m.tmdbId && i.mediaType === m.mediaType) ); + + // Only fetch active request state when it is actually used for + // filtering, so the default configuration adds no extra query. Only + // media IDs are selected; no request or user data leaves the server. + if (getSettings().main.hideRequested && relatedMedia.length > 0) { + const activeRequestMediaIds = await mediaRepository + .createQueryBuilder('media') + .select('media.id', 'id') + .distinct(true) + .innerJoin('media.requests', 'request') + .where('media.id IN (:...mediaIds)', { + mediaIds: relatedMedia.map((m) => m.id), + }) + .andWhere('request.status IN (:...statuses)', { + statuses: [MediaRequestStatus.PENDING, MediaRequestStatus.APPROVED], + }) + .getRawMany<{ id: number }>(); + + const activeIds = new Set(activeRequestMediaIds.map((row) => row.id)); + + relatedMedia.forEach((m) => { + m.hasActiveRequest = activeIds.has(m.id); + }); + } + + return relatedMedia; } catch (e) { logger.error(e.message); return []; @@ -187,6 +217,12 @@ class Media { public serviceUrl?: string; public serviceUrl4k?: string; + /** + * Transient flag set by `getRelatedMedia` when the "Hide Requested Media" + * setting is enabled: true if at least one request (any season, standard or + * 4K) is pending or approved. + */ + public hasActiveRequest?: boolean; public downloadStatus?: DownloadingItem[] = []; public downloadStatus4k?: DownloadingItem[] = []; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 3dfa619205..c044683b7f 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -31,6 +31,7 @@ export interface PublicSettingsResponse { applicationUrl: string; hideAvailable: boolean; hideBlocklisted: boolean; + hideRequested: boolean; localLogin: boolean; mediaServerLogin: boolean; movie4kEnabled: boolean; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 010c43d81d..b85571d651 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -141,6 +141,7 @@ export interface MainSettings { }; hideAvailable: boolean; hideBlocklisted: boolean; + hideRequested: boolean; localLogin: boolean; mediaServerLogin: boolean; newPlexLogin: boolean; @@ -193,6 +194,7 @@ interface FullPublicSettings extends PublicSettings { applicationUrl: string; hideAvailable: boolean; hideBlocklisted: boolean; + hideRequested: boolean; localLogin: boolean; mediaServerLogin: boolean; movie4kEnabled: boolean; @@ -414,6 +416,7 @@ class Settings { }, hideAvailable: false, hideBlocklisted: false, + hideRequested: false, localLogin: true, mediaServerLogin: true, newPlexLogin: true, @@ -709,6 +712,7 @@ class Settings { applicationUrl: this.data.main.applicationUrl, hideAvailable: this.data.main.hideAvailable, hideBlocklisted: this.data.main.hideBlocklisted, + hideRequested: this.data.main.hideRequested, localLogin: this.data.main.localLogin, mediaServerLogin: this.data.main.mediaServerLogin, jellyfinExternalHost: this.data.jellyfin.externalHostname, diff --git a/server/test/entity/Media.test.ts b/server/test/entity/Media.test.ts new file mode 100644 index 0000000000..0c99318e92 --- /dev/null +++ b/server/test/entity/Media.test.ts @@ -0,0 +1,209 @@ +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { MediaRequest } from '@server/entity/MediaRequest'; +import SeasonRequest from '@server/entity/SeasonRequest'; +import { User } from '@server/entity/User'; +import { getSettings } from '@server/lib/settings'; +import { setupTestDb } from '@server/test/db'; +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +// Prevent the AfterInsert/AfterUpdate hooks on MediaRequest from reaching +// out to TMDB / notification agents while seeding requests. +Object.defineProperty(MediaRequest, 'sendNotification', { + value: async () => undefined, + writable: true, + configurable: true, +}); + +setupTestDb(); + +async function seedMedia( + tmdbId: number, + mediaType: MediaType, + status: MediaStatus = MediaStatus.UNKNOWN +): Promise { + const mediaRepository = getRepository(Media); + return mediaRepository.save( + new Media({ + tmdbId, + mediaType, + status, + status4k: MediaStatus.UNKNOWN, + }) + ); +} + +async function seedRequest( + media: Media, + status: MediaRequestStatus, + { is4k = false, seasons = [] as number[] } = {} +): Promise { + const userRepository = getRepository(User); + const admin = await userRepository.findOneOrFail({ where: { id: 1 } }); + + const requestRepository = getRepository(MediaRequest); + return requestRepository.save( + new MediaRequest({ + media, + requestedBy: admin, + status, + type: media.mediaType, + is4k, + seasons: seasons.map( + (seasonNumber) => new SeasonRequest({ seasonNumber, status }) + ), + }) + ); +} + +async function getRelatedSingle(media: Media): Promise { + const results = await Media.getRelatedMedia(undefined, [ + { tmdbId: media.tmdbId, mediaType: media.mediaType }, + ]); + + assert.strictEqual(results.length, 1); + return results[0]; +} + +describe('Media.getRelatedMedia active request state', () => { + afterEach(() => { + getSettings().main.hideRequested = false; + }); + + it('does not compute hasActiveRequest when hideRequested is disabled', async () => { + const movie = await seedMedia(100, MediaType.MOVIE); + await seedRequest(movie, MediaRequestStatus.PENDING); + + const related = await getRelatedSingle(movie); + + assert.strictEqual(related.hasActiveRequest, undefined); + }); + + it('flags media with a PENDING request', async () => { + getSettings().main.hideRequested = true; + + const movie = await seedMedia(100, MediaType.MOVIE); + await seedRequest(movie, MediaRequestStatus.PENDING); + + const related = await getRelatedSingle(movie); + + assert.strictEqual(related.hasActiveRequest, true); + }); + + it('flags media with an APPROVED request', async () => { + getSettings().main.hideRequested = true; + + const movie = await seedMedia(100, MediaType.MOVIE); + await seedRequest(movie, MediaRequestStatus.APPROVED); + + const related = await getRelatedSingle(movie); + + assert.strictEqual(related.hasActiveRequest, true); + }); + + it('does not flag media with only DECLINED, FAILED, or COMPLETED requests', async () => { + getSettings().main.hideRequested = true; + + const movie = await seedMedia(100, MediaType.MOVIE); + await seedRequest(movie, MediaRequestStatus.DECLINED); + await seedRequest(movie, MediaRequestStatus.FAILED); + await seedRequest(movie, MediaRequestStatus.COMPLETED); + + const related = await getRelatedSingle(movie); + + assert.strictEqual(related.hasActiveRequest, false); + }); + + it('does not flag media without any requests', async () => { + getSettings().main.hideRequested = true; + + const movie = await seedMedia(100, MediaType.MOVIE); + + const related = await getRelatedSingle(movie); + + assert.strictEqual(related.hasActiveRequest, false); + }); + + it('flags a 4K-only request', async () => { + getSettings().main.hideRequested = true; + + const movie = await seedMedia(100, MediaType.MOVIE); + await seedRequest(movie, MediaRequestStatus.PENDING, { is4k: true }); + + const related = await getRelatedSingle(movie); + + assert.strictEqual(related.hasActiveRequest, true); + }); + + it('flags a partially available series with an active season request', async () => { + getSettings().main.hideRequested = true; + + const tv = await seedMedia( + 200, + MediaType.TV, + MediaStatus.PARTIALLY_AVAILABLE + ); + await seedRequest(tv, MediaRequestStatus.COMPLETED, { seasons: [1] }); + await seedRequest(tv, MediaRequestStatus.PENDING, { seasons: [2] }); + + const related = await getRelatedSingle(tv); + + assert.strictEqual(related.status, MediaStatus.PARTIALLY_AVAILABLE); + assert.strictEqual(related.hasActiveRequest, true); + }); + + it('does not flag a partially available series without active requests', async () => { + getSettings().main.hideRequested = true; + + const tv = await seedMedia( + 200, + MediaType.TV, + MediaStatus.PARTIALLY_AVAILABLE + ); + await seedRequest(tv, MediaRequestStatus.COMPLETED, { seasons: [1] }); + + const related = await getRelatedSingle(tv); + + assert.strictEqual(related.hasActiveRequest, false); + }); + + it('handles multiple media in a single batch', async () => { + getSettings().main.hideRequested = true; + + const requested = await seedMedia(100, MediaType.MOVIE); + const unrequested = await seedMedia(101, MediaType.MOVIE); + await seedRequest(requested, MediaRequestStatus.PENDING); + + const results = await Media.getRelatedMedia(undefined, [ + { tmdbId: requested.tmdbId, mediaType: MediaType.MOVIE }, + { tmdbId: unrequested.tmdbId, mediaType: MediaType.MOVIE }, + ]); + + assert.strictEqual(results.length, 2); + const byTmdbId = new Map(results.map((m) => [m.tmdbId, m])); + assert.strictEqual(byTmdbId.get(100)?.hasActiveRequest, true); + assert.strictEqual(byTmdbId.get(101)?.hasActiveRequest, false); + }); + + it('does not expose request or user data in the result', async () => { + getSettings().main.hideRequested = true; + + const movie = await seedMedia(100, MediaType.MOVIE); + await seedRequest(movie, MediaRequestStatus.PENDING); + + const related = await getRelatedSingle(movie); + + assert.strictEqual(related.requests, undefined); + + const serialized = JSON.stringify(related); + assert.ok(!serialized.includes('"requests"')); + assert.ok(!serialized.includes('"requestedBy"')); + assert.ok(!serialized.includes('"modifiedBy"')); + }); +}); diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 4418c12314..66d8c67707 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -4,6 +4,7 @@ import Slider from '@app/components/Slider'; import TitleCard from '@app/components/TitleCard'; import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; +import { filterVisibleTitles } from '@app/utils/mediaFilters'; import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; import { MediaStatus } from '@server/constants/media'; import { Permission } from '@server/lib/permissions'; @@ -65,22 +66,11 @@ const MediaSlider = ({ [] as (MovieResult | TvResult | PersonResult)[] ); - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - (i.mediaType === 'movie' || i.mediaType === 'tv') && - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - if (settings.currentSettings.hideBlocklisted) { - titles = titles.filter( - (i) => - (i.mediaType === 'movie' || i.mediaType === 'tv') && - i.mediaInfo?.status !== MediaStatus.BLOCKLISTED - ); - } + titles = filterVisibleTitles(titles, { + hideAvailable: settings.currentSettings.hideAvailable, + hideBlocklisted: settings.currentSettings.hideBlocklisted, + hideRequested: settings.currentSettings.hideRequested, + }); useEffect(() => { if ( diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index e5a54180bb..bf0a83317d 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -34,7 +34,7 @@ const Search = () => { { query: router.query.query, }, - { hideAvailable: false, hideBlocklisted: false } + { hideAvailable: false, hideBlocklisted: false, hideRequested: false } ); if (error) { diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index 00703ddd37..857cc07f06 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -62,6 +62,9 @@ const messages = defineMessages('components.Settings.SettingsMain', { hideAvailable: 'Hide Available Media', hideAvailableTip: 'Hide available media from the discover pages but not search results', + hideRequested: 'Hide Requested Media', + hideRequestedTip: + 'Hide media with pending or approved requests, including partially requested series, from the discover pages but not search results', cacheImages: 'Enable Image Caching', cacheImagesTip: 'Cache externally sourced images (requires a significant amount of disk space)', @@ -171,6 +174,7 @@ const SettingsMain = () => { applicationUrl: data?.applicationUrl, hideAvailable: data?.hideAvailable, hideBlocklisted: data?.hideBlocklisted, + hideRequested: data?.hideRequested, locale: data?.locale ?? 'en', discoverRegion: data?.discoverRegion, originalLanguage: data?.originalLanguage, @@ -193,6 +197,7 @@ const SettingsMain = () => { applicationUrl: values.applicationUrl, hideAvailable: values.hideAvailable, hideBlocklisted: values.hideBlocklisted, + hideRequested: values.hideRequested, locale: values.locale, discoverRegion: values.discoverRegion, streamingRegion: values.streamingRegion, @@ -515,6 +520,27 @@ const SettingsMain = () => { /> +
+ +
+ { + setFieldValue('hideRequested', !values.hideRequested); + }} + /> +
+