feat(perms): add separate REQUEST_MOVIE and REQUEST_TV permissions (#1474)

* feat(perms): add separate REQUEST_MOVIE and REQUEST_TV permissions

* fix(perms): do not require regular request perms for 4K requests
pull/1510/head
TheCatLady 3 years ago committed by GitHub
parent ed99e4976d
commit 91b9e0f679
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -17,6 +17,8 @@ export enum Permission {
AUTO_APPROVE_4K = 32768, AUTO_APPROVE_4K = 32768,
AUTO_APPROVE_4K_MOVIE = 65536, AUTO_APPROVE_4K_MOVIE = 65536,
AUTO_APPROVE_4K_TV = 131072, AUTO_APPROVE_4K_TV = 131072,
REQUEST_MOVIE = 262144,
REQUEST_TV = 524288,
} }
export interface PermissionCheckOptions { export interface PermissionCheckOptions {

@ -139,287 +139,289 @@ requestRoutes.get('/', async (req, res, next) => {
} }
}); });
requestRoutes.post( requestRoutes.post('/', async (req, res, next) => {
'/', const tmdb = new TheMovieDb();
isAuthenticated(Permission.REQUEST), const mediaRepository = getRepository(Media);
async (req, res, next) => { const requestRepository = getRepository(MediaRequest);
const tmdb = new TheMovieDb(); const userRepository = getRepository(User);
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
try { try {
let requestUser = req.user; let requestUser = req.user;
if ( if (
req.body.userId && req.body.userId &&
!req.user?.hasPermission([ !req.user?.hasPermission([
Permission.MANAGE_USERS, Permission.MANAGE_USERS,
Permission.MANAGE_REQUESTS, Permission.MANAGE_REQUESTS,
]) ])
) { ) {
return next({ return next({
status: 403, status: 403,
message: 'You do not have permission to modify the request user.', message: 'You do not have permission to modify the request user.',
}); });
} else if (req.body.userId) { } else if (req.body.userId) {
requestUser = await userRepository.findOneOrFail({ requestUser = await userRepository.findOneOrFail({
where: { id: req.body.userId }, where: { id: req.body.userId },
}); });
} }
if (!requestUser) { if (!requestUser) {
return next({ return next({
status: 500, status: 500,
message: 'User missing from request context.', message: 'User missing from request context.',
}); });
} }
if (req.body.is4k) { if (
if ( req.body.mediaType === MediaType.MOVIE &&
req.body.mediaType === MediaType.MOVIE && !req.user?.hasPermission(
!req.user?.hasPermission( req.body.is4k
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], ? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE]
{ : [Permission.REQUEST, Permission.REQUEST_MOVIE],
type: 'or', {
} type: 'or',
)
) {
return next({
status: 403,
message: 'You do not have permission to make 4K movie requests.',
});
} else if (
req.body.mediaType === MediaType.TV &&
!req.user?.hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_TV],
{
type: 'or',
}
)
) {
return next({
status: 403,
message: 'You do not have permission to make 4K series requests.',
});
} }
} )
) {
return next({
status: 403,
message: `You do not have permission to make ${
req.body.is4k ? '4K ' : ''
}movie requests.`,
});
} else if (
req.body.mediaType === MediaType.TV &&
!req.user?.hasPermission(
req.body.is4k
? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV]
: [Permission.REQUEST, Permission.REQUEST_TV],
{
type: 'or',
}
)
) {
return next({
status: 403,
message: `You do not have permission to make ${
req.body.is4k ? '4K ' : ''
}series requests.`,
});
}
const quotas = await requestUser.getQuota(); const quotas = await requestUser.getQuota();
if (req.body.mediaType === MediaType.MOVIE && quotas.movie.restricted) { if (req.body.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
return next({ return next({
status: 403, status: 403,
message: 'Movie Quota Exceeded', message: 'Movie Quota Exceeded',
}); });
} else if (req.body.mediaType === MediaType.TV && quotas.tv.restricted) { } else if (req.body.mediaType === MediaType.TV && quotas.tv.restricted) {
return next({ return next({
status: 403, status: 403,
message: 'Series Quota Exceeded', message: 'Series Quota Exceeded',
}); });
}
const tmdbMedia =
req.body.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: req.body.mediaId })
: await tmdb.getTvShow({ tvId: req.body.mediaId });
let media = await mediaRepository.findOne({
where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType },
relations: ['requests'],
});
if (!media) {
media = new Media({
tmdbId: tmdbMedia.id,
tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: req.body.mediaType,
});
} else {
if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) {
media.status = MediaStatus.PENDING;
} }
const tmdbMedia = if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) {
req.body.mediaType === MediaType.MOVIE media.status4k = MediaStatus.PENDING;
? await tmdb.getMovie({ movieId: req.body.mediaId }) }
: await tmdb.getTvShow({ tvId: req.body.mediaId }); }
let media = await mediaRepository.findOne({ if (req.body.mediaType === MediaType.MOVIE) {
where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType }, const existing = await requestRepository.findOne({
relations: ['requests'], where: {
media: {
tmdbId: tmdbMedia.id,
},
requestedBy: req.user,
is4k: req.body.is4k,
},
}); });
if (!media) { if (existing) {
media = new Media({ logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id, tmdbId: tmdbMedia.id,
tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: req.body.mediaType, mediaType: req.body.mediaType,
}); });
} else { return next({
if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) { status: 409,
media.status = MediaStatus.PENDING; message: 'Request for this media already exists.',
}
if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) {
media.status4k = MediaStatus.PENDING;
}
}
if (req.body.mediaType === MediaType.MOVIE) {
const existing = await requestRepository.findOne({
where: {
media: {
tmdbId: tmdbMedia.id,
},
requestedBy: req.user,
is4k: req.body.is4k,
},
}); });
}
if (existing) { await mediaRepository.save(media);
logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id, const request = new MediaRequest({
mediaType: req.body.mediaType, type: MediaType.MOVIE,
}); media,
return next({ requestedBy: requestUser,
status: 409, // If the user is an admin or has the "auto approve" permission, automatically approve the request
message: 'Request for this media already exists.', status: req.user?.hasPermission(
}); [
} req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? req.user
: undefined,
is4k: req.body.is4k,
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
tags: req.body.tags,
});
await mediaRepository.save(media); await requestRepository.save(request);
return res.status(201).json(request);
const request = new MediaRequest({ } else if (req.body.mediaType === MediaType.TV) {
type: MediaType.MOVIE, const requestedSeasons = req.body.seasons as number[];
media, let existingSeasons: number[] = [];
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request // We need to check existing requests on this title to make sure we don't double up on seasons that were
status: req.user?.hasPermission( // already requested. In the case they were, we just throw out any duplicates but still approve the request.
[ // (Unless there are no seasons, in which case we abort)
req.body.is4k if (media.requests) {
? Permission.AUTO_APPROVE_4K existingSeasons = media.requests
: Permission.AUTO_APPROVE, .filter(
req.body.is4k (request) =>
? Permission.AUTO_APPROVE_4K_MOVIE request.is4k === req.body.is4k &&
: Permission.AUTO_APPROVE_MOVIE, request.status !== MediaRequestStatus.DECLINED
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
) )
? req.user .reduce((seasons, request) => {
: undefined, const combinedSeasons = request.seasons.map(
is4k: req.body.is4k, (season) => season.seasonNumber
serverId: req.body.serverId, );
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
tags: req.body.tags,
});
await requestRepository.save(request);
return res.status(201).json(request);
} else if (req.body.mediaType === MediaType.TV) {
const requestedSeasons = req.body.seasons as number[];
let existingSeasons: number[] = [];
// We need to check existing requests on this title to make sure we don't double up on seasons that were
// already requested. In the case they were, we just throw out any duplicates but still approve the request.
// (Unless there are no seasons, in which case we abort)
if (media.requests) {
existingSeasons = media.requests
.filter(
(request) =>
request.is4k === req.body.is4k &&
request.status !== MediaRequestStatus.DECLINED
)
.reduce((seasons, request) => {
const combinedSeasons = request.seasons.map(
(season) => season.seasonNumber
);
return [...seasons, ...combinedSeasons];
}, [] as number[]);
}
const finalSeasons = requestedSeasons.filter( return [...seasons, ...combinedSeasons];
(rs) => !existingSeasons.includes(rs) }, [] as number[]);
); }
if (finalSeasons.length === 0) { const finalSeasons = requestedSeasons.filter(
return next({ (rs) => !existingSeasons.includes(rs)
status: 202, );
message: 'No seasons available to request',
});
}
await mediaRepository.save(media); if (finalSeasons.length === 0) {
return next({
const request = new MediaRequest({ status: 202,
type: MediaType.TV, message: 'No seasons available to request',
media,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? req.user
: undefined,
is4k: req.body.is4k,
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
languageProfileId: req.body.languageProfileId,
tags: req.body.tags,
seasons: finalSeasons.map(
(sn) =>
new SeasonRequest({
seasonNumber: sn,
status: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
})
),
}); });
await requestRepository.save(request);
return res.status(201).json(request);
} }
next({ status: 500, message: 'Invalid media type' }); await mediaRepository.save(media);
} catch (e) {
next({ status: 500, message: e.message }); const request = new MediaRequest({
type: MediaType.TV,
media,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? req.user
: undefined,
is4k: req.body.is4k,
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
languageProfileId: req.body.languageProfileId,
tags: req.body.tags,
seasons: finalSeasons.map(
(sn) =>
new SeasonRequest({
seasonNumber: sn,
status: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
})
),
});
await requestRepository.save(request);
return res.status(201).json(request);
} }
next({ status: 500, message: 'Invalid media type' });
} catch (e) {
next({ status: 500, message: e.message });
} }
); });
requestRoutes.get('/count', async (_req, res, next) => { requestRoutes.get('/count', async (_req, res, next) => {
const requestRepository = getRepository(MediaRequest); const requestRepository = getRepository(MediaRequest);

@ -108,11 +108,18 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
} }
const hasRequestable = const hasRequestable =
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
}) &&
data.parts.filter( data.parts.filter(
(part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN (part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN
).length > 0; ).length > 0;
const hasRequestable4k = const hasRequestable4k =
settings.currentSettings.movie4kEnabled &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], {
type: 'or',
}) &&
data.parts.filter( data.parts.filter(
(part) => (part) =>
!part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN !part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN
@ -323,55 +330,42 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
</span> </span>
</div> </div>
<div className="media-actions"> <div className="media-actions">
{hasPermission(Permission.REQUEST) && {(hasRequestable || hasRequestable4k) && (
(hasRequestable || <ButtonWithDropdown
(settings.currentSettings.movie4kEnabled && buttonType="primary"
hasPermission( onClick={() => {
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], setRequestModal(true);
{ type: 'or' } setIs4k(!hasRequestable);
) && }}
hasRequestable4k)) && ( text={
<ButtonWithDropdown <>
buttonType="primary" <DownloadIcon />
onClick={() => { <span>
setRequestModal(true); {intl.formatMessage(
setIs4k(!hasRequestable); hasRequestable
}} ? messages.requestcollection
text={ : messages.requestcollection4k
<> )}
<DownloadIcon /> </span>
<span> </>
{intl.formatMessage( }
hasRequestable >
? messages.requestcollection {hasRequestable && hasRequestable4k && (
: messages.requestcollection4k <ButtonWithDropdown.Item
)} buttonType="primary"
</span> onClick={() => {
</> setRequestModal(true);
} setIs4k(true);
> }}
{settings.currentSettings.movie4kEnabled && >
hasPermission( <DownloadIcon />
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], <span>
{ type: 'or' } {intl.formatMessage(messages.requestcollection4k)}
) && </span>
hasRequestable && </ButtonWithDropdown.Item>
hasRequestable4k && ( )}
<ButtonWithDropdown.Item </ButtonWithDropdown>
buttonType="primary" )}
onClick={() => {
setRequestModal(true);
setIs4k(true);
}}
>
<DownloadIcon />
<span>
{intl.formatMessage(messages.requestcollection4k)}
</span>
</ButtonWithDropdown.Item>
)}
</ButtonWithDropdown>
)}
</div> </div>
</div> </div>
{data.overview && ( {data.overview && (

@ -70,9 +70,9 @@ const messages = defineMessages({
openradarr4k: 'Open Movie in 4K Radarr', openradarr4k: 'Open Movie in 4K Radarr',
downloadstatus: 'Download Status', downloadstatus: 'Download Status',
playonplex: 'Play on Plex', playonplex: 'Play on Plex',
play4konplex: 'Play 4K on Plex', play4konplex: 'Play in 4K on Plex',
markavailable: 'Mark as Available', markavailable: 'Mark as Available',
mark4kavailable: 'Mark 4K as Available', mark4kavailable: 'Mark as Available in 4K',
}); });
interface MovieDetailsProps { interface MovieDetailsProps {
@ -112,7 +112,12 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const mediaLinks: PlayButtonLink[] = []; const mediaLinks: PlayButtonLink[] = [];
if (data.mediaInfo?.plexUrl) { if (
data.mediaInfo?.plexUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
})
) {
mediaLinks.push({ mediaLinks.push({
text: intl.formatMessage(messages.playonplex), text: intl.formatMessage(messages.playonplex),
url: data.mediaInfo?.plexUrl, url: data.mediaInfo?.plexUrl,

@ -12,42 +12,41 @@ export const messages = defineMessages({
'Grant permission to manage Overseerr users. Users with this permission cannot modify users with or grant the Admin privilege.', 'Grant permission to manage Overseerr users. Users with this permission cannot modify users with or grant the Admin privilege.',
settings: 'Manage Settings', settings: 'Manage Settings',
settingsDescription: settingsDescription:
'Grant permission to modify all Overseerr settings. A user must have this permission to grant it to others.', 'Grant permission to modify Overseerr settings. A user must have this permission to grant it to others.',
managerequests: 'Manage Requests', managerequests: 'Manage Requests',
managerequestsDescription: managerequestsDescription:
'Grant permission to manage Overseerr requests (includes approving and denying requests). All requests made by a user with this permission will be automatically approved.', 'Grant permission to manage Overseerr requests. All requests made by a user with this permission will be automatically approved.',
request: 'Request', request: 'Request',
requestDescription: 'Grant permission to request movies and series.', requestDescription: 'Grant permission to request non-4K media.',
vote: 'Vote', requestMovies: 'Request Movies',
voteDescription: requestMoviesDescription: 'Grant permission to request non-4K movies.',
'Grant permission to vote on requests (voting not yet implemented).', requestTv: 'Request Series',
requestTvDescription: 'Grant permission to request non-4K series.',
autoapprove: 'Auto-Approve', autoapprove: 'Auto-Approve',
autoapproveDescription: autoapproveDescription: 'Grant automatic approval for all non-4K requests.',
'Grant automatic approval for all non-4K requests made by this user.',
autoapproveMovies: 'Auto-Approve Movies', autoapproveMovies: 'Auto-Approve Movies',
autoapproveMoviesDescription: autoapproveMoviesDescription:
'Grant automatic approval for non-4K movie requests made by this user.', 'Grant automatic approval for non-4K movie requests.',
autoapproveSeries: 'Auto-Approve Series', autoapproveSeries: 'Auto-Approve Series',
autoapproveSeriesDescription: autoapproveSeriesDescription:
'Grant automatic approval for non-4K series requests made by this user.', 'Grant automatic approval for non-4K series requests.',
autoapprove4k: 'Auto-Approve 4K', autoapprove4k: 'Auto-Approve 4K',
autoapprove4kDescription: autoapprove4kDescription: 'Grant automatic approval for all 4K requests.',
'Grant automatic approval for all 4K requests made by this user.',
autoapprove4kMovies: 'Auto-Approve 4K Movies', autoapprove4kMovies: 'Auto-Approve 4K Movies',
autoapprove4kMoviesDescription: autoapprove4kMoviesDescription:
'Grant automatic approval for 4K movie requests made by this user.', 'Grant automatic approval for 4K movie requests.',
autoapprove4kSeries: 'Auto-Approve 4K Series', autoapprove4kSeries: 'Auto-Approve 4K Series',
autoapprove4kSeriesDescription: autoapprove4kSeriesDescription:
'Grant automatic approval for 4K series requests made by this user.', 'Grant automatic approval for 4K series requests.',
request4k: 'Request 4K', request4k: 'Request 4K',
request4kDescription: 'Grant permission to request 4K movies and series.', request4kDescription: 'Grant permission to request 4K media.',
request4kMovies: 'Request 4K Movies', request4kMovies: 'Request 4K Movies',
request4kMoviesDescription: 'Grant permission to request 4K movies.', request4kMoviesDescription: 'Grant permission to request 4K movies.',
request4kTv: 'Request 4K Series', request4kTv: 'Request 4K Series',
request4kTvDescription: 'Grant permission to request 4K Series.', request4kTvDescription: 'Grant permission to request 4K series.',
advancedrequest: 'Advanced Requests', advancedrequest: 'Advanced Requests',
advancedrequestDescription: advancedrequestDescription:
'Grant permission to use advanced request options (e.g., changing servers, profiles, or paths).', 'Grant permission to use advanced request options.',
viewrequests: 'View Requests', viewrequests: 'View Requests',
viewrequestsDescription: "Grant permission to view other users' requests.", viewrequestsDescription: "Grant permission to view other users' requests.",
}); });
@ -111,27 +110,18 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
name: intl.formatMessage(messages.request), name: intl.formatMessage(messages.request),
description: intl.formatMessage(messages.requestDescription), description: intl.formatMessage(messages.requestDescription),
permission: Permission.REQUEST, permission: Permission.REQUEST,
},
{
id: 'request4k',
name: intl.formatMessage(messages.request4k),
description: intl.formatMessage(messages.request4kDescription),
permission: Permission.REQUEST_4K,
requires: [{ permissions: [Permission.REQUEST] }],
children: [ children: [
{ {
id: 'request4k-movies', id: 'request-movies',
name: intl.formatMessage(messages.request4kMovies), name: intl.formatMessage(messages.requestMovies),
description: intl.formatMessage(messages.request4kMoviesDescription), description: intl.formatMessage(messages.requestMoviesDescription),
permission: Permission.REQUEST_4K_MOVIE, permission: Permission.REQUEST_MOVIE,
requires: [{ permissions: [Permission.REQUEST] }],
}, },
{ {
id: 'request4k-tv', id: 'request-tv',
name: intl.formatMessage(messages.request4kTv), name: intl.formatMessage(messages.requestTv),
description: intl.formatMessage(messages.request4kTvDescription), description: intl.formatMessage(messages.requestTvDescription),
permission: Permission.REQUEST_4K_TV, permission: Permission.REQUEST_TV,
requires: [{ permissions: [Permission.REQUEST] }],
}, },
], ],
}, },
@ -149,7 +139,12 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
messages.autoapproveMoviesDescription messages.autoapproveMoviesDescription
), ),
permission: Permission.AUTO_APPROVE_MOVIE, permission: Permission.AUTO_APPROVE_MOVIE,
requires: [{ permissions: [Permission.REQUEST] }], requires: [
{
permissions: [Permission.REQUEST, Permission.REQUEST_MOVIE],
type: 'or',
},
],
}, },
{ {
id: 'autoapprovetv', id: 'autoapprovetv',
@ -158,7 +153,32 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
messages.autoapproveSeriesDescription messages.autoapproveSeriesDescription
), ),
permission: Permission.AUTO_APPROVE_TV, permission: Permission.AUTO_APPROVE_TV,
requires: [{ permissions: [Permission.REQUEST] }], requires: [
{
permissions: [Permission.REQUEST, Permission.REQUEST_TV],
type: 'or',
},
],
},
],
},
{
id: 'request4k',
name: intl.formatMessage(messages.request4k),
description: intl.formatMessage(messages.request4kDescription),
permission: Permission.REQUEST_4K,
children: [
{
id: 'request4k-movies',
name: intl.formatMessage(messages.request4kMovies),
description: intl.formatMessage(messages.request4kMoviesDescription),
permission: Permission.REQUEST_4K_MOVIE,
},
{
id: 'request4k-tv',
name: intl.formatMessage(messages.request4kTv),
description: intl.formatMessage(messages.request4kTvDescription),
permission: Permission.REQUEST_4K_TV,
}, },
], ],
}, },
@ -169,8 +189,7 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
permission: Permission.AUTO_APPROVE_4K, permission: Permission.AUTO_APPROVE_4K,
requires: [ requires: [
{ {
permissions: [Permission.REQUEST, Permission.REQUEST_4K], permissions: [Permission.REQUEST_4K],
type: 'and',
}, },
], ],
children: [ children: [
@ -182,9 +201,6 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
), ),
permission: Permission.AUTO_APPROVE_4K_MOVIE, permission: Permission.AUTO_APPROVE_4K_MOVIE,
requires: [ requires: [
{
permissions: [Permission.REQUEST],
},
{ {
permissions: [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], permissions: [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
type: 'or', type: 'or',
@ -199,9 +215,6 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
), ),
permission: Permission.AUTO_APPROVE_4K_TV, permission: Permission.AUTO_APPROVE_4K_TV,
requires: [ requires: [
{
permissions: [Permission.REQUEST],
},
{ {
permissions: [Permission.REQUEST_4K, Permission.REQUEST_4K_TV], permissions: [Permission.REQUEST_4K, Permission.REQUEST_4K_TV],
type: 'or', type: 'or',

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { hasPermission } from '../../../server/lib/permissions'; import { hasPermission } from '../../../server/lib/permissions';
import { Permission, User } from '../../hooks/useUser';
import useSettings from '../../hooks/useSettings'; import useSettings from '../../hooks/useSettings';
import { Permission, User } from '../../hooks/useUser';
export interface PermissionItem { export interface PermissionItem {
id: string; id: string;

@ -123,7 +123,15 @@ const RequestButton: React.FC<RequestButtonProps> = ({
const buttons: ButtonOption[] = []; const buttons: ButtonOption[] = [];
if ( if (
(!media || media.status === MediaStatus.UNKNOWN) && (!media || media.status === MediaStatus.UNKNOWN) &&
hasPermission(Permission.REQUEST) hasPermission(
[
Permission.REQUEST,
mediaType === 'movie'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
],
{ type: 'or' }
)
) { ) {
buttons.push({ buttons.push({
id: 'request', id: 'request',
@ -138,9 +146,15 @@ const RequestButton: React.FC<RequestButtonProps> = ({
if ( if (
(!media || media.status4k === MediaStatus.UNKNOWN) && (!media || media.status4k === MediaStatus.UNKNOWN) &&
(hasPermission(Permission.REQUEST_4K) || hasPermission(
(mediaType === 'movie' && hasPermission(Permission.REQUEST_4K_MOVIE)) || [
(mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) && Permission.REQUEST_4K,
mediaType === 'movie'
? Permission.REQUEST_4K_MOVIE
: Permission.REQUEST_4K_TV,
],
{ type: 'or' }
) &&
((settings.currentSettings.movie4kEnabled && mediaType === 'movie') || ((settings.currentSettings.movie4kEnabled && mediaType === 'movie') ||
(settings.currentSettings.series4kEnabled && mediaType === 'tv')) (settings.currentSettings.series4kEnabled && mediaType === 'tv'))
) { ) {
@ -302,7 +316,9 @@ const RequestButton: React.FC<RequestButtonProps> = ({
if ( if (
mediaType === 'tv' && mediaType === 'tv' &&
(!activeRequest || activeRequest.requestedBy.id !== user?.id) && (!activeRequest || activeRequest.requestedBy.id !== user?.id) &&
hasPermission(Permission.REQUEST) && hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
type: 'or',
}) &&
media && media &&
media.status !== MediaStatus.AVAILABLE && media.status !== MediaStatus.AVAILABLE &&
media.status !== MediaStatus.UNKNOWN && media.status !== MediaStatus.UNKNOWN &&

@ -69,6 +69,14 @@ const TitleCard: React.FC<TitleCardProps> = ({
const closeModal = useCallback(() => setShowRequestModal(false), []); const closeModal = useCallback(() => setShowRequestModal(false), []);
const showRequestButton = hasPermission(
[
Permission.REQUEST,
mediaType === 'movie' ? Permission.REQUEST_MOVIE : Permission.REQUEST_TV,
],
{ type: 'or' }
);
return ( return (
<div className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'}> <div className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'}>
<RequestModal <RequestModal
@ -185,7 +193,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
<div className="flex items-end w-full h-full"> <div className="flex items-end w-full h-full">
<div <div
className={`px-2 text-white ${ className={`px-2 text-white ${
!hasPermission(Permission.REQUEST) || !showRequestButton ||
(currentStatus && currentStatus !== MediaStatus.UNKNOWN) (currentStatus && currentStatus !== MediaStatus.UNKNOWN)
? 'pb-2' ? 'pb-2'
: 'pb-11' : 'pb-11'
@ -209,7 +217,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
className="text-xs whitespace-normal" className="text-xs whitespace-normal"
style={{ style={{
WebkitLineClamp: WebkitLineClamp:
!hasPermission(Permission.REQUEST) || !showRequestButton ||
(currentStatus && (currentStatus &&
currentStatus !== MediaStatus.UNKNOWN) currentStatus !== MediaStatus.UNKNOWN)
? 5 ? 5
@ -228,7 +236,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
</Link> </Link>
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2"> <div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2">
{hasPermission(Permission.REQUEST) && {showRequestButton &&
(!currentStatus || currentStatus === MediaStatus.UNKNOWN) && ( (!currentStatus || currentStatus === MediaStatus.UNKNOWN) && (
<Button <Button
buttonType="primary" buttonType="primary"

@ -63,7 +63,7 @@ const messages = defineMessages({
manageModalNoRequests: 'No requests.', manageModalNoRequests: 'No requests.',
manageModalClearMedia: 'Clear Media Data', manageModalClearMedia: 'Clear Media Data',
manageModalClearMediaWarning: manageModalClearMediaWarning:
'* This will irreversibly remove all data for this TV series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.', '* This will irreversibly remove all data for this series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.',
originaltitle: 'Original Title', originaltitle: 'Original Title',
showtype: 'Series Type', showtype: 'Series Type',
anime: 'Anime', anime: 'Anime',
@ -73,9 +73,9 @@ const messages = defineMessages({
opensonarr4k: 'Open Series in 4K Sonarr', opensonarr4k: 'Open Series in 4K Sonarr',
downloadstatus: 'Download Status', downloadstatus: 'Download Status',
playonplex: 'Play on Plex', playonplex: 'Play on Plex',
play4konplex: 'Play 4K on Plex', play4konplex: 'Play in 4K on Plex',
markavailable: 'Mark as Available', markavailable: 'Mark as Available',
mark4kavailable: 'Mark 4K as Available', mark4kavailable: 'Mark as Available in 4K',
allseasonsmarkedavailable: '* All seasons will be marked as available.', allseasonsmarkedavailable: '* All seasons will be marked as available.',
seasons: '{seasonCount, plural, one {# Season} other {# Seasons}}', seasons: '{seasonCount, plural, one {# Season} other {# Seasons}}',
episodeRuntime: 'Episode Runtime', episodeRuntime: 'Episode Runtime',

@ -69,7 +69,7 @@
"components.MovieDetails.manageModalNoRequests": "No requests.", "components.MovieDetails.manageModalNoRequests": "No requests.",
"components.MovieDetails.manageModalRequests": "Requests", "components.MovieDetails.manageModalRequests": "Requests",
"components.MovieDetails.manageModalTitle": "Manage Movie", "components.MovieDetails.manageModalTitle": "Manage Movie",
"components.MovieDetails.mark4kavailable": "Mark 4K as Available", "components.MovieDetails.mark4kavailable": "Mark as Available in 4K",
"components.MovieDetails.markavailable": "Mark as Available", "components.MovieDetails.markavailable": "Mark as Available",
"components.MovieDetails.openradarr": "Open Movie in Radarr", "components.MovieDetails.openradarr": "Open Movie in Radarr",
"components.MovieDetails.openradarr4k": "Open Movie in 4K Radarr", "components.MovieDetails.openradarr4k": "Open Movie in 4K Radarr",
@ -77,7 +77,7 @@
"components.MovieDetails.originaltitle": "Original Title", "components.MovieDetails.originaltitle": "Original Title",
"components.MovieDetails.overview": "Overview", "components.MovieDetails.overview": "Overview",
"components.MovieDetails.overviewunavailable": "Overview unavailable.", "components.MovieDetails.overviewunavailable": "Overview unavailable.",
"components.MovieDetails.play4konplex": "Play 4K on Plex", "components.MovieDetails.play4konplex": "Play in 4K on Plex",
"components.MovieDetails.playonplex": "Play on Plex", "components.MovieDetails.playonplex": "Play on Plex",
"components.MovieDetails.recommendations": "Recommendations", "components.MovieDetails.recommendations": "Recommendations",
"components.MovieDetails.releasedate": "Release Date", "components.MovieDetails.releasedate": "Release Date",
@ -103,37 +103,39 @@
"components.PermissionEdit.admin": "Admin", "components.PermissionEdit.admin": "Admin",
"components.PermissionEdit.adminDescription": "Full administrator access. Bypasses all other permission checks.", "components.PermissionEdit.adminDescription": "Full administrator access. Bypasses all other permission checks.",
"components.PermissionEdit.advancedrequest": "Advanced Requests", "components.PermissionEdit.advancedrequest": "Advanced Requests",
"components.PermissionEdit.advancedrequestDescription": "Grant permission to use advanced request options (e.g., changing servers, profiles, or paths).", "components.PermissionEdit.advancedrequestDescription": "Grant permission to use advanced request options.",
"components.PermissionEdit.autoapprove": "Auto-Approve", "components.PermissionEdit.autoapprove": "Auto-Approve",
"components.PermissionEdit.autoapprove4k": "Auto-Approve 4K", "components.PermissionEdit.autoapprove4k": "Auto-Approve 4K",
"components.PermissionEdit.autoapprove4kDescription": "Grant automatic approval for all 4K requests made by this user.", "components.PermissionEdit.autoapprove4kDescription": "Grant automatic approval for all 4K requests.",
"components.PermissionEdit.autoapprove4kMovies": "Auto-Approve 4K Movies", "components.PermissionEdit.autoapprove4kMovies": "Auto-Approve 4K Movies",
"components.PermissionEdit.autoapprove4kMoviesDescription": "Grant automatic approval for 4K movie requests made by this user.", "components.PermissionEdit.autoapprove4kMoviesDescription": "Grant automatic approval for 4K movie requests.",
"components.PermissionEdit.autoapprove4kSeries": "Auto-Approve 4K Series", "components.PermissionEdit.autoapprove4kSeries": "Auto-Approve 4K Series",
"components.PermissionEdit.autoapprove4kSeriesDescription": "Grant automatic approval for 4K series requests made by this user.", "components.PermissionEdit.autoapprove4kSeriesDescription": "Grant automatic approval for 4K series requests.",
"components.PermissionEdit.autoapproveDescription": "Grant automatic approval for all non-4K requests made by this user.", "components.PermissionEdit.autoapproveDescription": "Grant automatic approval for all non-4K requests.",
"components.PermissionEdit.autoapproveMovies": "Auto-Approve Movies", "components.PermissionEdit.autoapproveMovies": "Auto-Approve Movies",
"components.PermissionEdit.autoapproveMoviesDescription": "Grant automatic approval for non-4K movie requests made by this user.", "components.PermissionEdit.autoapproveMoviesDescription": "Grant automatic approval for non-4K movie requests.",
"components.PermissionEdit.autoapproveSeries": "Auto-Approve Series", "components.PermissionEdit.autoapproveSeries": "Auto-Approve Series",
"components.PermissionEdit.autoapproveSeriesDescription": "Grant automatic approval for non-4K series requests made by this user.", "components.PermissionEdit.autoapproveSeriesDescription": "Grant automatic approval for non-4K series requests.",
"components.PermissionEdit.managerequests": "Manage Requests", "components.PermissionEdit.managerequests": "Manage Requests",
"components.PermissionEdit.managerequestsDescription": "Grant permission to manage Overseerr requests (includes approving and denying requests). All requests made by a user with this permission will be automatically approved.", "components.PermissionEdit.managerequestsDescription": "Grant permission to manage Overseerr requests. All requests made by a user with this permission will be automatically approved.",
"components.PermissionEdit.request": "Request", "components.PermissionEdit.request": "Request",
"components.PermissionEdit.request4k": "Request 4K", "components.PermissionEdit.request4k": "Request 4K",
"components.PermissionEdit.request4kDescription": "Grant permission to request 4K movies and series.", "components.PermissionEdit.request4kDescription": "Grant permission to request 4K media.",
"components.PermissionEdit.request4kMovies": "Request 4K Movies", "components.PermissionEdit.request4kMovies": "Request 4K Movies",
"components.PermissionEdit.request4kMoviesDescription": "Grant permission to request 4K movies.", "components.PermissionEdit.request4kMoviesDescription": "Grant permission to request 4K movies.",
"components.PermissionEdit.request4kTv": "Request 4K Series", "components.PermissionEdit.request4kTv": "Request 4K Series",
"components.PermissionEdit.request4kTvDescription": "Grant permission to request 4K Series.", "components.PermissionEdit.request4kTvDescription": "Grant permission to request 4K series.",
"components.PermissionEdit.requestDescription": "Grant permission to request movies and series.", "components.PermissionEdit.requestDescription": "Grant permission to request non-4K media.",
"components.PermissionEdit.requestMovies": "Request Movies",
"components.PermissionEdit.requestMoviesDescription": "Grant permission to request non-4K movies.",
"components.PermissionEdit.requestTv": "Request Series",
"components.PermissionEdit.requestTvDescription": "Grant permission to request non-4K series.",
"components.PermissionEdit.settings": "Manage Settings", "components.PermissionEdit.settings": "Manage Settings",
"components.PermissionEdit.settingsDescription": "Grant permission to modify all Overseerr settings. A user must have this permission to grant it to others.", "components.PermissionEdit.settingsDescription": "Grant permission to modify Overseerr settings. A user must have this permission to grant it to others.",
"components.PermissionEdit.users": "Manage Users", "components.PermissionEdit.users": "Manage Users",
"components.PermissionEdit.usersDescription": "Grant permission to manage Overseerr users. Users with this permission cannot modify users with or grant the Admin privilege.", "components.PermissionEdit.usersDescription": "Grant permission to manage Overseerr users. Users with this permission cannot modify users with or grant the Admin privilege.",
"components.PermissionEdit.viewrequests": "View Requests", "components.PermissionEdit.viewrequests": "View Requests",
"components.PermissionEdit.viewrequestsDescription": "Grant permission to view other users' requests.", "components.PermissionEdit.viewrequestsDescription": "Grant permission to view other users' requests.",
"components.PermissionEdit.vote": "Vote",
"components.PermissionEdit.voteDescription": "Grant permission to vote on requests (voting not yet implemented).",
"components.PersonDetails.alsoknownas": "Also Known As: {names}", "components.PersonDetails.alsoknownas": "Also Known As: {names}",
"components.PersonDetails.appearsin": "Appearances", "components.PersonDetails.appearsin": "Appearances",
"components.PersonDetails.ascharacter": "as {character}", "components.PersonDetails.ascharacter": "as {character}",
@ -658,11 +660,11 @@
"components.TvDetails.episodeRuntimeMinutes": "{runtime} minutes", "components.TvDetails.episodeRuntimeMinutes": "{runtime} minutes",
"components.TvDetails.firstAirDate": "First Air Date", "components.TvDetails.firstAirDate": "First Air Date",
"components.TvDetails.manageModalClearMedia": "Clear Media Data", "components.TvDetails.manageModalClearMedia": "Clear Media Data",
"components.TvDetails.manageModalClearMediaWarning": "* This will irreversibly remove all data for this TV series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.", "components.TvDetails.manageModalClearMediaWarning": "* This will irreversibly remove all data for this series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.",
"components.TvDetails.manageModalNoRequests": "No requests.", "components.TvDetails.manageModalNoRequests": "No requests.",
"components.TvDetails.manageModalRequests": "Requests", "components.TvDetails.manageModalRequests": "Requests",
"components.TvDetails.manageModalTitle": "Manage Series", "components.TvDetails.manageModalTitle": "Manage Series",
"components.TvDetails.mark4kavailable": "Mark 4K as Available", "components.TvDetails.mark4kavailable": "Mark as Available in 4K",
"components.TvDetails.markavailable": "Mark as Available", "components.TvDetails.markavailable": "Mark as Available",
"components.TvDetails.network": "{networkCount, plural, one {Network} other {Networks}}", "components.TvDetails.network": "{networkCount, plural, one {Network} other {Networks}}",
"components.TvDetails.nextAirDate": "Next Air Date", "components.TvDetails.nextAirDate": "Next Air Date",
@ -672,7 +674,7 @@
"components.TvDetails.originaltitle": "Original Title", "components.TvDetails.originaltitle": "Original Title",
"components.TvDetails.overview": "Overview", "components.TvDetails.overview": "Overview",
"components.TvDetails.overviewunavailable": "Overview unavailable.", "components.TvDetails.overviewunavailable": "Overview unavailable.",
"components.TvDetails.play4konplex": "Play 4K on Plex", "components.TvDetails.play4konplex": "Play in 4K on Plex",
"components.TvDetails.playonplex": "Play on Plex", "components.TvDetails.playonplex": "Play on Plex",
"components.TvDetails.recommendations": "Recommendations", "components.TvDetails.recommendations": "Recommendations",
"components.TvDetails.seasons": "{seasonCount, plural, one {# Season} other {# Seasons}}", "components.TvDetails.seasons": "{seasonCount, plural, one {# Season} other {# Seasons}}",

Loading…
Cancel
Save