Skip to content
Closed
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
8 changes: 8 additions & 0 deletions docs/using-seerr/settings/general.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions seerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ components:
hideAvailable:
type: boolean
example: false
hideRequested:
type: boolean
example: false
partialRequestsEnabled:
type: boolean
example: false
Expand Down Expand Up @@ -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'
Expand Down
40 changes: 38 additions & 2 deletions server/entity/Media.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 [];
Expand Down Expand Up @@ -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[] = [];

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 @@ -31,6 +31,7 @@ export interface PublicSettingsResponse {
applicationUrl: string;
hideAvailable: boolean;
hideBlocklisted: boolean;
hideRequested: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
movie4kEnabled: boolean;
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 @@ -141,6 +141,7 @@ export interface MainSettings {
};
hideAvailable: boolean;
hideBlocklisted: boolean;
hideRequested: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
newPlexLogin: boolean;
Expand Down Expand Up @@ -193,6 +194,7 @@ interface FullPublicSettings extends PublicSettings {
applicationUrl: string;
hideAvailable: boolean;
hideBlocklisted: boolean;
hideRequested: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
movie4kEnabled: boolean;
Expand Down Expand Up @@ -414,6 +416,7 @@ class Settings {
},
hideAvailable: false,
hideBlocklisted: false,
hideRequested: false,
localLogin: true,
mediaServerLogin: true,
newPlexLogin: true,
Expand Down Expand Up @@ -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,
Expand Down
209 changes: 209 additions & 0 deletions server/test/entity/Media.test.ts
Original file line number Diff line number Diff line change
@@ -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<Media> {
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<MediaRequest> {
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<Media> {
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"'));
});
});
22 changes: 6 additions & 16 deletions src/components/MediaSlider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const Search = () => {
{
query: router.query.query,
},
{ hideAvailable: false, hideBlocklisted: false }
{ hideAvailable: false, hideBlocklisted: false, hideRequested: false }
);

if (error) {
Expand Down
Loading