Add filesystem scan trigger and version badge features

Implements optional filesystem scan triggering for Plex and Audiobookshelf after file organization, with new settings in the admin UI, setup wizard, and API. Updates documentation to reflect scan trigger options and improved file organization/cleanup logic. Refactors dropdown menus to use smart positioning and portals for better UX. Adds a version API route and a VersionBadge component to display build info in the header. Updates Docker build to inject version metadata.
This commit is contained in:
kikootwo
2026-01-09 17:15:00 -05:00
parent 288421012d
commit 384601014a
25 changed files with 1346 additions and 243 deletions
@@ -45,15 +45,19 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
await logger?.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
// Find all completed requests + soft-deleted requests (orphaned downloads)
// IMPORTANT: Only cleanup requests that are truly complete and not being actively processed
// NOTE: Multiple requests can share the same torrent hash (e.g., re-requesting same audiobook)
// Before deleting torrent, we check if other active requests are using it
const completedRequests = await prisma.request.findMany({
where: {
OR: [
// Active requests with completed downloads
// Active requests that are fully available (scanned by Plex/ABS)
{
status: { in: ['available', 'downloaded'] },
status: 'available',
deletedAt: null,
},
// Soft-deleted requests (orphaned downloads still seeding)
// Soft-deleted requests (orphaned downloads)
// We'll check if torrent is shared with active requests before deletion
{
deletedAt: { not: null },
},
@@ -72,7 +76,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
take: 100, // Limit to 100 requests per run
});
await logger?.info(`Found ${completedRequests.length} completed requests to check`);
await logger?.info(`Found ${completedRequests.length} requests to check (status: 'available' or soft-deleted)`);
let cleaned = 0;
let skipped = 0;
@@ -144,7 +148,36 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
await logger?.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
// Delete torrent and files from qBittorrent
// CRITICAL: Check if any other active (non-deleted) request is using this same torrent hash
// This prevents deleting shared torrents when user re-requests the same audiobook
const otherActiveRequests = await prisma.request.findMany({
where: {
id: { not: request.id }, // Exclude current request
deletedAt: null, // Only check active requests
downloadHistory: {
some: {
torrentHash: downloadHistory.torrentHash,
selected: true,
},
},
},
select: { id: true, status: true },
});
if (otherActiveRequests.length > 0) {
await logger?.info(`Skipping torrent deletion - ${otherActiveRequests.length} other active request(s) still using this torrent (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
// If this is a soft-deleted request, hard delete it but DON'T delete the torrent
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
await logger?.info(`Hard-deleted orphaned request ${request.id} (kept shared torrent for active requests)`);
}
skipped++;
continue;
}
// Safe to delete - no other active requests using this torrent
await qbt.deleteTorrent(downloadHistory.torrentHash, true); // true = delete files
// If this is a soft-deleted request (orphaned download), hard delete it now
@@ -69,7 +69,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
torrentName: torrent.title,
nzbId: downloadClientId, // Store NZB ID
torrentSizeBytes: torrent.size,
torrentUrl: torrent.guid, // Source URL
torrentUrl: torrent.infoUrl || torrent.guid, // Indexer page URL (prefer infoUrl, fallback to guid)
magnetLink: torrent.downloadUrl, // Download URL (.nzb file)
seeders: torrent.seeders || 0, // Usenet doesn't have seeders, but include for consistency
leechers: 0,
@@ -130,7 +130,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
torrentName: torrent.title,
torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash
torrentSizeBytes: torrent.size,
torrentUrl: torrent.guid,
torrentUrl: torrent.infoUrl || torrent.guid, // Indexer page URL (prefer infoUrl, fallback to guid)
magnetLink: torrent.downloadUrl,
seeders: torrent.seeders || 0,
leechers: torrent.leechers || 0,
@@ -7,6 +7,8 @@ import { OrganizeFilesPayload, getJobQueueService } from '../services/job-queue.
import { prisma } from '../db';
import { getFileOrganizer } from '../utils/file-organizer';
import { createJobLogger } from '../utils/job-logger';
import { getLibraryService } from '../services/library';
import { getConfigService } from '../services/config.service';
/**
* Process organize files job
@@ -99,6 +101,54 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
errors: result.errors,
});
// Trigger filesystem scan if enabled (Plex or Audiobookshelf)
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
const configKey = backendMode === 'audiobookshelf'
? 'audiobookshelf.trigger_scan_after_import'
: 'plex.trigger_scan_after_import';
const scanEnabled = await configService.get(configKey);
if (scanEnabled === 'true') {
try {
// Get library service (returns PlexLibraryService or AudiobookshelfLibraryService)
const libraryService = await getLibraryService();
// Get configured library ID (backend-specific config)
const libraryId = backendMode === 'audiobookshelf'
? await configService.get('audiobookshelf.library_id')
: await configService.get('plex_audiobook_library_id');
if (!libraryId) {
throw new Error('Library ID not configured');
}
// Trigger scan (implementation is backend-specific)
await libraryService.triggerLibraryScan(libraryId);
await logger?.info(
`Triggered ${backendMode} filesystem scan for library ${libraryId}`
);
} catch (error) {
// Log error but don't fail the job
await logger?.error(
`Failed to trigger filesystem scan: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
error: error instanceof Error ? error.stack : undefined,
backend: backendMode
}
);
// Continue - scheduled scans will eventually detect the book
}
} else {
await logger?.info(
`${backendMode} filesystem scan trigger disabled (relying on filesystem watcher)`
);
}
return {
success: true,
message: 'Files organized successfully',