Add ebook-sidecar APIs and UI integration

Introduce ebook-sidecar support: add new API routes for ebook workflows (ebook-status, fetch-ebook, interactive-search-ebook, select-ebook) that handle searching, selection, request creation, approval, and download routing (Anna's Archive direct downloads vs indexer downloads).

Update admin approval flow to understand request.type (audiobook | ebook), handle pre-selected ebook torrents (including special handling for Anna's Archive with direct download jobs and download history), and enqueue ebook-specific search/download jobs.

Frontend changes: show request type badge in admin pending approvals and augment AudiobookDetailsModal to query ebook status, start fetch/interactive ebook searches, and surface toast notifications. Also include new request lifecycle handling (retryable/active statuses, approval logic, creating audiobook records for Plex-imported books) and ranking/normalization logic for interactive ebook search results.

Other: various plumbing to integrate config checks, job queue calls, and download history storage for ebook downloads.
This commit is contained in:
kikootwo
2026-02-03 03:05:23 -05:00
parent a17473e204
commit ff07ccfdb0
10 changed files with 1858 additions and 47 deletions
+159
View File
@@ -482,3 +482,162 @@ export function useSelectEbook() {
return { selectEbook, isLoading, error };
}
// ==================== ASIN-based Ebook Hooks ====================
// These hooks are used for requesting ebooks from the audiobook details modal
// where we only have an ASIN, not an existing request ID
export interface EbookStatus {
ebookSourcesEnabled: boolean;
hasActiveEbookRequest: boolean;
existingEbookStatus: string | null;
existingEbookRequestId: string | null;
}
export function useEbookStatus(asin: string | null) {
const { accessToken } = useAuth();
const endpoint = accessToken && asin ? `/api/audiobooks/${asin}/ebook-status` : null;
const { data, error, isLoading, mutate: revalidate } = useSWR<EbookStatus>(
endpoint,
fetcher,
{
refreshInterval: 10000, // Refresh every 10 seconds
}
);
return {
ebookStatus: data || null,
isLoading,
error,
revalidate,
};
}
export function useFetchEbookByAsin() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchEbook = async (asin: string) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/audiobooks/${asin}/fetch-ebook`, {
method: 'POST',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to request ebook');
}
// Revalidate requests and ebook status
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
return data;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { fetchEbook, isLoading, error };
}
export function useInteractiveSearchEbookByAsin() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const searchEbooks = async (asin: string, customTitle?: string) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/audiobooks/${asin}/interactive-search-ebook`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: customTitle ? JSON.stringify({ customTitle }) : undefined,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to search for ebooks');
}
return data.results || [];
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { searchEbooks, isLoading, error };
}
export function useSelectEbookByAsin() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectEbook = async (asin: string, ebook: any) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/audiobooks/${asin}/select-ebook`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ebook }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to download ebook');
}
// Revalidate requests and ebook status
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
return data;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { selectEbook, isLoading, error };
}
+2 -1
View File
@@ -168,7 +168,7 @@ export async function enrichAudiobooksWithMatches(
// Always enrich with request status (check ANY user's requests)
const asins = audiobooks.map(book => book.asin);
// Get all audiobook records for these ASINs with ALL requests
// Get all audiobook records for these ASINs with ALL audiobook requests (not ebook requests)
const audiobookRecords = await prisma.audiobook.findMany({
where: {
audibleAsin: { in: asins },
@@ -179,6 +179,7 @@ export async function enrichAudiobooksWithMatches(
requests: {
where: {
deletedAt: null, // Only include active (non-deleted) requests
type: 'audiobook', // Only check audiobook requests, not ebook requests
},
select: {
id: true,