From 2e9d67c117f3f84a6a93b7b49818484290d90170 Mon Sep 17 00:00:00 2001 From: breakthestatic Date: Fri, 14 Aug 2026 12:53:06 -0700 Subject: [PATCH] feat: add recently added scan for readarr --- server/job/schedule.ts | 25 +- server/lib/scanners/readarr/index.ts | 227 +++++++++++++----- server/lib/settings/index.ts | 4 + .../Settings/SettingsJobsCache/index.tsx | 1 + 4 files changed, 196 insertions(+), 61 deletions(-) diff --git a/server/job/schedule.ts b/server/job/schedule.ts index be44d64750..d28831e766 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -10,7 +10,10 @@ import { } from '@server/lib/scanners/jellyfin'; import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex'; import { radarrScanner } from '@server/lib/scanners/radarr'; -import { readarrScanner } from '@server/lib/scanners/readarr'; +import { + readarrRecentScanner, + readarrScanner, +} from '@server/lib/scanners/readarr'; import { sonarrScanner } from '@server/lib/scanners/sonarr'; import type { JobId } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; @@ -178,6 +181,26 @@ export const startJobs = (): void => { cancelFn: () => sonarrScanner.cancel(), }); + // Run readarr recently added scan every 10 minutes + scheduledJobs.push({ + id: 'readarr-recently-added-scan', + name: 'Readarr Recently Added Scan', + type: 'process', + interval: 'minutes', + cronSchedule: jobs['readarr-recently-added-scan'].schedule, + job: schedule.scheduleJob( + jobs['readarr-recently-added-scan'].schedule, + () => { + logger.info('Starting scheduled job: Readarr Recently Added Scan', { + label: 'Jobs', + }); + readarrRecentScanner.run(); + } + ), + running: () => readarrRecentScanner.status().running, + cancelFn: () => readarrRecentScanner.cancel(), + }); + // Run full readarr scan every 24 hours scheduledJobs.push({ id: 'readarr-scan', diff --git a/server/lib/scanners/readarr/index.ts b/server/lib/scanners/readarr/index.ts index 380f6e3601..b865dc95f0 100644 --- a/server/lib/scanners/readarr/index.ts +++ b/server/lib/scanners/readarr/index.ts @@ -30,9 +30,11 @@ class ReadarrScanner private didScanAudio = false; private serverReturnedEmpty = false; private serverAudioReturnedEmpty = false; + private isRecentOnly = false; - constructor() { + constructor(isRecentOnly = false) { super('Readarr Scan', { bundleSize: 50 }); + this.isRecentOnly = isRecentOnly; } public status(): SyncStatus { @@ -46,6 +48,11 @@ class ReadarrScanner } public async run(): Promise { + if (this.running) { + this.log('Scan already in progress. Skipping.', 'info'); + return; + } + const settings = getSettings(); const sessionId = this.startRun(); this.scannedHcIds.clear(); @@ -67,70 +74,51 @@ class ReadarrScanner for (const server of this.servers) { this.currentServer = server; if (server.syncEnabled) { - this.log( - `Beginning to process Readarr server: ${server.name}`, - 'info' - ); - - this.readarrApi = new ReadarrAPI({ - apiKey: server.apiKey, - url: ReadarrAPI.buildUrl(server, '/api/v1'), - }); - - this.items = await this.readarrApi.getBooks(); - - const serverAudio = this.enableAudioBook && server.is4k; - if (serverAudio) { - this.didScanAudio = true; + if (this.isRecentOnly) { + await this.runRecentScan(server, sessionId); } else { - this.didScanStandard = true; - } - - if (this.items.length === 0) { - if (serverAudio) { - this.serverAudioReturnedEmpty = true; - } else { - this.serverReturnedEmpty = true; - } - this.log( - `Readarr server ${server.name} returned no books. Orphan cleanup for this profile type will be skipped.`, - 'warn' - ); + await this.runFullScan(server, sessionId); } - - await this.loop(this.processReadarrBook.bind(this), { sessionId }); } else { this.log(`Sync not enabled. Skipping Readarr server: ${server.name}`); } } - // Only run cleanup if all servers of this profile type have sync enabled. - // If any server is skipped, we can't distinguish truly orphaned media from - // media that exists on an unscanned server (e.g. separate instances for - // different genres or languages). - const allStandardScanned = this.servers - .filter((s) => !this.enableAudioBook || !s.is4k) - .every((s) => s.syncEnabled); - const allAudioScanned = this.servers - .filter((s) => this.enableAudioBook && s.is4k) - .every((s) => s.syncEnabled); - - if (!allStandardScanned) { - this.didScanStandard = false; - } - if (!allAudioScanned) { - this.didScanAudio = false; - } + // Only run orphan cleanup during full scans + if (!this.isRecentOnly) { + // Only run cleanup if all servers of this profile type have sync enabled. + // If any server is skipped, we can't distinguish truly orphaned media from + // media that exists on an unscanned server (e.g. separate instances for + // different genres or languages). + const allStandardScanned = this.servers + .filter((s) => !this.enableAudioBook || !s.is4k) + .every((s) => s.syncEnabled); + const allAudioScanned = this.servers + .filter((s) => this.enableAudioBook && s.is4k) + .every((s) => s.syncEnabled); + + if (!allStandardScanned) { + this.didScanStandard = false; + } + if (!allAudioScanned) { + this.didScanAudio = false; + } + if (this.serverReturnedEmpty) { + this.didScanStandard = false; + } + if (this.serverAudioReturnedEmpty) { + this.didScanAudio = false; + } - if (this.serverReturnedEmpty) { - this.didScanStandard = false; - } - if (this.serverAudioReturnedEmpty) { - this.didScanAudio = false; + await this.cleanupOrphanedBooks(); } - await this.cleanupOrphanedBooks(); - this.log('Readarr scan complete', 'info'); + this.log( + this.isRecentOnly + ? 'Recently Added Scan Complete' + : 'Full Scan Complete', + 'info' + ); } catch (e) { this.log('Scan interrupted', 'error', { errorMessage: e.message }); } finally { @@ -138,12 +126,124 @@ class ReadarrScanner } } + private async runFullScan( + server: ReadarrSettings, + sessionId: string + ): Promise { + this.log(`Beginning to process Readarr server: ${server.name}`, 'info'); + + this.readarrApi = new ReadarrAPI({ + apiKey: server.apiKey, + url: ReadarrAPI.buildUrl(server, '/api/v1'), + }); + + this.items = await this.readarrApi.getBooks(); + + const serverAudio = this.enableAudioBook && server.is4k; + if (serverAudio) { + this.didScanAudio = true; + } else { + this.didScanStandard = true; + } + + if (this.items.length === 0) { + if (serverAudio) { + this.serverAudioReturnedEmpty = true; + } else { + this.serverReturnedEmpty = true; + } + this.log( + `Readarr server ${server.name} returned no books. Orphan cleanup for this profile type will be skipped.`, + 'warn' + ); + } + + await this.loop(this.processReadarrBook.bind(this), { sessionId }); + } + + private async runRecentScan( + server: ReadarrSettings, + sessionId: string + ): Promise { + this.log( + `Beginning to process recently added for Readarr server: ${server.name}`, + 'info' + ); + + this.readarrApi = new ReadarrAPI({ + apiKey: server.apiKey, + url: ReadarrAPI.buildUrl(server, '/api/v1'), + }); + + const allBooks = await this.readarrApi.getBooks(); + + // Compare Readarr's state against what Seerr already has in its database. + // We skip any book that: + // 1. Has no valid Hardcover ID (can't be processed) + // 2. Is not already in Seerr (discovery is the full scan's job) + // 3. Already exists in Seerr with the same status (nothing to update) + const mediaRepository = getRepository(Media); + const existingMedia = await mediaRepository.find({ + where: { mediaType: MediaType.BOOK }, + select: ['tmdbId', 'status', 'status4k'], + }); + const existingMediaMap = new Map(existingMedia.map((m) => [m.tmdbId, m])); + + const serverAudio = this.enableAudioBook && server.is4k; + + this.items = allBooks.filter((book) => { + const hcId = parseInt(book.foreignBookId, 10); + if (isNaN(hcId)) return false; + + const existing = existingMediaMap.get(hcId); + const hasFile = (book.statistics?.bookFileCount ?? 0) > 0; + + if (!existing) { + // Book not in Seerr — skip it. Discovery of new books is the + // full scan's responsibility. + return false; + } + + // Check if the status in Seerr matches what Readarr reports + const currentStatus = serverAudio ? existing.status4k : existing.status; + + if (hasFile && currentStatus === MediaStatus.AVAILABLE) { + return false; + } + + if ( + !hasFile && + (book.monitored || book.grabbed) && + currentStatus === MediaStatus.PROCESSING + ) { + return false; + } + + // Status mismatch — needs updating + return true; + }); + + this.log( + `Filtered ${allBooks.length} total books to ${this.items.length} with state changes`, + 'info' + ); + + if (this.items.length === 0) { + this.log( + `No recently added books found for server: ${server.name}`, + 'info' + ); + } else { + await this.loop(this.processReadarrBook.bind(this), { sessionId }); + } + } + private async processReadarrBook(readarrBook: ReadarrBook): Promise { const serverAudio = this.enableAudioBook && this.currentServer.is4k; const hcId = parseInt(readarrBook.foreignBookId, 10); if (isNaN(hcId)) { - this.log('Invalid Hardcover ID for book. Skipping item.', 'warn', { + this.log('Invalid Hardcover ID for book. Skipping item.', 'debug', { title: readarrBook.title, foreignBookId: readarrBook.foreignBookId, }); @@ -157,7 +257,14 @@ class ReadarrScanner } try { - const isFullyDownloaded = readarrBook.statistics?.percentOfBooks >= 100; + // Use bookFileCount as indicator of having files. In original Readarr, + // percentOfBooks (bookFileCount / bookCount * 100) works because + // bookCount includes books with files regardless of monitoring state. + // Some forks removed that fallback, with bookCount tied to monitoring + // state, so unmonitored books with files report percentOfBooks=0. + // bookFileCount > 0 inherently implies percentOfBooks >= 100 and works + // correctly for both implementations. + const hasFile = (readarrBook.statistics?.bookFileCount ?? 0) > 0; await this.processBook(hcId, { is4k: serverAudio, @@ -165,9 +272,8 @@ class ReadarrScanner externalServiceId: readarrBook.id, externalServiceSlug: readarrBook.titleSlug, title: readarrBook.title, - processing: - !isFullyDownloaded && (readarrBook.monitored || readarrBook.grabbed), - hasFile: isFullyDownloaded, + processing: !hasFile && (readarrBook.monitored || readarrBook.grabbed), + hasFile, }); } catch (e) { this.log('Failed to process Readarr media', 'error', { @@ -275,3 +381,4 @@ class ReadarrScanner } export const readarrScanner = new ReadarrScanner(); +export const readarrRecentScanner = new ReadarrScanner(true); diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index eb6a71ae70..f53ef143b4 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -375,6 +375,7 @@ export type JobId = | 'radarr-scan' | 'sonarr-scan' | 'readarr-scan' + | 'readarr-recently-added-scan' | 'download-sync' | 'download-sync-reset' | 'jellyfin-recently-added-scan' @@ -609,6 +610,9 @@ class Settings { 'readarr-scan': { schedule: '0 30 4 * * *', }, + 'readarr-recently-added-scan': { + schedule: '0 */10 * * * *', + }, 'availability-sync': { schedule: '0 0 5 * * *', }, diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index dd57f343c8..6a275c0028 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -86,6 +86,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages( 'availability-sync': 'Media Availability Sync', 'radarr-scan': 'Radarr Scan', 'readarr-scan': 'Readarr Scan', + 'readarr-recently-added-scan': 'Readarr Recently Added Scan', 'sonarr-scan': 'Sonarr Scan', 'download-sync': 'Download Sync', 'download-sync-reset': 'Download Sync Reset',