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:
TheCatLady
2022-01-20 05:36:59 -05:00
committed by GitHub
parent 86dff12cde
commit 0842c233d0
19 changed files with 1432 additions and 219 deletions

View File

@@ -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;

View File

@@ -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),

View File

@@ -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;