Compare commits

...

2 Commits

Author SHA1 Message Date
0xsysr3ll
fd33336a39 fix(api): description should be consistent
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-10-09 19:34:19 +02:00
0xsysr3ll
7756ec89e1 feat(requests): allow admins to bypass user quota limits
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-10-09 19:16:53 +02:00
10 changed files with 129 additions and 6 deletions

View File

@@ -1263,6 +1263,10 @@ components:
type: number
rootFolder:
type: string
ignoreQuota:
type: boolean
example: false
description: If true, this request will not count against the user's quota. Requires MANAGE_REQUESTS permission.
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

View File

@@ -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<MediaRequest>) {
Object.assign(this, init);
}

View File

@@ -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')

View File

@@ -22,4 +22,5 @@ export type MediaRequestBody = {
languageProfileId?: number;
userId?: number;
tags?: number[];
ignoreQuota?: boolean;
};

View File

@@ -0,0 +1,19 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIgnoreQuotaToMediaRequest1760028688313
implements MigrationInterface
{
name = 'AddIgnoreQuotaToMediaRequest1760028688313';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "media_request" ADD "ignoreQuota" boolean NOT NULL DEFAULT false`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "media_request" DROP COLUMN "ignoreQuota"`
);
}
}

View File

@@ -0,0 +1,33 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIgnoreQuotaToMediaRequest1760028688313
implements MigrationInterface
{
name = 'AddIgnoreQuotaToMediaRequest1760028688313';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

View File

@@ -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<boolean>(
defaultOverrides?.ignoreQuota ?? false
);
const { data: serverData, isValidating } =
useSWR<ServiceCommonServerWithDetails>(
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 = ({
/>
</div>
)}
{currentHasPermission([Permission.MANAGE_REQUESTS]) &&
quota &&
((type === 'movie' && quota.movie.limit && quota.movie.limit > 0) ||
(type === 'tv' && quota.tv.limit && quota.tv.limit > 0)) && (
<div className="mb-2">
<label htmlFor="ignoreQuota">
{intl.formatMessage(messages.ignoreQuotaTitle)}
</label>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-400">
{intl.formatMessage(messages.ignoreQuotaDescription)}
</p>
<SlideCheckbox
checked={ignoreQuota}
onClick={() => setIgnoreQuota(!ignoreQuota)}
/>
</div>
</div>
)}
{currentHasPermission([
Permission.MANAGE_REQUESTS,
Permission.MANAGE_USERS,

View File

@@ -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 = ({
<AdvancedRequester
type="movie"
is4k={is4k}
quota={quota}
onChange={(overrides) => {
setRequestOverrides(overrides);
}}

View File

@@ -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={

View File

@@ -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",