mirror of
https://github.com/seerr-team/seerr.git
synced 2025-12-23 23:58:07 -05: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 {
|
||||
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 { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { PaginatedResponse } from './common';
|
||||
import type { NonFunctionProperties, PaginatedResponse } from './common';
|
||||
|
||||
export interface RequestResultsResponse extends PaginatedResponse {
|
||||
results: MediaRequest[];
|
||||
results: NonFunctionProperties<MediaRequest>[];
|
||||
}
|
||||
|
||||
export type MediaRequestBody = {
|
||||
@@ -14,6 +14,7 @@ export type MediaRequestBody = {
|
||||
is4k?: boolean;
|
||||
serverId?: number;
|
||||
profileId?: number;
|
||||
profileName?: string;
|
||||
rootFolder?: string;
|
||||
languageProfileId?: number;
|
||||
userId?: number;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
@@ -19,6 +21,7 @@ import type {
|
||||
RequestResultsResponse,
|
||||
} from '@server/interfaces/api/requestInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { Router } from 'express';
|
||||
@@ -143,6 +146,62 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
.skip(skip)
|
||||
.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({
|
||||
pageInfo: {
|
||||
pages: Math.ceil(requestCount / pageSize),
|
||||
@@ -150,7 +209,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
results: requestCount,
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: requests,
|
||||
results: requestsWithProfileNames,
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import Image from 'next/image';
|
||||
@@ -58,7 +59,7 @@ const RequestCardPlaceholder = () => {
|
||||
};
|
||||
|
||||
interface RequestCardErrorProps {
|
||||
requestData?: MediaRequest;
|
||||
requestData?: NonFunctionProperties<MediaRequest>;
|
||||
}
|
||||
|
||||
const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
||||
@@ -213,7 +214,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
||||
};
|
||||
|
||||
interface RequestCardProps {
|
||||
request: MediaRequest;
|
||||
request: NonFunctionProperties<MediaRequest>;
|
||||
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
|
||||
}
|
||||
|
||||
@@ -238,16 +239,19 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
data: requestData,
|
||||
error: requestError,
|
||||
mutate: revalidate,
|
||||
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
|
||||
fallbackData: request,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
{
|
||||
downloadStatus: request.media.downloadStatus,
|
||||
downloadStatus4k: request.media.downloadStatus4k,
|
||||
},
|
||||
15000
|
||||
),
|
||||
});
|
||||
} = useSWR<NonFunctionProperties<MediaRequest>>(
|
||||
`/api/v1/request/${request.id}`,
|
||||
{
|
||||
fallbackData: request,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
{
|
||||
downloadStatus: request.media.downloadStatus,
|
||||
downloadStatus4k: request.media.downloadStatus4k,
|
||||
},
|
||||
15000
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
||||
mediaUrl: requestData?.media?.mediaUrl,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import Image from 'next/image';
|
||||
@@ -42,6 +43,7 @@ const messages = defineMessages('components.RequestList.RequestItem', {
|
||||
tmdbid: 'TMDB ID',
|
||||
tvdbid: 'TheTVDB ID',
|
||||
unknowntitle: 'Unknown Title',
|
||||
profileName: 'Profile',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
@@ -49,7 +51,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
};
|
||||
|
||||
interface RequestItemErrorProps {
|
||||
requestData?: MediaRequest;
|
||||
requestData?: NonFunctionProperties<MediaRequest>;
|
||||
revalidateList: () => void;
|
||||
}
|
||||
|
||||
@@ -285,7 +287,7 @@ const RequestItemError = ({
|
||||
};
|
||||
|
||||
interface RequestItemProps {
|
||||
request: MediaRequest;
|
||||
request: NonFunctionProperties<MediaRequest> & { profileName?: string };
|
||||
revalidateList: () => void;
|
||||
}
|
||||
|
||||
@@ -304,19 +306,18 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? url : null
|
||||
);
|
||||
const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>(
|
||||
`/api/v1/request/${request.id}`,
|
||||
{
|
||||
fallbackData: request,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
{
|
||||
downloadStatus: request.media.downloadStatus,
|
||||
downloadStatus4k: request.media.downloadStatus4k,
|
||||
},
|
||||
15000
|
||||
),
|
||||
}
|
||||
);
|
||||
const { data: requestData, mutate: revalidate } = useSWR<
|
||||
NonFunctionProperties<MediaRequest>
|
||||
>(`/api/v1/request/${request.id}`, {
|
||||
fallbackData: request,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
{
|
||||
downloadStatus: request.media.downloadStatus,
|
||||
downloadStatus4k: request.media.downloadStatus4k,
|
||||
},
|
||||
15000
|
||||
),
|
||||
});
|
||||
|
||||
const [isRetrying, setRetrying] = useState(false);
|
||||
|
||||
@@ -401,7 +402,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
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 && (
|
||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||
<CachedImage
|
||||
@@ -482,7 +483,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
)}
|
||||
</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">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(globalMessages.status)}
|
||||
@@ -632,6 +633,16 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
</span>
|
||||
</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 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 { MediaStatus } from '@server/constants/media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
@@ -38,7 +39,7 @@ const messages = defineMessages('components.RequestModal', {
|
||||
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
tmdbId: number;
|
||||
is4k?: boolean;
|
||||
editRequest?: MediaRequest;
|
||||
editRequest?: NonFunctionProperties<MediaRequest>;
|
||||
onCancel?: () => void;
|
||||
onComplete?: (newStatus: MediaStatus) => 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 type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type SeasonRequest from '@server/entity/SeasonRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
@@ -57,7 +58,7 @@ interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onComplete?: (newStatus: MediaStatus) => void;
|
||||
onUpdating?: (isUpdating: boolean) => void;
|
||||
is4k?: boolean;
|
||||
editRequest?: MediaRequest;
|
||||
editRequest?: NonFunctionProperties<MediaRequest>;
|
||||
}
|
||||
|
||||
const TvRequestModal = ({
|
||||
|
||||
@@ -4,13 +4,14 @@ import TvRequestModal from '@app/components/RequestModal/TvRequestModal';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import type { MediaStatus } from '@server/constants/media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
|
||||
interface RequestModalProps {
|
||||
show: boolean;
|
||||
type: 'movie' | 'tv' | 'collection';
|
||||
tmdbId: number;
|
||||
is4k?: boolean;
|
||||
editRequest?: MediaRequest;
|
||||
editRequest?: NonFunctionProperties<MediaRequest>;
|
||||
onComplete?: (newStatus: MediaStatus) => void;
|
||||
onCancel?: () => void;
|
||||
onUpdating?: (isUpdating: boolean) => void;
|
||||
|
||||
@@ -465,6 +465,7 @@
|
||||
"components.RequestList.RequestItem.mediaerror": "{mediaType} Not Found",
|
||||
"components.RequestList.RequestItem.modified": "Modified",
|
||||
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
|
||||
"components.RequestList.RequestItem.profileName": "Profile",
|
||||
"components.RequestList.RequestItem.requested": "Requested",
|
||||
"components.RequestList.RequestItem.requesteddate": "Requested",
|
||||
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
|
||||
|
||||
Reference in New Issue
Block a user