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