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:
TheCatLady
2022-01-20 05:36:59 -05:00
committed by GitHub
parent 86dff12cde
commit 0842c233d0
19 changed files with 1432 additions and 219 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('/')
),
});

View File

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

View File

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

View File

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