Add Overseerr pending request management option

pull/2949/head
Derek Stotz 3 months ago
parent c268739e1f
commit d30322378b

@ -14,4 +14,18 @@ widget:
type: overseerr
url: http://overseerr.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
pendingRequests: # optional, must be object, see below
manageRequests: true # optional, defaults to false
showImage: true # optional, defaults to false
showReleaseYear: true # options, defaults to false
```
## Pending Requests
You can enable the ability to see and manage your pending requests on Overseerr using `pendingRequests` with the options below.
`manageRequests`: When set to `true` it displays two buttons for each request to approve or deny the request.
`showImage`: When set to `true` it displays a small image of the show/movie poster and makes the request panel larger.
`showReleaseYear`: When set to `true` it shows the release year in parenthesis after the show/movie title.

@ -436,6 +436,9 @@ export function cleanServiceGroups(groups) {
// opnsense, pfsense
wan,
// overseerr
pendingRequests,
// proxmox
node,
@ -507,6 +510,9 @@ export function cleanServiceGroups(groups) {
if (["opnsense", "pfsense"].includes(type)) {
if (wan) cleanedService.widget.wan = wan;
}
if (type === "overseerr") {
if (pendingRequests !== undefined) cleanedService.widget.pendingRequests = pendingRequests;
}
if (["emby", "jellyfin"].includes(type)) {
if (enableBlocks !== undefined) cleanedService.widget.enableBlocks = JSON.parse(enableBlocks);
if (enableNowPlaying !== undefined) cleanedService.widget.enableNowPlaying = JSON.parse(enableNowPlaying);

@ -1,19 +1,42 @@
import { useTranslation } from "next-i18next";
import { PendingRequest, RequestContainer } from "./pendingRequest";
import Container from "components/services/widget/container";
import Block from "components/services/widget/block";
import useWidgetAPI from "utils/proxy/use-widget-api";
import { formatProxyUrlWithSegments } from "utils/proxy/api-helpers";
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: statsData, error: statsError } = useWidgetAPI(widget, "request/count");
const { data: statsData, error: statsError, mutate: statsMutate } = useWidgetAPI(widget, "request/count");
const { data: settingsData, error: settingsError } = useWidgetAPI(widget, "mainSettings");
const {
data: pendingRequestsData,
error: pendingRequestsError,
mutate: pendingRequestsMutate,
} = useWidgetAPI(widget, "pendingRequests");
if (statsError || pendingRequestsError || settingsError) {
const finalError = statsError ?? pendingRequestsError ?? settingsError;
return <Container service={service} error={finalError} />;
}
if (statsError) {
return <Container service={service} error={statsError} />;
async function handleUpdateRequestStatus(requestId, status) {
const url = formatProxyUrlWithSegments(widget, "updateRequestStatus", {
id: requestId,
status,
});
await fetch(url).then(() => {
statsMutate();
pendingRequestsMutate();
});
}
const pendingRequests = widget.pendingRequests ? pendingRequestsData?.results ?? [] : [];
if (!statsData) {
return (
<Container service={service}>
@ -26,11 +49,25 @@ export default function Component({ service }) {
}
return (
<Container service={service}>
<Block label="overseerr.pending" value={t("common.number", { value: statsData.pending })} />
<Block label="overseerr.processing" value={t("common.number", { value: statsData.processing })} />
<Block label="overseerr.approved" value={t("common.number", { value: statsData.approved })} />
<Block label="overseerr.available" value={t("common.number", { value: statsData.available })} />
</Container>
<>
<Container service={service}>
<Block label="overseerr.pending" value={t("common.number", { value: statsData.pending })} />
<Block label="overseerr.processing" value={t("common.number", { value: statsData.processing })} />
<Block label="overseerr.approved" value={t("common.number", { value: statsData.approved })} />
<Block label="overseerr.available" value={t("common.number", { value: statsData.available })} />
</Container>
<RequestContainer>
{pendingRequests.map((request) => (
<PendingRequest
key={request.id}
request={request}
widget={widget}
applicationUrl={settingsData?.applicationUrl}
onApprove={() => handleUpdateRequestStatus(request.id, "approve")}
onDecline={() => handleUpdateRequestStatus(request.id, "decline")}
/>
))}
</RequestContainer>
</>
);
}

@ -0,0 +1,95 @@
import { useState, useEffect } from "react";
import Image from "next/image";
import { IoMdCheckmarkCircleOutline, IoMdCloseCircleOutline } from "react-icons/io";
import classNames from "classnames";
import { formatProxyUrlWithSegments } from "utils/proxy/api-helpers";
const tmdbImageBaseUrl = "https://media.themoviedb.org/t/p/w220_and_h330_face";
function ImageThumbnail({ posterPath, requestUrl }) {
const imageUrl = `${tmdbImageBaseUrl}/${posterPath}`;
return (
<div className="h-10 w-7 mr-2">
<div className="relative h-full">
<a href={requestUrl} target="_blank" rel="noreferrer">
<Image
src={imageUrl}
alt="Your image"
layout="fill"
objectFit="contain"
className="rounded-sm transition-transform duration-300 transform-gpu hover:scale-125"
/>
</a>
</div>
</div>
);
}
function ReleaseYear({ date }) {
const year = (date || "").split("-")[0];
if (!year) return null;
return <span className="pl-2">({year})</span>;
}
export function RequestContainer({ children }) {
return <div className="overflow-auto max-h-48">{children}</div>;
}
export function PendingRequest({ widget, applicationUrl, request, onApprove, onDecline }) {
const [media, setMedia] = useState({});
const { showImage, showReleaseYear, manageRequests } = widget?.pendingRequests ?? {};
const mediaType = request?.media?.mediaType;
const mediaId = request?.media?.tmdbId ?? request?.media?.tvdbId;
const requestUrl = new URL(`${mediaType}/${mediaId}`, applicationUrl).toString();
// Request details do not include media information such as title or image path
// Fetch media details separately
async function getMediaDetails() {
if (!mediaId) return {};
const url = formatProxyUrlWithSegments(widget, mediaType === "movie" ? "movieDetails" : "tvDetails", {
id: mediaId,
});
return fetch(url).then((res) => res.json());
}
useEffect(() => {
getMediaDetails().then(setMedia);
}, [request, widget]);
return (
<div
className={classNames(
"flex flex-row text-theme-700 dark:text-theme-200 items-center text-xs relative rounded-md bg-theme-200/50 dark:bg-theme-900/20 m-1 p-2",
showImage ? "h-12" : "h-5",
)}
>
<div className="flex flex-row w-full items-center">
{showImage && <ImageThumbnail posterPath={media.posterPath} requestUrl={requestUrl} />}
<div className="flex-grow text-left">
<a href={requestUrl} target="_blank" rel="noreferrer">
<span>{media.title ?? media.name}</span>
{showReleaseYear && <ReleaseYear date={media.releaseDate} />}
</a>
</div>
{manageRequests && (
<div className="w-10 text-base flex flex-row justify-between">
<IoMdCheckmarkCircleOutline
className="hover:text-green-500 hover:scale-125"
onClick={() => onApprove(request.id)}
/>
<IoMdCloseCircleOutline
className="hover:text-red-500 hover:scale-125"
onClick={() => onDecline(request.id)}
/>
</div>
)}
</div>
</div>
);
}

@ -9,6 +9,25 @@ const widget = {
endpoint: "request/count",
validate: ["pending", "processing", "approved", "available"],
},
pendingRequests: {
endpoint: "request?filter=pending",
},
tvDetails: {
endpoint: "tv/{id}",
segments: ["id"],
},
movieDetails: {
endpoint: "movie/{id}",
segments: ["id"],
},
updateRequestStatus: {
method: "POST",
endpoint: "request/{id}/{status}",
segments: ["id", "status"],
},
mainSettings: {
endpoint: "settings/main",
},
},
};

Loading…
Cancel
Save