mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: Tautulli integration (#2230)
* feat: media/user watch history data via Tautulli * fix(frontend): only display slideover cog button if there is media to manage * fix(lang): tweak permission denied messages * refactor: reorder Media section in slideover * refactor: use new Tautulli stats API * fix(frontend): do not attempt to fetch data when user lacks req perms * fix: remove unneccessary get_user requests * feat(frontend): display user avatars * feat: add external URL setting * feat: add play counts for past week/month * fix(lang): tweak strings Co-authored-by: Ryan Cohen <ryan@sct.dev>
This commit is contained in:
@@ -8,7 +8,7 @@ import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import type Issue from '../../../server/entity/Issue';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import Button from '../Common/Button';
|
||||
import { issueOptions } from '../IssueModal/constants';
|
||||
|
||||
@@ -17,6 +17,7 @@ interface IssueBlockProps {
|
||||
}
|
||||
|
||||
const IssueBlock: React.FC<IssueBlockProps> = ({ issue }) => {
|
||||
const { user } = useUser();
|
||||
const intl = useIntl();
|
||||
const issueOption = issueOptions.find(
|
||||
(opt) => opt.issueType === issue.issueType
|
||||
@@ -27,7 +28,7 @@ const IssueBlock: React.FC<IssueBlockProps> = ({ issue }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 text-gray-300">
|
||||
<div className="px-4 py-3 text-gray-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5">
|
||||
<div className="flex flex-nowrap">
|
||||
@@ -39,7 +40,17 @@ const IssueBlock: React.FC<IssueBlockProps> = ({ issue }) => {
|
||||
<div className="flex mb-1 flex-nowrap white">
|
||||
<UserIcon className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5" />
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
{issue.createdBy.displayName}
|
||||
<Link
|
||||
href={
|
||||
issue.createdBy.id === user?.id
|
||||
? '/profile'
|
||||
: `/users/${issue.createdBy.id}`
|
||||
}
|
||||
>
|
||||
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||
{issue.createdBy.displayName}
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex mb-1 flex-nowrap white">
|
||||
@@ -55,9 +66,8 @@ const IssueBlock: React.FC<IssueBlockProps> = ({ issue }) => {
|
||||
</div>
|
||||
<div className="flex flex-wrap flex-shrink-0 ml-2">
|
||||
<Link href={`/issues/${issue.id}`} passHref>
|
||||
<Button buttonType="primary" buttonSize="sm" as="a">
|
||||
<Button buttonType="primary" as="a">
|
||||
<EyeIcon />
|
||||
<span>{intl.formatMessage(globalMessages.view)}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
@@ -1,10 +1,16 @@
|
||||
import { ServerIcon } from '@heroicons/react/outline';
|
||||
import { ServerIcon, ViewListIcon } from '@heroicons/react/outline';
|
||||
import { CheckCircleIcon, DocumentRemoveIcon } from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { IssueStatus } from '../../../server/constants/issue';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import { MediaWatchDataResponse } from '../../../server/interfaces/api/mediaInterfaces';
|
||||
import { MovieDetails } from '../../../server/models/Movie';
|
||||
import { TvDetails } from '../../../server/models/Tv';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
@@ -21,17 +27,26 @@ const messages = defineMessages({
|
||||
manageModalTitle: 'Manage {mediaType}',
|
||||
manageModalIssues: 'Open Issues',
|
||||
manageModalRequests: 'Requests',
|
||||
manageModalMedia: 'Media',
|
||||
manageModalMedia4k: '4K Media',
|
||||
manageModalAdvanced: 'Advanced',
|
||||
manageModalNoRequests: 'No requests.',
|
||||
manageModalClearMedia: 'Clear Media Data',
|
||||
manageModalClearMedia: 'Clear Data',
|
||||
manageModalClearMediaWarning:
|
||||
'* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.',
|
||||
openarr: 'Open in {arr}',
|
||||
openarr4k: 'Open in 4K {arr}',
|
||||
downloadstatus: 'Download Status',
|
||||
downloadstatus: 'Downloads',
|
||||
markavailable: 'Mark as Available',
|
||||
mark4kavailable: 'Mark as Available in 4K',
|
||||
allseasonsmarkedavailable: '* All seasons will be marked as available.',
|
||||
// Recreated here for lowercase versions to go with the modal clear media warning
|
||||
markallseasonsavailable: 'Mark All Seasons as Available',
|
||||
markallseasons4kavailable: 'Mark All Seasons as Available in 4K',
|
||||
opentautulli: 'Open in Tautulli',
|
||||
plays:
|
||||
'<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}',
|
||||
pastdays: 'Past {days, number} Days',
|
||||
alltime: 'All Time',
|
||||
playedby: 'Played By',
|
||||
movie: 'movie',
|
||||
tvshow: 'series',
|
||||
});
|
||||
@@ -60,29 +75,54 @@ interface ManageSlideOverTvProps extends ManageSlideOverProps {
|
||||
const ManageSlideOver: React.FC<
|
||||
ManageSlideOverMovieProps | ManageSlideOverTvProps
|
||||
> = ({ show, mediaType, onClose, data, revalidate }) => {
|
||||
const { hasPermission } = useUser();
|
||||
const { user: currentUser, hasPermission } = useUser();
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { data: watchData } = useSWR<MediaWatchDataResponse>(
|
||||
data.mediaInfo && hasPermission(Permission.ADMIN)
|
||||
? `/api/v1/media/${data.mediaInfo.id}/watch_data`
|
||||
: null
|
||||
);
|
||||
|
||||
const deleteMedia = async () => {
|
||||
if (data?.mediaInfo?.id) {
|
||||
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
|
||||
if (data.mediaInfo) {
|
||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const markAvailable = async (is4k = false) => {
|
||||
await axios.post(`/api/v1/media/${data?.mediaInfo?.id}/available`, {
|
||||
is4k,
|
||||
});
|
||||
revalidate();
|
||||
if (data.mediaInfo) {
|
||||
await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
|
||||
is4k,
|
||||
});
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const requests =
|
||||
data.mediaInfo?.requests?.filter(
|
||||
(request) => request.status !== MediaRequestStatus.DECLINED
|
||||
) ?? [];
|
||||
|
||||
const openIssues =
|
||||
data.mediaInfo?.issues?.filter(
|
||||
(issue) => issue.status === IssueStatus.OPEN
|
||||
) ?? [];
|
||||
|
||||
const styledPlayCount = (playCount: number): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
{intl.formatMessage(messages.plays, {
|
||||
playCount,
|
||||
strong: function strong(msg) {
|
||||
return <strong className="text-2xl font-semibold">{msg}</strong>;
|
||||
},
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SlideOver
|
||||
show={show}
|
||||
@@ -94,182 +134,371 @@ const ManageSlideOver: React.FC<
|
||||
onClose={() => onClose()}
|
||||
subText={isMovie(data) ? data.title : data.name}
|
||||
>
|
||||
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
|
||||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
|
||||
<>
|
||||
<h3 className="mb-2 text-xl">
|
||||
{intl.formatMessage(messages.downloadstatus)}
|
||||
</h3>
|
||||
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{data?.mediaInfo &&
|
||||
(data.mediaInfo.status !== MediaStatus.AVAILABLE ||
|
||||
(data.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.series4kEnabled)) && (
|
||||
<div className="mb-6">
|
||||
{data?.mediaInfo &&
|
||||
data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
||||
<Button
|
||||
onClick={() => markAvailable()}
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>{intl.formatMessage(messages.markavailable)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo &&
|
||||
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.series4kEnabled && (
|
||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
||||
<Button
|
||||
onClick={() => markAvailable(true)}
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>{intl.formatMessage(messages.mark4kavailable)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{mediaType === 'tv' && (
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.allseasonsmarkedavailable)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], {
|
||||
type: 'or',
|
||||
}) &&
|
||||
openIssues.length > 0 && (
|
||||
<>
|
||||
<h3 className="mb-2 text-xl">
|
||||
{intl.formatMessage(messages.manageModalIssues)}
|
||||
<div className="space-y-6">
|
||||
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
|
||||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.downloadstatus)}
|
||||
</h3>
|
||||
<div className="mb-4 overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{openIssues.map((issue) => (
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
<li
|
||||
key={`manage-issue-${issue.id}`}
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<IssueBlock issue={issue} />
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="mb-2 text-xl">
|
||||
{intl.formatMessage(messages.manageModalRequests)}
|
||||
</h3>
|
||||
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.requests?.map((request) => (
|
||||
<li
|
||||
key={`manage-request-${request.id}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<RequestBlock request={request} onUpdate={() => revalidate()} />
|
||||
</li>
|
||||
))}
|
||||
{(data.mediaInfo?.requests ?? []).length === 0 && (
|
||||
<li className="py-4 text-center text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalNoRequests)}
|
||||
</li>
|
||||
{hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], {
|
||||
type: 'or',
|
||||
}) &&
|
||||
openIssues.length > 0 && (
|
||||
<>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.manageModalIssues)}
|
||||
</h3>
|
||||
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{openIssues.map((issue) => (
|
||||
<li
|
||||
key={`manage-issue-${issue.id}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<IssueBlock issue={issue} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{hasPermission(Permission.ADMIN) &&
|
||||
(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && (
|
||||
<div className="mt-8">
|
||||
{data?.mediaInfo?.serviceUrl && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block mb-2 last:mb-0"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ServerIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.openarr, {
|
||||
mediaType: intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? globalMessages.movie
|
||||
: globalMessages.tvshow
|
||||
),
|
||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{data?.mediaInfo?.serviceUrl4k && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ServerIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.openarr4k, {
|
||||
mediaType: intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? globalMessages.movie
|
||||
: globalMessages.tvshow
|
||||
),
|
||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{requests.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.manageModalRequests)}
|
||||
</h3>
|
||||
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{requests.map((request) => (
|
||||
<li
|
||||
key={`manage-request-${request.id}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<RequestBlock
|
||||
request={request}
|
||||
onUpdate={() => revalidate()}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo && (
|
||||
<div className="mt-8">
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMedia()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<DocumentRemoveIcon />
|
||||
<span>{intl.formatMessage(messages.manageModalClearMedia)}</span>
|
||||
</ConfirmButton>
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalClearMediaWarning, {
|
||||
mediaType: intl.formatMessage(
|
||||
mediaType === 'movie' ? messages.movie : messages.tvshow
|
||||
),
|
||||
})}
|
||||
{hasPermission(Permission.ADMIN) &&
|
||||
(data.mediaInfo?.serviceUrl ||
|
||||
data.mediaInfo?.tautulliUrl ||
|
||||
watchData?.data?.playCount) && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.manageModalMedia)}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{!!watchData?.data && (
|
||||
<div>
|
||||
<div
|
||||
className={`grid grid-cols-1 divide-y divide-gray-500 overflow-hidden text-sm text-gray-300 bg-gray-600 shadow ${
|
||||
data.mediaInfo?.tautulliUrl
|
||||
? 'rounded-t-md'
|
||||
: 'rounded-md'
|
||||
}`}
|
||||
>
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-500">
|
||||
<div className="px-4 py-3">
|
||||
<div className="font-bold">
|
||||
{intl.formatMessage(messages.pastdays, { days: 7 })}
|
||||
</div>
|
||||
<div className="text-white">
|
||||
{styledPlayCount(watchData.data.playCount7Days)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<div className="font-bold">
|
||||
{intl.formatMessage(messages.pastdays, {
|
||||
days: 30,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-white">
|
||||
{styledPlayCount(watchData.data.playCount30Days)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<div className="font-bold">
|
||||
{intl.formatMessage(messages.alltime)}
|
||||
</div>
|
||||
<div className="text-white">
|
||||
{styledPlayCount(watchData.data.playCount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!!watchData.data.users.length && (
|
||||
<div className="flex flex-row px-4 pt-3 pb-2 space-x-2">
|
||||
<span className="font-bold leading-8 shrink-0">
|
||||
{intl.formatMessage(messages.playedby)}
|
||||
</span>
|
||||
<span className="flex flex-row flex-wrap">
|
||||
{watchData.data.users.map((user) => (
|
||||
<Link
|
||||
href={
|
||||
currentUser?.id === user.id
|
||||
? '/profile'
|
||||
: `/users/${user.id}`
|
||||
}
|
||||
key={`watch-user-${user.id}`}
|
||||
>
|
||||
<a className="z-0 mb-1 -mr-2 hover:z-50 shrink-0">
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.displayName}
|
||||
className="w-8 h-8 transition duration-300 scale-100 rounded-full ring-1 ring-gray-500 transform-gpu hover:scale-105"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{data.mediaInfo?.tautulliUrl && (
|
||||
<a
|
||||
href={data.mediaInfo.tautulliUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
className={`w-full ${
|
||||
watchData.data.playCount ? 'rounded-t-none' : ''
|
||||
}`}
|
||||
>
|
||||
<ViewListIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.opentautulli)}
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo?.serviceUrl && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ServerIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.openarr, {
|
||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasPermission(Permission.ADMIN) &&
|
||||
(data.mediaInfo?.serviceUrl4k ||
|
||||
data.mediaInfo?.tautulliUrl4k ||
|
||||
watchData?.data4k?.playCount) && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.manageModalMedia4k)}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{!!watchData?.data4k && (
|
||||
<div>
|
||||
<div
|
||||
className={`grid grid-cols-1 divide-y divide-gray-500 overflow-hidden text-sm text-gray-300 bg-gray-600 shadow ${
|
||||
data.mediaInfo?.tautulliUrl4k
|
||||
? 'rounded-t-md'
|
||||
: 'rounded-md'
|
||||
}`}
|
||||
>
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-500">
|
||||
<div className="px-4 py-3">
|
||||
<div className="font-bold">
|
||||
{intl.formatMessage(messages.pastdays, { days: 7 })}
|
||||
</div>
|
||||
<div className="text-white">
|
||||
{styledPlayCount(watchData.data4k.playCount7Days)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<div className="font-bold">
|
||||
{intl.formatMessage(messages.pastdays, {
|
||||
days: 30,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-white">
|
||||
{styledPlayCount(watchData.data4k.playCount30Days)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<div className="font-bold">
|
||||
{intl.formatMessage(messages.alltime)}
|
||||
</div>
|
||||
<div className="text-white">
|
||||
{styledPlayCount(watchData.data4k.playCount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!!watchData.data4k.users.length && (
|
||||
<div className="flex flex-row px-4 pt-3 pb-2 space-x-2">
|
||||
<span className="font-bold leading-8 shrink-0">
|
||||
{intl.formatMessage(messages.playedby)}
|
||||
</span>
|
||||
<span className="flex flex-row flex-wrap">
|
||||
{watchData.data4k.users.map((user) => (
|
||||
<Link
|
||||
href={
|
||||
currentUser?.id === user.id
|
||||
? '/profile'
|
||||
: `/users/${user.id}`
|
||||
}
|
||||
key={`watch-user-${user.id}`}
|
||||
>
|
||||
<a className="z-0 mb-1 -mr-2 hover:z-50 shrink-0">
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.displayName}
|
||||
className="w-8 h-8 transition duration-300 scale-100 rounded-full ring-1 ring-gray-500 transform-gpu hover:scale-105"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{data.mediaInfo?.tautulliUrl4k && (
|
||||
<a
|
||||
href={data.mediaInfo.tautulliUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
className={`w-full ${
|
||||
watchData.data4k.playCount ? 'rounded-t-none' : ''
|
||||
}`}
|
||||
>
|
||||
<ViewListIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.opentautulli)}
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo?.serviceUrl4k && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ServerIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.openarr4k, {
|
||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasPermission(Permission.ADMIN) && data?.mediaInfo && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.manageModalAdvanced)}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
||||
<Button
|
||||
onClick={() => markAvailable()}
|
||||
className="w-full"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? messages.markavailable
|
||||
: messages.markallseasonsavailable
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.series4kEnabled && (
|
||||
<Button
|
||||
onClick={() => markAvailable(true)}
|
||||
className="w-full"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? messages.mark4kavailable
|
||||
: messages.markallseasons4kavailable
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<div>
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMedia()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<DocumentRemoveIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.manageModalClearMedia)}
|
||||
</span>
|
||||
</ConfirmButton>
|
||||
<div className="mt-1 text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalClearMediaWarning, {
|
||||
mediaType: intl.formatMessage(
|
||||
mediaType === 'movie' ? messages.movie : messages.tvshow
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
};
|
||||
|
@@ -353,7 +353,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<ExclamationIcon />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && (
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="relative ml-2 first:ml-0"
|
||||
|
@@ -14,6 +14,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
import { MediaRequestStatus } from '../../../server/constants/media';
|
||||
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import useRequestOverride from '../../hooks/useRequestOverride';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Badge from '../Common/Badge';
|
||||
import Button from '../Common/Button';
|
||||
@@ -33,6 +34,7 @@ interface RequestBlockProps {
|
||||
}
|
||||
|
||||
const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
const { user } = useUser();
|
||||
const intl = useIntl();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
@@ -75,14 +77,20 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
/>
|
||||
<div className="px-4 py-4 text-gray-300">
|
||||
<div className="px-4 py-3 text-gray-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5">
|
||||
<div className="flex mb-1 flex-nowrap white">
|
||||
<UserIcon className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5" />
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
<Link href={`/users/${request.requestedBy.id}`}>
|
||||
<a className="text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||
<Link
|
||||
href={
|
||||
request.requestedBy.id === user?.id
|
||||
? '/profile'
|
||||
: `/users/${request.requestedBy.id}`
|
||||
}
|
||||
>
|
||||
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||
{request.requestedBy.displayName}
|
||||
</a>
|
||||
</Link>
|
||||
@@ -92,8 +100,14 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
<div className="flex flex-nowrap">
|
||||
<EyeIcon className="flex-shrink-0 mr-1.5 h-5 w-5" />
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
<Link href={`/users/${request.modifiedBy.id}`}>
|
||||
<a className="text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||
<Link
|
||||
href={
|
||||
request.modifiedBy.id === user?.id
|
||||
? '/profile'
|
||||
: `/users/${request.modifiedBy.id}`
|
||||
}
|
||||
>
|
||||
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||
{request.modifiedBy.displayName}
|
||||
</a>
|
||||
</Link>
|
||||
|
@@ -63,8 +63,8 @@ const messages = defineMessages({
|
||||
enableSearch: 'Enable Automatic Search',
|
||||
validationApplicationUrl: 'You must provide a valid URL',
|
||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
|
||||
validationBaseUrlTrailingSlash: 'Base URL must not end in a trailing slash',
|
||||
validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
|
||||
validationBaseUrlTrailingSlash: 'URL base must not end in a trailing slash',
|
||||
notagoptions: 'No tags.',
|
||||
selecttags: 'Select tags',
|
||||
announced: 'Announced',
|
||||
|
@@ -83,12 +83,7 @@ const SettingsMain: React.FC = () => {
|
||||
.test(
|
||||
'no-trailing-slash',
|
||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||
(value) => {
|
||||
if (value?.substr(value.length - 1) === '/') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
});
|
||||
|
||||
|
@@ -9,13 +9,17 @@ import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
import type { PlexDevice } from '../../../server/interfaces/api/plexInterfaces';
|
||||
import type { PlexSettings } from '../../../server/lib/settings';
|
||||
import type {
|
||||
PlexSettings,
|
||||
TautulliSettings,
|
||||
} from '../../../server/lib/settings';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Alert from '../Common/Alert';
|
||||
import Badge from '../Common/Badge';
|
||||
import Button from '../Common/Button';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import SensitiveInput from '../Common/SensitiveInput';
|
||||
import LibraryItem from './LibraryItem';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -59,7 +63,20 @@ const messages = defineMessages({
|
||||
webAppUrl: '<WebAppLink>Web App</WebAppLink> URL',
|
||||
webAppUrlTip:
|
||||
'Optionally direct users to the web app on your server instead of the "hosted" web app',
|
||||
validationWebAppUrl: 'You must provide a valid Plex Web App URL',
|
||||
tautulliSettings: 'Tautulli Settings',
|
||||
tautulliSettingsDescription:
|
||||
'Optionally configure the settings for your Tautulli server. Overseerr fetches watch history data for your Plex media from Tautulli.',
|
||||
urlBase: 'URL Base',
|
||||
tautulliApiKey: 'API Key',
|
||||
externalUrl: 'External URL',
|
||||
validationApiKey: 'You must provide an API key',
|
||||
validationUrl: 'You must provide a valid URL',
|
||||
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
||||
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
||||
toastTautulliSettingsSuccess: 'Tautulli settings saved successfully!',
|
||||
toastTautulliSettingsFailure:
|
||||
'Something went wrong while saving Tautulli settings.',
|
||||
});
|
||||
|
||||
interface Library {
|
||||
@@ -101,6 +118,8 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
error,
|
||||
mutate: revalidate,
|
||||
} = useSWR<PlexSettings>('/api/v1/settings/plex');
|
||||
const { data: dataTautulli, mutate: revalidateTautulli } =
|
||||
useSWR<TautulliSettings>('/api/v1/settings/tautulli');
|
||||
const { data: dataSync, mutate: revalidateSync } = useSWR<SyncStatus>(
|
||||
'/api/v1/settings/plex/sync',
|
||||
{
|
||||
@@ -109,6 +128,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
);
|
||||
const intl = useIntl();
|
||||
const { addToast, removeToast } = useToasts();
|
||||
|
||||
const PlexSettingsSchema = Yup.object().shape({
|
||||
hostname: Yup.string()
|
||||
.nullable()
|
||||
@@ -122,9 +142,66 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
.required(intl.formatMessage(messages.validationPortRequired)),
|
||||
webAppUrl: Yup.string()
|
||||
.nullable()
|
||||
.url(intl.formatMessage(messages.validationWebAppUrl)),
|
||||
.url(intl.formatMessage(messages.validationUrl)),
|
||||
});
|
||||
|
||||
const TautulliSettingsSchema = Yup.object().shape(
|
||||
{
|
||||
tautulliHostname: Yup.string()
|
||||
.when(['tautulliPort', 'tautulliApiKey'], {
|
||||
is: (value: unknown) => !!value,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationHostnameRequired)),
|
||||
otherwise: Yup.string().nullable(),
|
||||
})
|
||||
.matches(
|
||||
/^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
|
||||
intl.formatMessage(messages.validationHostnameRequired)
|
||||
),
|
||||
tautulliPort: Yup.number().when(['tautulliHostname', 'tautulliApiKey'], {
|
||||
is: (value: unknown) => !!value,
|
||||
then: Yup.number()
|
||||
.typeError(intl.formatMessage(messages.validationPortRequired))
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationPortRequired)),
|
||||
otherwise: Yup.number()
|
||||
.typeError(intl.formatMessage(messages.validationPortRequired))
|
||||
.nullable(),
|
||||
}),
|
||||
tautulliUrlBase: Yup.string()
|
||||
.test(
|
||||
'leading-slash',
|
||||
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
|
||||
(value) => !value || value.startsWith('/')
|
||||
)
|
||||
.test(
|
||||
'no-trailing-slash',
|
||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
tautulliApiKey: Yup.string().when(['tautulliHostname', 'tautulliPort'], {
|
||||
is: (value: unknown) => !!value,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationApiKey)),
|
||||
otherwise: Yup.string().nullable(),
|
||||
}),
|
||||
tautulliExternalUrl: Yup.string()
|
||||
.url(intl.formatMessage(messages.validationUrl))
|
||||
.test(
|
||||
'no-trailing-slash',
|
||||
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
},
|
||||
[
|
||||
['tautulliHostname', 'tautulliPort'],
|
||||
['tautulliHostname', 'tautulliApiKey'],
|
||||
['tautulliPort', 'tautulliApiKey'],
|
||||
]
|
||||
);
|
||||
|
||||
const activeLibraries =
|
||||
data?.libraries
|
||||
.filter((library) => library.enabled)
|
||||
@@ -247,7 +324,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
revalidate();
|
||||
};
|
||||
|
||||
if (!data && !error) {
|
||||
if ((!data || !dataTautulli) && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
return (
|
||||
@@ -646,6 +723,209 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!onComplete && (
|
||||
<>
|
||||
<div className="mt-10 mb-6">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.tautulliSettings)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.tautulliSettingsDescription)}
|
||||
</p>
|
||||
</div>
|
||||
<Formik
|
||||
initialValues={{
|
||||
tautulliHostname: dataTautulli?.hostname,
|
||||
tautulliPort: dataTautulli?.port ?? 8181,
|
||||
tautulliUseSsl: dataTautulli?.useSsl,
|
||||
tautulliUrlBase: dataTautulli?.urlBase,
|
||||
tautulliApiKey: dataTautulli?.apiKey,
|
||||
tautulliExternalUrl: dataTautulli?.externalUrl,
|
||||
}}
|
||||
validationSchema={TautulliSettingsSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/tautulli', {
|
||||
hostname: values.tautulliHostname,
|
||||
port: Number(values.tautulliPort),
|
||||
useSsl: values.tautulliUseSsl,
|
||||
urlBase: values.tautulliUrlBase,
|
||||
apiKey: values.tautulliApiKey,
|
||||
externalUrl: values.tautulliExternalUrl,
|
||||
} as TautulliSettings);
|
||||
|
||||
addToast(
|
||||
intl.formatMessage(messages.toastTautulliSettingsSuccess),
|
||||
{
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
addToast(
|
||||
intl.formatMessage(messages.toastTautulliSettingsFailure),
|
||||
{
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
revalidateTautulli();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
values,
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => {
|
||||
return (
|
||||
<form className="section" onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tautulliHostname" className="text-label">
|
||||
{intl.formatMessage(messages.hostname)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm">
|
||||
{values.tautulliUseSsl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="url"
|
||||
id="tautulliHostname"
|
||||
name="tautulliHostname"
|
||||
className="rounded-r-only"
|
||||
/>
|
||||
</div>
|
||||
{errors.tautulliHostname && touched.tautulliHostname && (
|
||||
<div className="error">{errors.tautulliHostname}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tautulliPort" className="text-label">
|
||||
{intl.formatMessage(messages.port)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
id="tautulliPort"
|
||||
name="tautulliPort"
|
||||
className="short"
|
||||
/>
|
||||
{errors.tautulliPort && touched.tautulliPort && (
|
||||
<div className="error">{errors.tautulliPort}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tautulliUseSsl" className="checkbox-label">
|
||||
{intl.formatMessage(messages.enablessl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="tautulliUseSsl"
|
||||
name="tautulliUseSsl"
|
||||
onChange={() => {
|
||||
setFieldValue(
|
||||
'tautulliUseSsl',
|
||||
!values.tautulliUseSsl
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tautulliUrlBase" className="text-label">
|
||||
{intl.formatMessage(messages.urlBase)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="url"
|
||||
id="tautulliUrlBase"
|
||||
name="tautulliUrlBase"
|
||||
/>
|
||||
</div>
|
||||
{errors.tautulliUrlBase && touched.tautulliUrlBase && (
|
||||
<div className="error">{errors.tautulliUrlBase}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tautulliApiKey" className="text-label">
|
||||
{intl.formatMessage(messages.tautulliApiKey)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="tautulliApiKey"
|
||||
name="tautulliApiKey"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
{errors.tautulliApiKey && touched.tautulliApiKey && (
|
||||
<div className="error">{errors.tautulliApiKey}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tautulliExternalUrl" className="text-label">
|
||||
{intl.formatMessage(messages.externalUrl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="url"
|
||||
id="tautulliExternalUrl"
|
||||
name="tautulliExternalUrl"
|
||||
/>
|
||||
</div>
|
||||
{errors.tautulliExternalUrl &&
|
||||
touched.tautulliExternalUrl && (
|
||||
<div className="error">
|
||||
{errors.tautulliExternalUrl}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -354,7 +354,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
<ExclamationIcon className="w-5" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && (
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="relative ml-2 first:ml-0"
|
||||
|
@@ -2,15 +2,16 @@ import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { defineMessages, FormattedNumber, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
QuotaResponse,
|
||||
UserRequestsResponse,
|
||||
UserWatchDataResponse,
|
||||
} from '../../../server/interfaces/api/userInterfaces';
|
||||
import { MovieDetails } from '../../../server/models/Movie';
|
||||
import { TvDetails } from '../../../server/models/Tv';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import { Permission, UserType, useUser } from '../../hooks/useUser';
|
||||
import Error from '../../pages/_error';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
@@ -18,6 +19,7 @@ import PageTitle from '../Common/PageTitle';
|
||||
import ProgressCircle from '../Common/ProgressCircle';
|
||||
import RequestCard from '../RequestCard';
|
||||
import Slider from '../Slider';
|
||||
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
|
||||
import ProfileHeader from './ProfileHeader';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -30,6 +32,7 @@ const messages = defineMessages({
|
||||
pastdays: '{type} (past {days} days)',
|
||||
movierequests: 'Movie Requests',
|
||||
seriesrequest: 'Series Requests',
|
||||
recentlywatched: 'Recently Watched',
|
||||
});
|
||||
|
||||
type MediaTitle = MovieDetails | TvDetails;
|
||||
@@ -46,10 +49,30 @@ const UserProfile: React.FC = () => {
|
||||
>({});
|
||||
|
||||
const { data: requests, error: requestError } = useSWR<UserRequestsResponse>(
|
||||
user ? `/api/v1/user/${user?.id}/requests?take=10&skip=0` : null
|
||||
user &&
|
||||
(user.id === currentUser?.id ||
|
||||
currentHasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
))
|
||||
? `/api/v1/user/${user?.id}/requests?take=10&skip=0`
|
||||
: null
|
||||
);
|
||||
const { data: quota } = useSWR<QuotaResponse>(
|
||||
user ? `/api/v1/user/${user.id}/quota` : null
|
||||
user &&
|
||||
(user.id === currentUser?.id ||
|
||||
currentHasPermission(
|
||||
[Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS],
|
||||
{ type: 'and' }
|
||||
))
|
||||
? `/api/v1/user/${user.id}/quota`
|
||||
: null
|
||||
);
|
||||
const { data: watchData } = useSWR<UserWatchDataResponse>(
|
||||
user?.userType === UserType.PLEX &&
|
||||
(user.id === currentUser?.id || currentHasPermission(Permission.ADMIN))
|
||||
? `/api/v1/user/${user.id}/watch_data`
|
||||
: null
|
||||
);
|
||||
|
||||
const updateAvailableTitles = useCallback(
|
||||
@@ -95,7 +118,10 @@ const UserProfile: React.FC = () => {
|
||||
<ProfileHeader user={user} />
|
||||
{quota &&
|
||||
(user.id === currentUser?.id ||
|
||||
currentHasPermission(Permission.MANAGE_USERS)) && (
|
||||
currentHasPermission(
|
||||
[Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS],
|
||||
{ type: 'and' }
|
||||
)) && (
|
||||
<div className="relative z-40">
|
||||
<dl className="grid grid-cols-1 gap-5 mt-5 lg:grid-cols-3">
|
||||
<div className="px-4 py-5 overflow-hidden bg-gray-800 bg-opacity-50 rounded-lg shadow ring-1 ring-gray-700 sm:p-6">
|
||||
@@ -103,10 +129,9 @@ const UserProfile: React.FC = () => {
|
||||
{intl.formatMessage(messages.totalrequests)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-white">
|
||||
{intl.formatNumber(user.requestCount)}
|
||||
<FormattedNumber value={user.requestCount} />
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`px-4 py-5 overflow-hidden bg-gray-800 bg-opacity-50 rounded-lg shadow ring-1 ${
|
||||
quota.movie.restricted
|
||||
@@ -162,7 +187,6 @@ const UserProfile: React.FC = () => {
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`px-4 py-5 overflow-hidden bg-gray-800 bg-opacity-50 rounded-lg shadow ring-1 ${
|
||||
quota.tv.restricted
|
||||
@@ -253,6 +277,29 @@ const UserProfile: React.FC = () => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(user.id === currentUser?.id ||
|
||||
currentHasPermission(Permission.ADMIN)) &&
|
||||
!!watchData?.recentlyWatched.length && (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<div className="slider-title">
|
||||
<span>{intl.formatMessage(messages.recentlywatched)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="media"
|
||||
isLoading={!watchData}
|
||||
isEmpty={!watchData?.recentlyWatched.length}
|
||||
items={watchData.recentlyWatched.map((item) => (
|
||||
<TmdbTitleCard
|
||||
key={`media-slider-item-${item.id}`}
|
||||
tmdbId={item.tmdbId}
|
||||
type={item.mediaType}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
Reference in New Issue
Block a user