From dd5a5962b4fd0b07dd74a837532f93a2bf949a10 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Mon, 18 May 2026 09:31:41 -0400 Subject: [PATCH] Add job descriptions and stale-name renames Show human-friendly per-job descriptions on the Admin Jobs page (JOB_DESCRIPTIONS) and remove the old "About Scheduled Jobs" info box. Add STALE_NAME_REWRITES and renameStaleJobNames() in SchedulerService to automatically rewrite legacy exact-literal job names (e.g. "Plex Library Scan") to neutral defaults on startup; updates are type-gated and use updateMany with exact matches so admin-customized names are not touched. Log successful renames and swallow rename errors so startup remains idempotent. Tests and documentation were updated to reflect the new UI text and to cover rename behavior. --- documentation/backend/services/scheduler.md | 1 + src/app/admin/jobs/page.tsx | 33 +++++++++-------- src/lib/services/scheduler.service.ts | 38 +++++++++++++++++++ tests/app/admin-jobs.page.test.tsx | 7 +++- tests/services/scheduler.service.test.ts | 41 +++++++++++++++++++++ 5 files changed, 104 insertions(+), 16 deletions(-) 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(); + }); });