import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TautulliAPI from '@server/api/tautulli'; import TheMovieDb from '@server/api/themoviedb'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import type { MediaResultsResponse, MediaWatchDataResponse, } from '@server/interfaces/api/mediaInterfaces'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; import type { FindOneOptions } from 'typeorm'; import { In } from 'typeorm'; const mediaRoutes = Router(); mediaRoutes.get('/', async (req, res, next) => { const mediaRepository = getRepository(Media); const pageSize = req.query.take ? Number(req.query.take) : 20; const skip = req.query.skip ? Number(req.query.skip) : 0; let statusFilter = undefined; switch (req.query.filter) { case 'available': statusFilter = MediaStatus.AVAILABLE; break; case 'partial': statusFilter = MediaStatus.PARTIALLY_AVAILABLE; break; case 'allavailable': statusFilter = In([ MediaStatus.AVAILABLE, MediaStatus.PARTIALLY_AVAILABLE, ]); break; case 'processing': statusFilter = MediaStatus.PROCESSING; break; case 'pending': statusFilter = MediaStatus.PENDING; break; default: statusFilter = undefined; } let sortFilter: FindOneOptions['order'] = { id: 'DESC', }; switch (req.query.sort) { case 'modified': sortFilter = { updatedAt: 'DESC', }; break; case 'mediaAdded': sortFilter = { mediaAddedAt: 'DESC', }; } try { const [media, mediaCount] = await mediaRepository.findAndCount({ order: sortFilter, where: statusFilter && { status: statusFilter, }, take: pageSize, skip, }); return res.status(200).json({ pageInfo: { pages: Math.ceil(mediaCount / pageSize), pageSize, results: mediaCount, page: Math.ceil(skip / pageSize) + 1, }, results: media, } as MediaResultsResponse); } catch (e) { next({ status: 500, message: e.message }); } }); mediaRoutes.post< { id: string; status: 'available' | 'partial' | 'processing' | 'pending' | 'unknown'; }, Media >( '/:id/:status', isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ where: { id: Number(req.params.id) }, }); if (!media) { return next({ status: 404, message: 'Media does not exist.' }); } const is4k = Boolean(req.body.is4k); switch (req.params.status) { case 'available': media[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE; if (media.mediaType === MediaType.TV) { // Mark all seasons available media.seasons.forEach((season) => { season[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE; }); } break; case 'partial': if (media.mediaType === MediaType.MOVIE) { return next({ status: 400, message: 'Only series can be set to be partially available', }); } media.status = MediaStatus.PARTIALLY_AVAILABLE; break; case 'processing': media.status = MediaStatus.PROCESSING; break; case 'pending': media.status = MediaStatus.PENDING; break; case 'unknown': media.status = MediaStatus.UNKNOWN; } await mediaRepository.save(media); return res.status(200).json(media); } ); mediaRoutes.delete( '/:id', isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { try { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOneOrFail({ where: { id: Number(req.params.id) }, }); await mediaRepository.remove(media); return res.status(204).send(); } catch (e) { logger.error('Something went wrong fetching media in delete request', { label: 'Media', message: e.message, }); next({ status: 404, message: 'Media not found' }); } } ); mediaRoutes.delete( '/:id/file', isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { try { const settings = getSettings(); const mediaRepository = getRepository(Media); const media = await mediaRepository.findOneOrFail({ where: { id: Number(req.params.id) }, }); const is4k = media.serviceUrl4k !== undefined; const isMovie = media.mediaType === MediaType.MOVIE; let serviceSettings; if (isMovie) { serviceSettings = settings.radarr.find( (radarr) => radarr.isDefault && radarr.is4k === is4k ); } else { serviceSettings = settings.sonarr.find( (sonarr) => sonarr.isDefault && sonarr.is4k === is4k ); } if ( media.serviceId && media.serviceId >= 0 && serviceSettings?.id !== media.serviceId ) { if (isMovie) { serviceSettings = settings.radarr.find( (radarr) => radarr.id === media.serviceId ); } else { serviceSettings = settings.sonarr.find( (sonarr) => sonarr.id === media.serviceId ); } } if (!serviceSettings) { logger.warn( `There is no default ${ is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr' }/ server configured. Did you set any of your ${ is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr' } servers as default?`, { label: 'Media Request', mediaId: media.id, } ); return; } let service; if (isMovie) { service = new RadarrAPI({ apiKey: serviceSettings?.apiKey, url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'), }); } else { service = new SonarrAPI({ apiKey: serviceSettings?.apiKey, url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'), }); } if (isMovie) { await (service as RadarrAPI).removeMovie( parseInt( is4k ? (media.externalServiceSlug4k as string) : (media.externalServiceSlug as string) ) ); } else { const tmdb = new TheMovieDb(); const series = await tmdb.getTvShow({ tvId: media.tmdbId }); const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; if (!tvdbId) { throw new Error('TVDB ID not found'); } await (service as SonarrAPI).removeSerie(tvdbId); } return res.status(204).send(); } catch (e) { logger.error('Something went wrong fetching media in delete request', { label: 'Media', message: e.message, }); next({ status: 404, message: 'Media not found' }); } } ); 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;