diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 0e5ab45a..0cd2f171 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -51,6 +51,11 @@ export interface CacheItem { }; } +export interface CacheResponse { + apiCaches: CacheItem[]; + imageCache: Record<'tmdb', { size: number; imageCount: number }>; +} + export interface StatusResponse { version: string; commitTag: string; diff --git a/server/job/schedule.ts b/server/job/schedule.ts index ab606449..6dcb8a90 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,4 +1,5 @@ import downloadTracker from '@server/lib/downloadtracker'; +import ImageProxy from '@server/lib/imageproxy'; import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex'; import { radarrScanner } from '@server/lib/scanners/radarr'; import { sonarrScanner } from '@server/lib/scanners/sonarr'; @@ -133,5 +134,21 @@ export const startJobs = (): void => { }), }); + // Run watchlist sync every 5 minutes + scheduledJobs.push({ + id: 'image-cache-cleanup', + name: 'Image Cache Cleanup', + type: 'process', + interval: 'long', + cronSchedule: jobs['image-cache-cleanup'].schedule, + job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => { + logger.info('Starting scheduled job: Image Cache Cleanup', { + label: 'Jobs', + }); + // Clean TMDB image cache + ImageProxy.clearCache('tmdb'); + }), + }); + logger.info('Scheduled jobs loaded', { label: 'Jobs' }); }; diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index 1b8aa21f..34a097d5 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -19,6 +19,89 @@ type ImageResponse = { }; class ImageProxy { + public static async clearCache(key: string) { + let deletedImages = 0; + const cacheDirectory = path.join( + __dirname, + '../../config/cache/images/', + key + ); + + const files = await promises.readdir(cacheDirectory); + + for (const file of files) { + const filePath = path.join(cacheDirectory, file); + const stat = await promises.lstat(filePath); + + if (stat.isDirectory()) { + const imageFiles = await promises.readdir(filePath); + + for (const imageFile of imageFiles) { + const [, expireAtSt] = imageFile.split('.'); + const expireAt = Number(expireAtSt); + const now = Date.now(); + + if (now > expireAt) { + await promises.rm(path.join(filePath, imageFile)); + deletedImages += 1; + } + } + } + } + + logger.info(`Cleared ${deletedImages} stale image(s) from cache`, { + label: 'Image Cache', + }); + } + + public static async getImageStats( + key: string + ): Promise<{ size: number; imageCount: number }> { + const cacheDirectory = path.join( + __dirname, + '../../config/cache/images/', + key + ); + + const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory); + const imageCount = await ImageProxy.getImageCount(cacheDirectory); + + return { + size: imageTotalSize, + imageCount, + }; + } + + private static async getDirectorySize(dir: string): Promise { + const files = await promises.readdir(dir, { + withFileTypes: true, + }); + + const paths = files.map(async (file) => { + const path = join(dir, file.name); + + if (file.isDirectory()) return await ImageProxy.getDirectorySize(path); + + if (file.isFile()) { + const { size } = await promises.stat(path); + + return size; + } + + return 0; + }); + + return (await Promise.all(paths)) + .flat(Infinity) + .reduce((i, size) => i + size, 0); + } + + private static async getImageCount(dir: string) { + const files = await promises.readdir(dir); + + return files.length; + } + private axios; private cacheVersion; private key; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 5a2d2b8a..cf475554 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -247,7 +247,8 @@ export type JobId = | 'radarr-scan' | 'sonarr-scan' | 'download-sync' - | 'download-sync-reset'; + | 'download-sync-reset' + | 'image-cache-cleanup'; interface AllSettings { clientId: string; @@ -414,6 +415,9 @@ class Settings { 'download-sync-reset': { schedule: '0 0 1 * * *', }, + 'image-cache-cleanup': { + schedule: '0 0 5 * * *', + }, }, }; if (initialSettings) { diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 41d2c745..7205b189 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -14,9 +14,10 @@ import type { import { scheduledJobs } from '@server/job/schedule'; import type { AvailableCacheIds } from '@server/lib/cache'; import cacheManager from '@server/lib/cache'; +import ImageProxy from '@server/lib/imageproxy'; import { Permission } from '@server/lib/permissions'; import { plexFullScanner } from '@server/lib/scanners/plex'; -import type { MainSettings } from '@server/lib/settings'; +import type { JobId, MainSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; @@ -491,7 +492,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { }); }); -settingsRoutes.post<{ jobId: string }>( +settingsRoutes.post<{ jobId: JobId }>( '/jobs/:jobId/cancel', (req, res, next) => { const scheduledJob = scheduledJobs.find( @@ -518,7 +519,7 @@ settingsRoutes.post<{ jobId: string }>( } ); -settingsRoutes.post<{ jobId: string }>( +settingsRoutes.post<{ jobId: JobId }>( '/jobs/:jobId/schedule', (req, res, next) => { const scheduledJob = scheduledJobs.find( @@ -553,16 +554,23 @@ settingsRoutes.post<{ jobId: string }>( } ); -settingsRoutes.get('/cache', (req, res) => { - const caches = cacheManager.getAllCaches(); +settingsRoutes.get('/cache', async (_req, res) => { + const cacheManagerCaches = cacheManager.getAllCaches(); - return res.status(200).json( - Object.values(caches).map((cache) => ({ - id: cache.id, - name: cache.name, - stats: cache.getStats(), - })) - ); + const apiCaches = Object.values(cacheManagerCaches).map((cache) => ({ + id: cache.id, + name: cache.name, + stats: cache.getStats(), + })); + + const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); + + return res.status(200).json({ + apiCaches, + imageCache: { + tmdb: tmdbImageCache, + }, + }); }); settingsRoutes.post<{ cacheId: AvailableCacheIds }>( diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index d9b31bd0..2600115b 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -11,7 +11,10 @@ import { formatBytes } from '@app/utils/numberHelpers'; import { Transition } from '@headlessui/react'; import { PlayIcon, StopIcon, TrashIcon } from '@heroicons/react/outline'; import { PencilIcon } from '@heroicons/react/solid'; -import type { CacheItem } from '@server/interfaces/api/settingsInterfaces'; +import type { + CacheItem, + CacheResponse, +} from '@server/interfaces/api/settingsInterfaces'; import type { JobId } from '@server/lib/settings'; import axios from 'axios'; import cronstrue from 'cronstrue/i18n'; @@ -54,6 +57,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({ 'sonarr-scan': 'Sonarr Scan', 'download-sync': 'Download Sync', 'download-sync-reset': 'Download Sync Reset', + 'image-cache-cleanup': 'Image Cache Cleanup', editJobSchedule: 'Modify Job', jobScheduleEditSaved: 'Job edited successfully!', jobScheduleEditFailed: 'Something went wrong while saving the job.', @@ -63,6 +67,11 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({ 'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}', editJobScheduleSelectorMinutes: 'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}', + imagecache: 'Image Cache', + imagecacheDescription: + 'When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in {appDataPath}/cache/images.', + imagecachecount: 'Images Cached', + imagecachesize: 'Total Cache Size', }); interface Job { @@ -128,7 +137,8 @@ const SettingsJobs = () => { } = useSWR('/api/v1/settings/jobs', { refreshInterval: 5000, }); - const { data: cacheData, mutate: cacheRevalidate } = useSWR( + const { data: appData } = useSWR('/api/v1/status/appdata'); + const { data: cacheData, mutate: cacheRevalidate } = useSWR( '/api/v1/settings/cache', { refreshInterval: 10000, @@ -430,7 +440,7 @@ const SettingsJobs = () => { - {cacheData?.map((cache) => ( + {cacheData?.apiCaches.map((cache) => ( {cache.name} {intl.formatNumber(cache.stats.hits)} @@ -449,6 +459,41 @@ const SettingsJobs = () => { +
+

{intl.formatMessage(messages.imagecache)}

+

+ {intl.formatMessage(messages.imagecacheDescription, { + code: (msg: React.ReactNode) => ( + {msg} + ), + appDataPath: appData ? appData.appDataPath : '/app/config', + })} +

+
+
+ + + + {intl.formatMessage(messages.cachename)} + + {intl.formatMessage(messages.imagecachecount)} + + {intl.formatMessage(messages.imagecachesize)} + + + + + The Movie Database (tmdb) + + {intl.formatNumber(cacheData?.imageCache.tmdb.imageCount ?? 0)} + + + {formatBytes(cacheData?.imageCache.tmdb.size ?? 0)} + + + +
+
); };