Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cypress/config/settings.cypress.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"mediaServerType": 1,
"partialRequestsEnabled": true,
"enableSpecialEpisodes": false,
"multiQualityRequestsEnabled": true,
"locale": "en"
},
"plex": {
Expand Down
2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
3 changes: 3 additions & 0 deletions seerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@ components:
enableSpecialEpisodes:
type: boolean
example: false
multiQualityRequestsEnabled:
type: boolean
example: true
NetworkSettings:
type: object
properties:
Expand Down
31 changes: 31 additions & 0 deletions server/entity/MediaRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { User } from './User';

export class RequestPermissionError extends Error {}
export class QuotaRestrictedError extends Error {}
export class QualityRestrictedError extends Error {}
export class DuplicateMediaRequestError extends Error {}
export class NoSeasonsAvailableError extends Error {}
export class BlocklistedMediaError extends Error {}
Expand Down Expand Up @@ -158,6 +159,36 @@ export class MediaRequest {
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
media.status4k = MediaStatus.PENDING;
}

if (
!settings.main.multiQualityRequestsEnabled &&
!user.hasPermission(Permission.MANAGE_REQUESTS)
) {
const otherQualityStatus = requestBody.is4k
? media.status
: media.status4k;

if (
otherQualityStatus !== MediaStatus.UNKNOWN &&
otherQualityStatus !== MediaStatus.DELETED
) {
logger.warn(
'Request blocked because media is already requested or available in another quality',
{
tmdbId: tmdbMedia.id,
mediaType: requestBody.mediaType,
is4k: requestBody.is4k,
label: 'Media Request',
}
);

throw new QualityRestrictedError(
`This media has already been requested or is available in ${
requestBody.is4k ? 'standard' : '4K'
} quality.`
);
}
}
}

const existing = await requestRepository
Expand Down
2 changes: 2 additions & 0 deletions server/interfaces/api/settingsInterfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export interface PublicSettingsResponse {
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
multiQualityRequestsEnabled: boolean;
separate4kRequestButton: boolean;
cacheImages: boolean;
vapidPublic: string;
enablePushRegistration: boolean;
Expand Down
8 changes: 8 additions & 0 deletions server/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ export interface MainSettings {
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
multiQualityRequestsEnabled: boolean;
separate4kRequestButton: boolean;
locale: string;
youtubeUrl: string;
}
Expand Down Expand Up @@ -206,6 +208,8 @@ interface FullPublicSettings extends PublicSettings {
jellyfinServerName?: string;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
multiQualityRequestsEnabled: boolean;
separate4kRequestButton: boolean;
cacheImages: boolean;
vapidPublic: string;
enablePushRegistration: boolean;
Expand Down Expand Up @@ -426,6 +430,8 @@ class Settings {
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
enableSpecialEpisodes: false,
multiQualityRequestsEnabled: true,
separate4kRequestButton: false,
locale: 'en',
youtubeUrl: '',
},
Expand Down Expand Up @@ -724,6 +730,8 @@ class Settings {
mediaServerType: this.main.mediaServerType,
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
enableSpecialEpisodes: this.data.main.enableSpecialEpisodes,
multiQualityRequestsEnabled: this.data.main.multiQualityRequestsEnabled,
separate4kRequestButton: this.data.main.separate4kRequestButton,
cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic,
enablePushRegistration: this.data.notifications.agents.webpush.enabled,
Expand Down
2 changes: 2 additions & 0 deletions server/routes/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
DuplicateMediaRequestError,
MediaRequest,
NoSeasonsAvailableError,
QualityRestrictedError,
QuotaRestrictedError,
RequestPermissionError,
} from '@server/entity/MediaRequest';
Expand Down Expand Up @@ -321,6 +322,7 @@ requestRoutes.post<never, MediaRequest, MediaRequestBody>(
switch (error.constructor) {
case RequestPermissionError:
case QuotaRestrictedError:
case QualityRestrictedError:
return next({ status: 403, message: error.message });
case DuplicateMediaRequestError:
return next({ status: 409, message: error.message });
Expand Down
1 change: 1 addition & 0 deletions src/components/CollectionDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
Expand Down
12 changes: 10 additions & 2 deletions src/components/Common/Badge/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,25 @@ interface BadgeProps {
| 'success'
| 'dark'
| 'light';
size?: 'default' | 'lg';
className?: string;
href?: string;
children: React.ReactNode;
}

const Badge = (
{ badgeType = 'default', className, href, children }: BadgeProps,
{
badgeType = 'default',
size = 'default',
className,
href,
children,
}: BadgeProps,
ref?: React.Ref<HTMLElement>
) => {
const badgeStyle = [
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap',
'inline-flex items-center font-semibold rounded-full whitespace-nowrap',
size === 'lg' ? 'px-3 py-1 text-sm leading-5' : 'px-2 text-xs leading-5',
];

if (href) {
Expand Down
2 changes: 2 additions & 0 deletions src/components/Common/ListView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const ListView = ({
}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
Expand All @@ -109,6 +110,7 @@ const ListView = ({
}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.name}
userScore={title.voteAverage}
Expand Down
10 changes: 7 additions & 3 deletions src/components/Common/StatusBadgeMini/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const StatusBadgeMini = ({
}: StatusBadgeMiniProps) => {
const badgeStyle = [
`rounded-full shadow-md ${
shrink ? 'w-4 sm:w-5 border p-0' : 'w-5 ring-1 p-0.5'
shrink ? 'h-4 w-4 border p-0 sm:h-5 sm:w-5' : 'h-5 w-5 ring-1 p-0.5'
}`,
];

Expand Down Expand Up @@ -72,12 +72,16 @@ const StatusBadgeMini = ({

return (
<div
className={`relative inline-flex whitespace-nowrap rounded-full border-gray-700 text-xs font-semibold leading-5 ring-gray-700 ${
className={`relative inline-flex items-center whitespace-nowrap rounded-full border-gray-700 text-xs font-semibold leading-5 ring-gray-700 ${
shrink ? '' : 'ring-1'
}`}
>
{is4k && (
<span className="pl-2 pr-1 text-white [text-shadow:-0.5px_-0.5px_0_#000,0.5px_-0.5px_0_#000,-0.5px_0.5px_0_#000,0.5px_0.5px_0_#000]">
4K
</span>
)}
<div className={badgeStyle.join(' ')}>{indicatorIcon}</div>
{is4k && <span className="pl-1 pr-2 text-gray-200">4K</span>}
</div>
);
};
Expand Down
2 changes: 2 additions & 0 deletions src/components/MediaSlider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ const MediaSlider = ({
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
Expand All @@ -143,6 +144,7 @@ const MediaSlider = ({
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.name}
userScore={title.voteAverage}
Expand Down
2 changes: 2 additions & 0 deletions src/components/MovieDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
mediaType="movie"
plexUrl={plexUrl}
serviceUrl={data.mediaInfo?.serviceUrl}
size="lg"
/>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
Expand All @@ -537,6 +538,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
mediaType="movie"
plexUrl={plexUrl4k}
serviceUrl={data.mediaInfo?.serviceUrl4k}
size="lg"
/>
)}
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/components/PersonDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ const PersonDetails = () => {
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
status4k={media.mediaInfo?.status4k}
canExpand
/>
{media.character && (
Expand Down Expand Up @@ -224,6 +225,7 @@ const PersonDetails = () => {
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
status4k={media.mediaInfo?.status4k}
canExpand
/>
{media.job && (
Expand Down
84 changes: 60 additions & 24 deletions src/components/RequestButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ const RequestButton = ({
mutate('/api/v1/request/count');
};

const isOtherQualityUnclaimed = (otherQualityStatus?: MediaStatus) =>
!otherQualityStatus ||
otherQualityStatus === MediaStatus.UNKNOWN ||
otherQualityStatus === MediaStatus.DELETED;

const canRequestQuality = (otherQualityStatus?: MediaStatus) =>
settings.currentSettings.multiQualityRequestsEnabled ||
hasPermission(Permission.MANAGE_REQUESTS) ||
isOtherQualityUnclaimed(otherQualityStatus);

const buttons: ButtonOption[] = [];

// If there are pending requests, show request management options first
Expand Down Expand Up @@ -272,6 +282,7 @@ const RequestButton = ({
(!media ||
media.status === MediaStatus.UNKNOWN ||
(media.status === MediaStatus.DELETED && !activeRequest)) &&
canRequestQuality(media?.status4k) &&
hasPermission(
[
Permission.REQUEST,
Expand Down Expand Up @@ -299,6 +310,7 @@ const RequestButton = ({
}) &&
media &&
media.status !== MediaStatus.BLOCKLISTED &&
canRequestQuality(media.status4k) &&
!isShowComplete
) {
buttons.push({
Expand All @@ -317,6 +329,7 @@ const RequestButton = ({
(!media ||
media.status4k === MediaStatus.UNKNOWN ||
(media.status4k === MediaStatus.DELETED && !active4kRequest)) &&
canRequestQuality(media?.status) &&
hasPermission(
[
Permission.REQUEST_4K,
Expand Down Expand Up @@ -346,6 +359,7 @@ const RequestButton = ({
}) &&
media &&
media.status4k !== MediaStatus.BLOCKLISTED &&
canRequestQuality(media.status) &&
!is4kShowComplete &&
settings.currentSettings.series4kEnabled
) {
Expand All @@ -360,12 +374,54 @@ const RequestButton = ({
});
}

const [buttonOne, ...others] = buttons;
// When enabled, split all 4K-related request actions into their own dedicated
// button (with its own dropdown), leaving the non-4K actions untouched.
// Otherwise everything stays in a single button + dropdown.
const splitButtons = settings.currentSettings.separate4kRequestButton;
const mainButtons = splitButtons
? buttons.filter((button) => !button.id.includes('4k'))
: buttons;
const fourKButtons = splitButtons
? buttons.filter((button) => button.id.includes('4k'))
: [];

if (!buttonOne) {
if (mainButtons.length === 0 && fourKButtons.length === 0) {
return null;
}

const renderButtonGroup = (group: ButtonOption[]) => {
const [buttonOne, ...others] = group;

if (!buttonOne) {
return null;
}

return (
<ButtonWithDropdown
text={
<>
{buttonOne.svg}
<span>{buttonOne.text}</span>
</>
}
onClick={buttonOne.action}
className="ml-2"
>
{others.length > 0
? others.map((button) => (
<ButtonWithDropdown.Item
onClick={button.action}
key={`request-option-${button.id}`}
>
{button.svg}
<span>{button.text}</span>
</ButtonWithDropdown.Item>
))
: null}
</ButtonWithDropdown>
);
};

return (
<>
<RequestModal
Expand All @@ -391,28 +447,8 @@ const RequestButton = ({
}}
onCancel={() => setShowRequest4kModal(false)}
/>
<ButtonWithDropdown
text={
<>
{buttonOne.svg}
<span>{buttonOne.text}</span>
</>
}
onClick={buttonOne.action}
className="ml-2"
>
{others && others.length > 0
? others.map((button) => (
<ButtonWithDropdown.Item
onClick={button.action}
key={`request-option-${button.id}`}
>
{button.svg}
<span>{button.text}</span>
</ButtonWithDropdown.Item>
))
: null}
</ButtonWithDropdown>
{renderButtonGroup(mainButtons)}
{renderButtonGroup(fourKButtons)}
</>
);
};
Expand Down
Loading