Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion server/job/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
227 changes: 167 additions & 60 deletions server/lib/scanners/readarr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -46,6 +48,11 @@ class ReadarrScanner
}

public async run(): Promise<void> {
if (this.running) {
this.log('Scan already in progress. Skipping.', 'info');
return;
}

const settings = getSettings();
const sessionId = this.startRun();
this.scannedHcIds.clear();
Expand All @@ -67,83 +74,176 @@ 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 {
this.endRun(sessionId);
}
}

private async runFullScan(
server: ReadarrSettings,
sessionId: string
): Promise<void> {
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<void> {
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<void> {
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,
});
Expand All @@ -157,17 +257,23 @@ 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,
serviceId: this.currentServer.id,
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', {
Expand Down Expand Up @@ -275,3 +381,4 @@ class ReadarrScanner
}

export const readarrScanner = new ReadarrScanner();
export const readarrRecentScanner = new ReadarrScanner(true);
4 changes: 4 additions & 0 deletions server/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 * * *',
},
Expand Down
1 change: 1 addition & 0 deletions src/components/Settings/SettingsJobsCache/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down