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:
228
server/api/tautulli.ts
Normal file
228
server/api/tautulli.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { User } from '../entity/User';
|
||||
import { TautulliSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
export interface TautulliHistoryRecord {
|
||||
date: number;
|
||||
duration: number;
|
||||
friendly_name: string;
|
||||
full_title: string;
|
||||
grandparent_rating_key: number;
|
||||
grandparent_title: string;
|
||||
original_title: string;
|
||||
group_count: number;
|
||||
group_ids?: string;
|
||||
guid: string;
|
||||
ip_address: string;
|
||||
live: number;
|
||||
machine_id: string;
|
||||
media_index: number;
|
||||
media_type: string;
|
||||
originally_available_at: string;
|
||||
parent_media_index: number;
|
||||
parent_rating_key: number;
|
||||
parent_title: string;
|
||||
paused_counter: number;
|
||||
percent_complete: number;
|
||||
platform: string;
|
||||
product: string;
|
||||
player: string;
|
||||
rating_key: number;
|
||||
reference_id?: number;
|
||||
row_id?: number;
|
||||
session_key?: string;
|
||||
started: number;
|
||||
state?: string;
|
||||
stopped: number;
|
||||
thumb: string;
|
||||
title: string;
|
||||
transcode_decision: string;
|
||||
user: string;
|
||||
user_id: number;
|
||||
watched_status: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
interface TautulliHistoryResponse {
|
||||
response: {
|
||||
result: string;
|
||||
message?: string;
|
||||
data: {
|
||||
draw: number;
|
||||
recordsTotal: number;
|
||||
recordsFiltered: number;
|
||||
total_duration: string;
|
||||
filter_duration: string;
|
||||
data: TautulliHistoryRecord[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface TautulliWatchStats {
|
||||
query_days: number;
|
||||
total_time: number;
|
||||
total_plays: number;
|
||||
}
|
||||
|
||||
interface TautulliWatchStatsResponse {
|
||||
response: {
|
||||
result: string;
|
||||
message?: string;
|
||||
data: TautulliWatchStats[];
|
||||
};
|
||||
}
|
||||
|
||||
interface TautulliWatchUser {
|
||||
friendly_name: string;
|
||||
user_id: number;
|
||||
user_thumb: string;
|
||||
username: string;
|
||||
total_plays: number;
|
||||
total_time: number;
|
||||
}
|
||||
|
||||
interface TautulliWatchUsersResponse {
|
||||
response: {
|
||||
result: string;
|
||||
message?: string;
|
||||
data: TautulliWatchUser[];
|
||||
};
|
||||
}
|
||||
|
||||
class TautulliAPI {
|
||||
private axios: AxiosInstance;
|
||||
|
||||
constructor(settings: TautulliSettings) {
|
||||
this.axios = axios.create({
|
||||
baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
|
||||
settings.port
|
||||
}${settings.urlBase ?? ''}`,
|
||||
params: { apikey: settings.apiKey },
|
||||
});
|
||||
}
|
||||
|
||||
public async getMediaWatchStats(
|
||||
ratingKey: string
|
||||
): Promise<TautulliWatchStats[]> {
|
||||
try {
|
||||
return (
|
||||
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||
params: {
|
||||
cmd: 'get_item_watch_time_stats',
|
||||
rating_key: ratingKey,
|
||||
grouping: 1,
|
||||
},
|
||||
})
|
||||
).data.response.data;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Something went wrong fetching media watch stats from Tautulli',
|
||||
{
|
||||
label: 'Tautulli API',
|
||||
errorMessage: e.message,
|
||||
ratingKey,
|
||||
}
|
||||
);
|
||||
throw new Error(
|
||||
`[Tautulli] Failed to fetch media watch stats: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMediaWatchUsers(
|
||||
ratingKey: string
|
||||
): Promise<TautulliWatchUser[]> {
|
||||
try {
|
||||
return (
|
||||
await this.axios.get<TautulliWatchUsersResponse>('/api/v2', {
|
||||
params: {
|
||||
cmd: 'get_item_user_stats',
|
||||
rating_key: ratingKey,
|
||||
grouping: 1,
|
||||
},
|
||||
})
|
||||
).data.response.data;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Something went wrong fetching media watch users from Tautulli',
|
||||
{
|
||||
label: 'Tautulli API',
|
||||
errorMessage: e.message,
|
||||
ratingKey,
|
||||
}
|
||||
);
|
||||
throw new Error(
|
||||
`[Tautulli] Failed to fetch media watch users: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserWatchStats(user: User): Promise<TautulliWatchStats> {
|
||||
try {
|
||||
if (!user.plexId) {
|
||||
throw new Error('User does not have an associated Plex ID');
|
||||
}
|
||||
|
||||
return (
|
||||
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||
params: {
|
||||
cmd: 'get_user_watch_time_stats',
|
||||
user_id: user.plexId,
|
||||
query_days: 0,
|
||||
grouping: 1,
|
||||
},
|
||||
})
|
||||
).data.response.data[0];
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Something went wrong fetching user watch stats from Tautulli',
|
||||
{
|
||||
label: 'Tautulli API',
|
||||
errorMessage: e.message,
|
||||
user: user.displayName,
|
||||
}
|
||||
);
|
||||
throw new Error(
|
||||
`[Tautulli] Failed to fetch user watch stats: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserWatchHistory(
|
||||
user: User
|
||||
): Promise<TautulliHistoryRecord[]> {
|
||||
try {
|
||||
if (!user.plexId) {
|
||||
throw new Error('User does not have an associated Plex ID');
|
||||
}
|
||||
|
||||
return (
|
||||
await this.axios.get<TautulliHistoryResponse>('/api/v2', {
|
||||
params: {
|
||||
cmd: 'get_history',
|
||||
grouping: 1,
|
||||
order_column: 'date',
|
||||
order_dir: 'desc',
|
||||
user_id: user.plexId,
|
||||
length: 100,
|
||||
},
|
||||
})
|
||||
).data.response.data.data;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Something went wrong fetching user watch history from Tautulli',
|
||||
{
|
||||
label: 'Tautulli API',
|
||||
errorMessage: e.message,
|
||||
user: user.displayName,
|
||||
}
|
||||
);
|
||||
throw new Error(
|
||||
`[Tautulli] Failed to fetch user watch history: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TautulliAPI;
|
||||
@@ -145,6 +145,9 @@ class Media {
|
||||
public plexUrl?: string;
|
||||
public plexUrl4k?: string;
|
||||
|
||||
public tautulliUrl?: string;
|
||||
public tautulliUrl4k?: string;
|
||||
|
||||
constructor(init?: Partial<Media>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
@@ -152,6 +155,7 @@ class Media {
|
||||
@AfterLoad()
|
||||
public setPlexUrls(): void {
|
||||
const { machineId, webAppUrl } = getSettings().plex;
|
||||
const { externalUrl: tautulliUrl } = getSettings().tautulli;
|
||||
|
||||
if (this.ratingKey) {
|
||||
this.plexUrl = `${
|
||||
@@ -159,6 +163,10 @@ class Media {
|
||||
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
|
||||
this.ratingKey
|
||||
}`;
|
||||
|
||||
if (tautulliUrl) {
|
||||
this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.ratingKey4k) {
|
||||
@@ -167,6 +175,10 @@ class Media {
|
||||
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
|
||||
this.ratingKey4k
|
||||
}`;
|
||||
|
||||
if (tautulliUrl) {
|
||||
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import type Media from '../../entity/Media';
|
||||
import { User } from '../../entity/User';
|
||||
import { PaginatedResponse } from './common';
|
||||
|
||||
export interface MediaResultsResponse extends PaginatedResponse {
|
||||
results: Media[];
|
||||
}
|
||||
|
||||
export interface MediaWatchDataResponse {
|
||||
data?: {
|
||||
users: User[];
|
||||
playCount: number;
|
||||
playCount7Days: number;
|
||||
playCount30Days: number;
|
||||
};
|
||||
data4k?: {
|
||||
users: User[];
|
||||
playCount: number;
|
||||
playCount7Days: number;
|
||||
playCount30Days: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Media from '../../entity/Media';
|
||||
import { MediaRequest } from '../../entity/MediaRequest';
|
||||
import type { User } from '../../entity/User';
|
||||
import { PaginatedResponse } from './common';
|
||||
@@ -22,3 +23,7 @@ export interface QuotaResponse {
|
||||
movie: QuotaStatus;
|
||||
tv: QuotaStatus;
|
||||
}
|
||||
export interface UserWatchDataResponse {
|
||||
recentlyWatched: Media[];
|
||||
playCount: number;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,15 @@ export interface PlexSettings {
|
||||
webAppUrl?: string;
|
||||
}
|
||||
|
||||
export interface TautulliSettings {
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
useSsl?: boolean;
|
||||
urlBase?: string;
|
||||
apiKey?: string;
|
||||
externalUrl?: string;
|
||||
}
|
||||
|
||||
export interface DVRSettings {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -244,6 +253,7 @@ interface AllSettings {
|
||||
vapidPrivate: string;
|
||||
main: MainSettings;
|
||||
plex: PlexSettings;
|
||||
tautulli: TautulliSettings;
|
||||
radarr: RadarrSettings[];
|
||||
sonarr: SonarrSettings[];
|
||||
public: PublicSettings;
|
||||
@@ -290,6 +300,7 @@ class Settings {
|
||||
useSsl: false,
|
||||
libraries: [],
|
||||
},
|
||||
tautulli: {},
|
||||
radarr: [],
|
||||
sonarr: [],
|
||||
public: {
|
||||
@@ -425,6 +436,14 @@ class Settings {
|
||||
this.data.plex = data;
|
||||
}
|
||||
|
||||
get tautulli(): TautulliSettings {
|
||||
return this.data.tautulli;
|
||||
}
|
||||
|
||||
set tautulli(data: TautulliSettings) {
|
||||
this.data.tautulli = data;
|
||||
}
|
||||
|
||||
get radarr(): RadarrSettings[] {
|
||||
return this.data.radarr;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Router } from 'express';
|
||||
import { getRepository, FindOperator, FindOneOptions, In } from 'typeorm';
|
||||
import Media from '../entity/Media';
|
||||
import { FindOneOptions, FindOperator, getRepository, In } from 'typeorm';
|
||||
import TautulliAPI from '../api/tautulli';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import Media from '../entity/Media';
|
||||
import { User } from '../entity/User';
|
||||
import {
|
||||
MediaResultsResponse,
|
||||
MediaWatchDataResponse,
|
||||
} from '../interfaces/api/mediaInterfaces';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { MediaResultsResponse } from '../interfaces/api/mediaInterfaces';
|
||||
|
||||
const mediaRoutes = Router();
|
||||
|
||||
@@ -161,4 +167,103 @@ mediaRoutes.delete(
|
||||
}
|
||||
);
|
||||
|
||||
mediaRoutes.get<{ id: string }, MediaWatchDataResponse>(
|
||||
'/:id/watch_data',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
async (req, res, next) => {
|
||||
const settings = getSettings().tautulli;
|
||||
|
||||
if (!settings.hostname || !settings.port || !settings.apiKey) {
|
||||
return next({
|
||||
status: 404,
|
||||
message: 'Tautulli API not configured.',
|
||||
});
|
||||
}
|
||||
|
||||
const media = await getRepository(Media).findOne({
|
||||
where: { id: Number(req.params.id) },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
return next({ status: 404, message: 'Media does not exist.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const tautulli = new TautulliAPI(settings);
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const response: MediaWatchDataResponse = {};
|
||||
|
||||
if (media.ratingKey) {
|
||||
const watchStats = await tautulli.getMediaWatchStats(media.ratingKey);
|
||||
const watchUsers = await tautulli.getMediaWatchUsers(media.ratingKey);
|
||||
|
||||
const users = await userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.plexId IN (:...plexIds)', {
|
||||
plexIds: watchUsers.map((u) => u.user_id),
|
||||
})
|
||||
.getMany();
|
||||
|
||||
const playCount =
|
||||
watchStats.find((i) => i.query_days == 0)?.total_plays ?? 0;
|
||||
|
||||
const playCount7Days =
|
||||
watchStats.find((i) => i.query_days == 7)?.total_plays ?? 0;
|
||||
|
||||
const playCount30Days =
|
||||
watchStats.find((i) => i.query_days == 30)?.total_plays ?? 0;
|
||||
|
||||
response.data = {
|
||||
users: users,
|
||||
playCount,
|
||||
playCount7Days,
|
||||
playCount30Days,
|
||||
};
|
||||
}
|
||||
|
||||
if (media.ratingKey4k) {
|
||||
const watchStats4k = await tautulli.getMediaWatchStats(
|
||||
media.ratingKey4k
|
||||
);
|
||||
const watchUsers4k = await tautulli.getMediaWatchUsers(
|
||||
media.ratingKey4k
|
||||
);
|
||||
|
||||
const users = await userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.plexId IN (:...plexIds)', {
|
||||
plexIds: watchUsers4k.map((u) => u.user_id),
|
||||
})
|
||||
.getMany();
|
||||
|
||||
const playCount =
|
||||
watchStats4k.find((i) => i.query_days == 0)?.total_plays ?? 0;
|
||||
|
||||
const playCount7Days =
|
||||
watchStats4k.find((i) => i.query_days == 7)?.total_plays ?? 0;
|
||||
|
||||
const playCount30Days =
|
||||
watchStats4k.find((i) => i.query_days == 30)?.total_plays ?? 0;
|
||||
|
||||
response.data4k = {
|
||||
users,
|
||||
playCount,
|
||||
playCount7Days,
|
||||
playCount30Days,
|
||||
};
|
||||
}
|
||||
|
||||
return res.status(200).json(response);
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong fetching media watch data', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
mediaId: req.params.id,
|
||||
});
|
||||
next({ status: 500, message: 'Failed to fetch watch data.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default mediaRoutes;
|
||||
|
||||
@@ -225,6 +225,21 @@ settingsRoutes.post('/plex/sync', (req, res) => {
|
||||
return res.status(200).json(plexFullScanner.status());
|
||||
});
|
||||
|
||||
settingsRoutes.get('/tautulli', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.tautulli);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/tautulli', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
Object.assign(settings.tautulli, req.body);
|
||||
settings.save();
|
||||
|
||||
return res.status(200).json(settings.tautulli);
|
||||
});
|
||||
|
||||
settingsRoutes.get(
|
||||
'/plex/users',
|
||||
isAuthenticated(Permission.MANAGE_USERS),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import { uniqWith } from 'lodash';
|
||||
import { getRepository, Not } from 'typeorm';
|
||||
import PlexTvAPI from '../../api/plextv';
|
||||
import TautulliAPI from '../../api/tautulli';
|
||||
import { UserType } from '../../constants/user';
|
||||
import Media from '../../entity/Media';
|
||||
import { MediaRequest } from '../../entity/MediaRequest';
|
||||
import { User } from '../../entity/User';
|
||||
import { UserPushSubscription } from '../../entity/UserPushSubscription';
|
||||
@@ -10,6 +13,7 @@ import {
|
||||
QuotaResponse,
|
||||
UserRequestsResponse,
|
||||
UserResultsResponse,
|
||||
UserWatchDataResponse,
|
||||
} from '../../interfaces/api/userInterfaces';
|
||||
import { hasPermission, Permission } from '../../lib/permissions';
|
||||
import { getSettings } from '../../lib/settings';
|
||||
@@ -475,7 +479,8 @@ router.get<{ id: string }, QuotaResponse>(
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to access this endpoint.',
|
||||
message:
|
||||
"You do not have permission to view this user's request limits.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -492,4 +497,82 @@ router.get<{ id: string }, QuotaResponse>(
|
||||
}
|
||||
);
|
||||
|
||||
router.get<{ id: string }, UserWatchDataResponse>(
|
||||
'/:id/watch_data',
|
||||
async (req, res, next) => {
|
||||
if (
|
||||
Number(req.params.id) !== req.user?.id &&
|
||||
!req.user?.hasPermission(Permission.ADMIN)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message:
|
||||
"You do not have permission to view this user's recently watched media.",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = getSettings().tautulli;
|
||||
|
||||
if (!settings.hostname || !settings.port || !settings.apiKey) {
|
||||
return next({
|
||||
status: 404,
|
||||
message: 'Tautulli API not configured.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const user = await getRepository(User).findOneOrFail({
|
||||
where: { id: Number(req.params.id) },
|
||||
select: ['id', 'plexId'],
|
||||
});
|
||||
|
||||
const tautulli = new TautulliAPI(settings);
|
||||
|
||||
const watchStats = await tautulli.getUserWatchStats(user);
|
||||
const watchHistory = await tautulli.getUserWatchHistory(user);
|
||||
|
||||
const media = (
|
||||
await Promise.all(
|
||||
uniqWith(watchHistory, (recordA, recordB) =>
|
||||
recordA.grandparent_rating_key && recordB.grandparent_rating_key
|
||||
? recordA.grandparent_rating_key ===
|
||||
recordB.grandparent_rating_key
|
||||
: recordA.parent_rating_key && recordB.parent_rating_key
|
||||
? recordA.parent_rating_key === recordB.parent_rating_key
|
||||
: recordA.rating_key === recordB.rating_key
|
||||
)
|
||||
.slice(0, 20)
|
||||
.map(
|
||||
async (record) =>
|
||||
await mediaRepository.findOne({
|
||||
where: {
|
||||
ratingKey:
|
||||
record.media_type === 'movie'
|
||||
? record.rating_key
|
||||
: record.grandparent_rating_key,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
).filter((media) => !!media) as Media[];
|
||||
|
||||
return res.status(200).json({
|
||||
recentlyWatched: media,
|
||||
playCount: watchStats.total_plays,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong fetching user watch data', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
userId: req.params.id,
|
||||
});
|
||||
next({
|
||||
status: 500,
|
||||
message: 'Failed to fetch user watch data.',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user