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 type: number
rootFolder: rootFolder:
type: string 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: required:
- id - id
- status - status
@@ -6139,6 +6143,10 @@ paths:
userId: userId:
type: number type: number
nullable: true 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: required:
- mediaType - mediaType
- mediaId - mediaId

View File

@@ -111,10 +111,20 @@ export class MediaRequest {
const quotas = await requestUser.getQuota(); const quotas = await requestUser.getQuota();
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) { const canBypassQuota = user.hasPermission([Permission.MANAGE_REQUESTS]);
throw new QuotaRestrictedError('Movie Quota exceeded.');
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) { if (!canBypassQuota) {
throw new QuotaRestrictedError('Series Quota exceeded.'); 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 = const tmdbMedia =
@@ -371,6 +381,7 @@ export class MediaRequest {
rootFolder: rootFolder, rootFolder: rootFolder,
tags: tags, tags: tags,
isAutoRequest: options.isAutoRequest ?? false, isAutoRequest: options.isAutoRequest ?? false,
ignoreQuota: requestBody.ignoreQuota ?? false,
}); });
await requestRepository.save(request); await requestRepository.save(request);
@@ -434,6 +445,7 @@ export class MediaRequest {
if (finalSeasons.length === 0) { if (finalSeasons.length === 0) {
throw new NoSeasonsAvailableError('No seasons available to request'); throw new NoSeasonsAvailableError('No seasons available to request');
} else if ( } else if (
!canBypassQuota &&
quotas.tv.limit && quotas.tv.limit &&
finalSeasons.length > (quotas.tv.remaining ?? 0) finalSeasons.length > (quotas.tv.remaining ?? 0)
) { ) {
@@ -502,6 +514,7 @@ export class MediaRequest {
}) })
), ),
isAutoRequest: options.isAutoRequest ?? false, isAutoRequest: options.isAutoRequest ?? false,
ignoreQuota: requestBody.ignoreQuota ?? false,
}); });
await requestRepository.save(request); await requestRepository.save(request);
@@ -606,6 +619,9 @@ export class MediaRequest {
@Column({ default: false }) @Column({ default: false })
public isAutoRequest: boolean; public isAutoRequest: boolean;
@Column({ default: false })
public ignoreQuota: boolean;
constructor(init?: Partial<MediaRequest>) { constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init); Object.assign(this, init);
} }

View File

@@ -290,6 +290,7 @@ export class User {
createdAt: AfterDate(movieDate), createdAt: AfterDate(movieDate),
type: MediaType.MOVIE, type: MediaType.MOVIE,
status: Not(MediaRequestStatus.DECLINED), status: Not(MediaRequestStatus.DECLINED),
ignoreQuota: false,
}, },
}) })
: 0; : 0;
@@ -323,6 +324,9 @@ export class User {
.andWhere('request.status != :declinedStatus', { .andWhere('request.status != :declinedStatus', {
declinedStatus: MediaRequestStatus.DECLINED, declinedStatus: MediaRequestStatus.DECLINED,
}) })
.andWhere('request.ignoreQuota = :ignoreQuota', {
ignoreQuota: false,
})
.addSelect((subQuery) => { .addSelect((subQuery) => {
return subQuery return subQuery
.select('COUNT(season.id)', 'seasonCount') .select('COUNT(season.id)', 'seasonCount')

View File

@@ -22,4 +22,5 @@ export type MediaRequestBody = {
languageProfileId?: number; languageProfileId?: number;
userId?: number; userId?: number;
tags?: 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 */ /* eslint-disable react-hooks/exhaustive-deps */
import CachedImage from '@app/components/Common/CachedImage'; import CachedImage from '@app/components/Common/CachedImage';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import SlideCheckbox from '@app/components/Common/SlideCheckbox';
import type { User } from '@app/hooks/useUser'; import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
@@ -38,6 +39,9 @@ const messages = defineMessages('components.RequestModal.AdvancedRequester', {
tags: 'Tags', tags: 'Tags',
selecttags: 'Select tags', selecttags: 'Select tags',
notagoptions: 'No 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 = { export type RequestOverrides = {
@@ -47,6 +51,7 @@ export type RequestOverrides = {
tags?: number[]; tags?: number[];
language?: number; language?: number;
user?: User; user?: User;
ignoreQuota?: boolean;
}; };
interface AdvancedRequesterProps { interface AdvancedRequesterProps {
@@ -55,6 +60,7 @@ interface AdvancedRequesterProps {
isAnime?: boolean; isAnime?: boolean;
defaultOverrides?: RequestOverrides; defaultOverrides?: RequestOverrides;
requestUser?: User; requestUser?: User;
quota?: { movie: { limit?: number }; tv: { limit?: number } };
onChange: (overrides: RequestOverrides) => void; onChange: (overrides: RequestOverrides) => void;
} }
@@ -64,6 +70,7 @@ const AdvancedRequester = ({
isAnime = false, isAnime = false,
defaultOverrides, defaultOverrides,
requestUser, requestUser,
quota,
onChange, onChange,
}: AdvancedRequesterProps) => { }: AdvancedRequesterProps) => {
const intl = useIntl(); const intl = useIntl();
@@ -97,6 +104,10 @@ const AdvancedRequester = ({
defaultOverrides?.tags ?? [] defaultOverrides?.tags ?? []
); );
const [ignoreQuota, setIgnoreQuota] = useState<boolean>(
defaultOverrides?.ignoreQuota ?? false
);
const { data: serverData, isValidating } = const { data: serverData, isValidating } =
useSWR<ServiceCommonServerWithDetails>( useSWR<ServiceCommonServerWithDetails>(
selectedServer !== null selectedServer !== null
@@ -273,6 +284,7 @@ const AdvancedRequester = ({
user: selectedUser ?? undefined, user: selectedUser ?? undefined,
language: selectedLanguage !== -1 ? selectedLanguage : undefined, language: selectedLanguage !== -1 ? selectedLanguage : undefined,
tags: selectedTags, tags: selectedTags,
ignoreQuota: ignoreQuota || undefined,
}); });
} }
}, [ }, [
@@ -282,6 +294,7 @@ const AdvancedRequester = ({
selectedUser, selectedUser,
selectedLanguage, selectedLanguage,
selectedTags, selectedTags,
ignoreQuota,
]); ]);
if (!data && !error) { if (!data && !error) {
@@ -540,6 +553,25 @@ const AdvancedRequester = ({
/> />
</div> </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([ {currentHasPermission([
Permission.MANAGE_REQUESTS, Permission.MANAGE_REQUESTS,
Permission.MANAGE_USERS, Permission.MANAGE_USERS,

View File

@@ -94,6 +94,7 @@ const MovieRequestModal = ({
mediaId: data?.id, mediaId: data?.id,
mediaType: 'movie', mediaType: 'movie',
is4k, is4k,
ignoreQuota: requestOverrides?.ignoreQuota,
...overrideParams, ...overrideParams,
}); });
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
@@ -320,7 +321,10 @@ const MovieRequestModal = ({
backgroundClickable backgroundClickable
onCancel={onCancel} onCancel={onCancel}
onOk={sendRequest} onOk={sendRequest}
okDisabled={isUpdating || quota?.movie.restricted} okDisabled={
isUpdating ||
(quota?.movie.restricted && !requestOverrides?.ignoreQuota)
}
title={intl.formatMessage( title={intl.formatMessage(
is4k ? messages.requestmovie4ktitle : messages.requestmovietitle is4k ? messages.requestmovie4ktitle : messages.requestmovietitle
)} )}
@@ -359,6 +363,7 @@ const MovieRequestModal = ({
<AdvancedRequester <AdvancedRequester
type="movie" type="movie"
is4k={is4k} is4k={is4k}
quota={quota}
onChange={(overrides) => { onChange={(overrides) => {
setRequestOverrides(overrides); setRequestOverrides(overrides);
}} }}

View File

@@ -199,6 +199,7 @@ const TvRequestModal = ({
tvdbId: tvdbId ?? data?.externalIds.tvdbId, tvdbId: tvdbId ?? data?.externalIds.tvdbId,
mediaType: 'tv', mediaType: 'tv',
is4k, is4k,
ignoreQuota: requestOverrides?.ignoreQuota,
seasons: settings.currentSettings.partialRequestsEnabled seasons: settings.currentSettings.partialRequestsEnabled
? selectedSeasons.sort((a, b) => a - b) ? selectedSeasons.sort((a, b) => a - b)
: getAllSeasons().filter( : getAllSeasons().filter(
@@ -439,7 +440,8 @@ const TvRequestModal = ({
? false ? false
: !settings.currentSettings.partialRequestsEnabled && : !settings.currentSettings.partialRequestsEnabled &&
quota?.tv.limit && quota?.tv.limit &&
unrequestedSeasons.length > quota.tv.limit unrequestedSeasons.length > quota.tv.limit &&
!requestOverrides?.ignoreQuota
? true ? true
: getAllRequestedSeasons().length >= getAllSeasons().length || : getAllRequestedSeasons().length >= getAllSeasons().length ||
(settings.currentSettings.partialRequestsEnabled && (settings.currentSettings.partialRequestsEnabled &&
@@ -726,6 +728,7 @@ const TvRequestModal = ({
isAnime={data?.keywords.some( isAnime={data?.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID (keyword) => keyword.id === ANIME_KEYWORD_ID
)} )}
quota={quota}
onChange={(overrides) => setRequestOverrides(overrides)} onChange={(overrides) => setRequestOverrides(overrides)}
requestUser={editRequest?.requestedBy} requestUser={editRequest?.requestedBy}
defaultOverrides={ defaultOverrides={

View File

@@ -520,6 +520,8 @@
"components.RequestModal.AdvancedRequester.default": "{name} (Default)", "components.RequestModal.AdvancedRequester.default": "{name} (Default)",
"components.RequestModal.AdvancedRequester.destinationserver": "Destination Server", "components.RequestModal.AdvancedRequester.destinationserver": "Destination Server",
"components.RequestModal.AdvancedRequester.folder": "{path} ({space})", "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.languageprofile": "Language Profile",
"components.RequestModal.AdvancedRequester.notagoptions": "No tags.", "components.RequestModal.AdvancedRequester.notagoptions": "No tags.",
"components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile", "components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile",