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.
This commit is contained in:
kikootwo
2026-05-18 09:31:41 -04:00
parent eef6ae3462
commit dd5a5962b4
5 changed files with 104 additions and 16 deletions
@@ -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
+18 -15
View File
@@ -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<string, string> = {
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<ScheduledJob[]>([]);
const [loading, setLoading] = useState(true);
@@ -214,7 +230,7 @@ function AdminJobsPageContent() {
{job.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{job.type}
{JOB_DESCRIPTIONS[job.type] ?? ''}
</div>
</div>
<span
@@ -322,7 +338,7 @@ function AdminJobsPageContent() {
{job.name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{job.type}
{JOB_DESCRIPTIONS[job.type] ?? ''}
</div>
</td>
<td className="px-6 py-4">
@@ -395,19 +411,6 @@ function AdminJobsPageContent() {
)}
</div>
{/* Info Box */}
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
About Scheduled Jobs
</h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> <strong>Library Scan:</strong> Automatically scans your media library for new audiobooks</li>
<li> <strong>Audible Data Refresh:</strong> Caches popular and new release audiobooks from Audible</li>
<li> Trigger jobs manually using the &quot;Trigger Now&quot; button</li>
<li> Schedule format follows cron syntax (minute hour day month weekday)</li>
</ul>
</div>
{/* Confirmation Dialog */}
{confirmDialog.isOpen && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-4">
+38
View File
@@ -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<void> {
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
*/
+6 -1
View File
@@ -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(<AdminJobsPage />);
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' }));
+41
View File
@@ -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();
});
});