mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
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:
@@ -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