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
@@ -8,7 +8,9 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
export interface RequestActionsDropdownProps {
request: {
@@ -37,7 +39,7 @@ export function RequestActionsDropdown({
}: RequestActionsDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
// Determine available actions based on status
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
@@ -104,27 +106,13 @@ export function RequestActionsDropdown({
}
};
return (
<div className="relative" ref={dropdownRef}>
{/* Three-dot menu button */}
<button
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="inline-flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Actions"
>
<svg
className="w-5 h-5 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
{/* Dropdown menu */}
{isOpen && (
<div className="absolute right-0 mt-2 w-56 rounded-lg shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50">
// Dropdown menu content (rendered via portal)
const dropdownMenu = isOpen && style && (
<div
ref={dropdownRef}
style={style}
className="w-56 rounded-lg shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50 max-h-[calc(100vh-2rem)] overflow-y-auto"
>
<div className="py-1" role="menu">
{/* Manual Search */}
{canSearch && (
@@ -284,7 +272,30 @@ export function RequestActionsDropdown({
)}
</div>
</div>
)}
);
return (
<>
{/* Three-dot menu button */}
<div className="relative" ref={containerRef}>
<button
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="inline-flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Actions"
>
<svg
className="w-5 h-5 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
</div>
{/* Dropdown menu (rendered via portal) */}
{typeof window !== 'undefined' && dropdownMenu && createPortal(dropdownMenu, document.body)}
{/* Interactive Search Modal */}
<InteractiveTorrentSearchModal
@@ -296,6 +307,6 @@ export function RequestActionsDropdown({
author: request.author,
}}
/>
</div>
</>
);
}
+54
View File
@@ -38,11 +38,13 @@ interface Settings {
url: string;
token: string;
libraryId: string;
triggerScanAfterImport: boolean;
};
audiobookshelf: {
serverUrl: string;
apiToken: string;
libraryId: string;
triggerScanAfterImport: boolean;
};
oidc: {
enabled: boolean;
@@ -1193,6 +1195,32 @@ export default function AdminSettings() {
)}
</div>
<div className="space-y-2">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.plex.triggerScanAfterImport}
onChange={(e) => {
setSettings({
...settings,
plex: { ...settings.plex, triggerScanAfterImport: e.target.checked },
});
}}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
/>
<div className="flex-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Trigger library scan after import
</span>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Automatically triggers Plex to scan its filesystem after organizing downloaded files.
Only enable this if you have Plex's filesystem watcher (automatic scanning) disabled.
Most users should leave this disabled and rely on Plex's built-in automatic detection.
</p>
</div>
</label>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<Button
onClick={testPlexConnection}
@@ -1302,6 +1330,32 @@ export default function AdminSettings() {
)}
</div>
<div className="space-y-2">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.audiobookshelf.triggerScanAfterImport}
onChange={(e) => {
setSettings({
...settings,
audiobookshelf: { ...settings.audiobookshelf, triggerScanAfterImport: e.target.checked },
});
}}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
/>
<div className="flex-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Trigger library scan after import
</span>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Automatically triggers Audiobookshelf to scan its filesystem after organizing downloaded files.
Only enable this if you have Audiobookshelf's filesystem watcher (automatic scanning) disabled.
Most users should leave this disabled and rely on Audiobookshelf's built-in automatic detection.
</p>
</div>
</label>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<Button
onClick={testABSConnection}
@@ -12,7 +12,7 @@ export async function PUT(request: NextRequest) {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { serverUrl, apiToken, libraryId } = body;
const { serverUrl, apiToken, libraryId, triggerScanAfterImport } = body;
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
@@ -21,6 +21,7 @@ export async function PUT(request: NextRequest) {
const updates: ConfigUpdate[] = [
{ key: 'audiobookshelf.server_url', value: serverUrl || '' },
{ key: 'audiobookshelf.library_id', value: libraryId || '' },
{ key: 'audiobookshelf.trigger_scan_after_import', value: triggerScanAfterImport === true ? 'true' : 'false' },
];
// Only update API token if it's not the masked placeholder
+8 -1
View File
@@ -12,7 +12,7 @@ export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url, token, libraryId } = await request.json();
const { url, token, libraryId, triggerScanAfterImport } = await request.json();
if (!url || !token || !libraryId) {
return NextResponse.json(
@@ -43,6 +43,13 @@ export async function PUT(request: NextRequest) {
create: { key: 'plex_audiobook_library_id', value: libraryId },
});
// Save trigger_scan_after_import setting
await prisma.configuration.upsert({
where: { key: 'plex.trigger_scan_after_import' },
update: { value: triggerScanAfterImport === true ? 'true' : 'false' },
create: { key: 'plex.trigger_scan_after_import', value: triggerScanAfterImport === true ? 'true' : 'false' },
});
// Fetch and save machine identifier (for server-specific access tokens)
// This is needed for BookDate per-user rating functionality
try {
+2
View File
@@ -37,11 +37,13 @@ export async function GET(request: NextRequest) {
url: configMap.get('plex_url') || '',
token: maskValue('token', configMap.get('plex_token')),
libraryId: configMap.get('plex_audiobook_library_id') || '',
triggerScanAfterImport: configMap.get('plex.trigger_scan_after_import') === 'true',
},
audiobookshelf: {
serverUrl: configMap.get('audiobookshelf.server_url') || '',
apiToken: maskValue('api_token', configMap.get('audiobookshelf.api_token')),
libraryId: configMap.get('audiobookshelf.library_id') || '',
triggerScanAfterImport: configMap.get('audiobookshelf.trigger_scan_after_import') === 'true',
},
oidc: {
enabled: configMap.get('oidc.enabled') === 'true',
+14
View File
@@ -188,6 +188,13 @@ export async function POST(request: NextRequest) {
create: { key: 'plex_machine_identifier', value: machineIdentifier },
});
}
// Save trigger_scan_after_import setting
await prisma.configuration.upsert({
where: { key: 'plex.trigger_scan_after_import' },
update: { value: plex.trigger_scan_after_import === true ? 'true' : 'false' },
create: { key: 'plex.trigger_scan_after_import', value: plex.trigger_scan_after_import === true ? 'true' : 'false' },
});
} else {
// Audiobookshelf configuration
await prisma.configuration.upsert({
@@ -209,6 +216,13 @@ export async function POST(request: NextRequest) {
create: { key: 'audiobookshelf.library_id', value: audiobookshelf.library_id },
});
// Save trigger_scan_after_import setting
await prisma.configuration.upsert({
where: { key: 'audiobookshelf.trigger_scan_after_import' },
update: { value: audiobookshelf.trigger_scan_after_import === true ? 'true' : 'false' },
create: { key: 'audiobookshelf.trigger_scan_after_import', value: audiobookshelf.trigger_scan_after_import === true ? 'true' : 'false' },
});
// OIDC configuration (if enabled)
if (authMethod === 'oidc' || authMethod === 'both') {
await prisma.configuration.upsert({
+23
View File
@@ -0,0 +1,23 @@
/**
* Component: Version API Route
* Documentation: documentation/backend/services/version.md
*/
import { NextResponse } from 'next/server';
export async function GET() {
const gitCommit = process.env.APP_VERSION || 'unknown';
const buildDate = process.env.BUILD_DATE || 'unknown';
// Get short commit hash (first 7 characters)
const shortCommit = gitCommit !== 'unknown' && gitCommit.length >= 7
? gitCommit.substring(0, 7)
: gitCommit;
return NextResponse.json({
version: `v.${shortCommit}`,
commit: gitCommit,
shortCommit,
buildDate,
});
}
+6
View File
@@ -43,11 +43,13 @@ interface SetupState {
plexUrl: string;
plexToken: string;
plexLibraryId: string;
plexTriggerScanAfterImport: boolean;
// Audiobookshelf config (if mode=audiobookshelf)
absUrl: string;
absApiToken: string;
absLibraryId: string;
absTriggerScanAfterImport: boolean;
// Auth config (if mode=audiobookshelf)
authMethod: 'oidc' | 'manual' | 'both';
@@ -113,11 +115,13 @@ export default function SetupWizard() {
plexUrl: '',
plexToken: '',
plexLibraryId: '',
plexTriggerScanAfterImport: false,
// Audiobookshelf config
absUrl: '',
absApiToken: '',
absLibraryId: '',
absTriggerScanAfterImport: false,
// Auth config
authMethod: 'oidc',
@@ -391,6 +395,7 @@ export default function SetupWizard() {
plexUrl={state.plexUrl}
plexToken={state.plexToken}
plexLibraryId={state.plexLibraryId}
plexTriggerScanAfterImport={state.plexTriggerScanAfterImport}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
@@ -408,6 +413,7 @@ export default function SetupWizard() {
absUrl={state.absUrl}
absApiToken={state.absApiToken}
absLibraryId={state.absLibraryId}
absTriggerScanAfterImport={state.absTriggerScanAfterImport}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
+25 -1
View File
@@ -13,7 +13,8 @@ interface AudiobookshelfStepProps {
absUrl: string;
absApiToken: string;
absLibraryId: string;
onUpdate: (field: string, value: string) => void;
absTriggerScanAfterImport: boolean;
onUpdate: (field: string, value: string | boolean) => void;
onNext: () => void;
onBack: () => void;
}
@@ -28,6 +29,7 @@ export function AudiobookshelfStep({
absUrl,
absApiToken,
absLibraryId,
absTriggerScanAfterImport,
onUpdate,
onNext,
onBack,
@@ -226,6 +228,28 @@ export function AudiobookshelfStep({
</p>
</div>
)}
{libraries.length > 0 && (
<div className="space-y-2">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={absTriggerScanAfterImport}
onChange={(e) => onUpdate('absTriggerScanAfterImport', e.target.checked)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
/>
<div className="flex-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Trigger library scan after import
</span>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Only enable this if you have Audiobookshelf's filesystem watcher (automatic scanning) disabled.
Most users should leave this unchecked and rely on Audiobookshelf's built-in automatic detection.
</p>
</div>
</label>
</div>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
+25 -1
View File
@@ -13,7 +13,8 @@ interface PlexStepProps {
plexUrl: string;
plexToken: string;
plexLibraryId: string;
onUpdate: (field: string, value: string) => void;
plexTriggerScanAfterImport: boolean;
onUpdate: (field: string, value: string | boolean) => void;
onNext: () => void;
onBack: () => void;
}
@@ -28,6 +29,7 @@ export function PlexStep({
plexUrl,
plexToken,
plexLibraryId,
plexTriggerScanAfterImport,
onUpdate,
onNext,
onBack,
@@ -233,6 +235,28 @@ export function PlexStep({
</p>
</div>
)}
{libraries.length > 0 && (
<div className="space-y-2">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={plexTriggerScanAfterImport}
onChange={(e) => onUpdate('plexTriggerScanAfterImport', e.target.checked)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
/>
<div className="flex-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Trigger library scan after import
</span>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Only enable this if you have Plex's filesystem watcher (automatic scanning) disabled.
Most users should leave this unchecked and rely on Plex's built-in automatic detection.
</p>
</div>
</label>
</div>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">