mirror of
https://github.com/seerr-team/seerr.git
synced 2026-10-12 02:22:17 +00:00
fix(requests): enforce the quota when editing a request (#3378)
This commit is contained in:
@@ -12,7 +12,7 @@ import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
|
||||
import requestLock from '@server/utils/requestLock';
|
||||
import requestLock, { userKey } from '@server/utils/requestLock';
|
||||
import { truncate } from 'lodash';
|
||||
import {
|
||||
AfterInsert,
|
||||
@@ -48,7 +48,14 @@ export class MediaRequest {
|
||||
user: User,
|
||||
options: MediaRequestOptions = {}
|
||||
): Promise<MediaRequest> {
|
||||
return requestLock.dispatch(requestBody.userId || user.id, () =>
|
||||
// Only a caller allowed to set the request user may queue on their lock
|
||||
const lockUserId =
|
||||
requestBody.userId &&
|
||||
user.hasPermission([Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS])
|
||||
? requestBody.userId
|
||||
: user.id;
|
||||
|
||||
return requestLock.dispatch(userKey(lockUserId), () =>
|
||||
MediaRequest.createRequest(requestBody, user, options)
|
||||
);
|
||||
}
|
||||
|
||||
+298
-31
@@ -21,6 +21,7 @@ import { User } from '@server/entity/User';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { checkUser } from '@server/middleware/auth';
|
||||
import { MediaRequestSubscriber } from '@server/subscriber/MediaRequestSubscriber';
|
||||
import { setupTestDb } from '@server/test/db';
|
||||
import type { Express } from 'express';
|
||||
import express from 'express';
|
||||
@@ -291,6 +292,20 @@ describe('DELETE /request/:requestId', () => {
|
||||
|
||||
assert.strictEqual(res.status, 404);
|
||||
});
|
||||
|
||||
it('deletes a request once when two deletes race', async () => {
|
||||
const repo = getRepository(MediaRequest);
|
||||
const mediaRequest = await seedRequest();
|
||||
const admin = await loginAs('[email protected]', 'test1234');
|
||||
|
||||
const results = await Promise.all([
|
||||
admin.delete(`/request/${mediaRequest.id}`),
|
||||
admin.delete(`/request/${mediaRequest.id}`),
|
||||
]);
|
||||
|
||||
assert.deepStrictEqual(results.map((r) => r.status).sort(), [204, 404]);
|
||||
assert.strictEqual(await repo.count({ where: { id: mediaRequest.id } }), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /request/:requestId (movie)', () => {
|
||||
@@ -339,45 +354,70 @@ describe('PUT /request/:requestId (movie)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /request/:requestId (tv)', () => {
|
||||
it('does not add a season held by another request', async () => {
|
||||
const userRepo = getRepository(User);
|
||||
const mediaRepo = getRepository(Media);
|
||||
const requestRepo = getRepository(MediaRequest);
|
||||
async function seedUser(
|
||||
email: string,
|
||||
quotas: {
|
||||
movieQuotaLimit?: number;
|
||||
tvQuotaLimit?: number;
|
||||
tvQuotaDays?: number;
|
||||
} = {}
|
||||
) {
|
||||
const userRepo = getRepository(User);
|
||||
const user = await userRepo.findOneOrFail({ where: { email } });
|
||||
Object.assign(user, quotas);
|
||||
|
||||
const owner = await userRepo.findOneOrFail({
|
||||
where: { email: '[email protected]' },
|
||||
});
|
||||
const otherUser = await userRepo.findOneOrFail({
|
||||
where: { email: '[email protected]' },
|
||||
});
|
||||
return userRepo.save(user);
|
||||
}
|
||||
|
||||
const media = await mediaRepo.save(
|
||||
async function seedTvMedia(tmdbId: number) {
|
||||
const mediaRepo = getRepository(Media);
|
||||
|
||||
return (
|
||||
(await mediaRepo.findOne({
|
||||
where: { tmdbId, mediaType: MediaType.TV },
|
||||
})) ??
|
||||
(await mediaRepo.save(
|
||||
new Media({
|
||||
mediaType: MediaType.TV,
|
||||
tmdbId: 67890,
|
||||
tmdbId,
|
||||
status: MediaStatus.PENDING,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
const seedTvRequest = (requestedBy: User, seasons: number[]) =>
|
||||
requestRepo.save(
|
||||
new MediaRequest({
|
||||
type: MediaType.TV,
|
||||
status: MediaRequestStatus.PENDING,
|
||||
media,
|
||||
requestedBy,
|
||||
is4k: false,
|
||||
seasons: seasons.map(
|
||||
(seasonNumber) =>
|
||||
new SeasonRequest({
|
||||
seasonNumber,
|
||||
status: MediaRequestStatus.PENDING,
|
||||
})
|
||||
),
|
||||
})
|
||||
);
|
||||
async function seedTvRequest(
|
||||
requestedBy: User,
|
||||
seasons: number[],
|
||||
{ tmdbId = 67890, ignoreQuota = false, createdAt = new Date() } = {}
|
||||
) {
|
||||
return getRepository(MediaRequest).save(
|
||||
new MediaRequest({
|
||||
type: MediaType.TV,
|
||||
status: MediaRequestStatus.PENDING,
|
||||
media: await seedTvMedia(tmdbId),
|
||||
requestedBy,
|
||||
is4k: false,
|
||||
ignoreQuota,
|
||||
createdAt,
|
||||
seasons: seasons.map(
|
||||
(seasonNumber) =>
|
||||
new SeasonRequest({
|
||||
seasonNumber,
|
||||
status: MediaRequestStatus.PENDING,
|
||||
})
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe('PUT /request/:requestId (tv)', () => {
|
||||
it('does not add a season held by another request', async () => {
|
||||
const requestRepo = getRepository(MediaRequest);
|
||||
|
||||
const owner = await seedUser('[email protected]');
|
||||
const otherUser = await seedUser('[email protected]');
|
||||
|
||||
const mediaRequest = await seedTvRequest(owner, [1, 2]);
|
||||
const otherRequest = await seedTvRequest(otherUser, [3]);
|
||||
@@ -408,6 +448,189 @@ describe('PUT /request/:requestId (tv)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /request/:requestId (quota)', () => {
|
||||
it('rejects adding seasons beyond the season limit', async () => {
|
||||
const requestRepo = getRepository(MediaRequest);
|
||||
const owner = await seedUser('[email protected]', { tvQuotaLimit: 2 });
|
||||
const mediaRequest = await seedTvRequest(owner, [1, 2]);
|
||||
|
||||
const agent = await loginAs('[email protected]', 'test1234');
|
||||
const res = await agent.put(`/request/${mediaRequest.id}`).send({
|
||||
mediaType: MediaType.TV,
|
||||
seasons: [1, 2, 3],
|
||||
});
|
||||
|
||||
assert.strictEqual(res.status, 403);
|
||||
|
||||
const saved = await requestRepo.findOneOrFail({
|
||||
where: { id: mediaRequest.id },
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b),
|
||||
[1, 2]
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects adding seasons to a request older than the quota window', async () => {
|
||||
const requestRepo = getRepository(MediaRequest);
|
||||
const owner = await seedUser('[email protected]', {
|
||||
tvQuotaLimit: 2,
|
||||
tvQuotaDays: 7,
|
||||
});
|
||||
const mediaRequest = await seedTvRequest(owner, [1, 2], {
|
||||
createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
const agent = await loginAs('[email protected]', 'test1234');
|
||||
const res = await agent.put(`/request/${mediaRequest.id}`).send({
|
||||
mediaType: MediaType.TV,
|
||||
seasons: [1, 2, 3],
|
||||
});
|
||||
|
||||
assert.strictEqual(res.status, 403);
|
||||
|
||||
const saved = await requestRepo.findOneOrFail({
|
||||
where: { id: mediaRequest.id },
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b),
|
||||
[1, 2]
|
||||
);
|
||||
});
|
||||
|
||||
it('allows swapping seasons at the season limit', async () => {
|
||||
const requestRepo = getRepository(MediaRequest);
|
||||
const owner = await seedUser('[email protected]', { tvQuotaLimit: 2 });
|
||||
const mediaRequest = await seedTvRequest(owner, [1, 2]);
|
||||
|
||||
const agent = await loginAs('[email protected]', 'test1234');
|
||||
const res = await agent.put(`/request/${mediaRequest.id}`).send({
|
||||
mediaType: MediaType.TV,
|
||||
seasons: [3, 4],
|
||||
});
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
const saved = await requestRepo.findOneOrFail({
|
||||
where: { id: mediaRequest.id },
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b),
|
||||
[3, 4]
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects reassignment to a user without room for the existing seasons', async () => {
|
||||
const requestRepo = getRepository(MediaRequest);
|
||||
const owner = await seedUser('[email protected]');
|
||||
const target = await seedUser('[email protected]', { tvQuotaLimit: 1 });
|
||||
const mediaRequest = await seedTvRequest(owner, [1, 2]);
|
||||
|
||||
const agent = await loginAs('[email protected]', 'test1234');
|
||||
const res = await agent.put(`/request/${mediaRequest.id}`).send({
|
||||
mediaType: MediaType.TV,
|
||||
seasons: [1, 2],
|
||||
userId: target.id,
|
||||
});
|
||||
|
||||
assert.strictEqual(res.status, 403);
|
||||
|
||||
const saved = await requestRepo.findOneOrFail({
|
||||
where: { id: mediaRequest.id },
|
||||
});
|
||||
assert.strictEqual(saved.requestedBy.id, owner.id);
|
||||
});
|
||||
|
||||
it('rejects reassignment of a movie request to a user at their limit', async () => {
|
||||
const requestRepo = getRepository(MediaRequest);
|
||||
const mediaRepo = getRepository(Media);
|
||||
|
||||
const owner = await seedUser('[email protected]');
|
||||
const target = await seedUser('[email protected]', { movieQuotaLimit: 1 });
|
||||
|
||||
// Uses up the target's single movie request
|
||||
await seedRequest();
|
||||
|
||||
const media = await mediaRepo.save(
|
||||
new Media({
|
||||
mediaType: MediaType.MOVIE,
|
||||
tmdbId: 55555,
|
||||
status: MediaStatus.PENDING,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
const mediaRequest = await requestRepo.save(
|
||||
new MediaRequest({
|
||||
type: MediaType.MOVIE,
|
||||
status: MediaRequestStatus.PENDING,
|
||||
media,
|
||||
requestedBy: owner,
|
||||
is4k: false,
|
||||
})
|
||||
);
|
||||
|
||||
const agent = await loginAs('[email protected]', 'test1234');
|
||||
const res = await agent.put(`/request/${mediaRequest.id}`).send({
|
||||
mediaType: MediaType.MOVIE,
|
||||
userId: target.id,
|
||||
});
|
||||
|
||||
assert.strictEqual(res.status, 403);
|
||||
|
||||
const saved = await requestRepo.findOneOrFail({
|
||||
where: { id: mediaRequest.id },
|
||||
});
|
||||
assert.strictEqual(saved.requestedBy.id, owner.id);
|
||||
});
|
||||
|
||||
it('allows reassignment to a user who bypasses quotas', async () => {
|
||||
const requestRepo = getRepository(MediaRequest);
|
||||
const owner = await seedUser('[email protected]', { tvQuotaLimit: 1 });
|
||||
const target = await seedUser('[email protected]');
|
||||
const mediaRequest = await seedTvRequest(owner, [1, 2]);
|
||||
|
||||
const agent = await loginAs('[email protected]', 'test1234');
|
||||
const res = await agent.put(`/request/${mediaRequest.id}`).send({
|
||||
mediaType: MediaType.TV,
|
||||
seasons: [1, 2],
|
||||
userId: target.id,
|
||||
});
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
const saved = await requestRepo.findOneOrFail({
|
||||
where: { id: mediaRequest.id },
|
||||
});
|
||||
assert.strictEqual(saved.requestedBy.id, target.id);
|
||||
});
|
||||
|
||||
it('allows an edit that exceeds the limit when the request ignores quota', async () => {
|
||||
const requestRepo = getRepository(MediaRequest);
|
||||
const owner = await seedUser('[email protected]', { tvQuotaLimit: 2 });
|
||||
|
||||
await seedTvRequest(owner, [1, 2], { tmdbId: 77777 });
|
||||
const mediaRequest = await seedTvRequest(owner, [1, 2], {
|
||||
ignoreQuota: true,
|
||||
});
|
||||
|
||||
const agent = await loginAs('[email protected]', 'test1234');
|
||||
const res = await agent.put(`/request/${mediaRequest.id}`).send({
|
||||
mediaType: MediaType.TV,
|
||||
seasons: [1, 2, 3],
|
||||
});
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
const saved = await requestRepo.findOneOrFail({
|
||||
where: { id: mediaRequest.id },
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b),
|
||||
[1, 2, 3]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /request/:requestId/:status', () => {
|
||||
const cases = [
|
||||
{ action: 'approve', expected: MediaRequestStatus.APPROVED },
|
||||
@@ -469,6 +692,27 @@ describe('POST /request/:requestId/:status', () => {
|
||||
assert.strictEqual(persisted.status, MediaRequestStatus.APPROVED);
|
||||
});
|
||||
|
||||
it('applies only one of a concurrent approve and decline', async () => {
|
||||
const repo = getRepository(MediaRequest);
|
||||
const pending = await seedRequest();
|
||||
const admin = await loginAs('[email protected]', 'test1234');
|
||||
|
||||
const [approve, decline] = await Promise.all([
|
||||
admin.post(`/request/${pending.id}/approve`),
|
||||
admin.post(`/request/${pending.id}/decline`),
|
||||
]);
|
||||
|
||||
assert.deepStrictEqual([approve.status, decline.status].sort(), [200, 409]);
|
||||
|
||||
const persisted = await repo.findOneOrFail({ where: { id: pending.id } });
|
||||
assert.strictEqual(
|
||||
persisted.status,
|
||||
approve.status === 200
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.DECLINED
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects the removed pending verb even on a non-pending request', async () => {
|
||||
const repo = getRepository(MediaRequest);
|
||||
const declined = await seedRequest(MediaRequestStatus.DECLINED);
|
||||
@@ -519,6 +763,29 @@ describe('POST /request/:requestId/retry', () => {
|
||||
const persisted = await repo.findOneOrFail({ where: { id: pending.id } });
|
||||
assert.strictEqual(persisted.status, MediaRequestStatus.PENDING);
|
||||
});
|
||||
|
||||
it('sends a concurrently retried request to *arr once', async (t) => {
|
||||
const repo = getRepository(MediaRequest);
|
||||
const failed = await seedRequest(MediaRequestStatus.FAILED);
|
||||
const admin = await loginAs('[email protected]', 'test1234');
|
||||
|
||||
const sendToRadarr = t.mock.method(
|
||||
MediaRequestSubscriber.prototype,
|
||||
'sendToRadarr',
|
||||
async () => undefined
|
||||
);
|
||||
|
||||
const results = await Promise.all([
|
||||
admin.post(`/request/${failed.id}/retry`),
|
||||
admin.post(`/request/${failed.id}/retry`),
|
||||
]);
|
||||
|
||||
assert.deepStrictEqual(results.map((r) => r.status).sort(), [200, 409]);
|
||||
assert.strictEqual(sendToRadarr.mock.callCount(), 1);
|
||||
|
||||
const persisted = await repo.findOneOrFail({ where: { id: failed.id } });
|
||||
assert.strictEqual(persisted.status, MediaRequestStatus.APPROVED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /request/:requestId, deleted media status restoration', () => {
|
||||
|
||||
+250
-183
@@ -25,6 +25,7 @@ import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import requestLock, { requestKey, userKey } from '@server/utils/requestLock';
|
||||
import { Router } from 'express';
|
||||
|
||||
const requestRoutes = Router();
|
||||
@@ -462,143 +463,200 @@ requestRoutes.put<{ requestId: string }>(
|
||||
async (req, res, next) => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const userRepository = getRepository(User);
|
||||
const requestId = Number(req.params.requestId);
|
||||
try {
|
||||
const request = await requestRepository.findOne({
|
||||
where: {
|
||||
id: Number(req.params.requestId),
|
||||
},
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
return next({ status: 404, message: 'Request not found.' });
|
||||
}
|
||||
|
||||
if (
|
||||
(request.requestedBy.id !== req.user?.id ||
|
||||
(req.body.mediaType !== 'tv' &&
|
||||
!req.user?.hasPermission(Permission.REQUEST_ADVANCED))) &&
|
||||
!req.user?.hasPermission(Permission.MANAGE_REQUESTS)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to modify this request.',
|
||||
// Ordering is request then owner here, user then media on create, so no cycle
|
||||
return await requestLock.dispatch(requestKey(requestId), async () => {
|
||||
const request = await requestRepository.findOne({
|
||||
where: {
|
||||
id: requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (request.status !== MediaRequestStatus.PENDING) {
|
||||
return next({
|
||||
status: 409,
|
||||
message: 'Only pending requests can be modified.',
|
||||
});
|
||||
}
|
||||
|
||||
let requestUser = request.requestedBy;
|
||||
|
||||
if (
|
||||
req.body.userId &&
|
||||
req.body.userId !== request.requestedBy.id &&
|
||||
!req.user?.hasPermission([
|
||||
Permission.MANAGE_USERS,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
])
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to modify the request user.',
|
||||
});
|
||||
} else if (req.body.userId) {
|
||||
requestUser = await userRepository.findOneOrFail({
|
||||
where: { id: req.body.userId },
|
||||
});
|
||||
}
|
||||
|
||||
if (req.body.mediaType === MediaType.MOVIE) {
|
||||
request.serverId = req.body.serverId;
|
||||
request.profileId = req.body.profileId;
|
||||
request.rootFolder = req.body.rootFolder;
|
||||
request.tags = req.body.tags;
|
||||
request.requestedBy = requestUser as User;
|
||||
|
||||
await requestRepository.save(request);
|
||||
} else if (req.body.mediaType === MediaType.TV) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
request.serverId = req.body.serverId;
|
||||
request.profileId = req.body.profileId;
|
||||
request.rootFolder = req.body.rootFolder;
|
||||
request.languageProfileId = req.body.languageProfileId;
|
||||
request.tags = req.body.tags;
|
||||
request.requestedBy = requestUser as User;
|
||||
|
||||
const requestedSeasons = req.body.seasons as number[] | undefined;
|
||||
|
||||
if (!requestedSeasons || requestedSeasons.length === 0) {
|
||||
throw new Error(
|
||||
'Missing seasons. If you want to cancel a series request, use the DELETE method.'
|
||||
);
|
||||
if (!request) {
|
||||
return next({ status: 404, message: 'Request not found.' });
|
||||
}
|
||||
|
||||
// Get existing media so we can work with all the requests
|
||||
const media = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: request.media.tmdbId, mediaType: MediaType.TV },
|
||||
relations: { requests: true },
|
||||
});
|
||||
if (
|
||||
(request.requestedBy.id !== req.user?.id ||
|
||||
(req.body.mediaType !== 'tv' &&
|
||||
!req.user?.hasPermission(Permission.REQUEST_ADVANCED))) &&
|
||||
!req.user?.hasPermission(Permission.MANAGE_REQUESTS)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to modify this request.',
|
||||
});
|
||||
}
|
||||
|
||||
// Get all requested seasons that are not part of this request we are editing
|
||||
const existingSeasons = media.requests
|
||||
.filter(
|
||||
(r) =>
|
||||
r.is4k === request.is4k &&
|
||||
r.id !== request.id &&
|
||||
r.status !== MediaRequestStatus.DECLINED &&
|
||||
r.status !== MediaRequestStatus.COMPLETED
|
||||
)
|
||||
.reduce((seasons, r) => {
|
||||
const combinedSeasons = r.seasons.map(
|
||||
(season) => season.seasonNumber
|
||||
if (request.status !== MediaRequestStatus.PENDING) {
|
||||
return next({
|
||||
status: 409,
|
||||
message: 'Only pending requests can be modified.',
|
||||
});
|
||||
}
|
||||
|
||||
const previousOwnerId = request.requestedBy.id;
|
||||
let requestUser = request.requestedBy;
|
||||
|
||||
if (
|
||||
req.body.userId &&
|
||||
req.body.userId !== request.requestedBy.id &&
|
||||
!req.user?.hasPermission([
|
||||
Permission.MANAGE_USERS,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
])
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to modify the request user.',
|
||||
});
|
||||
} else if (req.body.userId) {
|
||||
requestUser = await userRepository.findOneOrFail({
|
||||
where: { id: req.body.userId },
|
||||
});
|
||||
}
|
||||
|
||||
// Reassignment moves every season on the request onto the new owner's
|
||||
// quota, so it is charged in full rather than as a delta
|
||||
const ownerChanging = requestUser.id !== previousOwnerId;
|
||||
|
||||
return requestLock.dispatch(userKey(requestUser.id), async () => {
|
||||
if (req.body.mediaType === MediaType.MOVIE) {
|
||||
if (ownerChanging && !request.ignoreQuota) {
|
||||
const quotas = await requestUser.getQuota();
|
||||
|
||||
if (quotas.movie.restricted) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Movie Quota exceeded.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
request.serverId = req.body.serverId;
|
||||
request.profileId = req.body.profileId;
|
||||
request.rootFolder = req.body.rootFolder;
|
||||
request.tags = req.body.tags;
|
||||
request.requestedBy = requestUser as User;
|
||||
|
||||
await requestRepository.save(request);
|
||||
} else if (req.body.mediaType === MediaType.TV) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
request.serverId = req.body.serverId;
|
||||
request.profileId = req.body.profileId;
|
||||
request.rootFolder = req.body.rootFolder;
|
||||
request.languageProfileId = req.body.languageProfileId;
|
||||
request.tags = req.body.tags;
|
||||
request.requestedBy = requestUser as User;
|
||||
|
||||
const requestedSeasons = req.body.seasons as number[] | undefined;
|
||||
|
||||
if (!requestedSeasons || requestedSeasons.length === 0) {
|
||||
throw new Error(
|
||||
'Missing seasons. If you want to cancel a series request, use the DELETE method.'
|
||||
);
|
||||
}
|
||||
|
||||
// Get existing media so we can work with all the requests
|
||||
const media = await mediaRepository.findOneOrFail({
|
||||
where: {
|
||||
tmdbId: request.media.tmdbId,
|
||||
mediaType: MediaType.TV,
|
||||
},
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
// Get all requested seasons that are not part of this request we are editing
|
||||
const existingSeasons = media.requests
|
||||
.filter(
|
||||
(r) =>
|
||||
r.is4k === request.is4k &&
|
||||
r.id !== request.id &&
|
||||
r.status !== MediaRequestStatus.DECLINED &&
|
||||
r.status !== MediaRequestStatus.COMPLETED
|
||||
)
|
||||
.reduce((seasons, r) => {
|
||||
const combinedSeasons = r.seasons.map(
|
||||
(season) => season.seasonNumber
|
||||
);
|
||||
|
||||
return [...seasons, ...combinedSeasons];
|
||||
}, [] as number[]);
|
||||
|
||||
const filteredSeasons = requestedSeasons.filter(
|
||||
(rs) => !existingSeasons.includes(rs)
|
||||
);
|
||||
|
||||
return [...seasons, ...combinedSeasons];
|
||||
}, [] as number[]);
|
||||
if (filteredSeasons.length === 0) {
|
||||
return next({
|
||||
status: 202,
|
||||
message: 'No seasons available to request',
|
||||
});
|
||||
}
|
||||
|
||||
const filteredSeasons = requestedSeasons.filter(
|
||||
(rs) => !existingSeasons.includes(rs)
|
||||
);
|
||||
const newSeasons = filteredSeasons.filter(
|
||||
(sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn)
|
||||
);
|
||||
|
||||
if (filteredSeasons.length === 0) {
|
||||
return next({
|
||||
status: 202,
|
||||
message: 'No seasons available to request',
|
||||
});
|
||||
}
|
||||
if (!request.ignoreQuota) {
|
||||
const quotas = await requestUser.getQuota();
|
||||
|
||||
const newSeasons = filteredSeasons.filter(
|
||||
(sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn)
|
||||
);
|
||||
// Only the seasons getQuota already counted for this owner are
|
||||
// paid for, so the edit is charged for everything it left out
|
||||
const quotaWindowStart = new Date();
|
||||
if (quotas.tv.days) {
|
||||
quotaWindowStart.setDate(
|
||||
quotaWindowStart.getDate() - quotas.tv.days
|
||||
);
|
||||
}
|
||||
|
||||
request.seasons = request.seasons.filter((rs) =>
|
||||
filteredSeasons.includes(rs.seasonNumber)
|
||||
);
|
||||
const countedAlready =
|
||||
!ownerChanging &&
|
||||
(!quotas.tv.days || request.createdAt > quotaWindowStart);
|
||||
|
||||
if (newSeasons.length > 0) {
|
||||
logger.debug('Adding new seasons to request', {
|
||||
label: 'Media Request',
|
||||
newSeasons,
|
||||
});
|
||||
request.seasons.push(
|
||||
...newSeasons.map(
|
||||
(ns) =>
|
||||
new SeasonRequest({
|
||||
seasonNumber: ns,
|
||||
status: MediaRequestStatus.PENDING,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
const priorSeasonCount = countedAlready
|
||||
? request.seasons.length
|
||||
: 0;
|
||||
const requiredSeasons = filteredSeasons.length - priorSeasonCount;
|
||||
|
||||
await requestRepository.save(request);
|
||||
}
|
||||
if (
|
||||
quotas.tv.limit &&
|
||||
requiredSeasons > (quotas.tv.remaining ?? 0)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Series Quota exceeded.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json(request);
|
||||
request.seasons = request.seasons.filter((rs) =>
|
||||
filteredSeasons.includes(rs.seasonNumber)
|
||||
);
|
||||
|
||||
if (newSeasons.length > 0) {
|
||||
logger.debug('Adding new seasons to request', {
|
||||
label: 'Media Request',
|
||||
newSeasons,
|
||||
});
|
||||
request.seasons.push(
|
||||
...newSeasons.map(
|
||||
(ns) =>
|
||||
new SeasonRequest({
|
||||
seasonNumber: ns,
|
||||
status: MediaRequestStatus.PENDING,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await requestRepository.save(request);
|
||||
}
|
||||
|
||||
return res.status(200).json(request);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
@@ -607,27 +665,30 @@ requestRoutes.put<{ requestId: string }>(
|
||||
|
||||
requestRoutes.delete('/:requestId', async (req, res, next) => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const requestId = Number(req.params.requestId);
|
||||
|
||||
try {
|
||||
const request = await requestRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.requestId) },
|
||||
relations: { requestedBy: true, modifiedBy: true },
|
||||
});
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
(request.requestedBy.id !== req.user?.id ||
|
||||
request.status !== MediaRequestStatus.PENDING)
|
||||
) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: 'You do not have permission to delete this request.',
|
||||
return await requestLock.dispatch(requestKey(requestId), async () => {
|
||||
const request = await requestRepository.findOneOrFail({
|
||||
where: { id: requestId },
|
||||
relations: { requestedBy: true, modifiedBy: true },
|
||||
});
|
||||
}
|
||||
|
||||
await requestRepository.remove(request);
|
||||
if (
|
||||
!req.user?.hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
(request.requestedBy.id !== req.user?.id ||
|
||||
request.status !== MediaRequestStatus.PENDING)
|
||||
) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: 'You do not have permission to delete this request.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(204).send();
|
||||
await requestRepository.remove(request);
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong deleting a request.', {
|
||||
label: 'API',
|
||||
@@ -644,26 +705,29 @@ requestRoutes.post<{
|
||||
isAuthenticated(Permission.MANAGE_REQUESTS),
|
||||
async (req, res, next) => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const requestId = Number(req.params.requestId);
|
||||
|
||||
try {
|
||||
const request = await requestRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.requestId) },
|
||||
relations: { requestedBy: true, modifiedBy: true },
|
||||
});
|
||||
|
||||
if (request.status !== MediaRequestStatus.FAILED) {
|
||||
return next({
|
||||
status: 409,
|
||||
message: 'Only failed requests can be retried.',
|
||||
return await requestLock.dispatch(requestKey(requestId), async () => {
|
||||
const request = await requestRepository.findOneOrFail({
|
||||
where: { id: requestId },
|
||||
relations: { requestedBy: true, modifiedBy: true },
|
||||
});
|
||||
}
|
||||
|
||||
// this also triggers updating the parent media's status & sending to *arr
|
||||
request.status = MediaRequestStatus.APPROVED;
|
||||
request.modifiedBy = req.user;
|
||||
await requestRepository.save(request);
|
||||
if (request.status !== MediaRequestStatus.FAILED) {
|
||||
return next({
|
||||
status: 409,
|
||||
message: 'Only failed requests can be retried.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json(request);
|
||||
// this also triggers updating the parent media's status & sending to *arr
|
||||
request.status = MediaRequestStatus.APPROVED;
|
||||
request.modifiedBy = req.user;
|
||||
await requestRepository.save(request);
|
||||
|
||||
return res.status(200).json(request);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Error processing request retry', {
|
||||
label: 'Media Request',
|
||||
@@ -682,41 +746,44 @@ requestRoutes.post<{
|
||||
isAuthenticated(Permission.MANAGE_REQUESTS),
|
||||
async (req, res, next) => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const requestId = Number(req.params.requestId);
|
||||
|
||||
try {
|
||||
const request = await requestRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.requestId) },
|
||||
relations: { requestedBy: true, modifiedBy: true },
|
||||
});
|
||||
|
||||
let newStatus: MediaRequestStatus;
|
||||
|
||||
switch (req.params.status) {
|
||||
case 'approve':
|
||||
newStatus = MediaRequestStatus.APPROVED;
|
||||
break;
|
||||
case 'decline':
|
||||
newStatus = MediaRequestStatus.DECLINED;
|
||||
break;
|
||||
default:
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Status must be approve or decline.',
|
||||
});
|
||||
}
|
||||
|
||||
if (request.status !== MediaRequestStatus.PENDING) {
|
||||
return next({
|
||||
status: 409,
|
||||
message: 'Only pending requests can be approved or declined.',
|
||||
return await requestLock.dispatch(requestKey(requestId), async () => {
|
||||
const request = await requestRepository.findOneOrFail({
|
||||
where: { id: requestId },
|
||||
relations: { requestedBy: true, modifiedBy: true },
|
||||
});
|
||||
}
|
||||
|
||||
request.status = newStatus;
|
||||
request.modifiedBy = req.user;
|
||||
await requestRepository.save(request);
|
||||
let newStatus: MediaRequestStatus;
|
||||
|
||||
return res.status(200).json(request);
|
||||
switch (req.params.status) {
|
||||
case 'approve':
|
||||
newStatus = MediaRequestStatus.APPROVED;
|
||||
break;
|
||||
case 'decline':
|
||||
newStatus = MediaRequestStatus.DECLINED;
|
||||
break;
|
||||
default:
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Status must be approve or decline.',
|
||||
});
|
||||
}
|
||||
|
||||
if (request.status !== MediaRequestStatus.PENDING) {
|
||||
return next({
|
||||
status: 409,
|
||||
message: 'Only pending requests can be approved or declined.',
|
||||
});
|
||||
}
|
||||
|
||||
request.status = newStatus;
|
||||
request.modifiedBy = req.user;
|
||||
await requestRepository.save(request);
|
||||
|
||||
return res.status(200).json(request);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Error processing request update', {
|
||||
label: 'Media Request',
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import AsyncLock from '@server/utils/asyncLock';
|
||||
|
||||
// Keyed on user id. Never dispatch from a subscriber or transaction as a waiter
|
||||
// would block while holding the save's connection.
|
||||
// Keyed on user id or request id. Never dispatch from a subscriber or
|
||||
// transaction as a waiter would block while holding the save's connection.
|
||||
const requestLock = new AsyncLock();
|
||||
|
||||
export const userKey = (userId: number) => `user:${userId}`;
|
||||
export const requestKey = (requestId: number) => `request:${requestId}`;
|
||||
|
||||
export default requestLock;
|
||||
|
||||
Reference in New Issue
Block a user