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:
Oliver Laing
2024-08-01 14:59:45 +02:00
committed by GitHub
parent 36d98a2681
commit 64453320d3
9 changed files with 127 additions and 35 deletions

View File

@@ -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>>;

View File

@@ -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;

View File

@@ -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 });

View File

@@ -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,

View File

@@ -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">

View File

@@ -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;

View File

@@ -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 = ({

View File

@@ -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;

View File

@@ -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}}",