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>
pull/2445/head^2
TheCatLady 2 years ago committed by GitHub
parent 86dff12cde
commit 0842c233d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -165,6 +165,9 @@ components:
port:
type: number
example: 32400
useSsl:
type: boolean
nullable: true
libraries:
type: array
readOnly: true
@ -172,6 +175,7 @@ components:
$ref: '#/components/schemas/PlexLibrary'
webAppUrl:
type: string
nullable: true
example: 'https://app.plex.tv/desktop'
required:
- name
@ -298,6 +302,26 @@ components:
- provides
- owned
- connection
TautulliSettings:
type: object
properties:
hostname:
type: string
nullable: true
example: 'tautulli.example.com'
port:
type: number
nullable: true
example: 8181
useSsl:
type: boolean
nullable: true
apiKey:
type: string
nullable: true
externalUrl:
type: string
nullable: true
RadarrSettings:
type: object
properties:
@ -2024,6 +2048,37 @@ paths:
type: string
thumb:
type: string
/settings/tautulli:
get:
summary: Get Tautulli settings
description: Retrieves current Tautulli settings.
tags:
- settings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/TautulliSettings'
post:
summary: Update Tautulli settings
description: Updates Tautulli settings with the provided values.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TautulliSettings'
responses:
'200':
description: 'Values were successfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/TautulliSettings'
/settings/radarr:
get:
summary: Get Radarr settings
@ -3643,6 +3698,35 @@ paths:
permissions:
type: number
example: 2
/user/{userId}/watch_data:
get:
summary: Get watch data
description: |
Returns play count, play duration, and recently watched media.
Requires the `ADMIN` permission to fetch results for other users.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
responses:
'200':
description: Users
content:
application/json:
schema:
type: object
properties:
recentlyWatched:
type: array
items:
$ref: '#/components/schemas/MediaInfo'
playCount:
type: number
/search:
get:
summary: Search for movies, TV shows, or people
@ -4914,7 +4998,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/PersonDetails'
/person/{personId}/combined_credits:
get:
summary: Get combined credits
@ -5051,6 +5134,57 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/MediaInfo'
/media/{mediaId}/watch_data:
get:
summary: Get watch data
description: |
Returns play count, play duration, and users who have watched the media.
Requires the `ADMIN` permission.
tags:
- media
parameters:
- in: path
name: mediaId
description: Media ID
required: true
example: '1'
schema:
type: string
responses:
'200':
description: Users
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
playCount7Days:
type: number
playCount30Days:
type: number
playCount:
type: number
users:
type: array
items:
$ref: '#/components/schemas/User'
data4k:
type: object
properties:
playCount7Days:
type: number
playCount30Days:
type: number
playCount:
type: number
users:
type: array
items:
$ref: '#/components/schemas/User'
/collection/{collectionId}:
get:
summary: Get collection details

@ -0,0 +1,228 @@
import axios, { AxiosInstance } from 'axios';
import { User } from '../entity/User';
import { TautulliSettings } from '../lib/settings';
import logger from '../logger';
export interface TautulliHistoryRecord {
date: number;
duration: number;
friendly_name: string;
full_title: string;
grandparent_rating_key: number;
grandparent_title: string;
original_title: string;
group_count: number;
group_ids?: string;
guid: string;
ip_address: string;
live: number;
machine_id: string;
media_index: number;
media_type: string;
originally_available_at: string;
parent_media_index: number;
parent_rating_key: number;
parent_title: string;
paused_counter: number;
percent_complete: number;
platform: string;
product: string;
player: string;
rating_key: number;
reference_id?: number;
row_id?: number;
session_key?: string;
started: number;
state?: string;
stopped: number;
thumb: string;
title: string;
transcode_decision: string;
user: string;
user_id: number;
watched_status: number;
year: number;
}
interface TautulliHistoryResponse {
response: {
result: string;
message?: string;
data: {
draw: number;
recordsTotal: number;
recordsFiltered: number;
total_duration: string;
filter_duration: string;
data: TautulliHistoryRecord[];
};
};
}
interface TautulliWatchStats {
query_days: number;
total_time: number;
total_plays: number;
}
interface TautulliWatchStatsResponse {
response: {
result: string;
message?: string;
data: TautulliWatchStats[];
};
}
interface TautulliWatchUser {
friendly_name: string;
user_id: number;
user_thumb: string;
username: string;
total_plays: number;
total_time: number;
}
interface TautulliWatchUsersResponse {
response: {
result: string;
message?: string;
data: TautulliWatchUser[];
};
}
class TautulliAPI {
private axios: AxiosInstance;
constructor(settings: TautulliSettings) {
this.axios = axios.create({
baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
settings.port
}${settings.urlBase ?? ''}`,
params: { apikey: settings.apiKey },
});
}
public async getMediaWatchStats(
ratingKey: string
): Promise<TautulliWatchStats[]> {
try {
return (
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
params: {
cmd: 'get_item_watch_time_stats',
rating_key: ratingKey,
grouping: 1,
},
})
).data.response.data;
} catch (e) {
logger.error(
'Something went wrong fetching media watch stats from Tautulli',
{
label: 'Tautulli API',
errorMessage: e.message,
ratingKey,
}
);
throw new Error(
`[Tautulli] Failed to fetch media watch stats: ${e.message}`
);
}
}
public async getMediaWatchUsers(
ratingKey: string
): Promise<TautulliWatchUser[]> {
try {
return (
await this.axios.get<TautulliWatchUsersResponse>('/api/v2', {
params: {
cmd: 'get_item_user_stats',
rating_key: ratingKey,
grouping: 1,
},
})
).data.response.data;
} catch (e) {
logger.error(
'Something went wrong fetching media watch users from Tautulli',
{
label: 'Tautulli API',
errorMessage: e.message,
ratingKey,
}
);
throw new Error(
`[Tautulli] Failed to fetch media watch users: ${e.message}`
);
}
}
public async getUserWatchStats(user: User): Promise<TautulliWatchStats> {
try {
if (!user.plexId) {
throw new Error('User does not have an associated Plex ID');
}
return (
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
params: {
cmd: 'get_user_watch_time_stats',
user_id: user.plexId,
query_days: 0,
grouping: 1,
},
})
).data.response.data[0];
} catch (e) {
logger.error(
'Something went wrong fetching user watch stats from Tautulli',
{
label: 'Tautulli API',
errorMessage: e.message,
user: user.displayName,
}
);
throw new Error(
`[Tautulli] Failed to fetch user watch stats: ${e.message}`
);
}
}
public async getUserWatchHistory(
user: User
): Promise<TautulliHistoryRecord[]> {
try {
if (!user.plexId) {
throw new Error('User does not have an associated Plex ID');
}
return (
await this.axios.get<TautulliHistoryResponse>('/api/v2', {
params: {
cmd: 'get_history',
grouping: 1,
order_column: 'date',
order_dir: 'desc',
user_id: user.plexId,
length: 100,
},
})
).data.response.data.data;
} catch (e) {
logger.error(
'Something went wrong fetching user watch history from Tautulli',
{
label: 'Tautulli API',
errorMessage: e.message,
user: user.displayName,
}
);
throw new Error(
`[Tautulli] Failed to fetch user watch history: ${e.message}`
);
}
}
}
export default TautulliAPI;

@ -145,6 +145,9 @@ class Media {
public plexUrl?: string;
public plexUrl4k?: string;
public tautulliUrl?: string;
public tautulliUrl4k?: string;
constructor(init?: Partial<Media>) {
Object.assign(this, init);
}
@ -152,6 +155,7 @@ class Media {
@AfterLoad()
public setPlexUrls(): void {
const { machineId, webAppUrl } = getSettings().plex;
const { externalUrl: tautulliUrl } = getSettings().tautulli;
if (this.ratingKey) {
this.plexUrl = `${
@ -159,6 +163,10 @@ class Media {
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey
}`;
if (tautulliUrl) {
this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`;
}
}
if (this.ratingKey4k) {
@ -167,6 +175,10 @@ class Media {
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey4k
}`;
if (tautulliUrl) {
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
}
}
}

@ -1,6 +1,22 @@
import type Media from '../../entity/Media';
import { User } from '../../entity/User';
import { PaginatedResponse } from './common';
export interface MediaResultsResponse extends PaginatedResponse {
results: Media[];
}
export interface MediaWatchDataResponse {
data?: {
users: User[];
playCount: number;
playCount7Days: number;
playCount30Days: number;
};
data4k?: {
users: User[];
playCount: number;
playCount7Days: number;
playCount30Days: number;
};
}

@ -1,3 +1,4 @@
import Media from '../../entity/Media';
import { MediaRequest } from '../../entity/MediaRequest';
import type { User } from '../../entity/User';
import { PaginatedResponse } from './common';
@ -22,3 +23,7 @@ export interface QuotaResponse {
movie: QuotaStatus;
tv: QuotaStatus;
}
export interface UserWatchDataResponse {
recentlyWatched: Media[];
playCount: number;
}

@ -35,6 +35,15 @@ export interface PlexSettings {
webAppUrl?: string;
}
export interface TautulliSettings {
hostname?: string;
port?: number;
useSsl?: boolean;
urlBase?: string;
apiKey?: string;
externalUrl?: string;
}
export interface DVRSettings {
id: number;
name: string;
@ -244,6 +253,7 @@ interface AllSettings {
vapidPrivate: string;
main: MainSettings;
plex: PlexSettings;
tautulli: TautulliSettings;
radarr: RadarrSettings[];
sonarr: SonarrSettings[];
public: PublicSettings;
@ -290,6 +300,7 @@ class Settings {
useSsl: false,
libraries: [],
},
tautulli: {},
radarr: [],
sonarr: [],
public: {
@ -425,6 +436,14 @@ class Settings {
this.data.plex = data;
}
get tautulli(): TautulliSettings {
return this.data.tautulli;
}
set tautulli(data: TautulliSettings) {
this.data.tautulli = data;
}
get radarr(): RadarrSettings[] {
return this.data.radarr;
}

@ -1,11 +1,17 @@
import { Router } from 'express';
import { getRepository, FindOperator, FindOneOptions, In } from 'typeorm';
import Media from '../entity/Media';
import { FindOneOptions, FindOperator, getRepository, In } from 'typeorm';
import TautulliAPI from '../api/tautulli';
import { MediaStatus, MediaType } from '../constants/media';
import Media from '../entity/Media';
import { User } from '../entity/User';
import {
MediaResultsResponse,
MediaWatchDataResponse,
} from '../interfaces/api/mediaInterfaces';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
import { Permission } from '../lib/permissions';
import { MediaResultsResponse } from '../interfaces/api/mediaInterfaces';
const mediaRoutes = Router();
@ -161,4 +167,103 @@ mediaRoutes.delete(
}
);
mediaRoutes.get<{ id: string }, MediaWatchDataResponse>(
'/:id/watch_data',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const settings = getSettings().tautulli;
if (!settings.hostname || !settings.port || !settings.apiKey) {
return next({
status: 404,
message: 'Tautulli API not configured.',
});
}
const media = await getRepository(Media).findOne({
where: { id: Number(req.params.id) },
});
if (!media) {
return next({ status: 404, message: 'Media does not exist.' });
}
try {
const tautulli = new TautulliAPI(settings);
const userRepository = getRepository(User);
const response: MediaWatchDataResponse = {};
if (media.ratingKey) {
const watchStats = await tautulli.getMediaWatchStats(media.ratingKey);
const watchUsers = await tautulli.getMediaWatchUsers(media.ratingKey);
const users = await userRepository
.createQueryBuilder('user')
.where('user.plexId IN (:...plexIds)', {
plexIds: watchUsers.map((u) => u.user_id),
})
.getMany();
const playCount =
watchStats.find((i) => i.query_days == 0)?.total_plays ?? 0;
const playCount7Days =
watchStats.find((i) => i.query_days == 7)?.total_plays ?? 0;
const playCount30Days =
watchStats.find((i) => i.query_days == 30)?.total_plays ?? 0;
response.data = {
users: users,
playCount,
playCount7Days,
playCount30Days,
};
}
if (media.ratingKey4k) {
const watchStats4k = await tautulli.getMediaWatchStats(
media.ratingKey4k
);
const watchUsers4k = await tautulli.getMediaWatchUsers(
media.ratingKey4k
);
const users = await userRepository
.createQueryBuilder('user')
.where('user.plexId IN (:...plexIds)', {
plexIds: watchUsers4k.map((u) => u.user_id),
})
.getMany();
const playCount =
watchStats4k.find((i) => i.query_days == 0)?.total_plays ?? 0;
const playCount7Days =
watchStats4k.find((i) => i.query_days == 7)?.total_plays ?? 0;
const playCount30Days =
watchStats4k.find((i) => i.query_days == 30)?.total_plays ?? 0;
response.data4k = {
users,
playCount,
playCount7Days,
playCount30Days,
};
}
return res.status(200).json(response);
} catch (e) {
logger.error('Something went wrong fetching media watch data', {
label: 'API',
errorMessage: e.message,
mediaId: req.params.id,
});
next({ status: 500, message: 'Failed to fetch watch data.' });
}
}
);
export default mediaRoutes;

@ -225,6 +225,21 @@ settingsRoutes.post('/plex/sync', (req, res) => {
return res.status(200).json(plexFullScanner.status());
});
settingsRoutes.get('/tautulli', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.tautulli);
});
settingsRoutes.post('/tautulli', async (req, res) => {
const settings = getSettings();
Object.assign(settings.tautulli, req.body);
settings.save();
return res.status(200).json(settings.tautulli);
});
settingsRoutes.get(
'/plex/users',
isAuthenticated(Permission.MANAGE_USERS),

@ -1,8 +1,11 @@
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { uniqWith } from 'lodash';
import { getRepository, Not } from 'typeorm';
import PlexTvAPI from '../../api/plextv';
import TautulliAPI from '../../api/tautulli';
import { UserType } from '../../constants/user';
import Media from '../../entity/Media';
import { MediaRequest } from '../../entity/MediaRequest';
import { User } from '../../entity/User';
import { UserPushSubscription } from '../../entity/UserPushSubscription';
@ -10,6 +13,7 @@ import {
QuotaResponse,
UserRequestsResponse,
UserResultsResponse,
UserWatchDataResponse,
} from '../../interfaces/api/userInterfaces';
import { hasPermission, Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings';
@ -475,7 +479,8 @@ router.get<{ id: string }, QuotaResponse>(
) {
return next({
status: 403,
message: 'You do not have permission to access this endpoint.',
message:
"You do not have permission to view this user's request limits.",
});
}
@ -492,4 +497,82 @@ router.get<{ id: string }, QuotaResponse>(
}
);
router.get<{ id: string }, UserWatchDataResponse>(
'/:id/watch_data',
async (req, res, next) => {
if (
Number(req.params.id) !== req.user?.id &&
!req.user?.hasPermission(Permission.ADMIN)
) {
return next({
status: 403,
message:
"You do not have permission to view this user's recently watched media.",
});
}
const settings = getSettings().tautulli;
if (!settings.hostname || !settings.port || !settings.apiKey) {
return next({
status: 404,
message: 'Tautulli API not configured.',
});
}
try {
const mediaRepository = getRepository(Media);
const user = await getRepository(User).findOneOrFail({
where: { id: Number(req.params.id) },
select: ['id', 'plexId'],
});
const tautulli = new TautulliAPI(settings);
const watchStats = await tautulli.getUserWatchStats(user);
const watchHistory = await tautulli.getUserWatchHistory(user);
const media = (
await Promise.all(
uniqWith(watchHistory, (recordA, recordB) =>
recordA.grandparent_rating_key && recordB.grandparent_rating_key
? recordA.grandparent_rating_key ===
recordB.grandparent_rating_key
: recordA.parent_rating_key && recordB.parent_rating_key
? recordA.parent_rating_key === recordB.parent_rating_key
: recordA.rating_key === recordB.rating_key
)
.slice(0, 20)
.map(
async (record) =>
await mediaRepository.findOne({
where: {
ratingKey:
record.media_type === 'movie'
? record.rating_key
: record.grandparent_rating_key,
},
})
)
)
).filter((media) => !!media) as Media[];
return res.status(200).json({
recentlyWatched: media,
playCount: watchStats.total_plays,
});
} catch (e) {
logger.error('Something went wrong fetching user watch data', {
label: 'API',
errorMessage: e.message,
userId: req.params.id,
});
next({
status: 500,
message: 'Failed to fetch user watch data.',
});
}
}
);
export default router;

@ -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"
<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="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"
>
<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"
<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"
>
<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>
)}
<DownloadBlock downloadItem={status} is4k />
</li>
))}
</ul>
</div>
</div>
)}
{hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], {
type: 'or',
}) &&
openIssues.length > 0 && (
<>
<h3 className="mb-2 text-xl">
{intl.formatMessage(messages.manageModalIssues)}
{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>
</>
)}
{requests.length > 0 && (
<div>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalRequests)}
</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) => (
{requests.map((request) => (
<li
key={`manage-issue-${issue.id}`}
key={`manage-request-${request.id}`}
className="border-b border-gray-700 last:border-b-0"
>
<IssueBlock issue={issue} />
<RequestBlock
request={request}
onUpdate={() => revalidate()}
/>
</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.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>
)}
</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 />
{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(messages.openarr, {
mediaType: intl.formatMessage(
mediaType === 'movie'
? globalMessages.movie
: globalMessages.tvshow
),
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
})}
{intl.formatMessage(
mediaType === 'movie'
? messages.markavailable
: messages.markallseasonsavailable
)}
</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(
)}
{data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.series4kEnabled && (
<Button
onClick={() => markAvailable(true)}
className="w-full"
buttonType="success"
>
<CheckCircleIcon />
<span>
{intl.formatMessage(
mediaType === 'movie'
? globalMessages.movie
: globalMessages.tvshow
),
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
})}
? 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>
</Button>
</a>
)}
</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>
)}
{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
),
})}
</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}
/>
))}
/>
</>
)}
</>
);
};

@ -129,19 +129,28 @@
"components.Login.signinwithplex": "Use your Plex account",
"components.Login.validationemailrequired": "You must provide a valid email address",
"components.Login.validationpasswordrequired": "You must provide a password",
"components.ManageSlideOver.allseasonsmarkedavailable": "* All seasons will be marked as available.",
"components.ManageSlideOver.downloadstatus": "Download Status",
"components.ManageSlideOver.manageModalClearMedia": "Clear Media Data",
"components.ManageSlideOver.alltime": "All Time",
"components.ManageSlideOver.downloadstatus": "Downloads",
"components.ManageSlideOver.manageModalAdvanced": "Advanced",
"components.ManageSlideOver.manageModalClearMedia": "Clear Data",
"components.ManageSlideOver.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.",
"components.ManageSlideOver.manageModalIssues": "Open Issues",
"components.ManageSlideOver.manageModalMedia": "Media",
"components.ManageSlideOver.manageModalMedia4k": "4K Media",
"components.ManageSlideOver.manageModalNoRequests": "No requests.",
"components.ManageSlideOver.manageModalRequests": "Requests",
"components.ManageSlideOver.manageModalTitle": "Manage {mediaType}",
"components.ManageSlideOver.mark4kavailable": "Mark as Available in 4K",
"components.ManageSlideOver.markallseasons4kavailable": "Mark All Seasons as Available in 4K",
"components.ManageSlideOver.markallseasonsavailable": "Mark All Seasons as Available",
"components.ManageSlideOver.markavailable": "Mark as Available",
"components.ManageSlideOver.movie": "movie",
"components.ManageSlideOver.openarr": "Open in {arr}",
"components.ManageSlideOver.openarr4k": "Open in 4K {arr}",
"components.ManageSlideOver.opentautulli": "Open in Tautulli",
"components.ManageSlideOver.pastdays": "Past {days, number} Days",
"components.ManageSlideOver.playedby": "Played By",
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}",
"components.ManageSlideOver.tvshow": "series",
"components.MediaSlider.ShowMoreCard.seemore": "See More",
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
@ -538,8 +547,8 @@
"components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key",
"components.Settings.RadarrModal.validationApplicationUrl": "You must provide a valid URL",
"components.Settings.RadarrModal.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Settings.RadarrModal.validationBaseUrlLeadingSlash": "Base URL must have a leading slash",
"components.Settings.RadarrModal.validationBaseUrlTrailingSlash": "Base URL must not end in a trailing slash",
"components.Settings.RadarrModal.validationBaseUrlLeadingSlash": "URL base must have a leading slash",
"components.Settings.RadarrModal.validationBaseUrlTrailingSlash": "URL base must not end in a trailing slash",
"components.Settings.RadarrModal.validationHostnameRequired": "You must provide a valid hostname or IP address",
"components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "You must select a minimum availability",
"components.Settings.RadarrModal.validationNameRequired": "You must provide a server name",
@ -706,6 +715,7 @@
"components.Settings.deleteserverconfirm": "Are you sure you want to delete this server?",
"components.Settings.email": "Email",
"components.Settings.enablessl": "Use SSL",
"components.Settings.externalUrl": "External URL",
"components.Settings.general": "General",
"components.Settings.generalsettings": "General Settings",
"components.Settings.generalsettingsDescription": "Configure global and default settings for Overseerr.",
@ -760,6 +770,9 @@
"components.Settings.sonarrsettings": "Sonarr Settings",
"components.Settings.ssl": "SSL",
"components.Settings.startscan": "Start Scan",
"components.Settings.tautulliApiKey": "API Key",
"components.Settings.tautulliSettings": "Tautulli Settings",
"components.Settings.tautulliSettingsDescription": "Optionally configure the settings for your Tautulli server. Overseerr fetches watch history data for your Plex media from Tautulli.",
"components.Settings.toastApiKeyFailure": "Something went wrong while generating a new API key.",
"components.Settings.toastApiKeySuccess": "New API key generated successfully!",
"components.Settings.toastPlexConnecting": "Attempting to connect to Plex…",
@ -770,14 +783,21 @@
"components.Settings.toastPlexRefreshSuccess": "Plex server list retrieved successfully!",
"components.Settings.toastSettingsFailure": "Something went wrong while saving settings.",
"components.Settings.toastSettingsSuccess": "Settings saved successfully!",
"components.Settings.toastTautulliSettingsFailure": "Something went wrong while saving Tautulli settings.",
"components.Settings.toastTautulliSettingsSuccess": "Tautulli settings saved successfully!",
"components.Settings.trustProxy": "Enable Proxy Support",
"components.Settings.trustProxyTip": "Allow Overseerr to correctly register client IP addresses behind a proxy (Overseerr must be reloaded for changes to take effect)",
"components.Settings.urlBase": "URL Base",
"components.Settings.validationApiKey": "You must provide an API key",
"components.Settings.validationApplicationTitle": "You must provide an application title",
"components.Settings.validationApplicationUrl": "You must provide a valid URL",
"components.Settings.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Settings.validationHostnameRequired": "You must provide a valid hostname or IP address",
"components.Settings.validationPortRequired": "You must provide a valid port number",
"components.Settings.validationWebAppUrl": "You must provide a valid Plex Web App URL",
"components.Settings.validationUrl": "You must provide a valid URL",
"components.Settings.validationUrlBaseLeadingSlash": "URL base must have a leading slash",
"components.Settings.validationUrlBaseTrailingSlash": "URL base must not end in a trailing slash",
"components.Settings.validationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Settings.webAppUrl": "<WebAppLink>Web App</WebAppLink> URL",
"components.Settings.webAppUrlTip": "Optionally direct users to the web app on your server instead of the \"hosted\" web app",
"components.Settings.webhook": "Webhook",
@ -952,6 +972,7 @@
"components.UserProfile.movierequests": "Movie Requests",
"components.UserProfile.norequests": "No requests.",
"components.UserProfile.pastdays": "{type} (past {days} days)",
"components.UserProfile.recentlywatched": "Recently Watched",
"components.UserProfile.recentrequests": "Recent Requests",
"components.UserProfile.requestsperdays": "{limit} remaining",
"components.UserProfile.seriesrequest": "Series Requests",

Loading…
Cancel
Save