mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -6,15 +6,19 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { VersionBadge } from '@/components/ui/VersionBadge';
|
||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||
|
||||
export function Header() {
|
||||
const { user, logout } = useAuth();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||
const [showBookDate, setShowBookDate] = useState(false);
|
||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu);
|
||||
|
||||
// Check if BookDate is configured
|
||||
useEffect(() => {
|
||||
@@ -67,21 +71,50 @@ export function Header() {
|
||||
}
|
||||
};
|
||||
|
||||
// User menu dropdown (rendered via portal)
|
||||
const userMenuDropdown = showUserMenu && style && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={style}
|
||||
className="w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg py-1 z-50 max-h-[calc(100vh-2rem)] overflow-y-auto"
|
||||
>
|
||||
<Link
|
||||
href="/profile"
|
||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout();
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm sticky top-0 z-40">
|
||||
<div className="container mx-auto px-4 py-3 md:py-4 max-w-7xl">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<img
|
||||
src="/rmab_32x32.png"
|
||||
alt="ReadMeABook Logo"
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
<span className="text-lg md:text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
ReadMeABook
|
||||
</span>
|
||||
</Link>
|
||||
{/* Logo and Version Badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<img
|
||||
src="/rmab_32x32.png"
|
||||
alt="ReadMeABook Logo"
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
<span className="text-lg md:text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
ReadMeABook
|
||||
</span>
|
||||
</Link>
|
||||
<VersionBadge />
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
@@ -154,7 +187,7 @@ export function Header() {
|
||||
</button>
|
||||
|
||||
{user ? (
|
||||
<div className="relative">
|
||||
<div className="relative" ref={containerRef}>
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
@@ -174,27 +207,6 @@ export function Header() {
|
||||
{user.username}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg py-1 z-50">
|
||||
<Link
|
||||
href="/profile"
|
||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout();
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={handleLogin} variant="primary" size="sm">
|
||||
@@ -253,6 +265,9 @@ export function Header() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User menu dropdown (rendered via portal) */}
|
||||
{typeof window !== 'undefined' && userMenuDropdown && createPortal(userMenuDropdown, document.body)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Component: Version Badge
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export function VersionBadge() {
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Try to get version from build-time env var first (instant, no API call)
|
||||
const buildTimeVersion = process.env.NEXT_PUBLIC_GIT_COMMIT;
|
||||
|
||||
if (buildTimeVersion && buildTimeVersion !== 'unknown') {
|
||||
// Get short commit hash (first 7 characters)
|
||||
const shortCommit = buildTimeVersion.length >= 7
|
||||
? buildTimeVersion.substring(0, 7)
|
||||
: buildTimeVersion;
|
||||
setVersion(`v.${shortCommit}`);
|
||||
} else {
|
||||
// Fallback to API call if build-time env var is not available
|
||||
fetch('/api/version')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setVersion(data.version);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch version:', error);
|
||||
setVersion('v.dev');
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!version) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-md bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 border border-gray-300 dark:border-gray-600 shadow-sm"
|
||||
title={`Version ${version}`}
|
||||
>
|
||||
<span className="text-xs font-mono font-medium text-gray-700 dark:text-gray-300">
|
||||
{version}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Hook: Smart Dropdown Positioning with Portal Support
|
||||
*
|
||||
* Automatically positions dropdown menus to avoid viewport overflow.
|
||||
* Detects available space above/below the trigger element and positions
|
||||
* the dropdown accordingly. Returns absolute coordinates for portal rendering.
|
||||
*/
|
||||
|
||||
import { useRef, useState, useEffect, RefObject } from 'react';
|
||||
|
||||
interface UseSmartDropdownPositionReturn {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
dropdownRef: RefObject<HTMLDivElement | null>;
|
||||
positionAbove: boolean;
|
||||
style: {
|
||||
position: 'fixed';
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left: number;
|
||||
right?: number;
|
||||
minWidth: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for smart dropdown positioning with portal support
|
||||
*
|
||||
* @param isOpen - Whether the dropdown is currently open
|
||||
* @returns Object containing refs, positioning state, and absolute coordinates for portal rendering
|
||||
*
|
||||
* @example
|
||||
* const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
|
||||
*
|
||||
* <div ref={containerRef}>
|
||||
* <button>Toggle</button>
|
||||
* </div>
|
||||
* {isOpen && createPortal(
|
||||
* <div ref={dropdownRef} style={style}>Menu items</div>,
|
||||
* document.body
|
||||
* )}
|
||||
*/
|
||||
export function useSmartDropdownPosition(isOpen: boolean): UseSmartDropdownPositionReturn {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [positionAbove, setPositionAbove] = useState(false);
|
||||
const [style, setStyle] = useState<UseSmartDropdownPositionReturn['style']>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setStyle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const calculatePosition = () => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buttonRect = containerRef.current.getBoundingClientRect();
|
||||
|
||||
// Get dropdown dimensions if available, otherwise estimate
|
||||
const dropdownHeight = dropdownRef.current
|
||||
? dropdownRef.current.getBoundingClientRect().height
|
||||
: 300; // Reasonable default estimate
|
||||
|
||||
const dropdownWidth = dropdownRef.current
|
||||
? dropdownRef.current.getBoundingClientRect().width
|
||||
: 224; // Default width estimate (w-56)
|
||||
|
||||
// Calculate available space (with 16px buffer from viewport edges)
|
||||
const spaceBelow = window.innerHeight - buttonRect.bottom - 16;
|
||||
const spaceAbove = buttonRect.top - 16;
|
||||
|
||||
// Position above if not enough space below
|
||||
const shouldPositionAbove = spaceBelow < dropdownHeight && spaceAbove >= dropdownHeight;
|
||||
|
||||
setPositionAbove(shouldPositionAbove);
|
||||
|
||||
// Calculate absolute position for portal rendering
|
||||
// Align right edge of dropdown with right edge of button
|
||||
const newStyle: UseSmartDropdownPositionReturn['style'] = {
|
||||
position: 'fixed',
|
||||
left: Math.max(8, buttonRect.right - dropdownWidth), // Keep 8px from left edge
|
||||
minWidth: buttonRect.width,
|
||||
};
|
||||
|
||||
if (shouldPositionAbove) {
|
||||
// Position above the button
|
||||
newStyle.bottom = window.innerHeight - buttonRect.top + 8; // 8px margin
|
||||
} else {
|
||||
// Position below the button
|
||||
newStyle.top = buttonRect.bottom + 8; // 8px margin
|
||||
}
|
||||
|
||||
setStyle(newStyle);
|
||||
};
|
||||
|
||||
// Use requestAnimationFrame for immediate measurement after render
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
calculatePosition();
|
||||
});
|
||||
|
||||
// Recalculate on scroll/resize (debounced)
|
||||
const debouncedCalculate = debounce(calculatePosition, 150);
|
||||
|
||||
// Use capture phase for scroll to catch scrolling in any parent
|
||||
window.addEventListener('scroll', debouncedCalculate, true);
|
||||
window.addEventListener('resize', debouncedCalculate);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('scroll', debouncedCalculate, true);
|
||||
window.removeEventListener('resize', debouncedCalculate);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return { containerRef, dropdownRef, positionAbove, style };
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce helper function
|
||||
*/
|
||||
function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -92,9 +92,33 @@ export async function searchABSItems(libraryId: string, query: string) {
|
||||
|
||||
/**
|
||||
* Trigger a library scan
|
||||
* Note: This endpoint returns plain text "OK" instead of JSON
|
||||
*/
|
||||
export async function triggerABSScan(libraryId: string) {
|
||||
await absRequest(`/libraries/${libraryId}/scan`, { method: 'POST' });
|
||||
const configService = getConfigService();
|
||||
const serverUrl = await configService.get('audiobookshelf.server_url');
|
||||
const apiToken = await configService.get('audiobookshelf.api_token');
|
||||
|
||||
if (!serverUrl || !apiToken) {
|
||||
throw new Error('Audiobookshelf not configured');
|
||||
}
|
||||
|
||||
const url = `${serverUrl.replace(/\/$/, '')}/api/libraries/${libraryId}/scan`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ABS API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Endpoint returns plain text "OK", not JSON - don't try to parse it
|
||||
await response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+342
-35
@@ -6,7 +6,7 @@
|
||||
* with proper chapter markers.
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { exec, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
@@ -72,34 +72,37 @@ export interface MergeResult {
|
||||
|
||||
/**
|
||||
* Detect if the given files appear to be chapter files that should be merged
|
||||
*
|
||||
* New approach: Use simple heuristic (>3 files of same format) and rely on
|
||||
* analyzeChapterFiles() to determine if ordering is possible via metadata or filenames.
|
||||
* This is more permissive and catches edge cases where filenames don't match patterns
|
||||
* but metadata (track numbers) provides correct ordering.
|
||||
*/
|
||||
export async function detectChapterFiles(files: string[]): Promise<boolean> {
|
||||
// Need at least 2 files to merge
|
||||
if (files.length < 2) {
|
||||
export async function detectChapterFiles(files: string[], logger?: JobLogger): Promise<boolean> {
|
||||
// Need at least 3 files to consider as multi-chapter audiobook
|
||||
// (2 files might be "Book" + "Credits", so require 3+)
|
||||
if (files.length < 3) {
|
||||
await logger?.info(`Chapter detection: Only ${files.length} file(s) - not enough for chapter merge (minimum: 3)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// All files must have same audio format
|
||||
const extensions = new Set(files.map(f => path.extname(f).toLowerCase()));
|
||||
if (extensions.size > 1) {
|
||||
await logger?.info(`Chapter detection: Mixed formats detected (${[...extensions].join(', ')}) - skipping merge`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be a supported format
|
||||
const ext = [...extensions][0];
|
||||
if (!SUPPORTED_FORMATS.includes(ext)) {
|
||||
await logger?.info(`Chapter detection: Unsupported format (${ext}) - skipping merge`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if files match chapter patterns
|
||||
const filenames = files.map(f => path.basename(f));
|
||||
const matchingFiles = filenames.filter(filename =>
|
||||
CHAPTER_PATTERNS.some(pattern => pattern.test(filename))
|
||||
);
|
||||
|
||||
// At least 80% of files should match chapter patterns
|
||||
const matchRatio = matchingFiles.length / filenames.length;
|
||||
return matchRatio >= 0.8;
|
||||
// Passed basic checks - attempt merge
|
||||
await logger?.info(`Chapter detection: ${files.length} files with format ${ext} - attempting chapter merge`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,6 +243,8 @@ export async function analyzeChapterFiles(
|
||||
filePaths: string[],
|
||||
logger?: JobLogger
|
||||
): Promise<ChapterFile[]> {
|
||||
await logger?.info(`Analyzing ${filePaths.length} chapter files...`);
|
||||
|
||||
// Probe all files in parallel
|
||||
const probePromises = filePaths.map(async (filePath) => {
|
||||
const probe = await probeAudioFile(filePath);
|
||||
@@ -256,6 +261,11 @@ export async function analyzeChapterFiles(
|
||||
|
||||
const files = await Promise.all(probePromises);
|
||||
|
||||
// Log sample filenames for debugging
|
||||
const sampleCount = Math.min(3, files.length);
|
||||
const sampleFilenames = files.slice(0, sampleCount).map(f => f.filename);
|
||||
await logger?.info(`Sample filenames: ${sampleFilenames.join(', ')}${files.length > sampleCount ? ', ...' : ''}`);
|
||||
|
||||
// Create filename-based order (natural sort)
|
||||
const filenameOrder = [...files].sort((a, b) =>
|
||||
naturalSortCompare(a.filename, b.filename)
|
||||
@@ -266,9 +276,15 @@ export async function analyzeChapterFiles(
|
||||
let useMetadataOrder = false;
|
||||
let metadataOrder: ChapterFile[] = [];
|
||||
|
||||
await logger?.info(`Metadata analysis: ${files.filter(f => f.trackNumber).length}/${files.length} files have track numbers`);
|
||||
|
||||
if (hasAllTrackNumbers) {
|
||||
metadataOrder = [...files].sort((a, b) => (a.trackNumber || 0) - (b.trackNumber || 0));
|
||||
|
||||
// Log track number range
|
||||
const trackNumbers = metadataOrder.map(f => f.trackNumber);
|
||||
await logger?.info(`Track numbers: ${trackNumbers.slice(0, 3).join(', ')}${trackNumbers.length > 3 ? ` ... ${trackNumbers[trackNumbers.length - 1]}` : ''}`);
|
||||
|
||||
// Check if track numbers are sequential
|
||||
const isSequential = metadataOrder.every((f, i) => {
|
||||
const expectedTrack = i + 1;
|
||||
@@ -280,26 +296,34 @@ export async function analyzeChapterFiles(
|
||||
const ordersMatch = filenameOrder.every((f, i) => f.path === metadataOrder[i].path);
|
||||
|
||||
if (ordersMatch) {
|
||||
await logger?.info('Chapter ordering: filename and metadata orders match - high confidence');
|
||||
await logger?.info('Chapter ordering: Filename and metadata orders match - high confidence');
|
||||
} else {
|
||||
await logger?.warn('Chapter ordering: filename order differs from metadata - using metadata order (more reliable)');
|
||||
await logger?.info('Chapter ordering: Filename differs from metadata - using metadata order (more reliable)');
|
||||
useMetadataOrder = true;
|
||||
}
|
||||
} else {
|
||||
await logger?.warn('Chapter ordering: metadata track numbers not sequential - using filename order');
|
||||
await logger?.warn('Chapter ordering: Track numbers not sequential (gaps or duplicates) - using filename order');
|
||||
}
|
||||
} else {
|
||||
await logger?.info('Chapter ordering: incomplete metadata track numbers - using filename order');
|
||||
const missingCount = files.filter(f => !f.trackNumber).length;
|
||||
await logger?.info(`Chapter ordering: ${missingCount} file(s) missing track numbers - using filename order`);
|
||||
}
|
||||
|
||||
// Use the determined order
|
||||
const orderedFiles = useMetadataOrder ? metadataOrder : filenameOrder;
|
||||
|
||||
// Log ordering decision summary
|
||||
await logger?.info(`Using ${useMetadataOrder ? 'metadata' : 'filename'}-based ordering for ${orderedFiles.length} chapters`);
|
||||
|
||||
// Compute chapter titles
|
||||
for (let i = 0; i < orderedFiles.length; i++) {
|
||||
orderedFiles[i].chapterTitle = getChapterTitle(orderedFiles[i], i);
|
||||
}
|
||||
|
||||
// Log sample chapter titles
|
||||
const sampleTitles = orderedFiles.slice(0, 3).map((f, i) => `Ch${i + 1}: "${f.chapterTitle}"`);
|
||||
await logger?.info(`Sample chapter titles: ${sampleTitles.join(', ')}${orderedFiles.length > 3 ? ', ...' : ''}`);
|
||||
|
||||
return orderedFiles;
|
||||
}
|
||||
|
||||
@@ -337,26 +361,133 @@ function generateChapterMetadata(chapters: ChapterFile[]): string {
|
||||
|
||||
/**
|
||||
* Determine optimal bitrate for MP3 conversion
|
||||
* Uses source bitrate if < 128kbps, otherwise 128k
|
||||
* Uses the average bitrate across all source files to preserve quality
|
||||
*/
|
||||
function determineOutputBitrate(chapters: ChapterFile[]): string {
|
||||
// Find minimum bitrate across all files
|
||||
// Get all bitrates
|
||||
const bitrates = chapters
|
||||
.filter(c => c.bitrate !== undefined)
|
||||
.map(c => c.bitrate as number);
|
||||
|
||||
if (bitrates.length === 0) {
|
||||
// No bitrate info available, use reasonable default
|
||||
return '128k';
|
||||
}
|
||||
|
||||
const minBitrate = Math.min(...bitrates);
|
||||
// Calculate average bitrate
|
||||
const avgBitrate = Math.round(bitrates.reduce((sum, br) => sum + br, 0) / bitrates.length);
|
||||
|
||||
// Use source bitrate if lower than 128k, otherwise cap at 128k
|
||||
if (minBitrate < 128) {
|
||||
return `${minBitrate}k`;
|
||||
// Cap at reasonable maximum (320k for MP3, which is max for most sources)
|
||||
const cappedBitrate = Math.min(avgBitrate, 320);
|
||||
|
||||
// Floor at reasonable minimum (64k for audiobooks)
|
||||
const finalBitrate = Math.max(cappedBitrate, 64);
|
||||
|
||||
return `${finalBitrate}k`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if libfdk_aac encoder is available (higher quality than native AAC)
|
||||
*/
|
||||
async function checkLibFdkAac(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execPromise('ffmpeg -encoders 2>&1', { timeout: 5000 });
|
||||
return stdout.includes('libfdk_aac');
|
||||
} catch {
|
||||
// ffmpeg not available or error checking - assume not available
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return '128k';
|
||||
/**
|
||||
* Execute FFmpeg command with real-time progress logging
|
||||
*/
|
||||
async function executeFFmpegWithProgress(
|
||||
command: string,
|
||||
timeout: number,
|
||||
expectedDuration: number, // milliseconds
|
||||
logger?: JobLogger
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Parse the command to extract args (remove 'ffmpeg' and handle quotes)
|
||||
const args = command
|
||||
.replace(/^ffmpeg\s+/, '')
|
||||
.match(/(?:[^\s"]+|"[^"]*")+/g)
|
||||
?.map(arg => arg.replace(/^"|"$/g, '')) || [];
|
||||
|
||||
const ffmpeg = spawn('ffmpeg', args);
|
||||
|
||||
let stderrBuffer = '';
|
||||
let lastProgressLog = Date.now();
|
||||
let lastProgressPercent = 0;
|
||||
|
||||
// Set timeout
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
ffmpeg.kill();
|
||||
reject(new Error(`FFmpeg timeout after ${Math.ceil(timeout / 60000)} minutes`));
|
||||
}, timeout);
|
||||
|
||||
// Capture stderr for progress and errors
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
stderrBuffer += output;
|
||||
|
||||
// Parse FFmpeg progress output
|
||||
// Format: frame=... fps=... q=... size=... time=HH:MM:SS.MS bitrate=... speed=...
|
||||
const timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
|
||||
|
||||
if (timeMatch) {
|
||||
const hours = parseInt(timeMatch[1]);
|
||||
const minutes = parseInt(timeMatch[2]);
|
||||
const seconds = parseInt(timeMatch[3]);
|
||||
const currentTimeMs = (hours * 3600 + minutes * 60 + seconds) * 1000;
|
||||
|
||||
const progressPercent = Math.min(100, Math.round((currentTimeMs / expectedDuration) * 100));
|
||||
|
||||
// Log progress every 10% or every 5 minutes (whichever comes first)
|
||||
const timeSinceLastLog = Date.now() - lastProgressLog;
|
||||
const percentChange = progressPercent - lastProgressPercent;
|
||||
|
||||
if (percentChange >= 10 || timeSinceLastLog >= 5 * 60 * 1000) {
|
||||
// Also parse speed if available
|
||||
const speedMatch = output.match(/speed=\s*([\d.]+)x/);
|
||||
const speed = speedMatch ? parseFloat(speedMatch[1]) : null;
|
||||
|
||||
const speedInfo = speed ? ` (${speed.toFixed(1)}x realtime)` : '';
|
||||
logger?.info(`Encoding progress: ${progressPercent}%${speedInfo} - ${formatDuration(currentTimeMs)} / ${formatDuration(expectedDuration)}`).catch(() => {});
|
||||
|
||||
lastProgressLog = Date.now();
|
||||
lastProgressPercent = progressPercent;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
|
||||
if (code === 0) {
|
||||
// Check stderr for errors even if exit code is 0
|
||||
if (stderrBuffer.includes('Error') || stderrBuffer.includes('Invalid')) {
|
||||
logger?.warn(`FFmpeg completed but reported issues: ${stderrBuffer.substring(stderrBuffer.lastIndexOf('Error'), stderrBuffer.lastIndexOf('Error') + 200)}`).catch(() => {});
|
||||
}
|
||||
resolve();
|
||||
} else {
|
||||
// Extract meaningful error from stderr
|
||||
const errorLines = stderrBuffer.split('\n').filter(line =>
|
||||
line.includes('Error') || line.includes('Invalid') || line.includes('failed')
|
||||
);
|
||||
const errorMsg = errorLines.length > 0
|
||||
? errorLines.slice(-3).join('; ')
|
||||
: `FFmpeg exited with code ${code}`;
|
||||
reject(new Error(errorMsg));
|
||||
}
|
||||
});
|
||||
|
||||
ffmpeg.on('error', (error) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -368,6 +499,7 @@ export async function mergeChapters(
|
||||
logger?: JobLogger
|
||||
): Promise<MergeResult> {
|
||||
if (chapters.length === 0) {
|
||||
await logger?.error('Chapter merge failed: No chapters provided');
|
||||
return { success: false, error: 'No chapters to merge' };
|
||||
}
|
||||
|
||||
@@ -376,6 +508,34 @@ export async function mergeChapters(
|
||||
const metadataFile = path.join(tempDir, `chapters_${Date.now()}.txt`);
|
||||
|
||||
try {
|
||||
await logger?.info(`Starting chapter merge: "${options.title}" by ${options.author}`);
|
||||
await logger?.info(`Output: ${path.basename(options.outputPath)}`);
|
||||
|
||||
// Calculate total duration and estimated size
|
||||
const totalDuration = chapters.reduce((sum, c) => sum + c.duration, 0);
|
||||
const estimatedSize = await estimateOutputSize(chapters.map(c => c.path));
|
||||
await logger?.info(`Total duration: ${formatDuration(totalDuration)}, Estimated size: ${Math.round(estimatedSize / 1024 / 1024)}MB`);
|
||||
|
||||
// Validate all source files are readable and not corrupt
|
||||
await logger?.info('Validating source files...');
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
const chapter = chapters[i];
|
||||
try {
|
||||
await fs.access(chapter.path, fs.constants.R_OK);
|
||||
|
||||
// Quick probe to verify file is valid (use cached data if available)
|
||||
// This catches obviously corrupt source files before we try to merge
|
||||
const stats = await fs.stat(chapter.path);
|
||||
if (stats.size === 0) {
|
||||
throw new Error(`File ${i + 1}/${chapters.length} (${path.basename(chapter.path)}) is empty (0 bytes)`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error(`Source file validation failed at file ${i + 1}/${chapters.length} (${path.basename(chapter.path)}): ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
await logger?.info(`✓ All ${chapters.length} source files validated`);
|
||||
|
||||
// Ensure temp directory exists
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
@@ -384,10 +544,12 @@ export async function mergeChapters(
|
||||
.map(c => `file '${c.path.replace(/'/g, "'\\''")}'`)
|
||||
.join('\n');
|
||||
await fs.writeFile(concatFile, concatContent);
|
||||
await logger?.info(`Created concat list with ${chapters.length} files`);
|
||||
|
||||
// Create chapter metadata file
|
||||
const chapterMetadata = generateChapterMetadata(chapters);
|
||||
await fs.writeFile(metadataFile, chapterMetadata);
|
||||
await logger?.info(`Generated chapter metadata with ${chapters.length} chapter markers`);
|
||||
|
||||
// Determine if we need to re-encode (MP3 input requires conversion to AAC)
|
||||
const inputFormat = path.extname(chapters[0].path).toLowerCase();
|
||||
@@ -402,19 +564,38 @@ export async function mergeChapters(
|
||||
'-i', `"${concatFile}"`,
|
||||
'-i', `"${metadataFile}"`,
|
||||
'-map_metadata', '1',
|
||||
'-map', '0:a', // Explicit audio stream mapping
|
||||
];
|
||||
|
||||
if (needsReencode) {
|
||||
// MP3 -> M4B requires re-encoding to AAC
|
||||
const bitrate = determineOutputBitrate(chapters);
|
||||
args.push('-codec:a', 'aac', '-b:a', bitrate);
|
||||
await logger?.info(`Re-encoding MP3 to AAC at ${bitrate}`);
|
||||
|
||||
// Check for libfdk_aac (higher quality) or fall back to native aac
|
||||
const hasFdkAac = await checkLibFdkAac();
|
||||
|
||||
if (hasFdkAac) {
|
||||
args.push('-c:a', 'libfdk_aac');
|
||||
args.push('-vbr', '4'); // VBR mode 4 (~128-160kbps, high quality)
|
||||
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`);
|
||||
} else {
|
||||
args.push('-c:a', 'aac');
|
||||
args.push('-b:a', bitrate);
|
||||
args.push('-profile:a', 'aac_low'); // AAC-LC profile for maximum compatibility
|
||||
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using native AAC at ${bitrate}`);
|
||||
}
|
||||
} else {
|
||||
// M4A/M4B -> M4B can use codec copy (fast, lossless)
|
||||
args.push('-codec', 'copy');
|
||||
await logger?.info('Using codec copy (no re-encoding)');
|
||||
args.push('-c', 'copy');
|
||||
await logger?.info(`Merge strategy: Codec copy (lossless, fast - no re-encoding needed for ${inputFormat} input)`);
|
||||
}
|
||||
|
||||
// Add critical flags for reliability and performance
|
||||
args.push('-movflags', '+faststart'); // CRITICAL: Move moov atom to beginning (fixes slow playback)
|
||||
args.push('-fflags', '+genpts'); // Regenerate presentation timestamps (fixes timing issues)
|
||||
args.push('-avoid_negative_ts', 'make_zero'); // Handle negative timestamps
|
||||
args.push('-max_muxing_queue_size', '9999'); // Prevent buffer overflow on long files
|
||||
|
||||
// Add book metadata
|
||||
const escapeMetadata = (val: string): string =>
|
||||
val.replace(/"/g, '\\"').replace(/'/g, "\\'");
|
||||
@@ -435,6 +616,7 @@ export async function mergeChapters(
|
||||
if (options.asin) {
|
||||
// Custom iTunes tag for ASIN
|
||||
args.push('-metadata', `----:com.apple.iTunes:ASIN="${escapeMetadata(options.asin)}"`);
|
||||
await logger?.info(`Embedding ASIN: ${options.asin}`);
|
||||
}
|
||||
|
||||
// Output format
|
||||
@@ -443,15 +625,36 @@ export async function mergeChapters(
|
||||
|
||||
const command = args.join(' ');
|
||||
|
||||
// Calculate timeout: base 5 minutes + 30 seconds per chapter
|
||||
const timeout = (5 * 60 * 1000) + (chapters.length * 30 * 1000);
|
||||
// Calculate timeout based on operation type and total duration
|
||||
const totalDurationMinutes = totalDuration / 1000 / 60;
|
||||
|
||||
await logger?.info(`Merging ${chapters.length} chapters...`);
|
||||
const timeout = needsReencode
|
||||
? Math.max(
|
||||
90 * 60 * 1000, // Minimum 90 minutes for re-encoding
|
||||
Math.round((totalDurationMinutes / 5) * 60 * 1000) + (60 * 60 * 1000) // duration/5 (worst case 5x realtime) + 60min safety margin
|
||||
)
|
||||
: (5 * 60 * 1000) + (chapters.length * 30 * 1000); // Codec copy: 5min + 30s per chapter
|
||||
|
||||
const timeoutMinutes = Math.ceil(timeout / 60000);
|
||||
|
||||
await logger?.info(`Executing FFmpeg merge (timeout: ${timeoutMinutes} minutes)...`);
|
||||
|
||||
if (needsReencode && totalDurationMinutes > 60) {
|
||||
const estimatedMinEncoding = Math.round(totalDurationMinutes / 10); // Best case: 10x realtime
|
||||
const estimatedMaxEncoding = Math.round(totalDurationMinutes / 5); // Worst case: 5x realtime
|
||||
await logger?.info(`This is a long audiobook (${Math.round(totalDurationMinutes / 60)}h). Encoding may take ${estimatedMinEncoding}-${estimatedMaxEncoding} minutes depending on CPU speed.`);
|
||||
}
|
||||
|
||||
// Log command for debugging (truncate if too long)
|
||||
const commandPreview = command.length > 500 ? command.substring(0, 500) + '...' : command;
|
||||
await logger?.info(`FFmpeg command: ${commandPreview}`);
|
||||
|
||||
// Execute FFmpeg with progress logging
|
||||
try {
|
||||
await execPromise(command, { timeout });
|
||||
await executeFFmpegWithProgress(command, timeout, totalDuration, logger);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
await logger?.error(`FFmpeg merge failed: ${errorMsg}`);
|
||||
throw new Error(`FFmpeg merge failed: ${errorMsg}`);
|
||||
}
|
||||
|
||||
@@ -459,13 +662,35 @@ export async function mergeChapters(
|
||||
try {
|
||||
await fs.access(options.outputPath);
|
||||
} catch {
|
||||
await logger?.error('Merge failed: Output file not created');
|
||||
throw new Error('Merged file not created');
|
||||
}
|
||||
|
||||
// Calculate total duration
|
||||
const totalDuration = chapters.reduce((sum, c) => sum + c.duration, 0);
|
||||
// Validate merged file
|
||||
const validation = await validateMergedFile(options.outputPath, totalDuration, logger);
|
||||
|
||||
await logger?.info(`Merge complete: ${chapters.length} chapters, ${formatDuration(totalDuration)}`);
|
||||
if (!validation.valid) {
|
||||
await logger?.error(`Output validation failed: ${validation.error}`);
|
||||
// Delete corrupt file
|
||||
try {
|
||||
await fs.unlink(options.outputPath);
|
||||
await logger?.info('Deleted corrupt output file');
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw new Error(`Merge validation failed: ${validation.error}`);
|
||||
}
|
||||
|
||||
// Get actual output file size
|
||||
const stats = await fs.stat(options.outputPath);
|
||||
const actualSizeMB = Math.round(stats.size / 1024 / 1024);
|
||||
|
||||
await logger?.info(`✓ Chapter merge successful!`);
|
||||
await logger?.info(` - Chapters: ${chapters.length}`);
|
||||
await logger?.info(` - Duration: ${formatDuration(validation.actualDuration || totalDuration)}`);
|
||||
await logger?.info(` - Size: ${actualSizeMB}MB`);
|
||||
await logger?.info(` - Format: M4B with embedded chapter markers`);
|
||||
await logger?.info(` - Validation: Passed (duration accurate, file playable)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -475,22 +700,104 @@ export async function mergeChapters(
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
await logger?.error(`Chapter merge failed: ${errorMsg}`);
|
||||
return { success: false, error: errorMsg };
|
||||
} finally {
|
||||
// Clean up temp files
|
||||
try {
|
||||
await fs.unlink(concatFile);
|
||||
await logger?.info('Cleaned up temporary concat file');
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
try {
|
||||
await fs.unlink(metadataFile);
|
||||
await logger?.info('Cleaned up temporary metadata file');
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate merged M4B file
|
||||
* Checks duration accuracy and playability to catch corruption
|
||||
*/
|
||||
async function validateMergedFile(
|
||||
outputPath: string,
|
||||
expectedDuration: number, // milliseconds
|
||||
logger?: JobLogger
|
||||
): Promise<{ valid: boolean; error?: string; actualDuration?: number }> {
|
||||
try {
|
||||
await logger?.info('Validating merged file...');
|
||||
|
||||
// 1. Probe output file to get actual duration
|
||||
const probe = await probeAudioFile(outputPath);
|
||||
const actualDuration = probe.duration;
|
||||
|
||||
await logger?.info(`Duration check: expected ${formatDuration(expectedDuration)}, got ${formatDuration(actualDuration)}`);
|
||||
|
||||
// 2. Check duration match (within 2% tolerance for encoding variations)
|
||||
const durationDiff = Math.abs(actualDuration - expectedDuration);
|
||||
const tolerance = expectedDuration * 0.02; // 2% tolerance
|
||||
|
||||
if (durationDiff > tolerance) {
|
||||
const percentDiff = ((durationDiff / expectedDuration) * 100).toFixed(1);
|
||||
return {
|
||||
valid: false,
|
||||
error: `Duration mismatch (${percentDiff}% off): expected ${formatDuration(expectedDuration)}, got ${formatDuration(actualDuration)}. File may be truncated or corrupted.`,
|
||||
actualDuration
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Fast decode test - verify beginning and end of file are playable
|
||||
// This catches truncation/corruption without decoding entire file
|
||||
await logger?.info('Testing file integrity (first and last 10 seconds)...');
|
||||
|
||||
try {
|
||||
// Test first 10 seconds
|
||||
const firstDecodeCommand = `ffmpeg -v error -i "${outputPath}" -t 10 -f null -`;
|
||||
await execPromise(firstDecodeCommand, { timeout: 30000 }); // 30 sec timeout
|
||||
|
||||
// Test last 10 seconds (seeks to 10 seconds before end)
|
||||
const lastDecodeCommand = `ffmpeg -v error -sseof -10 -i "${outputPath}" -f null -`;
|
||||
await execPromise(lastDecodeCommand, { timeout: 30000 }); // 30 sec timeout
|
||||
|
||||
await logger?.info('✓ File integrity test passed (beginning and end playable)');
|
||||
} catch (decodeError) {
|
||||
const errorMsg = decodeError instanceof Error ? decodeError.message : 'Unknown error';
|
||||
return {
|
||||
valid: false,
|
||||
error: `File integrity test failed: ${errorMsg}. File may be corrupted or truncated.`,
|
||||
actualDuration
|
||||
};
|
||||
}
|
||||
|
||||
// 4. File size sanity check
|
||||
const stats = await fs.stat(outputPath);
|
||||
const sizeMB = stats.size / 1024 / 1024;
|
||||
const durationMinutes = expectedDuration / 1000 / 60;
|
||||
const expectedMinSize = durationMinutes * 0.5; // ~0.5MB per minute minimum for compressed audio
|
||||
|
||||
if (sizeMB < expectedMinSize) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `File size too small (${Math.round(sizeMB)}MB) for ${formatDuration(expectedDuration)} duration. Expected at least ${Math.round(expectedMinSize)}MB. File may be truncated.`,
|
||||
actualDuration
|
||||
};
|
||||
}
|
||||
|
||||
await logger?.info(`✓ Validation passed: duration ${formatDuration(actualDuration)}, size ${Math.round(sizeMB)}MB`);
|
||||
|
||||
return { valid: true, actualDuration };
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human readable string
|
||||
*/
|
||||
|
||||
@@ -96,6 +96,8 @@ export class FileOrganizer {
|
||||
|
||||
// Check for chapter merging if multiple files
|
||||
if (audioFiles.length > 1) {
|
||||
await logger?.info(`Multiple audio files detected (${audioFiles.length} files) - checking chapter merge settings...`);
|
||||
|
||||
try {
|
||||
const chapterMergingConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'chapter_merging_enabled' },
|
||||
@@ -103,72 +105,88 @@ export class FileOrganizer {
|
||||
|
||||
const chapterMergingEnabled = chapterMergingConfig?.value === 'true';
|
||||
|
||||
if (chapterMergingEnabled) {
|
||||
if (!chapterMergingEnabled) {
|
||||
await logger?.info(`Chapter merging disabled in settings - organizing ${audioFiles.length} files individually`);
|
||||
} else {
|
||||
await logger?.info(`Chapter merging enabled - analyzing files...`);
|
||||
|
||||
// Build full paths to source files
|
||||
const sourceFilePaths = audioFiles.map((audioFile) =>
|
||||
isFile ? downloadPath : path.join(downloadPath, audioFile)
|
||||
);
|
||||
|
||||
const isChapterDownload = await detectChapterFiles(sourceFilePaths);
|
||||
const isChapterDownload = await detectChapterFiles(sourceFilePaths, logger ?? undefined);
|
||||
|
||||
if (isChapterDownload) {
|
||||
await logger?.info(`Detected ${audioFiles.length} chapter files, attempting merge...`);
|
||||
|
||||
// Check disk space
|
||||
const estimatedSize = await estimateOutputSize(sourceFilePaths);
|
||||
const availableSpace = await checkDiskSpace(this.tempDir);
|
||||
|
||||
if (availableSpace !== null && availableSpace < estimatedSize) {
|
||||
await logger?.warn(`Insufficient disk space for merge (need ${Math.round(estimatedSize / 1024 / 1024)}MB, have ${Math.round(availableSpace / 1024 / 1024)}MB). Skipping merge.`);
|
||||
await logger?.warn(`Insufficient disk space for merge (need ${Math.round(estimatedSize / 1024 / 1024)}MB, have ${Math.round(availableSpace / 1024 / 1024)}MB). Organizing files individually.`);
|
||||
} else {
|
||||
// Log disk space check passed
|
||||
if (availableSpace !== null) {
|
||||
await logger?.info(`Disk space check passed: ${Math.round(availableSpace / 1024 / 1024)}MB available, ${Math.round(estimatedSize / 1024 / 1024)}MB needed`);
|
||||
}
|
||||
|
||||
// Analyze and order chapter files
|
||||
const chapters = await analyzeChapterFiles(sourceFilePaths, logger ?? undefined);
|
||||
|
||||
// Create output path in temp directory
|
||||
const outputFilename = `${this.sanitizePath(audiobook.title)}.m4b`;
|
||||
const outputPath = path.join(this.tempDir, outputFilename);
|
||||
// Validate that we have valid ordering
|
||||
if (chapters.length === 0) {
|
||||
await logger?.warn(`Chapter analysis failed: No valid chapters found. Organizing files individually.`);
|
||||
} else {
|
||||
// Create output path in temp directory
|
||||
const outputFilename = `${this.sanitizePath(audiobook.title)}.m4b`;
|
||||
const outputPath = path.join(this.tempDir, outputFilename);
|
||||
|
||||
// Perform merge
|
||||
const mergeResult = await mergeChapters(
|
||||
chapters,
|
||||
{
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
narrator: audiobook.narrator,
|
||||
year: audiobook.year,
|
||||
asin: audiobook.asin,
|
||||
outputPath,
|
||||
},
|
||||
logger ?? undefined
|
||||
);
|
||||
|
||||
if (mergeResult.success && mergeResult.outputPath) {
|
||||
await logger?.info(
|
||||
`Merge successful: ${mergeResult.chapterCount} chapters, ${formatDuration(mergeResult.totalDuration || 0)}`
|
||||
// Perform merge
|
||||
const mergeResult = await mergeChapters(
|
||||
chapters,
|
||||
{
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
narrator: audiobook.narrator,
|
||||
year: audiobook.year,
|
||||
asin: audiobook.asin,
|
||||
outputPath,
|
||||
},
|
||||
logger ?? undefined
|
||||
);
|
||||
|
||||
// Replace audioFiles array with single merged file
|
||||
audioFiles.length = 0;
|
||||
audioFiles.push(mergeResult.outputPath);
|
||||
if (mergeResult.success && mergeResult.outputPath) {
|
||||
// Replace audioFiles array with single merged file
|
||||
audioFiles.length = 0;
|
||||
audioFiles.push(mergeResult.outputPath);
|
||||
|
||||
// Mark for cleanup after copy
|
||||
tempMergedFile = mergeResult.outputPath;
|
||||
// Mark for cleanup after copy
|
||||
tempMergedFile = mergeResult.outputPath;
|
||||
|
||||
// Update isFile flag since we now have a single file path
|
||||
// (not in the download directory structure)
|
||||
} else {
|
||||
await logger?.warn(`Chapter merge failed: ${mergeResult.error}. Falling back to individual files.`);
|
||||
result.errors.push(`Chapter merge failed: ${mergeResult.error}`);
|
||||
// Continue with original audioFiles array
|
||||
await logger?.info(`Chapter merge complete - organizing single M4B file`);
|
||||
|
||||
// Update isFile flag since we now have a single file path
|
||||
// (not in the download directory structure)
|
||||
} else {
|
||||
await logger?.warn(`Chapter merge failed: ${mergeResult.error}. Organizing ${audioFiles.length} files individually.`);
|
||||
result.errors.push(`Chapter merge failed: ${mergeResult.error}`);
|
||||
// Continue with original audioFiles array
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// detectChapterFiles already logged the reason for skipping
|
||||
await logger?.info(`Organizing ${audioFiles.length} files individually`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await logger?.error(`Chapter merging error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
result.errors.push(`Chapter merging error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
await logger?.warn(`Falling back to organizing ${audioFiles.length} files individually`);
|
||||
// Continue with original audioFiles array
|
||||
}
|
||||
} else {
|
||||
await logger?.info(`Single audio file detected - no chapter merging needed`);
|
||||
}
|
||||
|
||||
// Tag metadata BEFORE moving files (prevents Plex race condition)
|
||||
|
||||
Reference in New Issue
Block a user