diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index f485424fcc..4f67fb76e2 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -25,6 +25,7 @@ "mediaServerType": 1, "partialRequestsEnabled": true, "enableSpecialEpisodes": false, + "multiQualityRequestsEnabled": true, "locale": "en" }, "plex": { diff --git a/next-env.d.ts b/next-env.d.ts index 19709046af..7996d352f4 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/seerr-api.yml b/seerr-api.yml index e517722609..d5947ecb82 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -254,6 +254,9 @@ components: enableSpecialEpisodes: type: boolean example: false + multiQualityRequestsEnabled: + type: boolean + example: true NetworkSettings: type: object properties: diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 0252799220..3ff208d5ed 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -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 {} @@ -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 diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 3dfa619205..a152b09e29 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -41,6 +41,8 @@ export interface PublicSettingsResponse { mediaServerType: number; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; + multiQualityRequestsEnabled: boolean; + separate4kRequestButton: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index c1124c22f4..a7a86a89ba 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -154,6 +154,8 @@ export interface MainSettings { mediaServerType: number; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; + multiQualityRequestsEnabled: boolean; + separate4kRequestButton: boolean; locale: string; youtubeUrl: string; } @@ -206,6 +208,8 @@ interface FullPublicSettings extends PublicSettings { jellyfinServerName?: string; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; + multiQualityRequestsEnabled: boolean; + separate4kRequestButton: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; @@ -426,6 +430,8 @@ class Settings { mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, enableSpecialEpisodes: false, + multiQualityRequestsEnabled: true, + separate4kRequestButton: false, locale: 'en', youtubeUrl: '', }, @@ -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, diff --git a/server/routes/request.ts b/server/routes/request.ts index fafa90692e..db8a1ea6f6 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -12,6 +12,7 @@ import { DuplicateMediaRequestError, MediaRequest, NoSeasonsAvailableError, + QualityRestrictedError, QuotaRestrictedError, RequestPermissionError, } from '@server/entity/MediaRequest'; @@ -321,6 +322,7 @@ requestRoutes.post( 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 }); diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index a8ee90a684..e51670a78f 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -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} diff --git a/src/components/Common/Badge/index.tsx b/src/components/Common/Badge/index.tsx index 5ee23bdfd0..31fe088cdf 100644 --- a/src/components/Common/Badge/index.tsx +++ b/src/components/Common/Badge/index.tsx @@ -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 ) => { 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) { diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 142d8f77e2..6b7440386f 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -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} @@ -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} diff --git a/src/components/Common/StatusBadgeMini/index.tsx b/src/components/Common/StatusBadgeMini/index.tsx index ea93a2a9b5..262ee90113 100644 --- a/src/components/Common/StatusBadgeMini/index.tsx +++ b/src/components/Common/StatusBadgeMini/index.tsx @@ -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' }`, ]; @@ -72,12 +72,16 @@ const StatusBadgeMini = ({ return (
+ {is4k && ( + + 4K + + )}
{indicatorIcon}
- {is4k && 4K}
); }; diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 4418c12314..a2e2aeee2a 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -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} @@ -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} diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 56a3c0a1f7..861bea945a 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -513,6 +513,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { mediaType="movie" plexUrl={plexUrl} serviceUrl={data.mediaInfo?.serviceUrl} + size="lg" /> {settings.currentSettings.movie4kEnabled && hasPermission( @@ -537,6 +538,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { mediaType="movie" plexUrl={plexUrl4k} serviceUrl={data.mediaInfo?.serviceUrl4k} + size="lg" /> )} diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index c8538793c2..38fca5ed81 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -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 && ( @@ -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 && ( diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 55b14c6c96..46c8c5b1ed 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -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 @@ -272,6 +282,7 @@ const RequestButton = ({ (!media || media.status === MediaStatus.UNKNOWN || (media.status === MediaStatus.DELETED && !activeRequest)) && + canRequestQuality(media?.status4k) && hasPermission( [ Permission.REQUEST, @@ -299,6 +310,7 @@ const RequestButton = ({ }) && media && media.status !== MediaStatus.BLOCKLISTED && + canRequestQuality(media.status4k) && !isShowComplete ) { buttons.push({ @@ -317,6 +329,7 @@ const RequestButton = ({ (!media || media.status4k === MediaStatus.UNKNOWN || (media.status4k === MediaStatus.DELETED && !active4kRequest)) && + canRequestQuality(media?.status) && hasPermission( [ Permission.REQUEST_4K, @@ -346,6 +359,7 @@ const RequestButton = ({ }) && media && media.status4k !== MediaStatus.BLOCKLISTED && + canRequestQuality(media.status) && !is4kShowComplete && settings.currentSettings.series4kEnabled ) { @@ -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 ( + + {buttonOne.svg} + {buttonOne.text} + + } + onClick={buttonOne.action} + className="ml-2" + > + {others.length > 0 + ? others.map((button) => ( + + {button.svg} + {button.text} + + )) + : null} + + ); + }; + return ( <> setShowRequest4kModal(false)} /> - - {buttonOne.svg} - {buttonOne.text} - - } - onClick={buttonOne.action} - className="ml-2" - > - {others && others.length > 0 - ? others.map((button) => ( - - {button.svg} - {button.text} - - )) - : null} - + {renderButtonGroup(mainButtons)} + {renderButtonGroup(fourKButtons)} ); }; diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 941b8dc71e..cbab91c3d8 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -210,7 +210,7 @@ const TvRequestModal = ({ if (response.data) { if (onComplete) { - onComplete(response.data.media.status); + onComplete(response.data.media[is4k ? 'status4k' : 'status']); } addToast( diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index 00703ddd37..6a61c50652 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -70,6 +70,12 @@ const messages = defineMessages('components.Settings.SettingsMain', { validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', partialRequestsEnabled: 'Allow Partial Series Requests', enableSpecialEpisodes: 'Allow Special Episodes Requests', + multiQualityRequestsEnabled: 'Allow Multi-Quality Requests', + multiQualityRequestsEnabledTip: + 'Allow users to request the same title in both standard and 4K quality. When disabled, a title that has already been requested or is available in one quality can no longer be requested in the other, except by users with the Manage Requests permission', + separate4kRequestButton: 'Separate 4K Request Button', + separate4kRequestButtonTip: + 'Show the 4K request as its own button next to the Request button on title pages instead of inside the request dropdown', locale: 'Display Language', youtubeUrl: 'YouTube URL', youtubeUrlTip: @@ -181,6 +187,8 @@ const SettingsMain = () => { blocklistedTagsLimit: data?.blocklistedTagsLimit || 50, partialRequestsEnabled: data?.partialRequestsEnabled, enableSpecialEpisodes: data?.enableSpecialEpisodes, + multiQualityRequestsEnabled: data?.multiQualityRequestsEnabled, + separate4kRequestButton: data?.separate4kRequestButton, cacheImages: data?.cacheImages, youtubeUrl: data?.youtubeUrl, }} @@ -203,6 +211,8 @@ const SettingsMain = () => { blocklistedTagsLimit: values.blocklistedTagsLimit, partialRequestsEnabled: values.partialRequestsEnabled, enableSpecialEpisodes: values.enableSpecialEpisodes, + multiQualityRequestsEnabled: values.multiQualityRequestsEnabled, + separate4kRequestButton: values.separate4kRequestButton, cacheImages: values.cacheImages, youtubeUrl: values.youtubeUrl, }); @@ -584,6 +594,60 @@ const SettingsMain = () => { /> +
+ +
+ { + setFieldValue( + 'multiQualityRequestsEnabled', + !values.multiQualityRequestsEnabled + ); + }} + /> +
+
+
+ +
+ { + setFieldValue( + 'separate4kRequestButton', + !values.separate4kRequestButton + ); + }} + /> +
+