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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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