diff --git a/seerr-api.yml b/seerr-api.yml index 3b1d134f..d4f4c6a3 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -1263,6 +1263,10 @@ components: type: number rootFolder: type: string + ignoreQuota: + type: boolean + example: false + description: If true, this request does not count against the user's quota required: - id - status @@ -6139,6 +6143,10 @@ paths: userId: type: number nullable: true + ignoreQuota: + type: boolean + example: false + description: If true, this request will not count against the user's quota. Requires MANAGE_REQUESTS permission. required: - mediaType - mediaId diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index cdfa17c3..4034ca56 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -111,10 +111,20 @@ export class MediaRequest { const quotas = await requestUser.getQuota(); - if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) { - throw new QuotaRestrictedError('Movie Quota exceeded.'); - } else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) { - throw new QuotaRestrictedError('Series Quota exceeded.'); + const canBypassQuota = user.hasPermission([Permission.MANAGE_REQUESTS]); + + if (!canBypassQuota) { + if ( + requestBody.mediaType === MediaType.MOVIE && + quotas.movie.restricted + ) { + throw new QuotaRestrictedError('Movie Quota exceeded.'); + } else if ( + requestBody.mediaType === MediaType.TV && + quotas.tv.restricted + ) { + throw new QuotaRestrictedError('Series Quota exceeded.'); + } } const tmdbMedia = @@ -371,6 +381,7 @@ export class MediaRequest { rootFolder: rootFolder, tags: tags, isAutoRequest: options.isAutoRequest ?? false, + ignoreQuota: requestBody.ignoreQuota ?? false, }); await requestRepository.save(request); @@ -434,6 +445,7 @@ export class MediaRequest { if (finalSeasons.length === 0) { throw new NoSeasonsAvailableError('No seasons available to request'); } else if ( + !canBypassQuota && quotas.tv.limit && finalSeasons.length > (quotas.tv.remaining ?? 0) ) { @@ -502,6 +514,7 @@ export class MediaRequest { }) ), isAutoRequest: options.isAutoRequest ?? false, + ignoreQuota: requestBody.ignoreQuota ?? false, }); await requestRepository.save(request); @@ -606,6 +619,9 @@ export class MediaRequest { @Column({ default: false }) public isAutoRequest: boolean; + @Column({ default: false }) + public ignoreQuota: boolean; + constructor(init?: Partial) { Object.assign(this, init); } diff --git a/server/entity/User.ts b/server/entity/User.ts index 8a96f396..750c631c 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -290,6 +290,7 @@ export class User { createdAt: AfterDate(movieDate), type: MediaType.MOVIE, status: Not(MediaRequestStatus.DECLINED), + ignoreQuota: false, }, }) : 0; @@ -323,6 +324,9 @@ export class User { .andWhere('request.status != :declinedStatus', { declinedStatus: MediaRequestStatus.DECLINED, }) + .andWhere('request.ignoreQuota = :ignoreQuota', { + ignoreQuota: false, + }) .addSelect((subQuery) => { return subQuery .select('COUNT(season.id)', 'seasonCount') diff --git a/server/interfaces/api/requestInterfaces.ts b/server/interfaces/api/requestInterfaces.ts index 4a41ae99..9f7c0116 100644 --- a/server/interfaces/api/requestInterfaces.ts +++ b/server/interfaces/api/requestInterfaces.ts @@ -22,4 +22,5 @@ export type MediaRequestBody = { languageProfileId?: number; userId?: number; tags?: number[]; + ignoreQuota?: boolean; }; diff --git a/server/migration/postgres/1760028688313-AddIgnoreQuotaToMediaRequest.ts b/server/migration/postgres/1760028688313-AddIgnoreQuotaToMediaRequest.ts new file mode 100644 index 00000000..d0e6c310 --- /dev/null +++ b/server/migration/postgres/1760028688313-AddIgnoreQuotaToMediaRequest.ts @@ -0,0 +1,19 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddIgnoreQuotaToMediaRequest1760028688313 + implements MigrationInterface +{ + name = 'AddIgnoreQuotaToMediaRequest1760028688313'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" ADD "ignoreQuota" boolean NOT NULL DEFAULT false` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" DROP COLUMN "ignoreQuota"` + ); + } +} diff --git a/server/migration/sqlite/1760028688313-AddIgnoreQuotaToMediaRequest.ts b/server/migration/sqlite/1760028688313-AddIgnoreQuotaToMediaRequest.ts new file mode 100644 index 00000000..12bc17d9 --- /dev/null +++ b/server/migration/sqlite/1760028688313-AddIgnoreQuotaToMediaRequest.ts @@ -0,0 +1,33 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddIgnoreQuotaToMediaRequest1760028688313 + implements MigrationInterface +{ + name = 'AddIgnoreQuotaToMediaRequest1760028688313'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedBy" integer, "modifiedBy" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), "ignoreQuota" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_media_request_media" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_media_request_user" FOREIGN KEY ("requestedBy") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_media_request_user_2" FOREIGN KEY ("modifiedBy") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedBy", "modifiedBy", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedBy", "modifiedBy", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest" FROM "media_request"` + ); + await queryRunner.query(`DROP TABLE "media_request"`); + await queryRunner.query( + `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedBy" integer, "modifiedBy" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_media_request_media" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_media_request_user" FOREIGN KEY ("requestedBy") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_media_request_user_2" FOREIGN KEY ("modifiedBy") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedBy", "modifiedBy", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedBy", "modifiedBy", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + } +} diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index ad11db82..2be8d64d 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -1,6 +1,7 @@ /* eslint-disable react-hooks/exhaustive-deps */ import CachedImage from '@app/components/Common/CachedImage'; import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; +import SlideCheckbox from '@app/components/Common/SlideCheckbox'; import type { User } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -38,6 +39,9 @@ const messages = defineMessages('components.RequestModal.AdvancedRequester', { tags: 'Tags', selecttags: 'Select tags', notagoptions: 'No tags.', + ignoreQuotaTitle: 'Bypass User Quota', + ignoreQuotaDescription: + "This request will not count against the user's quota limits. Use with caution.", }); export type RequestOverrides = { @@ -47,6 +51,7 @@ export type RequestOverrides = { tags?: number[]; language?: number; user?: User; + ignoreQuota?: boolean; }; interface AdvancedRequesterProps { @@ -55,6 +60,7 @@ interface AdvancedRequesterProps { isAnime?: boolean; defaultOverrides?: RequestOverrides; requestUser?: User; + quota?: { movie: { limit?: number }; tv: { limit?: number } }; onChange: (overrides: RequestOverrides) => void; } @@ -64,6 +70,7 @@ const AdvancedRequester = ({ isAnime = false, defaultOverrides, requestUser, + quota, onChange, }: AdvancedRequesterProps) => { const intl = useIntl(); @@ -97,6 +104,10 @@ const AdvancedRequester = ({ defaultOverrides?.tags ?? [] ); + const [ignoreQuota, setIgnoreQuota] = useState( + defaultOverrides?.ignoreQuota ?? false + ); + const { data: serverData, isValidating } = useSWR( selectedServer !== null @@ -273,6 +284,7 @@ const AdvancedRequester = ({ user: selectedUser ?? undefined, language: selectedLanguage !== -1 ? selectedLanguage : undefined, tags: selectedTags, + ignoreQuota: ignoreQuota || undefined, }); } }, [ @@ -282,6 +294,7 @@ const AdvancedRequester = ({ selectedUser, selectedLanguage, selectedTags, + ignoreQuota, ]); if (!data && !error) { @@ -540,6 +553,25 @@ const AdvancedRequester = ({ /> )} + {currentHasPermission([Permission.MANAGE_REQUESTS]) && + quota && + ((type === 'movie' && quota.movie.limit && quota.movie.limit > 0) || + (type === 'tv' && quota.tv.limit && quota.tv.limit > 0)) && ( +
+ +
+

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

+ setIgnoreQuota(!ignoreQuota)} + /> +
+
+ )} {currentHasPermission([ Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS, diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index 134a937f..e314733e 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -94,6 +94,7 @@ const MovieRequestModal = ({ mediaId: data?.id, mediaType: 'movie', is4k, + ignoreQuota: requestOverrides?.ignoreQuota, ...overrideParams, }); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); @@ -320,7 +321,10 @@ const MovieRequestModal = ({ backgroundClickable onCancel={onCancel} onOk={sendRequest} - okDisabled={isUpdating || quota?.movie.restricted} + okDisabled={ + isUpdating || + (quota?.movie.restricted && !requestOverrides?.ignoreQuota) + } title={intl.formatMessage( is4k ? messages.requestmovie4ktitle : messages.requestmovietitle )} @@ -359,6 +363,7 @@ const MovieRequestModal = ({ { setRequestOverrides(overrides); }} diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 5d2249de..7c449a90 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -199,6 +199,7 @@ const TvRequestModal = ({ tvdbId: tvdbId ?? data?.externalIds.tvdbId, mediaType: 'tv', is4k, + ignoreQuota: requestOverrides?.ignoreQuota, seasons: settings.currentSettings.partialRequestsEnabled ? selectedSeasons.sort((a, b) => a - b) : getAllSeasons().filter( @@ -439,7 +440,8 @@ const TvRequestModal = ({ ? false : !settings.currentSettings.partialRequestsEnabled && quota?.tv.limit && - unrequestedSeasons.length > quota.tv.limit + unrequestedSeasons.length > quota.tv.limit && + !requestOverrides?.ignoreQuota ? true : getAllRequestedSeasons().length >= getAllSeasons().length || (settings.currentSettings.partialRequestsEnabled && @@ -726,6 +728,7 @@ const TvRequestModal = ({ isAnime={data?.keywords.some( (keyword) => keyword.id === ANIME_KEYWORD_ID )} + quota={quota} onChange={(overrides) => setRequestOverrides(overrides)} requestUser={editRequest?.requestedBy} defaultOverrides={ diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index c088143a..7c9cdd93 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -520,6 +520,8 @@ "components.RequestModal.AdvancedRequester.default": "{name} (Default)", "components.RequestModal.AdvancedRequester.destinationserver": "Destination Server", "components.RequestModal.AdvancedRequester.folder": "{path} ({space})", + "components.RequestModal.AdvancedRequester.ignoreQuotaDescription": "This request will not count against the user's quota limits. Use with caution.", + "components.RequestModal.AdvancedRequester.ignoreQuotaTitle": "Bypass User Quota", "components.RequestModal.AdvancedRequester.languageprofile": "Language Profile", "components.RequestModal.AdvancedRequester.notagoptions": "No tags.", "components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile",