diff --git a/documentation/backend/services/scheduler.md b/documentation/backend/services/scheduler.md index c2e2903..61eea2e 100644 --- a/documentation/backend/services/scheduler.md +++ b/documentation/backend/services/scheduler.md @@ -12,6 +12,7 @@ Manages recurring/scheduled jobs providing automated tasks (Plex scans, Audible - Schedule editing UI with toast notifications - Human-friendly schedule descriptions and editor (preset/custom/advanced modes) - Real-time cron expression preview +- Admin Jobs page shows per-job descriptions inline; startup auto-renames legacy "Plex *" job names to neutral defaults (type-gated, exact-literal match only) ## Scheduled Jobs diff --git a/src/app/admin/jobs/page.tsx b/src/app/admin/jobs/page.tsx index b812d35..2580c04 100644 --- a/src/app/admin/jobs/page.tsx +++ b/src/app/admin/jobs/page.tsx @@ -28,6 +28,22 @@ interface ScheduledJob { nextRun: string | null; } +// Plain-English subtitle shown under each job's name on /admin/jobs. +// Keyed by ScheduledJobType. Unknown types render no subtitle (silent absence — +// we never leak raw type keys like `plex_library_scan` into the UI). +const JOB_DESCRIPTIONS: Record = { + plex_library_scan: 'Scans your full media library to detect newly added audiobooks.', + plex_recently_added_check: 'Checks for the newest items added to your library since the last scan.', + audible_refresh: 'Refreshes popular & new-release audiobooks from Audible.', + retry_missing_torrents: 'Retries searches for requests that previously found no results.', + retry_failed_imports: 'Re-attempts import for downloads that failed to organize.', + find_missing_ebooks: 'Looks for ebook companions to audiobooks you already have.', + cleanup_seeded_torrents: "Removes torrents once they've met your seeding requirements.", + monitor_rss_feeds: 'Watches indexer RSS feeds for matches against pending requests.', + sync_reading_shelves: 'Pulls new books from your Goodreads/Hardcover shelves.', + check_watched_lists: 'Checks watched series & authors for new releases.', +}; + function AdminJobsPageContent() { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); @@ -214,7 +230,7 @@ function AdminJobsPageContent() { {job.name}
- {job.type} + {JOB_DESCRIPTIONS[job.type] ?? ''}
- {job.type} + {JOB_DESCRIPTIONS[job.type] ?? ''}
@@ -395,19 +411,6 @@ function AdminJobsPageContent() { )} - {/* Info Box */} -
-

- About Scheduled Jobs -

-
    -
  • Library Scan: Automatically scans your media library for new audiobooks
  • -
  • Audible Data Refresh: Caches popular and new release audiobooks from Audible
  • -
  • • Trigger jobs manually using the "Trigger Now" button
  • -
  • • Schedule format follows cron syntax (minute hour day month weekday)
  • -
-
- {/* Confirmation Dialog */} {confirmDialog.isOpen && (
diff --git a/src/lib/services/scheduler.service.ts b/src/lib/services/scheduler.service.ts index e4bcb8e..678782c 100644 --- a/src/lib/services/scheduler.service.ts +++ b/src/lib/services/scheduler.service.ts @@ -10,6 +10,18 @@ import { RMABLogger } from '../utils/logger'; const logger = RMABLogger.create('Scheduler'); +// Legacy literal `name` values that older installs may still have in the DB. +// Each entry maps an exact stale literal to its current neutral default, +// type-gated so partial-matches to the word "Plex" can never be touched. +const STALE_NAME_REWRITES: ReadonlyArray<{ + type: string; + staleName: string; + neutralName: string; +}> = [ + { type: 'plex_library_scan', staleName: 'Plex Library Scan', neutralName: 'Library Scan' }, + { type: 'plex_recently_added_check', staleName: 'Plex Recently Added Check', neutralName: 'Recently Added Check' }, +]; + export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'find_missing_ebooks' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_reading_shelves' | 'check_watched_lists'; export interface ScheduledJob { @@ -62,6 +74,9 @@ export class SchedulerService { // Clean up deprecated scheduled jobs await this.cleanupDeprecatedJobs(); + // Rewrite legacy literal names (e.g. "Plex Library Scan") to current neutral defaults + await this.renameStaleJobNames(); + // Create default jobs if they don't exist await this.ensureDefaultJobs(); @@ -184,6 +199,29 @@ export class SchedulerService { } } + /** + * Rewrite legacy literal `name` values to their current neutral defaults. + * Type-gated on BOTH `name` and `type` exact-equals — admin-customized names + * that happen to contain "Plex" are never touched. + */ + private async renameStaleJobNames(): Promise { + try { + for (const entry of STALE_NAME_REWRITES) { + const result = await prisma.scheduledJob.updateMany({ + where: { name: entry.staleName, type: entry.type }, + data: { name: entry.neutralName }, + }); + if (result.count > 0) { + logger.info(`Renamed scheduled job: "${entry.staleName}" → "${entry.neutralName}" (${result.count} row${result.count === 1 ? '' : 's'})`); + } + } + } catch (error) { + logger.error('Failed to rename stale scheduled job names', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + /** * Remove any old jobs that are no longer supported */ diff --git a/tests/app/admin-jobs.page.test.tsx b/tests/app/admin-jobs.page.test.tsx index 1536aa2..c9976a7 100644 --- a/tests/app/admin-jobs.page.test.tsx +++ b/tests/app/admin-jobs.page.test.tsx @@ -43,7 +43,7 @@ describe('AdminJobsPage', () => { { id: 'job-1', name: 'Library Scan', - type: 'scan_plex', + type: 'plex_library_scan', schedule: '0 * * * *', enabled: true, lastRun: null, @@ -56,6 +56,11 @@ describe('AdminJobsPage', () => { render(); expect((await screen.findAllByText('Library Scan'))[0]).toBeInTheDocument(); + expect( + (await screen.findAllByText('Scans your full media library to detect newly added audiobooks.'))[0] + ).toBeInTheDocument(); + expect(screen.queryByText('plex_library_scan')).not.toBeInTheDocument(); + expect(screen.queryByText('About Scheduled Jobs')).not.toBeInTheDocument(); fireEvent.click(screen.getAllByRole('button', { name: /Trigger Now/i })[0]); fireEvent.click(screen.getByRole('button', { name: 'Trigger Job' })); diff --git a/tests/services/scheduler.service.test.ts b/tests/services/scheduler.service.test.ts index 2ca5f7b..730fbce 100644 --- a/tests/services/scheduler.service.test.ts +++ b/tests/services/scheduler.service.test.ts @@ -504,4 +504,45 @@ describe('SchedulerService', () => { await expect(service.triggerJobNow('job-10')).rejects.toThrow('Audiobookshelf is not configured'); }); + + it('rewrites stale literal job names with type-gated updateMany on startup', async () => { + prismaMock.scheduledJob.findFirst.mockResolvedValue({ id: 'existing' }); + prismaMock.scheduledJob.updateMany.mockResolvedValue({ count: 1 }); + prismaMock.scheduledJob.findMany + .mockResolvedValueOnce([]) // cleanupDeprecatedJobs + .mockResolvedValueOnce([]) // scheduleAllJobs + .mockResolvedValue([]); // triggerOverdueJobs + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + await service.start(); + + const updateManyCalls = prismaMock.scheduledJob.updateMany.mock.calls; + expect(updateManyCalls).toHaveLength(2); + + // Type-gating safety: each WHERE must match BOTH name AND type exact-equals. + expect(updateManyCalls[0][0]).toEqual({ + where: { name: 'Plex Library Scan', type: 'plex_library_scan' }, + data: { name: 'Library Scan' }, + }); + expect(updateManyCalls[1][0]).toEqual({ + where: { name: 'Plex Recently Added Check', type: 'plex_recently_added_check' }, + data: { name: 'Recently Added Check' }, + }); + }); + + it('swallows rename errors and continues startup (idempotent)', async () => { + prismaMock.scheduledJob.findFirst.mockResolvedValue({ id: 'existing' }); + prismaMock.scheduledJob.updateMany.mockRejectedValue(new Error('db blip')); + prismaMock.scheduledJob.findMany + .mockResolvedValueOnce([]) // cleanupDeprecatedJobs + .mockResolvedValueOnce([]) // scheduleAllJobs + .mockResolvedValue([]); // triggerOverdueJobs + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + + // Must not throw — rename failure is non-fatal. + await expect(service.start()).resolves.toBeUndefined(); + }); });