diff --git a/overseerr-api.yml b/overseerr-api.yml index 429b31e77..7a87e8b83 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -165,6 +165,9 @@ components: port: type: number example: 32400 + useSsl: + type: boolean + nullable: true libraries: type: array readOnly: true @@ -172,6 +175,7 @@ components: $ref: '#/components/schemas/PlexLibrary' webAppUrl: type: string + nullable: true example: 'https://app.plex.tv/desktop' required: - name @@ -298,6 +302,26 @@ components: - provides - owned - connection + TautulliSettings: + type: object + properties: + hostname: + type: string + nullable: true + example: 'tautulli.example.com' + port: + type: number + nullable: true + example: 8181 + useSsl: + type: boolean + nullable: true + apiKey: + type: string + nullable: true + externalUrl: + type: string + nullable: true RadarrSettings: type: object properties: @@ -2024,6 +2048,37 @@ paths: type: string thumb: type: string + /settings/tautulli: + get: + summary: Get Tautulli settings + description: Retrieves current Tautulli settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TautulliSettings' + post: + summary: Update Tautulli settings + description: Updates Tautulli settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TautulliSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/TautulliSettings' /settings/radarr: get: summary: Get Radarr settings @@ -3643,6 +3698,35 @@ paths: permissions: type: number example: 2 + /user/{userId}/watch_data: + get: + summary: Get watch data + description: | + Returns play count, play duration, and recently watched media. + + Requires the `ADMIN` permission to fetch results for other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: Users + content: + application/json: + schema: + type: object + properties: + recentlyWatched: + type: array + items: + $ref: '#/components/schemas/MediaInfo' + playCount: + type: number /search: get: summary: Search for movies, TV shows, or people @@ -4914,7 +4998,6 @@ paths: application/json: schema: $ref: '#/components/schemas/PersonDetails' - /person/{personId}/combined_credits: get: summary: Get combined credits @@ -5051,6 +5134,57 @@ paths: application/json: schema: $ref: '#/components/schemas/MediaInfo' + /media/{mediaId}/watch_data: + get: + summary: Get watch data + description: | + Returns play count, play duration, and users who have watched the media. + + Requires the `ADMIN` permission. + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + responses: + '200': + description: Users + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + playCount7Days: + type: number + playCount30Days: + type: number + playCount: + type: number + users: + type: array + items: + $ref: '#/components/schemas/User' + data4k: + type: object + properties: + playCount7Days: + type: number + playCount30Days: + type: number + playCount: + type: number + users: + type: array + items: + $ref: '#/components/schemas/User' /collection/{collectionId}: get: summary: Get collection details diff --git a/server/api/tautulli.ts b/server/api/tautulli.ts new file mode 100644 index 000000000..727c7d08c --- /dev/null +++ b/server/api/tautulli.ts @@ -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 { + try { + return ( + await this.axios.get('/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 { + try { + return ( + await this.axios.get('/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 { + try { + if (!user.plexId) { + throw new Error('User does not have an associated Plex ID'); + } + + return ( + await this.axios.get('/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 { + try { + if (!user.plexId) { + throw new Error('User does not have an associated Plex ID'); + } + + return ( + await this.axios.get('/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; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 9cb8cd793..9d106d4f5 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -145,6 +145,9 @@ class Media { public plexUrl?: string; public plexUrl4k?: string; + public tautulliUrl?: string; + public tautulliUrl4k?: string; + constructor(init?: Partial) { Object.assign(this, init); } @@ -152,6 +155,7 @@ class Media { @AfterLoad() public setPlexUrls(): void { const { machineId, webAppUrl } = getSettings().plex; + const { externalUrl: tautulliUrl } = getSettings().tautulli; if (this.ratingKey) { this.plexUrl = `${ @@ -159,6 +163,10 @@ class Media { }#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${ this.ratingKey }`; + + if (tautulliUrl) { + this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`; + } } if (this.ratingKey4k) { @@ -167,6 +175,10 @@ class Media { }#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${ this.ratingKey4k }`; + + if (tautulliUrl) { + this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`; + } } } diff --git a/server/interfaces/api/mediaInterfaces.ts b/server/interfaces/api/mediaInterfaces.ts index e530d2d2c..d17716d20 100644 --- a/server/interfaces/api/mediaInterfaces.ts +++ b/server/interfaces/api/mediaInterfaces.ts @@ -1,6 +1,22 @@ import type Media from '../../entity/Media'; +import { User } from '../../entity/User'; import { PaginatedResponse } from './common'; export interface MediaResultsResponse extends PaginatedResponse { results: Media[]; } + +export interface MediaWatchDataResponse { + data?: { + users: User[]; + playCount: number; + playCount7Days: number; + playCount30Days: number; + }; + data4k?: { + users: User[]; + playCount: number; + playCount7Days: number; + playCount30Days: number; + }; +} diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts index facacd54c..e5f564826 100644 --- a/server/interfaces/api/userInterfaces.ts +++ b/server/interfaces/api/userInterfaces.ts @@ -1,3 +1,4 @@ +import Media from '../../entity/Media'; import { MediaRequest } from '../../entity/MediaRequest'; import type { User } from '../../entity/User'; import { PaginatedResponse } from './common'; @@ -22,3 +23,7 @@ export interface QuotaResponse { movie: QuotaStatus; tv: QuotaStatus; } +export interface UserWatchDataResponse { + recentlyWatched: Media[]; + playCount: number; +} diff --git a/server/lib/settings.ts b/server/lib/settings.ts index c500157cc..4c3b715cd 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -35,6 +35,15 @@ export interface PlexSettings { webAppUrl?: string; } +export interface TautulliSettings { + hostname?: string; + port?: number; + useSsl?: boolean; + urlBase?: string; + apiKey?: string; + externalUrl?: string; +} + export interface DVRSettings { id: number; name: string; @@ -244,6 +253,7 @@ interface AllSettings { vapidPrivate: string; main: MainSettings; plex: PlexSettings; + tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; public: PublicSettings; @@ -290,6 +300,7 @@ class Settings { useSsl: false, libraries: [], }, + tautulli: {}, radarr: [], sonarr: [], public: { @@ -425,6 +436,14 @@ class Settings { this.data.plex = data; } + get tautulli(): TautulliSettings { + return this.data.tautulli; + } + + set tautulli(data: TautulliSettings) { + this.data.tautulli = data; + } + get radarr(): RadarrSettings[] { return this.data.radarr; } diff --git a/server/routes/media.ts b/server/routes/media.ts index 348197821..429b2010f 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,11 +1,17 @@ import { Router } from 'express'; -import { getRepository, FindOperator, FindOneOptions, In } from 'typeorm'; -import Media from '../entity/Media'; +import { FindOneOptions, FindOperator, getRepository, In } from 'typeorm'; +import TautulliAPI from '../api/tautulli'; import { MediaStatus, MediaType } from '../constants/media'; +import Media from '../entity/Media'; +import { User } from '../entity/User'; +import { + MediaResultsResponse, + MediaWatchDataResponse, +} from '../interfaces/api/mediaInterfaces'; +import { Permission } from '../lib/permissions'; +import { getSettings } from '../lib/settings'; import logger from '../logger'; import { isAuthenticated } from '../middleware/auth'; -import { Permission } from '../lib/permissions'; -import { MediaResultsResponse } from '../interfaces/api/mediaInterfaces'; const mediaRoutes = Router(); @@ -161,4 +167,103 @@ mediaRoutes.delete( } ); +mediaRoutes.get<{ id: string }, MediaWatchDataResponse>( + '/:id/watch_data', + isAuthenticated(Permission.ADMIN), + async (req, res, next) => { + const settings = getSettings().tautulli; + + if (!settings.hostname || !settings.port || !settings.apiKey) { + return next({ + status: 404, + message: 'Tautulli API not configured.', + }); + } + + const media = await getRepository(Media).findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!media) { + return next({ status: 404, message: 'Media does not exist.' }); + } + + try { + const tautulli = new TautulliAPI(settings); + const userRepository = getRepository(User); + + const response: MediaWatchDataResponse = {}; + + if (media.ratingKey) { + const watchStats = await tautulli.getMediaWatchStats(media.ratingKey); + const watchUsers = await tautulli.getMediaWatchUsers(media.ratingKey); + + const users = await userRepository + .createQueryBuilder('user') + .where('user.plexId IN (:...plexIds)', { + plexIds: watchUsers.map((u) => u.user_id), + }) + .getMany(); + + const playCount = + watchStats.find((i) => i.query_days == 0)?.total_plays ?? 0; + + const playCount7Days = + watchStats.find((i) => i.query_days == 7)?.total_plays ?? 0; + + const playCount30Days = + watchStats.find((i) => i.query_days == 30)?.total_plays ?? 0; + + response.data = { + users: users, + playCount, + playCount7Days, + playCount30Days, + }; + } + + if (media.ratingKey4k) { + const watchStats4k = await tautulli.getMediaWatchStats( + media.ratingKey4k + ); + const watchUsers4k = await tautulli.getMediaWatchUsers( + media.ratingKey4k + ); + + const users = await userRepository + .createQueryBuilder('user') + .where('user.plexId IN (:...plexIds)', { + plexIds: watchUsers4k.map((u) => u.user_id), + }) + .getMany(); + + const playCount = + watchStats4k.find((i) => i.query_days == 0)?.total_plays ?? 0; + + const playCount7Days = + watchStats4k.find((i) => i.query_days == 7)?.total_plays ?? 0; + + const playCount30Days = + watchStats4k.find((i) => i.query_days == 30)?.total_plays ?? 0; + + response.data4k = { + users, + playCount, + playCount7Days, + playCount30Days, + }; + } + + return res.status(200).json(response); + } catch (e) { + logger.error('Something went wrong fetching media watch data', { + label: 'API', + errorMessage: e.message, + mediaId: req.params.id, + }); + next({ status: 500, message: 'Failed to fetch watch data.' }); + } + } +); + export default mediaRoutes; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 7a5938e33..eca38bc26 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -225,6 +225,21 @@ settingsRoutes.post('/plex/sync', (req, res) => { return res.status(200).json(plexFullScanner.status()); }); +settingsRoutes.get('/tautulli', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.tautulli); +}); + +settingsRoutes.post('/tautulli', async (req, res) => { + const settings = getSettings(); + + Object.assign(settings.tautulli, req.body); + settings.save(); + + return res.status(200).json(settings.tautulli); +}); + settingsRoutes.get( '/plex/users', isAuthenticated(Permission.MANAGE_USERS), diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 8352726b0..9daa446ac 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -1,8 +1,11 @@ import { Router } from 'express'; import gravatarUrl from 'gravatar-url'; +import { uniqWith } from 'lodash'; import { getRepository, Not } from 'typeorm'; import PlexTvAPI from '../../api/plextv'; +import TautulliAPI from '../../api/tautulli'; import { UserType } from '../../constants/user'; +import Media from '../../entity/Media'; import { MediaRequest } from '../../entity/MediaRequest'; import { User } from '../../entity/User'; import { UserPushSubscription } from '../../entity/UserPushSubscription'; @@ -10,6 +13,7 @@ import { QuotaResponse, UserRequestsResponse, UserResultsResponse, + UserWatchDataResponse, } from '../../interfaces/api/userInterfaces'; import { hasPermission, Permission } from '../../lib/permissions'; import { getSettings } from '../../lib/settings'; @@ -475,7 +479,8 @@ router.get<{ id: string }, QuotaResponse>( ) { return next({ status: 403, - message: 'You do not have permission to access this endpoint.', + message: + "You do not have permission to view this user's request limits.", }); } @@ -492,4 +497,82 @@ router.get<{ id: string }, QuotaResponse>( } ); +router.get<{ id: string }, UserWatchDataResponse>( + '/:id/watch_data', + async (req, res, next) => { + if ( + Number(req.params.id) !== req.user?.id && + !req.user?.hasPermission(Permission.ADMIN) + ) { + return next({ + status: 403, + message: + "You do not have permission to view this user's recently watched media.", + }); + } + + const settings = getSettings().tautulli; + + if (!settings.hostname || !settings.port || !settings.apiKey) { + return next({ + status: 404, + message: 'Tautulli API not configured.', + }); + } + + try { + const mediaRepository = getRepository(Media); + const user = await getRepository(User).findOneOrFail({ + where: { id: Number(req.params.id) }, + select: ['id', 'plexId'], + }); + + const tautulli = new TautulliAPI(settings); + + const watchStats = await tautulli.getUserWatchStats(user); + const watchHistory = await tautulli.getUserWatchHistory(user); + + const media = ( + await Promise.all( + uniqWith(watchHistory, (recordA, recordB) => + recordA.grandparent_rating_key && recordB.grandparent_rating_key + ? recordA.grandparent_rating_key === + recordB.grandparent_rating_key + : recordA.parent_rating_key && recordB.parent_rating_key + ? recordA.parent_rating_key === recordB.parent_rating_key + : recordA.rating_key === recordB.rating_key + ) + .slice(0, 20) + .map( + async (record) => + await mediaRepository.findOne({ + where: { + ratingKey: + record.media_type === 'movie' + ? record.rating_key + : record.grandparent_rating_key, + }, + }) + ) + ) + ).filter((media) => !!media) as Media[]; + + return res.status(200).json({ + recentlyWatched: media, + playCount: watchStats.total_plays, + }); + } catch (e) { + logger.error('Something went wrong fetching user watch data', { + label: 'API', + errorMessage: e.message, + userId: req.params.id, + }); + next({ + status: 500, + message: 'Failed to fetch user watch data.', + }); + } + } +); + export default router; diff --git a/src/components/IssueBlock/index.tsx b/src/components/IssueBlock/index.tsx index 2d3cfb33e..318827814 100644 --- a/src/components/IssueBlock/index.tsx +++ b/src/components/IssueBlock/index.tsx @@ -8,7 +8,7 @@ import Link from 'next/link'; import React from 'react'; import { useIntl } from 'react-intl'; import type Issue from '../../../server/entity/Issue'; -import globalMessages from '../../i18n/globalMessages'; +import { useUser } from '../../hooks/useUser'; import Button from '../Common/Button'; import { issueOptions } from '../IssueModal/constants'; @@ -17,6 +17,7 @@ interface IssueBlockProps { } const IssueBlock: React.FC = ({ issue }) => { + const { user } = useUser(); const intl = useIntl(); const issueOption = issueOptions.find( (opt) => opt.issueType === issue.issueType @@ -27,7 +28,7 @@ const IssueBlock: React.FC = ({ issue }) => { } return ( -
+
@@ -39,7 +40,17 @@ const IssueBlock: React.FC = ({ issue }) => {
- {issue.createdBy.displayName} + + + {issue.createdBy.displayName} + +
@@ -55,9 +66,8 @@ const IssueBlock: React.FC = ({ issue }) => {
-
diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index 9b8898ef3..43064302b 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -1,10 +1,16 @@ -import { ServerIcon } from '@heroicons/react/outline'; +import { ServerIcon, ViewListIcon } from '@heroicons/react/outline'; import { CheckCircleIcon, DocumentRemoveIcon } from '@heroicons/react/solid'; import axios from 'axios'; +import Link from 'next/link'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import useSWR from 'swr'; import { IssueStatus } from '../../../server/constants/issue'; -import { MediaStatus } from '../../../server/constants/media'; +import { + MediaRequestStatus, + MediaStatus, +} from '../../../server/constants/media'; +import { MediaWatchDataResponse } from '../../../server/interfaces/api/mediaInterfaces'; import { MovieDetails } from '../../../server/models/Movie'; import { TvDetails } from '../../../server/models/Tv'; import useSettings from '../../hooks/useSettings'; @@ -21,17 +27,26 @@ const messages = defineMessages({ manageModalTitle: 'Manage {mediaType}', manageModalIssues: 'Open Issues', manageModalRequests: 'Requests', + manageModalMedia: 'Media', + manageModalMedia4k: '4K Media', + manageModalAdvanced: 'Advanced', manageModalNoRequests: 'No requests.', - manageModalClearMedia: 'Clear Media Data', + manageModalClearMedia: 'Clear Data', manageModalClearMediaWarning: '* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.', openarr: 'Open in {arr}', openarr4k: 'Open in 4K {arr}', - downloadstatus: 'Download Status', + downloadstatus: 'Downloads', markavailable: 'Mark as Available', mark4kavailable: 'Mark as Available in 4K', - allseasonsmarkedavailable: '* All seasons will be marked as available.', - // Recreated here for lowercase versions to go with the modal clear media warning + markallseasonsavailable: 'Mark All Seasons as Available', + markallseasons4kavailable: 'Mark All Seasons as Available in 4K', + opentautulli: 'Open in Tautulli', + plays: + '{playCount, number} {playCount, plural, one {play} other {plays}}', + pastdays: 'Past {days, number} Days', + alltime: 'All Time', + playedby: 'Played By', movie: 'movie', tvshow: 'series', }); @@ -60,29 +75,54 @@ interface ManageSlideOverTvProps extends ManageSlideOverProps { const ManageSlideOver: React.FC< ManageSlideOverMovieProps | ManageSlideOverTvProps > = ({ show, mediaType, onClose, data, revalidate }) => { - const { hasPermission } = useUser(); + const { user: currentUser, hasPermission } = useUser(); const intl = useIntl(); const settings = useSettings(); + const { data: watchData } = useSWR( + data.mediaInfo && hasPermission(Permission.ADMIN) + ? `/api/v1/media/${data.mediaInfo.id}/watch_data` + : null + ); const deleteMedia = async () => { - if (data?.mediaInfo?.id) { - await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`); + if (data.mediaInfo) { + await axios.delete(`/api/v1/media/${data.mediaInfo.id}`); revalidate(); } }; const markAvailable = async (is4k = false) => { - await axios.post(`/api/v1/media/${data?.mediaInfo?.id}/available`, { - is4k, - }); - revalidate(); + if (data.mediaInfo) { + await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, { + is4k, + }); + revalidate(); + } }; + const requests = + data.mediaInfo?.requests?.filter( + (request) => request.status !== MediaRequestStatus.DECLINED + ) ?? []; + const openIssues = data.mediaInfo?.issues?.filter( (issue) => issue.status === IssueStatus.OPEN ) ?? []; + const styledPlayCount = (playCount: number): JSX.Element => { + return ( + <> + {intl.formatMessage(messages.plays, { + playCount, + strong: function strong(msg) { + return {msg}; + }, + })} + + ); + }; + return ( onClose()} subText={isMovie(data) ? data.title : data.name} > - {((data?.mediaInfo?.downloadStatus ?? []).length > 0 || - (data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && ( - <> -

- {intl.formatMessage(messages.downloadstatus)} -

-
-
    - {data.mediaInfo?.downloadStatus?.map((status, index) => ( -
  • - -
  • - ))} - {data.mediaInfo?.downloadStatus4k?.map((status, index) => ( -
  • - -
  • - ))} -
-
- - )} - {data?.mediaInfo && - (data.mediaInfo.status !== MediaStatus.AVAILABLE || - (data.mediaInfo.status4k !== MediaStatus.AVAILABLE && - settings.currentSettings.series4kEnabled)) && ( -
- {data?.mediaInfo && - data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( -
- -
- )} - {data?.mediaInfo && - data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && - settings.currentSettings.series4kEnabled && ( -
- -
- )} - {mediaType === 'tv' && ( -
- {intl.formatMessage(messages.allseasonsmarkedavailable)} -
- )} + + + ))} + +
)} - {hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], { - type: 'or', - }) && - openIssues.length > 0 && ( - <> -

- {intl.formatMessage(messages.manageModalIssues)} + {hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], { + type: 'or', + }) && + openIssues.length > 0 && ( + <> +

+ {intl.formatMessage(messages.manageModalIssues)} +

+
+
    + {openIssues.map((issue) => ( +
  • + +
  • + ))} +
+
+ + )} + {requests.length > 0 && ( +
+

+ {intl.formatMessage(messages.manageModalRequests)}

-
+
    - {openIssues.map((issue) => ( + {requests.map((request) => (
  • - + revalidate()} + />
  • ))}
- +
)} -

- {intl.formatMessage(messages.manageModalRequests)} -

-
-
    - {data.mediaInfo?.requests?.map((request) => ( -
  • - revalidate()} /> -
  • - ))} - {(data.mediaInfo?.requests ?? []).length === 0 && ( -
  • - {intl.formatMessage(messages.manageModalNoRequests)} -
  • + {hasPermission(Permission.ADMIN) && + (data.mediaInfo?.serviceUrl || + data.mediaInfo?.tautulliUrl || + watchData?.data?.playCount) && ( +
    +

    + {intl.formatMessage(messages.manageModalMedia)} +

    +
    + {!!watchData?.data && ( +
    +
    +
    +
    +
    + {intl.formatMessage(messages.pastdays, { days: 7 })} +
    +
    + {styledPlayCount(watchData.data.playCount7Days)} +
    +
    +
    +
    + {intl.formatMessage(messages.pastdays, { + days: 30, + })} +
    +
    + {styledPlayCount(watchData.data.playCount30Days)} +
    +
    +
    +
    + {intl.formatMessage(messages.alltime)} +
    +
    + {styledPlayCount(watchData.data.playCount)} +
    +
    +
    + {!!watchData.data.users.length && ( +
    + + {intl.formatMessage(messages.playedby)} + + + {watchData.data.users.map((user) => ( + + + {user.displayName} + + + ))} + +
    + )} +
    + {data.mediaInfo?.tautulliUrl && ( + + + + )} +
    + )} + {data?.mediaInfo?.serviceUrl && ( + + + + )} +
    +
    )} -
-
- {hasPermission(Permission.ADMIN) && - (data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && ( -
- {data?.mediaInfo?.serviceUrl && ( - - + + )} +
+ )} + {data?.mediaInfo?.serviceUrl4k && ( + + + + )} +
+
+ )} + {hasPermission(Permission.ADMIN) && data?.mediaInfo && ( +
+

+ {intl.formatMessage(messages.manageModalAdvanced)} +

+
+ {data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( + - - )} - {data?.mediaInfo?.serviceUrl4k && ( - - + )} +
+ deleteMedia()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + + {intl.formatMessage(messages.manageModalClearMedia)} - - - )} + +
+ {intl.formatMessage(messages.manageModalClearMediaWarning, { + mediaType: intl.formatMessage( + mediaType === 'movie' ? messages.movie : messages.tvshow + ), + })} +
+
+
)} - {data?.mediaInfo && ( -
- deleteMedia()} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - - {intl.formatMessage(messages.manageModalClearMedia)} - -
- {intl.formatMessage(messages.manageModalClearMediaWarning, { - mediaType: intl.formatMessage( - mediaType === 'movie' ? messages.movie : messages.tvshow - ), - })} -
-
- )} +
); }; diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 980531666..b72fdc586 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -353,7 +353,7 @@ const MovieDetails: React.FC = ({ movie }) => { )} - {hasPermission(Permission.MANAGE_REQUESTS) && ( + {hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && ( + +
+
+ + ); + }} + + + )} ); }; diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index a1371c359..ee1b7a52e 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -354,7 +354,7 @@ const TvDetails: React.FC = ({ tv }) => { )} - {hasPermission(Permission.MANAGE_REQUESTS) && ( + {hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && (