Add e-book sidecar integration and improve request handling

Introduces optional e-book sidecar downloads from Anna's Archive, including admin UI, settings API, FlareSolverr integration, and documentation. Enhances request creation logic to prevent duplicate downloads by checking for 'downloaded' and 'available' statuses, updates UI to reflect processing state, and adds SABnzbd support to download and cleanup flows. Also updates ranking algorithm documentation and improves cache invalidation for recent requests.
This commit is contained in:
kikootwo
2026-01-07 17:19:42 -05:00
parent 24ea53bd2f
commit 95c25ff73a
26 changed files with 1968 additions and 116 deletions
+5
View File
@@ -22,6 +22,7 @@ export interface AudiobookMatchInput {
export interface AudiobookMatchResult {
plexGuid: string;
plexRatingKey: string | null;
title: string;
author: string;
}
@@ -82,6 +83,7 @@ export async function findPlexMatch(
},
select: {
plexGuid: true,
plexRatingKey: true,
title: true,
author: true,
asin: true, // Include ASIN field for direct matching
@@ -297,6 +299,9 @@ export async function enrichAudiobooksWithMatches(
id: true,
audibleAsin: true,
requests: {
where: {
deletedAt: null, // Only include active (non-deleted) requests
},
select: {
id: true,
status: true,
+51
View File
@@ -9,6 +9,7 @@ import axios from 'axios';
import { createJobLogger, JobLogger } from './job-logger';
import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger';
import { prisma } from '../db';
import { downloadEbook } from '../services/ebook-scraper';
export interface AudiobookMetadata {
title: string;
@@ -261,6 +262,56 @@ export class FileOrganizer {
}
}
// E-book sidecar: Download accompanying e-book if enabled
try {
const ebookConfig = await prisma.configuration.findUnique({
where: { key: 'ebook_sidecar_enabled' },
});
const ebookEnabled = ebookConfig?.value === 'true';
if (ebookEnabled) {
await logger?.info(`E-book sidecar enabled, searching for e-book...`);
// Get configuration
const [formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_preferred_format' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_base_url' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_flaresolverr_url' } }),
]);
const preferredFormat = formatConfig?.value || 'epub';
const baseUrl = baseUrlConfig?.value || 'https://annas-archive.li';
const flaresolverrUrl = flaresolverrConfig?.value || undefined;
// Download e-book (will try ASIN first, then fall back to title+author)
const ebookResult = await downloadEbook(
audiobook.asin || '', // ASIN (optional - will fallback to title+author if empty)
audiobook.title,
audiobook.author,
targetPath, // Same directory as audiobook
preferredFormat,
baseUrl,
logger ?? undefined,
flaresolverrUrl
);
if (ebookResult.success && ebookResult.filePath) {
await logger?.info(`E-book downloaded: ${path.basename(ebookResult.filePath)}`);
result.filesMovedCount++;
} else {
await logger?.warn(`E-book download failed: ${ebookResult.error}`);
result.errors.push(`E-book sidecar: ${ebookResult.error}`);
}
}
} catch (error) {
await logger?.warn(
`E-book sidecar error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
result.errors.push('E-book sidecar failed');
// Don't throw - audiobook organization continues
}
result.targetPath = targetPath;
result.success = true;
+26 -3
View File
@@ -350,14 +350,37 @@ export class RankingAlgorithm {
const beforeWords = extractWords(beforeTitle, stopWords);
// Title is complete if:
// 1. No significant words before it (not "This Inevitable Ruin" + "Dungeon Crawler Carl")
// 1. Acceptable prefix (no words, OR structured metadata like "Author - Series - ")
// 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching")
const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ','];
const hasNoWordsPrefix = beforeWords.length === 0;
const hasMetadataSuffix = afterTitle === '' ||
metadataMarkers.some(marker => afterTitle.startsWith(marker));
const isCompleteTitle = hasNoWordsPrefix && hasMetadataSuffix;
// Check prefix validity:
// - No words before = clean match
// - Title preceded by separator (` - `, `: `) = structured metadata (Author - Series - Title)
// - Author name in prefix = author attribution before title
const hasNoWordsPrefix = beforeWords.length === 0;
// Check if title is immediately preceded by a metadata separator
// This handles "Author - Series - 01 - Title" patterns
const precedingText = beforeTitle.trimEnd();
const titlePrecededBySeparator =
precedingText.endsWith('-') ||
precedingText.endsWith(':') ||
precedingText.endsWith('—');
// Check if author name appears in the prefix
// This handles "Author Name - Title" patterns
const authorInPrefix = requestAuthor.length > 2 &&
beforeTitle.includes(requestAuthor);
const hasAcceptablePrefix =
hasNoWordsPrefix ||
titlePrecededBySeparator ||
authorInPrefix;
const isCompleteTitle = hasAcceptablePrefix && hasMetadataSuffix;
if (isCompleteTitle) {
// Complete title match → full points