mirror of
https://github.com/BreizhHardware/jellyseerr.git
synced 2026-03-18 21:50:40 +01:00
feat: show quality profile on request (#847)
* feat: backend fetch and return quality profile * feat: show request profile name * fix: wrong backend types * feat: i18n keys * fix: don't display quality profile if not set * fix: remove development artifact * fix: reduce parent div padding
This commit is contained in:
@@ -8,3 +8,16 @@ interface PageInfo {
|
|||||||
export interface PaginatedResponse {
|
export interface PaginatedResponse {
|
||||||
pageInfo: PageInfo;
|
pageInfo: PageInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the keys of an object that are not functions
|
||||||
|
*/
|
||||||
|
type NonFunctionPropertyNames<T> = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
[K in keyof T]: T[K] extends Function ? never : K;
|
||||||
|
}[keyof T];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the properties of an object that are not functions
|
||||||
|
*/
|
||||||
|
export type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { MediaType } from '@server/constants/media';
|
import type { MediaType } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import type { PaginatedResponse } from './common';
|
import type { NonFunctionProperties, PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface RequestResultsResponse extends PaginatedResponse {
|
export interface RequestResultsResponse extends PaginatedResponse {
|
||||||
results: MediaRequest[];
|
results: NonFunctionProperties<MediaRequest>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MediaRequestBody = {
|
export type MediaRequestBody = {
|
||||||
@@ -14,6 +14,7 @@ export type MediaRequestBody = {
|
|||||||
is4k?: boolean;
|
is4k?: boolean;
|
||||||
serverId?: number;
|
serverId?: number;
|
||||||
profileId?: number;
|
profileId?: number;
|
||||||
|
profileName?: string;
|
||||||
rootFolder?: string;
|
rootFolder?: string;
|
||||||
languageProfileId?: number;
|
languageProfileId?: number;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import {
|
import {
|
||||||
MediaRequestStatus,
|
MediaRequestStatus,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
@@ -19,6 +21,7 @@ import type {
|
|||||||
RequestResultsResponse,
|
RequestResultsResponse,
|
||||||
} from '@server/interfaces/api/requestInterfaces';
|
} from '@server/interfaces/api/requestInterfaces';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
@@ -143,6 +146,62 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
.skip(skip)
|
.skip(skip)
|
||||||
.getManyAndCount();
|
.getManyAndCount();
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
// get all quality profiles for every configured sonarr server
|
||||||
|
const sonarrServers = await Promise.all(
|
||||||
|
settings.sonarr.map(async (sonarrSetting) => {
|
||||||
|
const sonarr = new SonarrAPI({
|
||||||
|
apiKey: sonarrSetting.apiKey,
|
||||||
|
url: SonarrAPI.buildUrl(sonarrSetting, '/api/v3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sonarrSetting.id,
|
||||||
|
profiles: await sonarr.getProfiles(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// get all quality profiles for every configured radarr server
|
||||||
|
const radarrServers = await Promise.all(
|
||||||
|
settings.radarr.map(async (radarrSetting) => {
|
||||||
|
const radarr = new RadarrAPI({
|
||||||
|
apiKey: radarrSetting.apiKey,
|
||||||
|
url: RadarrAPI.buildUrl(radarrSetting, '/api/v3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: radarrSetting.id,
|
||||||
|
profiles: await radarr.getProfiles(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// add profile names to the media requests, with undefined if not found
|
||||||
|
const requestsWithProfileNames = requests.map((r) => {
|
||||||
|
switch (r.type) {
|
||||||
|
case MediaType.MOVIE: {
|
||||||
|
const profileName = radarrServers
|
||||||
|
.find((serverr) => serverr.id === r.serverId)
|
||||||
|
?.profiles.find((profile) => profile.id === r.profileId)?.name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
profileName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case MediaType.TV: {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
profileName: sonarrServers
|
||||||
|
.find((serverr) => serverr.id === r.serverId)
|
||||||
|
?.profiles.find((profile) => profile.id === r.profileId)?.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
pageInfo: {
|
pageInfo: {
|
||||||
pages: Math.ceil(requestCount / pageSize),
|
pages: Math.ceil(requestCount / pageSize),
|
||||||
@@ -150,7 +209,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
results: requestCount,
|
results: requestCount,
|
||||||
page: Math.ceil(skip / pageSize) + 1,
|
page: Math.ceil(skip / pageSize) + 1,
|
||||||
},
|
},
|
||||||
results: requests,
|
results: requestsWithProfileNames,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next({ status: 500, message: e.message });
|
next({ status: 500, message: e.message });
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
@@ -58,7 +59,7 @@ const RequestCardPlaceholder = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestCardErrorProps {
|
interface RequestCardErrorProps {
|
||||||
requestData?: MediaRequest;
|
requestData?: NonFunctionProperties<MediaRequest>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
||||||
@@ -213,7 +214,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestCardProps {
|
interface RequestCardProps {
|
||||||
request: MediaRequest;
|
request: NonFunctionProperties<MediaRequest>;
|
||||||
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
|
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,16 +239,19 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
data: requestData,
|
data: requestData,
|
||||||
error: requestError,
|
error: requestError,
|
||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
|
} = useSWR<NonFunctionProperties<MediaRequest>>(
|
||||||
fallbackData: request,
|
`/api/v1/request/${request.id}`,
|
||||||
refreshInterval: refreshIntervalHelper(
|
{
|
||||||
{
|
fallbackData: request,
|
||||||
downloadStatus: request.media.downloadStatus,
|
refreshInterval: refreshIntervalHelper(
|
||||||
downloadStatus4k: request.media.downloadStatus4k,
|
{
|
||||||
},
|
downloadStatus: request.media.downloadStatus,
|
||||||
15000
|
downloadStatus4k: request.media.downloadStatus4k,
|
||||||
),
|
},
|
||||||
});
|
15000
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
||||||
mediaUrl: requestData?.media?.mediaUrl,
|
mediaUrl: requestData?.media?.mediaUrl,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
@@ -42,6 +43,7 @@ const messages = defineMessages('components.RequestList.RequestItem', {
|
|||||||
tmdbid: 'TMDB ID',
|
tmdbid: 'TMDB ID',
|
||||||
tvdbid: 'TheTVDB ID',
|
tvdbid: 'TheTVDB ID',
|
||||||
unknowntitle: 'Unknown Title',
|
unknowntitle: 'Unknown Title',
|
||||||
|
profileName: 'Profile',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||||
@@ -49,7 +51,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestItemErrorProps {
|
interface RequestItemErrorProps {
|
||||||
requestData?: MediaRequest;
|
requestData?: NonFunctionProperties<MediaRequest>;
|
||||||
revalidateList: () => void;
|
revalidateList: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +287,7 @@ const RequestItemError = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestItemProps {
|
interface RequestItemProps {
|
||||||
request: MediaRequest;
|
request: NonFunctionProperties<MediaRequest> & { profileName?: string };
|
||||||
revalidateList: () => void;
|
revalidateList: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,19 +306,18 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||||
inView ? url : null
|
inView ? url : null
|
||||||
);
|
);
|
||||||
const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>(
|
const { data: requestData, mutate: revalidate } = useSWR<
|
||||||
`/api/v1/request/${request.id}`,
|
NonFunctionProperties<MediaRequest>
|
||||||
{
|
>(`/api/v1/request/${request.id}`, {
|
||||||
fallbackData: request,
|
fallbackData: request,
|
||||||
refreshInterval: refreshIntervalHelper(
|
refreshInterval: refreshIntervalHelper(
|
||||||
{
|
{
|
||||||
downloadStatus: request.media.downloadStatus,
|
downloadStatus: request.media.downloadStatus,
|
||||||
downloadStatus4k: request.media.downloadStatus4k,
|
downloadStatus4k: request.media.downloadStatus4k,
|
||||||
},
|
},
|
||||||
15000
|
15000
|
||||||
),
|
),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const [isRetrying, setRetrying] = useState(false);
|
const [isRetrying, setRetrying] = useState(false);
|
||||||
|
|
||||||
@@ -401,7 +402,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
|
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-2 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
|
||||||
{title.backdropPath && (
|
{title.backdropPath && (
|
||||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
@@ -482,7 +483,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
|
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center gap-1 overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
|
||||||
<div className="card-field">
|
<div className="card-field">
|
||||||
<span className="card-field-name">
|
<span className="card-field-name">
|
||||||
{intl.formatMessage(globalMessages.status)}
|
{intl.formatMessage(globalMessages.status)}
|
||||||
@@ -632,6 +633,16 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{request.profileName && (
|
||||||
|
<div className="card-field">
|
||||||
|
<span className="card-field-name">
|
||||||
|
{intl.formatMessage(messages.profileName)}
|
||||||
|
</span>
|
||||||
|
<span className="flex truncate text-sm text-gray-300">
|
||||||
|
{request.profileName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
|
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import globalMessages from '@app/i18n/globalMessages';
|
|||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
@@ -38,7 +39,7 @@ const messages = defineMessages('components.RequestModal', {
|
|||||||
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
is4k?: boolean;
|
is4k?: boolean;
|
||||||
editRequest?: MediaRequest;
|
editRequest?: NonFunctionProperties<MediaRequest>;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onComplete?: (newStatus: MediaStatus) => void;
|
onComplete?: (newStatus: MediaStatus) => void;
|
||||||
onUpdating?: (isUpdating: boolean) => void;
|
onUpdating?: (isUpdating: boolean) => void;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
|||||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import type SeasonRequest from '@server/entity/SeasonRequest';
|
import type SeasonRequest from '@server/entity/SeasonRequest';
|
||||||
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
@@ -57,7 +58,7 @@ interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
onComplete?: (newStatus: MediaStatus) => void;
|
onComplete?: (newStatus: MediaStatus) => void;
|
||||||
onUpdating?: (isUpdating: boolean) => void;
|
onUpdating?: (isUpdating: boolean) => void;
|
||||||
is4k?: boolean;
|
is4k?: boolean;
|
||||||
editRequest?: MediaRequest;
|
editRequest?: NonFunctionProperties<MediaRequest>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TvRequestModal = ({
|
const TvRequestModal = ({
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import TvRequestModal from '@app/components/RequestModal/TvRequestModal';
|
|||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import type { MediaStatus } from '@server/constants/media';
|
import type { MediaStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
|
|
||||||
interface RequestModalProps {
|
interface RequestModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
type: 'movie' | 'tv' | 'collection';
|
type: 'movie' | 'tv' | 'collection';
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
is4k?: boolean;
|
is4k?: boolean;
|
||||||
editRequest?: MediaRequest;
|
editRequest?: NonFunctionProperties<MediaRequest>;
|
||||||
onComplete?: (newStatus: MediaStatus) => void;
|
onComplete?: (newStatus: MediaStatus) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onUpdating?: (isUpdating: boolean) => void;
|
onUpdating?: (isUpdating: boolean) => void;
|
||||||
|
|||||||
@@ -465,6 +465,7 @@
|
|||||||
"components.RequestList.RequestItem.mediaerror": "{mediaType} Not Found",
|
"components.RequestList.RequestItem.mediaerror": "{mediaType} Not Found",
|
||||||
"components.RequestList.RequestItem.modified": "Modified",
|
"components.RequestList.RequestItem.modified": "Modified",
|
||||||
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
|
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
|
||||||
|
"components.RequestList.RequestItem.profileName": "Profile",
|
||||||
"components.RequestList.RequestItem.requested": "Requested",
|
"components.RequestList.RequestItem.requested": "Requested",
|
||||||
"components.RequestList.RequestItem.requesteddate": "Requested",
|
"components.RequestList.RequestItem.requesteddate": "Requested",
|
||||||
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
|
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
|
||||||
|
|||||||
Reference in New Issue
Block a user