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:
|
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
|
||||||
|
|||||||
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 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">
|
|
||||||
<ul>
|
|
||||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
|
||||||
<li
|
|
||||||
key={`dl-status-${status.externalId}-${index}`}
|
|
||||||
className="border-b border-gray-700 last:border-b-0"
|
|
||||||
>
|
|
||||||
<DownloadBlock downloadItem={status} />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
|
||||||
<li
|
|
||||||
key={`dl-status-${status.externalId}-${index}`}
|
|
||||||
className="border-b border-gray-700 last:border-b-0"
|
|
||||||
>
|
|
||||||
<DownloadBlock downloadItem={status} is4k />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{data?.mediaInfo &&
|
|
||||||
(data.mediaInfo.status !== MediaStatus.AVAILABLE ||
|
|
||||||
(data.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
|
||||||
settings.currentSettings.series4kEnabled)) && (
|
|
||||||
<div className="mb-6">
|
|
||||||
{data?.mediaInfo &&
|
|
||||||
data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
|
||||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
|
||||||
<Button
|
|
||||||
onClick={() => markAvailable()}
|
|
||||||
className="w-full sm:mb-0"
|
|
||||||
buttonType="success"
|
|
||||||
>
|
|
||||||
<CheckCircleIcon />
|
|
||||||
<span>{intl.formatMessage(messages.markavailable)}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data?.mediaInfo &&
|
|
||||||
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
|
||||||
settings.currentSettings.series4kEnabled && (
|
|
||||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
|
||||||
<Button
|
|
||||||
onClick={() => markAvailable(true)}
|
|
||||||
className="w-full sm:mb-0"
|
|
||||||
buttonType="success"
|
|
||||||
>
|
|
||||||
<CheckCircleIcon />
|
|
||||||
<span>{intl.formatMessage(messages.mark4kavailable)}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mediaType === 'tv' && (
|
|
||||||
<div className="mt-3 text-xs text-gray-400">
|
|
||||||
{intl.formatMessage(messages.allseasonsmarkedavailable)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], {
|
|
||||||
type: 'or',
|
|
||||||
}) &&
|
|
||||||
openIssues.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h3 className="mb-2 text-xl">
|
|
||||||
{intl.formatMessage(messages.manageModalIssues)}
|
|
||||||
</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) => (
|
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||||
<li
|
<li
|
||||||
key={`manage-issue-${issue.id}`}
|
key={`dl-status-${status.externalId}-${index}`}
|
||||||
className="border-b border-gray-700 last:border-b-0"
|
className="border-b border-gray-700 last:border-b-0"
|
||||||
>
|
>
|
||||||
<IssueBlock issue={issue} />
|
<DownloadBlock downloadItem={status} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||||
|
<li
|
||||||
|
key={`dl-status-${status.externalId}-${index}`}
|
||||||
|
className="border-b border-gray-700 last:border-b-0"
|
||||||
|
>
|
||||||
|
<DownloadBlock downloadItem={status} is4k />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h3 className="mb-2 text-xl">
|
{hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], {
|
||||||
{intl.formatMessage(messages.manageModalRequests)}
|
type: 'or',
|
||||||
</h3>
|
}) &&
|
||||||
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
openIssues.length > 0 && (
|
||||||
<ul>
|
<>
|
||||||
{data.mediaInfo?.requests?.map((request) => (
|
<h3 className="mb-2 text-xl font-bold">
|
||||||
<li
|
{intl.formatMessage(messages.manageModalIssues)}
|
||||||
key={`manage-request-${request.id}`}
|
</h3>
|
||||||
className="border-b border-gray-700 last:border-b-0"
|
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
||||||
>
|
<ul>
|
||||||
<RequestBlock request={request} onUpdate={() => revalidate()} />
|
{openIssues.map((issue) => (
|
||||||
</li>
|
<li
|
||||||
))}
|
key={`manage-issue-${issue.id}`}
|
||||||
{(data.mediaInfo?.requests ?? []).length === 0 && (
|
className="border-b border-gray-700 last:border-b-0"
|
||||||
<li className="py-4 text-center text-gray-400">
|
>
|
||||||
{intl.formatMessage(messages.manageModalNoRequests)}
|
<IssueBlock issue={issue} />
|
||||||
</li>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</ul>
|
{requests.length > 0 && (
|
||||||
</div>
|
<div>
|
||||||
{hasPermission(Permission.ADMIN) &&
|
<h3 className="mb-2 text-xl font-bold">
|
||||||
(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && (
|
{intl.formatMessage(messages.manageModalRequests)}
|
||||||
<div className="mt-8">
|
</h3>
|
||||||
{data?.mediaInfo?.serviceUrl && (
|
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
||||||
<a
|
<ul>
|
||||||
href={data?.mediaInfo?.serviceUrl}
|
{requests.map((request) => (
|
||||||
target="_blank"
|
<li
|
||||||
rel="noreferrer"
|
key={`manage-request-${request.id}`}
|
||||||
className="block mb-2 last:mb-0"
|
className="border-b border-gray-700 last:border-b-0"
|
||||||
>
|
>
|
||||||
<Button buttonType="ghost" className="w-full">
|
<RequestBlock
|
||||||
<ServerIcon />
|
request={request}
|
||||||
<span>
|
onUpdate={() => revalidate()}
|
||||||
{intl.formatMessage(messages.openarr, {
|
/>
|
||||||
mediaType: intl.formatMessage(
|
</li>
|
||||||
mediaType === 'movie'
|
))}
|
||||||
? globalMessages.movie
|
</ul>
|
||||||
: globalMessages.tvshow
|
</div>
|
||||||
),
|
|
||||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{data?.mediaInfo?.serviceUrl4k && (
|
|
||||||
<a
|
|
||||||
href={data?.mediaInfo?.serviceUrl4k}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<Button buttonType="ghost" className="w-full">
|
|
||||||
<ServerIcon />
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage(messages.openarr4k, {
|
|
||||||
mediaType: intl.formatMessage(
|
|
||||||
mediaType === 'movie'
|
|
||||||
? globalMessages.movie
|
|
||||||
: globalMessages.tvshow
|
|
||||||
),
|
|
||||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data?.mediaInfo && (
|
{hasPermission(Permission.ADMIN) &&
|
||||||
<div className="mt-8">
|
(data.mediaInfo?.serviceUrl ||
|
||||||
<ConfirmButton
|
data.mediaInfo?.tautulliUrl ||
|
||||||
onClick={() => deleteMedia()}
|
watchData?.data?.playCount) && (
|
||||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
<div>
|
||||||
className="w-full"
|
<h3 className="mb-2 text-xl font-bold">
|
||||||
>
|
{intl.formatMessage(messages.manageModalMedia)}
|
||||||
<DocumentRemoveIcon />
|
</h3>
|
||||||
<span>{intl.formatMessage(messages.manageModalClearMedia)}</span>
|
<div className="space-y-2">
|
||||||
</ConfirmButton>
|
{!!watchData?.data && (
|
||||||
<div className="mt-3 text-xs text-gray-400">
|
<div>
|
||||||
{intl.formatMessage(messages.manageModalClearMediaWarning, {
|
<div
|
||||||
mediaType: intl.formatMessage(
|
className={`grid grid-cols-1 divide-y divide-gray-500 overflow-hidden text-sm text-gray-300 bg-gray-600 shadow ${
|
||||||
mediaType === 'movie' ? messages.movie : messages.tvshow
|
data.mediaInfo?.tautulliUrl
|
||||||
),
|
? 'rounded-t-md'
|
||||||
})}
|
: 'rounded-md'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-3 divide-x divide-gray-500">
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="font-bold">
|
||||||
|
{intl.formatMessage(messages.pastdays, { days: 7 })}
|
||||||
|
</div>
|
||||||
|
<div className="text-white">
|
||||||
|
{styledPlayCount(watchData.data.playCount7Days)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="font-bold">
|
||||||
|
{intl.formatMessage(messages.pastdays, {
|
||||||
|
days: 30,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-white">
|
||||||
|
{styledPlayCount(watchData.data.playCount30Days)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="font-bold">
|
||||||
|
{intl.formatMessage(messages.alltime)}
|
||||||
|
</div>
|
||||||
|
<div className="text-white">
|
||||||
|
{styledPlayCount(watchData.data.playCount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!!watchData.data.users.length && (
|
||||||
|
<div className="flex flex-row px-4 pt-3 pb-2 space-x-2">
|
||||||
|
<span className="font-bold leading-8 shrink-0">
|
||||||
|
{intl.formatMessage(messages.playedby)}
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-row flex-wrap">
|
||||||
|
{watchData.data.users.map((user) => (
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
currentUser?.id === user.id
|
||||||
|
? '/profile'
|
||||||
|
: `/users/${user.id}`
|
||||||
|
}
|
||||||
|
key={`watch-user-${user.id}`}
|
||||||
|
>
|
||||||
|
<a className="z-0 mb-1 -mr-2 hover:z-50 shrink-0">
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.displayName}
|
||||||
|
className="w-8 h-8 transition duration-300 scale-100 rounded-full ring-1 ring-gray-500 transform-gpu hover:scale-105"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{data.mediaInfo?.tautulliUrl && (
|
||||||
|
<a
|
||||||
|
href={data.mediaInfo.tautulliUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
buttonType="ghost"
|
||||||
|
className={`w-full ${
|
||||||
|
watchData.data.playCount ? 'rounded-t-none' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ViewListIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.opentautulli)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data?.mediaInfo?.serviceUrl && (
|
||||||
|
<a
|
||||||
|
href={data?.mediaInfo?.serviceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<Button buttonType="ghost" className="w-full">
|
||||||
|
<ServerIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.openarr, {
|
||||||
|
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasPermission(Permission.ADMIN) &&
|
||||||
|
(data.mediaInfo?.serviceUrl4k ||
|
||||||
|
data.mediaInfo?.tautulliUrl4k ||
|
||||||
|
watchData?.data4k?.playCount) && (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-xl font-bold">
|
||||||
|
{intl.formatMessage(messages.manageModalMedia4k)}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{!!watchData?.data4k && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={`grid grid-cols-1 divide-y divide-gray-500 overflow-hidden text-sm text-gray-300 bg-gray-600 shadow ${
|
||||||
|
data.mediaInfo?.tautulliUrl4k
|
||||||
|
? 'rounded-t-md'
|
||||||
|
: 'rounded-md'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-3 divide-x divide-gray-500">
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="font-bold">
|
||||||
|
{intl.formatMessage(messages.pastdays, { days: 7 })}
|
||||||
|
</div>
|
||||||
|
<div className="text-white">
|
||||||
|
{styledPlayCount(watchData.data4k.playCount7Days)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="font-bold">
|
||||||
|
{intl.formatMessage(messages.pastdays, {
|
||||||
|
days: 30,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-white">
|
||||||
|
{styledPlayCount(watchData.data4k.playCount30Days)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="font-bold">
|
||||||
|
{intl.formatMessage(messages.alltime)}
|
||||||
|
</div>
|
||||||
|
<div className="text-white">
|
||||||
|
{styledPlayCount(watchData.data4k.playCount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!!watchData.data4k.users.length && (
|
||||||
|
<div className="flex flex-row px-4 pt-3 pb-2 space-x-2">
|
||||||
|
<span className="font-bold leading-8 shrink-0">
|
||||||
|
{intl.formatMessage(messages.playedby)}
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-row flex-wrap">
|
||||||
|
{watchData.data4k.users.map((user) => (
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
currentUser?.id === user.id
|
||||||
|
? '/profile'
|
||||||
|
: `/users/${user.id}`
|
||||||
|
}
|
||||||
|
key={`watch-user-${user.id}`}
|
||||||
|
>
|
||||||
|
<a className="z-0 mb-1 -mr-2 hover:z-50 shrink-0">
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.displayName}
|
||||||
|
className="w-8 h-8 transition duration-300 scale-100 rounded-full ring-1 ring-gray-500 transform-gpu hover:scale-105"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{data.mediaInfo?.tautulliUrl4k && (
|
||||||
|
<a
|
||||||
|
href={data.mediaInfo.tautulliUrl4k}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
buttonType="ghost"
|
||||||
|
className={`w-full ${
|
||||||
|
watchData.data4k.playCount ? 'rounded-t-none' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ViewListIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.opentautulli)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data?.mediaInfo?.serviceUrl4k && (
|
||||||
|
<a
|
||||||
|
href={data?.mediaInfo?.serviceUrl4k}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<Button buttonType="ghost" className="w-full">
|
||||||
|
<ServerIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.openarr4k, {
|
||||||
|
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasPermission(Permission.ADMIN) && data?.mediaInfo && (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-xl font-bold">
|
||||||
|
{intl.formatMessage(messages.manageModalAdvanced)}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
||||||
|
<Button
|
||||||
|
onClick={() => markAvailable()}
|
||||||
|
className="w-full"
|
||||||
|
buttonType="success"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(
|
||||||
|
mediaType === 'movie'
|
||||||
|
? messages.markavailable
|
||||||
|
: messages.markallseasonsavailable
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||||
|
settings.currentSettings.series4kEnabled && (
|
||||||
|
<Button
|
||||||
|
onClick={() => markAvailable(true)}
|
||||||
|
className="w-full"
|
||||||
|
buttonType="success"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(
|
||||||
|
mediaType === 'movie'
|
||||||
|
? messages.mark4kavailable
|
||||||
|
: messages.markallseasons4kavailable
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<ConfirmButton
|
||||||
|
onClick={() => deleteMedia()}
|
||||||
|
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<DocumentRemoveIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.manageModalClearMedia)}
|
||||||
|
</span>
|
||||||
|
</ConfirmButton>
|
||||||
|
<div className="mt-1 text-xs text-gray-400">
|
||||||
|
{intl.formatMessage(messages.manageModalClearMediaWarning, {
|
||||||
|
mediaType: intl.formatMessage(
|
||||||
|
mediaType === 'movie' ? messages.movie : messages.tvshow
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</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",
|
||||||
|
|||||||
Reference in New Issue
Block a user