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: port:
type: number type: number
example: 32400 example: 32400
useSsl:
type: boolean
nullable: true
libraries: libraries:
type: array type: array
readOnly: true readOnly: true
@ -172,6 +175,7 @@ components:
$ref: '#/components/schemas/PlexLibrary' $ref: '#/components/schemas/PlexLibrary'
webAppUrl: webAppUrl:
type: string type: string
nullable: true
example: 'https://app.plex.tv/desktop' example: 'https://app.plex.tv/desktop'
required: required:
- name - name
@ -298,6 +302,26 @@ components:
- provides - provides
- owned - owned
- connection - 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: RadarrSettings:
type: object type: object
properties: properties:
@ -2024,6 +2048,37 @@ paths:
type: string type: string
thumb: thumb:
type: string 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: /settings/radarr:
get: get:
summary: Get Radarr settings summary: Get Radarr settings
@ -3643,6 +3698,35 @@ paths:
permissions: permissions:
type: number type: number
example: 2 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: /search:
get: get:
summary: Search for movies, TV shows, or people summary: Search for movies, TV shows, or people
@ -4914,7 +4998,6 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PersonDetails' $ref: '#/components/schemas/PersonDetails'
/person/{personId}/combined_credits: /person/{personId}/combined_credits:
get: get:
summary: Get combined credits summary: Get combined credits
@ -5051,6 +5134,57 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/MediaInfo' $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}: /collection/{collectionId}:
get: get:
summary: Get collection details 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 plexUrl?: string;
public plexUrl4k?: string; public plexUrl4k?: string;
public tautulliUrl?: string;
public tautulliUrl4k?: string;
constructor(init?: Partial<Media>) { constructor(init?: Partial<Media>) {
Object.assign(this, init); Object.assign(this, init);
} }
@ -152,6 +155,7 @@ class Media {
@AfterLoad() @AfterLoad()
public setPlexUrls(): void { public setPlexUrls(): void {
const { machineId, webAppUrl } = getSettings().plex; const { machineId, webAppUrl } = getSettings().plex;
const { externalUrl: tautulliUrl } = getSettings().tautulli;
if (this.ratingKey) { if (this.ratingKey) {
this.plexUrl = `${ this.plexUrl = `${
@ -159,6 +163,10 @@ class Media {
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${ }#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey this.ratingKey
}`; }`;
if (tautulliUrl) {
this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`;
}
} }
if (this.ratingKey4k) { if (this.ratingKey4k) {
@ -167,6 +175,10 @@ class Media {
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${ }#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey4k this.ratingKey4k
}`; }`;
if (tautulliUrl) {
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
}
} }
} }

@ -1,6 +1,22 @@
import type Media from '../../entity/Media'; import type Media from '../../entity/Media';
import { User } from '../../entity/User';
import { PaginatedResponse } from './common'; import { PaginatedResponse } from './common';
export interface MediaResultsResponse extends PaginatedResponse { export interface MediaResultsResponse extends PaginatedResponse {
results: Media[]; 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 { MediaRequest } from '../../entity/MediaRequest';
import type { User } from '../../entity/User'; import type { User } from '../../entity/User';
import { PaginatedResponse } from './common'; import { PaginatedResponse } from './common';
@ -22,3 +23,7 @@ export interface QuotaResponse {
movie: QuotaStatus; movie: QuotaStatus;
tv: QuotaStatus; tv: QuotaStatus;
} }
export interface UserWatchDataResponse {
recentlyWatched: Media[];
playCount: number;
}

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

@ -1,11 +1,17 @@
import { Router } from 'express'; import { Router } from 'express';
import { getRepository, FindOperator, FindOneOptions, In } from 'typeorm'; import { FindOneOptions, FindOperator, getRepository, In } from 'typeorm';
import Media from '../entity/Media'; import TautulliAPI from '../api/tautulli';
import { MediaStatus, MediaType } from '../constants/media'; 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 logger from '../logger';
import { isAuthenticated } from '../middleware/auth'; import { isAuthenticated } from '../middleware/auth';
import { Permission } from '../lib/permissions';
import { MediaResultsResponse } from '../interfaces/api/mediaInterfaces';
const mediaRoutes = Router(); 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; export default mediaRoutes;

@ -225,6 +225,21 @@ settingsRoutes.post('/plex/sync', (req, res) => {
return res.status(200).json(plexFullScanner.status()); 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( settingsRoutes.get(
'/plex/users', '/plex/users',
isAuthenticated(Permission.MANAGE_USERS), isAuthenticated(Permission.MANAGE_USERS),

@ -1,8 +1,11 @@
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url'; import gravatarUrl from 'gravatar-url';
import { uniqWith } from 'lodash';
import { getRepository, Not } from 'typeorm'; import { getRepository, Not } from 'typeorm';
import PlexTvAPI from '../../api/plextv'; import PlexTvAPI from '../../api/plextv';
import TautulliAPI from '../../api/tautulli';
import { UserType } from '../../constants/user'; import { UserType } from '../../constants/user';
import Media from '../../entity/Media';
import { MediaRequest } from '../../entity/MediaRequest'; import { MediaRequest } from '../../entity/MediaRequest';
import { User } from '../../entity/User'; import { User } from '../../entity/User';
import { UserPushSubscription } from '../../entity/UserPushSubscription'; import { UserPushSubscription } from '../../entity/UserPushSubscription';
@ -10,6 +13,7 @@ import {
QuotaResponse, QuotaResponse,
UserRequestsResponse, UserRequestsResponse,
UserResultsResponse, UserResultsResponse,
UserWatchDataResponse,
} from '../../interfaces/api/userInterfaces'; } from '../../interfaces/api/userInterfaces';
import { hasPermission, Permission } from '../../lib/permissions'; import { hasPermission, Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings'; import { getSettings } from '../../lib/settings';
@ -475,7 +479,8 @@ router.get<{ id: string }, QuotaResponse>(
) { ) {
return next({ return next({
status: 403, 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; export default router;

@ -8,7 +8,7 @@ import Link from 'next/link';
import React from 'react'; import React from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import type Issue from '../../../server/entity/Issue'; import type Issue from '../../../server/entity/Issue';
import globalMessages from '../../i18n/globalMessages'; import { useUser } from '../../hooks/useUser';
import Button from '../Common/Button'; import Button from '../Common/Button';
import { issueOptions } from '../IssueModal/constants'; import { issueOptions } from '../IssueModal/constants';
@ -17,6 +17,7 @@ interface IssueBlockProps {
} }
const IssueBlock: React.FC<IssueBlockProps> = ({ issue }) => { const IssueBlock: React.FC<IssueBlockProps> = ({ issue }) => {
const { user } = useUser();
const intl = useIntl(); const intl = useIntl();
const issueOption = issueOptions.find( const issueOption = issueOptions.find(
(opt) => opt.issueType === issue.issueType (opt) => opt.issueType === issue.issueType
@ -27,7 +28,7 @@ const IssueBlock: React.FC<IssueBlockProps> = ({ issue }) => {
} }
return ( 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 items-center justify-between">
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5"> <div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5">
<div className="flex flex-nowrap"> <div className="flex flex-nowrap">
@ -39,7 +40,17 @@ const IssueBlock: React.FC<IssueBlockProps> = ({ issue }) => {
<div className="flex mb-1 flex-nowrap white"> <div className="flex mb-1 flex-nowrap white">
<UserIcon className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5" /> <UserIcon className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5" />
<span className="w-40 truncate md:w-auto"> <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> </span>
</div> </div>
<div className="flex mb-1 flex-nowrap white"> <div className="flex mb-1 flex-nowrap white">
@ -55,9 +66,8 @@ const IssueBlock: React.FC<IssueBlockProps> = ({ issue }) => {
</div> </div>
<div className="flex flex-wrap flex-shrink-0 ml-2"> <div className="flex flex-wrap flex-shrink-0 ml-2">
<Link href={`/issues/${issue.id}`} passHref> <Link href={`/issues/${issue.id}`} passHref>
<Button buttonType="primary" buttonSize="sm" as="a"> <Button buttonType="primary" as="a">
<EyeIcon /> <EyeIcon />
<span>{intl.formatMessage(globalMessages.view)}</span>
</Button> </Button>
</Link> </Link>
</div> </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 { CheckCircleIcon, DocumentRemoveIcon } from '@heroicons/react/solid';
import axios from 'axios'; import axios from 'axios';
import Link from 'next/link';
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { IssueStatus } from '../../../server/constants/issue'; 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 { MovieDetails } from '../../../server/models/Movie';
import { TvDetails } from '../../../server/models/Tv'; import { TvDetails } from '../../../server/models/Tv';
import useSettings from '../../hooks/useSettings'; import useSettings from '../../hooks/useSettings';
@ -21,17 +27,26 @@ const messages = defineMessages({
manageModalTitle: 'Manage {mediaType}', manageModalTitle: 'Manage {mediaType}',
manageModalIssues: 'Open Issues', manageModalIssues: 'Open Issues',
manageModalRequests: 'Requests', manageModalRequests: 'Requests',
manageModalMedia: 'Media',
manageModalMedia4k: '4K Media',
manageModalAdvanced: 'Advanced',
manageModalNoRequests: 'No requests.', manageModalNoRequests: 'No requests.',
manageModalClearMedia: 'Clear Media Data', manageModalClearMedia: 'Clear Data',
manageModalClearMediaWarning: 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.', '* 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}', openarr: 'Open in {arr}',
openarr4k: 'Open in 4K {arr}', openarr4k: 'Open in 4K {arr}',
downloadstatus: 'Download Status', downloadstatus: 'Downloads',
markavailable: 'Mark as Available', markavailable: 'Mark as Available',
mark4kavailable: 'Mark as Available in 4K', mark4kavailable: 'Mark as Available in 4K',
allseasonsmarkedavailable: '* All seasons will be marked as available.', markallseasonsavailable: 'Mark All Seasons as Available',
// Recreated here for lowercase versions to go with the modal clear media warning 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', movie: 'movie',
tvshow: 'series', tvshow: 'series',
}); });
@ -60,29 +75,54 @@ interface ManageSlideOverTvProps extends ManageSlideOverProps {
const ManageSlideOver: React.FC< const ManageSlideOver: React.FC<
ManageSlideOverMovieProps | ManageSlideOverTvProps ManageSlideOverMovieProps | ManageSlideOverTvProps
> = ({ show, mediaType, onClose, data, revalidate }) => { > = ({ show, mediaType, onClose, data, revalidate }) => {
const { hasPermission } = useUser(); const { user: currentUser, hasPermission } = useUser();
const intl = useIntl(); const intl = useIntl();
const settings = useSettings(); 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 () => { const deleteMedia = async () => {
if (data?.mediaInfo?.id) { if (data.mediaInfo) {
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`); await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
revalidate(); revalidate();
} }
}; };
const markAvailable = async (is4k = false) => { const markAvailable = async (is4k = false) => {
await axios.post(`/api/v1/media/${data?.mediaInfo?.id}/available`, { if (data.mediaInfo) {
is4k, await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
}); is4k,
revalidate(); });
revalidate();
}
}; };
const requests =
data.mediaInfo?.requests?.filter(
(request) => request.status !== MediaRequestStatus.DECLINED
) ?? [];
const openIssues = const openIssues =
data.mediaInfo?.issues?.filter( data.mediaInfo?.issues?.filter(
(issue) => issue.status === IssueStatus.OPEN (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 ( return (
<SlideOver <SlideOver
show={show} show={show}
@ -94,182 +134,371 @@ const ManageSlideOver: React.FC<
onClose={() => onClose()} onClose={() => onClose()}
subText={isMovie(data) ? data.title : data.name} subText={isMovie(data) ? data.title : data.name}
> >
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 || <div className="space-y-6">
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && ( {((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
<> (data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
<h3 className="mb-2 text-xl"> <div>
{intl.formatMessage(messages.downloadstatus)} <h3 className="mb-2 text-xl font-bold">
</h3> {intl.formatMessage(messages.downloadstatus)}
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow"> </h3>
<ul> <div className="overflow-hidden bg-gray-600 rounded-md shadow">
{data.mediaInfo?.downloadStatus?.map((status, index) => ( <ul>
<li {data.mediaInfo?.downloadStatus?.map((status, index) => (
key={`dl-status-${status.externalId}-${index}`} <li
className="border-b border-gray-700 last:border-b-0" 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 /> <DownloadBlock downloadItem={status} />
<span>{intl.formatMessage(messages.markavailable)}</span> </li>
</Button> ))}
</div> {data.mediaInfo?.downloadStatus4k?.map((status, index) => (
)} <li
{data?.mediaInfo && key={`dl-status-${status.externalId}-${index}`}
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && className="border-b border-gray-700 last:border-b-0"
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 /> <DownloadBlock downloadItem={status} is4k />
<span>{intl.formatMessage(messages.mark4kavailable)}</span> </li>
</Button> ))}
</div> </ul>
)} </div>
{mediaType === 'tv' && (
<div className="mt-3 text-xs text-gray-400">
{intl.formatMessage(messages.allseasonsmarkedavailable)}
</div>
)}
</div> </div>
)} )}
{hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], { {hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], {
type: 'or', type: 'or',
}) && }) &&
openIssues.length > 0 && ( openIssues.length > 0 && (
<> <>
<h3 className="mb-2 text-xl"> <h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalIssues)} {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> </h3>
<div className="mb-4 overflow-hidden bg-gray-600 rounded-md shadow"> <div className="overflow-hidden bg-gray-600 rounded-md shadow">
<ul> <ul>
{openIssues.map((issue) => ( {requests.map((request) => (
<li <li
key={`manage-issue-${issue.id}`} key={`manage-request-${request.id}`}
className="border-b border-gray-700 last:border-b-0" className="border-b border-gray-700 last:border-b-0"
> >
<IssueBlock issue={issue} /> <RequestBlock
request={request}
onUpdate={() => revalidate()}
/>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
</> </div>
)} )}
<h3 className="mb-2 text-xl"> {hasPermission(Permission.ADMIN) &&
{intl.formatMessage(messages.manageModalRequests)} (data.mediaInfo?.serviceUrl ||
</h3> data.mediaInfo?.tautulliUrl ||
<div className="overflow-hidden bg-gray-600 rounded-md shadow"> watchData?.data?.playCount) && (
<ul> <div>
{data.mediaInfo?.requests?.map((request) => ( <h3 className="mb-2 text-xl font-bold">
<li {intl.formatMessage(messages.manageModalMedia)}
key={`manage-request-${request.id}`} </h3>
className="border-b border-gray-700 last:border-b-0" <div className="space-y-2">
> {!!watchData?.data && (
<RequestBlock request={request} onUpdate={() => revalidate()} /> <div>
</li> <div
))} className={`grid grid-cols-1 divide-y divide-gray-500 overflow-hidden text-sm text-gray-300 bg-gray-600 shadow ${
{(data.mediaInfo?.requests ?? []).length === 0 && ( data.mediaInfo?.tautulliUrl
<li className="py-4 text-center text-gray-400"> ? 'rounded-t-md'
{intl.formatMessage(messages.manageModalNoRequests)} : 'rounded-md'
</li> }`}
>
<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> {hasPermission(Permission.ADMIN) &&
</div> (data.mediaInfo?.serviceUrl4k ||
{hasPermission(Permission.ADMIN) && data.mediaInfo?.tautulliUrl4k ||
(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && ( watchData?.data4k?.playCount) && (
<div className="mt-8"> <div>
{data?.mediaInfo?.serviceUrl && ( <h3 className="mb-2 text-xl font-bold">
<a {intl.formatMessage(messages.manageModalMedia4k)}
href={data?.mediaInfo?.serviceUrl} </h3>
target="_blank" <div className="space-y-2">
rel="noreferrer" {!!watchData?.data4k && (
className="block mb-2 last:mb-0" <div>
> <div
<Button buttonType="ghost" className="w-full"> className={`grid grid-cols-1 divide-y divide-gray-500 overflow-hidden text-sm text-gray-300 bg-gray-600 shadow ${
<ServerIcon /> 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> <span>
{intl.formatMessage(messages.openarr, { {intl.formatMessage(
mediaType: intl.formatMessage( mediaType === 'movie'
mediaType === 'movie' ? messages.markavailable
? globalMessages.movie : messages.markallseasonsavailable
: globalMessages.tvshow )}
),
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
})}
</span> </span>
</Button> </Button>
</a> )}
)} {data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
{data?.mediaInfo?.serviceUrl4k && ( settings.currentSettings.series4kEnabled && (
<a <Button
href={data?.mediaInfo?.serviceUrl4k} onClick={() => markAvailable(true)}
target="_blank" className="w-full"
rel="noreferrer" buttonType="success"
> >
<Button buttonType="ghost" className="w-full"> <CheckCircleIcon />
<ServerIcon /> <span>
<span> {intl.formatMessage(
{intl.formatMessage(messages.openarr4k, {
mediaType: intl.formatMessage(
mediaType === 'movie' mediaType === 'movie'
? globalMessages.movie ? messages.mark4kavailable
: globalMessages.tvshow : messages.markallseasons4kavailable
), )}
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', </span>
})} </Button>
)}
<div>
<ConfirmButton
onClick={() => deleteMedia()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<DocumentRemoveIcon />
<span>
{intl.formatMessage(messages.manageModalClearMedia)}
</span> </span>
</Button> </ConfirmButton>
</a> <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>
)} )}
{data?.mediaInfo && ( </div>
<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>
)}
</SlideOver> </SlideOver>
); );
}; };

@ -353,7 +353,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<ExclamationIcon /> <ExclamationIcon />
</Button> </Button>
)} )}
{hasPermission(Permission.MANAGE_REQUESTS) && ( {hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && (
<Button <Button
buttonType="default" buttonType="default"
className="relative ml-2 first:ml-0" className="relative ml-2 first:ml-0"

@ -14,6 +14,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { MediaRequestStatus } from '../../../server/constants/media'; import { MediaRequestStatus } from '../../../server/constants/media';
import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { MediaRequest } from '../../../server/entity/MediaRequest';
import useRequestOverride from '../../hooks/useRequestOverride'; import useRequestOverride from '../../hooks/useRequestOverride';
import { useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
import Badge from '../Common/Badge'; import Badge from '../Common/Badge';
import Button from '../Common/Button'; import Button from '../Common/Button';
@ -33,6 +34,7 @@ interface RequestBlockProps {
} }
const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => { const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
const { user } = useUser();
const intl = useIntl(); const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
@ -75,14 +77,20 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
setShowEditModal(false); 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 items-center justify-between">
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5"> <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"> <div className="flex mb-1 flex-nowrap white">
<UserIcon className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5" /> <UserIcon className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5" />
<span className="w-40 truncate md:w-auto"> <span className="w-40 truncate md:w-auto">
<Link href={`/users/${request.requestedBy.id}`}> <Link
<a className="text-gray-100 transition duration-300 hover:text-white hover:underline"> 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} {request.requestedBy.displayName}
</a> </a>
</Link> </Link>
@ -92,8 +100,14 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
<div className="flex flex-nowrap"> <div className="flex flex-nowrap">
<EyeIcon className="flex-shrink-0 mr-1.5 h-5 w-5" /> <EyeIcon className="flex-shrink-0 mr-1.5 h-5 w-5" />
<span className="w-40 truncate md:w-auto"> <span className="w-40 truncate md:w-auto">
<Link href={`/users/${request.modifiedBy.id}`}> <Link
<a className="text-gray-100 transition duration-300 hover:text-white hover:underline"> 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} {request.modifiedBy.displayName}
</a> </a>
</Link> </Link>

@ -63,8 +63,8 @@ const messages = defineMessages({
enableSearch: 'Enable Automatic Search', enableSearch: 'Enable Automatic Search',
validationApplicationUrl: 'You must provide a valid URL', validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash', validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
validationBaseUrlTrailingSlash: 'Base URL must not end in a trailing slash', validationBaseUrlTrailingSlash: 'URL base must not end in a trailing slash',
notagoptions: 'No tags.', notagoptions: 'No tags.',
selecttags: 'Select tags', selecttags: 'Select tags',
announced: 'Announced', announced: 'Announced',

@ -83,12 +83,7 @@ const SettingsMain: React.FC = () => {
.test( .test(
'no-trailing-slash', 'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash), intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
(value) => { (value) => !value || !value.endsWith('/')
if (value?.substr(value.length - 1) === '/') {
return false;
}
return true;
}
), ),
}); });

@ -9,13 +9,17 @@ import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
import * as Yup from 'yup'; import * as Yup from 'yup';
import type { PlexDevice } from '../../../server/interfaces/api/plexInterfaces'; 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 globalMessages from '../../i18n/globalMessages';
import Alert from '../Common/Alert'; import Alert from '../Common/Alert';
import Badge from '../Common/Badge'; import Badge from '../Common/Badge';
import Button from '../Common/Button'; import Button from '../Common/Button';
import LoadingSpinner from '../Common/LoadingSpinner'; import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import SensitiveInput from '../Common/SensitiveInput';
import LibraryItem from './LibraryItem'; import LibraryItem from './LibraryItem';
const messages = defineMessages({ const messages = defineMessages({
@ -59,7 +63,20 @@ const messages = defineMessages({
webAppUrl: '<WebAppLink>Web App</WebAppLink> URL', webAppUrl: '<WebAppLink>Web App</WebAppLink> URL',
webAppUrlTip: webAppUrlTip:
'Optionally direct users to the web app on your server instead of the "hosted" web app', '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 { interface Library {
@ -101,6 +118,8 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
error, error,
mutate: revalidate, mutate: revalidate,
} = useSWR<PlexSettings>('/api/v1/settings/plex'); } = useSWR<PlexSettings>('/api/v1/settings/plex');
const { data: dataTautulli, mutate: revalidateTautulli } =
useSWR<TautulliSettings>('/api/v1/settings/tautulli');
const { data: dataSync, mutate: revalidateSync } = useSWR<SyncStatus>( const { data: dataSync, mutate: revalidateSync } = useSWR<SyncStatus>(
'/api/v1/settings/plex/sync', '/api/v1/settings/plex/sync',
{ {
@ -109,6 +128,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
); );
const intl = useIntl(); const intl = useIntl();
const { addToast, removeToast } = useToasts(); const { addToast, removeToast } = useToasts();
const PlexSettingsSchema = Yup.object().shape({ const PlexSettingsSchema = Yup.object().shape({
hostname: Yup.string() hostname: Yup.string()
.nullable() .nullable()
@ -122,9 +142,66 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
.required(intl.formatMessage(messages.validationPortRequired)), .required(intl.formatMessage(messages.validationPortRequired)),
webAppUrl: Yup.string() webAppUrl: Yup.string()
.nullable() .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 = const activeLibraries =
data?.libraries data?.libraries
.filter((library) => library.enabled) .filter((library) => library.enabled)
@ -247,7 +324,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
revalidate(); revalidate();
}; };
if (!data && !error) { if ((!data || !dataTautulli) && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
} }
return ( return (
@ -646,6 +723,209 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
</div> </div>
</div> </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" /> <ExclamationIcon className="w-5" />
</Button> </Button>
)} )}
{hasPermission(Permission.MANAGE_REQUESTS) && ( {hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && (
<Button <Button
buttonType="default" buttonType="default"
className="relative ml-2 first:ml-0" className="relative ml-2 first:ml-0"

@ -2,15 +2,16 @@ import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useCallback, useEffect, useState } from 'react'; 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 useSWR from 'swr';
import { import {
QuotaResponse, QuotaResponse,
UserRequestsResponse, UserRequestsResponse,
UserWatchDataResponse,
} from '../../../server/interfaces/api/userInterfaces'; } from '../../../server/interfaces/api/userInterfaces';
import { MovieDetails } from '../../../server/models/Movie'; import { MovieDetails } from '../../../server/models/Movie';
import { TvDetails } from '../../../server/models/Tv'; 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 Error from '../../pages/_error';
import ImageFader from '../Common/ImageFader'; import ImageFader from '../Common/ImageFader';
import LoadingSpinner from '../Common/LoadingSpinner'; import LoadingSpinner from '../Common/LoadingSpinner';
@ -18,6 +19,7 @@ import PageTitle from '../Common/PageTitle';
import ProgressCircle from '../Common/ProgressCircle'; import ProgressCircle from '../Common/ProgressCircle';
import RequestCard from '../RequestCard'; import RequestCard from '../RequestCard';
import Slider from '../Slider'; import Slider from '../Slider';
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
import ProfileHeader from './ProfileHeader'; import ProfileHeader from './ProfileHeader';
const messages = defineMessages({ const messages = defineMessages({
@ -30,6 +32,7 @@ const messages = defineMessages({
pastdays: '{type} (past {days} days)', pastdays: '{type} (past {days} days)',
movierequests: 'Movie Requests', movierequests: 'Movie Requests',
seriesrequest: 'Series Requests', seriesrequest: 'Series Requests',
recentlywatched: 'Recently Watched',
}); });
type MediaTitle = MovieDetails | TvDetails; type MediaTitle = MovieDetails | TvDetails;
@ -46,10 +49,30 @@ const UserProfile: React.FC = () => {
>({}); >({});
const { data: requests, error: requestError } = useSWR<UserRequestsResponse>( 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>( 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( const updateAvailableTitles = useCallback(
@ -95,7 +118,10 @@ const UserProfile: React.FC = () => {
<ProfileHeader user={user} /> <ProfileHeader user={user} />
{quota && {quota &&
(user.id === currentUser?.id || (user.id === currentUser?.id ||
currentHasPermission(Permission.MANAGE_USERS)) && ( currentHasPermission(
[Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS],
{ type: 'and' }
)) && (
<div className="relative z-40"> <div className="relative z-40">
<dl className="grid grid-cols-1 gap-5 mt-5 lg:grid-cols-3"> <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"> <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)} {intl.formatMessage(messages.totalrequests)}
</dt> </dt>
<dd className="mt-1 text-3xl font-semibold text-white"> <dd className="mt-1 text-3xl font-semibold text-white">
{intl.formatNumber(user.requestCount)} <FormattedNumber value={user.requestCount} />
</dd> </dd>
</div> </div>
<div <div
className={`px-4 py-5 overflow-hidden bg-gray-800 bg-opacity-50 rounded-lg shadow ring-1 ${ className={`px-4 py-5 overflow-hidden bg-gray-800 bg-opacity-50 rounded-lg shadow ring-1 ${
quota.movie.restricted quota.movie.restricted
@ -162,7 +187,6 @@ const UserProfile: React.FC = () => {
)} )}
</dd> </dd>
</div> </div>
<div <div
className={`px-4 py-5 overflow-hidden bg-gray-800 bg-opacity-50 rounded-lg shadow ring-1 ${ className={`px-4 py-5 overflow-hidden bg-gray-800 bg-opacity-50 rounded-lg shadow ring-1 ${
quota.tv.restricted 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.signinwithplex": "Use your Plex account",
"components.Login.validationemailrequired": "You must provide a valid email address", "components.Login.validationemailrequired": "You must provide a valid email address",
"components.Login.validationpasswordrequired": "You must provide a password", "components.Login.validationpasswordrequired": "You must provide a password",
"components.ManageSlideOver.allseasonsmarkedavailable": "* All seasons will be marked as available.", "components.ManageSlideOver.alltime": "All Time",
"components.ManageSlideOver.downloadstatus": "Download Status", "components.ManageSlideOver.downloadstatus": "Downloads",
"components.ManageSlideOver.manageModalClearMedia": "Clear Media Data", "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.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.manageModalIssues": "Open Issues",
"components.ManageSlideOver.manageModalMedia": "Media",
"components.ManageSlideOver.manageModalMedia4k": "4K Media",
"components.ManageSlideOver.manageModalNoRequests": "No requests.", "components.ManageSlideOver.manageModalNoRequests": "No requests.",
"components.ManageSlideOver.manageModalRequests": "Requests", "components.ManageSlideOver.manageModalRequests": "Requests",
"components.ManageSlideOver.manageModalTitle": "Manage {mediaType}", "components.ManageSlideOver.manageModalTitle": "Manage {mediaType}",
"components.ManageSlideOver.mark4kavailable": "Mark as Available in 4K", "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.markavailable": "Mark as Available",
"components.ManageSlideOver.movie": "movie", "components.ManageSlideOver.movie": "movie",
"components.ManageSlideOver.openarr": "Open in {arr}", "components.ManageSlideOver.openarr": "Open in {arr}",
"components.ManageSlideOver.openarr4k": "Open in 4K {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.ManageSlideOver.tvshow": "series",
"components.MediaSlider.ShowMoreCard.seemore": "See More", "components.MediaSlider.ShowMoreCard.seemore": "See More",
"components.MovieDetails.MovieCast.fullcast": "Full Cast", "components.MovieDetails.MovieCast.fullcast": "Full Cast",
@ -538,8 +547,8 @@
"components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key", "components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key",
"components.Settings.RadarrModal.validationApplicationUrl": "You must provide a valid URL", "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.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Settings.RadarrModal.validationBaseUrlLeadingSlash": "Base URL must have a leading slash", "components.Settings.RadarrModal.validationBaseUrlLeadingSlash": "URL base must have a leading slash",
"components.Settings.RadarrModal.validationBaseUrlTrailingSlash": "Base URL must not end in a trailing 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.validationHostnameRequired": "You must provide a valid hostname or IP address",
"components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "You must select a minimum availability", "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "You must select a minimum availability",
"components.Settings.RadarrModal.validationNameRequired": "You must provide a server name", "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.deleteserverconfirm": "Are you sure you want to delete this server?",
"components.Settings.email": "Email", "components.Settings.email": "Email",
"components.Settings.enablessl": "Use SSL", "components.Settings.enablessl": "Use SSL",
"components.Settings.externalUrl": "External URL",
"components.Settings.general": "General", "components.Settings.general": "General",
"components.Settings.generalsettings": "General Settings", "components.Settings.generalsettings": "General Settings",
"components.Settings.generalsettingsDescription": "Configure global and default settings for Overseerr.", "components.Settings.generalsettingsDescription": "Configure global and default settings for Overseerr.",
@ -760,6 +770,9 @@
"components.Settings.sonarrsettings": "Sonarr Settings", "components.Settings.sonarrsettings": "Sonarr Settings",
"components.Settings.ssl": "SSL", "components.Settings.ssl": "SSL",
"components.Settings.startscan": "Start Scan", "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.toastApiKeyFailure": "Something went wrong while generating a new API key.",
"components.Settings.toastApiKeySuccess": "New API key generated successfully!", "components.Settings.toastApiKeySuccess": "New API key generated successfully!",
"components.Settings.toastPlexConnecting": "Attempting to connect to Plex…", "components.Settings.toastPlexConnecting": "Attempting to connect to Plex…",
@ -770,14 +783,21 @@
"components.Settings.toastPlexRefreshSuccess": "Plex server list retrieved successfully!", "components.Settings.toastPlexRefreshSuccess": "Plex server list retrieved successfully!",
"components.Settings.toastSettingsFailure": "Something went wrong while saving settings.", "components.Settings.toastSettingsFailure": "Something went wrong while saving settings.",
"components.Settings.toastSettingsSuccess": "Settings saved successfully!", "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.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.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.validationApplicationTitle": "You must provide an application title",
"components.Settings.validationApplicationUrl": "You must provide a valid URL", "components.Settings.validationApplicationUrl": "You must provide a valid URL",
"components.Settings.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash", "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.validationHostnameRequired": "You must provide a valid hostname or IP address",
"components.Settings.validationPortRequired": "You must provide a valid port number", "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.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.webAppUrlTip": "Optionally direct users to the web app on your server instead of the \"hosted\" web app",
"components.Settings.webhook": "Webhook", "components.Settings.webhook": "Webhook",
@ -952,6 +972,7 @@
"components.UserProfile.movierequests": "Movie Requests", "components.UserProfile.movierequests": "Movie Requests",
"components.UserProfile.norequests": "No requests.", "components.UserProfile.norequests": "No requests.",
"components.UserProfile.pastdays": "{type} (past {days} days)", "components.UserProfile.pastdays": "{type} (past {days} days)",
"components.UserProfile.recentlywatched": "Recently Watched",
"components.UserProfile.recentrequests": "Recent Requests", "components.UserProfile.recentrequests": "Recent Requests",
"components.UserProfile.requestsperdays": "{limit} remaining", "components.UserProfile.requestsperdays": "{limit} remaining",
"components.UserProfile.seriesrequest": "Series Requests", "components.UserProfile.seriesrequest": "Series Requests",

Loading…
Cancel
Save