diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md
index bb677cb..0d87a7c 100644
--- a/documentation/TABLEOFCONTENTS.md
+++ b/documentation/TABLEOFCONTENTS.md
@@ -48,7 +48,7 @@
- **qBittorrent integration (torrents)** → [phase3/qbittorrent.md](phase3/qbittorrent.md)
- **SABnzbd integration (Usenet/NZB)** → [phase3/sabnzbd.md](phase3/sabnzbd.md)
- **File organization, seeding** → [phase3/file-organization.md](phase3/file-organization.md)
-- **Chapter merging (PRD, not implemented)** → [features/chapter-merging.md](features/chapter-merging.md)
+- **Chapter merging (auto-merge to M4B)** → [features/chapter-merging.md](features/chapter-merging.md)
## Background Jobs
- **Bull queue, processors, retry logic** → [backend/services/jobs.md](backend/services/jobs.md)
@@ -93,7 +93,7 @@
**"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md)
**"OAuth redirects to localhost / PUBLIC_URL not working"** → [backend/services/environment.md](backend/services/environment.md)
**"What environment variables do I need?"** → [backend/services/environment.md](backend/services/environment.md)
-**"How does chapter merging work?"** → [features/chapter-merging.md](features/chapter-merging.md) (PRD only, not implemented)
+**"How does chapter merging work?"** → [features/chapter-merging.md](features/chapter-merging.md)
**"How does Audiobookshelf integration work?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
**"How do I use OIDC/Authentik/Keycloak?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
**"How does manual user registration work?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
diff --git a/documentation/features/chapter-merging.md b/documentation/features/chapter-merging.md
index b083419..c344575 100644
--- a/documentation/features/chapter-merging.md
+++ b/documentation/features/chapter-merging.md
@@ -1,6 +1,6 @@
# Chapter Merging Feature
-**Status:** ❌ Not Started | Product Requirements Document
+**Status:** ✅ Implemented | Auto-merge multi-file chapters to M4B
## Overview
diff --git a/documentation/phase3/ranking-algorithm.md b/documentation/phase3/ranking-algorithm.md
index 5d3edc5..565bed2 100644
--- a/documentation/phase3/ranking-algorithm.md
+++ b/documentation/phase3/ranking-algorithm.md
@@ -6,7 +6,7 @@ Evaluates and scores torrents to automatically select best audiobook download.
## Scoring Criteria (100 points max)
-**1. Title/Author Match (50 pts max) - MOST IMPORTANT**
+**1. Title/Author Match (60 pts max) - MOST IMPORTANT**
**Multi-Stage Matching:**
@@ -24,7 +24,7 @@ Evaluates and scores torrents to automatically select best audiobook download.
- "Dennis E. Taylor - Bobiverse - 01 - We Are Legion" → 3/3 = 100% → **PASSES**
- Prevents wrong series books from matching while handling common subtitle patterns
-**Stage 2: Title Matching (0-35 pts)**
+**Stage 2: Title Matching (0-45 pts)**
- Only scored if Stage 1 passes
- **Tries full title first, then required title (without parentheses)** if no match
- Example: "We Are Legion (We Are Bob)" tries both full title and "We Are Legion"
@@ -35,7 +35,7 @@ Evaluates and scores torrents to automatically select best audiobook download.
- Title preceded by metadata separator (` - `, `: `, `—`) — handles "Author - Series - 01 - Title"
- Author name appears in prefix — handles "Author Name - Title"
- **Acceptable suffix**: Followed by metadata markers: " by", " [", " -", " (", " {", " :", "," or end of string
-- Complete match → 35 pts
+- Complete match → 45 pts
- Unstructured prefix (words without separators) → fuzzy similarity (partial credit)
- Prevents: "This Inevitable Ruin Dungeon Crawler Carl" matching "Dungeon Crawler Carl"
- Suffix continues with non-metadata → fuzzy similarity (partial credit)
@@ -61,12 +61,7 @@ Evaluates and scores torrents to automatically select best audiobook download.
**3. Seeder Count (15 pts max)**
- Formula: `Math.min(15, Math.log10(seeders + 1) * 6)`
- 1 seeder: 0pts, 10 seeders: 6pts, 100 seeders: 12pts, 1000+: 15pts
-
-**4. Size Reasonableness (10 pts max)**
-- Expected: 1-2 MB/min (64-128 kbps)
-- Perfect match: 10 pts
-- Deviation → penalty
-- Unknown duration: 5 pts (neutral)
+- Note: Usenet/NZB results without seeders get full 15 pts (centralized availability)
## Bonus Points System
@@ -148,7 +143,6 @@ interface RankedTorrent extends TorrentResult {
breakdown: {
formatScore: number;
seederScore: number;
- sizeScore: number;
matchScore: number;
totalScore: number; // Same as score
notes: string[];
diff --git a/prisma/migrations/20260108120000_add_chapter_merging_config/migration.sql b/prisma/migrations/20260108120000_add_chapter_merging_config/migration.sql
new file mode 100644
index 0000000..be20ab9
--- /dev/null
+++ b/prisma/migrations/20260108120000_add_chapter_merging_config/migration.sql
@@ -0,0 +1,16 @@
+-- Add chapter merging configuration
+-- This allows admin to enable/disable automatic merging of multi-file chapter downloads into single M4B
+
+-- Insert default configuration for chapter merging (disabled by default)
+INSERT INTO configuration (id, key, value, encrypted, category, description, created_at, updated_at)
+VALUES (
+ gen_random_uuid(),
+ 'chapter_merging_enabled',
+ 'false',
+ false,
+ 'automation',
+ 'Automatically merge multi-file chapter downloads into a single M4B audiobook with chapter markers. Improves playback experience and library organization.',
+ NOW(),
+ NOW()
+)
+ON CONFLICT (key) DO NOTHING;
diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx
index 0dd85dc..e938a87 100644
--- a/src/app/admin/settings/page.tsx
+++ b/src/app/admin/settings/page.tsx
@@ -81,6 +81,7 @@ interface Settings {
downloadDir: string;
mediaDir: string;
metadataTaggingEnabled: boolean;
+ chapterMergingEnabled: boolean;
};
ebook: {
enabled: boolean;
@@ -1974,6 +1975,37 @@ export default function AdminSettings() {
+ {/* Chapter Merging Toggle */}
+
+
+
{
+ setSettings({
+ ...settings,
+ paths: { ...settings.paths, chapterMergingEnabled: e.target.checked },
+ });
+ setValidated({ ...validated, paths: false });
+ }}
+ className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
+ />
+
+
+ Auto-merge chapters to M4B
+
+
+ Automatically merge multi-file chapter downloads into a single M4B audiobook with chapter
+ markers. Improves playback experience and library organization.
+
+
+
+
+
{
return requireAdmin(req, async () => {
try {
- const { downloadDir, mediaDir, metadataTaggingEnabled } = await request.json();
+ const { downloadDir, mediaDir, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -53,6 +53,18 @@ export async function PUT(request: NextRequest) {
},
});
+ // Update chapter merging setting
+ await prisma.configuration.upsert({
+ where: { key: 'chapter_merging_enabled' },
+ update: { value: String(chapterMergingEnabled ?? false) },
+ create: {
+ key: 'chapter_merging_enabled',
+ value: String(chapterMergingEnabled ?? false),
+ category: 'automation',
+ description: 'Automatically merge multi-file chapter downloads into single M4B with chapter markers',
+ },
+ });
+
console.log('[Admin] Paths settings updated');
// Invalidate qBittorrent service singleton to force reload of download_dir
diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts
index 63611aa..8e01e8e 100644
--- a/src/app/api/admin/settings/route.ts
+++ b/src/app/api/admin/settings/route.ts
@@ -81,6 +81,7 @@ export async function GET(request: NextRequest) {
downloadDir: configMap.get('download_dir') || '/downloads',
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
+ chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
},
ebook: {
enabled: configMap.get('ebook_sidecar_enabled') === 'true',
diff --git a/src/app/api/audiobooks/request-with-torrent/route.ts b/src/app/api/audiobooks/request-with-torrent/route.ts
index 5bdecbd..f459a59 100644
--- a/src/app/api/audiobooks/request-with-torrent/route.ts
+++ b/src/app/api/audiobooks/request-with-torrent/route.ts
@@ -28,8 +28,8 @@ const RequestWithTorrentSchema = z.object({
guid: z.string(),
title: z.string(),
size: z.number(),
- seeders: z.number(),
- leechers: z.number(),
+ seeders: z.number().optional(), // Optional for NZB/Usenet results
+ leechers: z.number().optional(), // Optional for NZB/Usenet results
indexer: z.string(),
downloadUrl: z.string(),
publishDate: z.string().transform((str) => new Date(str)),
diff --git a/src/app/api/audiobooks/search-torrents/route.ts b/src/app/api/audiobooks/search-torrents/route.ts
index c8d9487..0fba954 100644
--- a/src/app/api/audiobooks/search-torrents/route.ts
+++ b/src/app/api/audiobooks/search-torrents/route.ts
@@ -88,39 +88,26 @@ export async function POST(request: NextRequest) {
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
const rankedResults = rankTorrents(results, { title, author }, indexerPriorities, flagConfigs);
- // Dual threshold filtering:
- // 1. Base score must be >= 50 (quality minimum)
- // 2. Final score must be >= 50 (not disqualified by negative bonuses)
- const filteredResults = rankedResults.filter(result =>
- result.score >= 50 && result.finalScore >= 50
- );
-
- const disqualifiedByNegativeBonus = rankedResults.filter(result =>
- result.score >= 50 && result.finalScore < 50
- ).length;
-
- console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`);
- if (disqualifiedByNegativeBonus > 0) {
- console.log(`[AudiobookSearch] ${disqualifiedByNegativeBonus} torrents disqualified by negative flag bonuses`);
- }
+ // No threshold filtering - show all results like interactive search
+ // User can see scores and make their own decision
+ console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results (no threshold filter - user decides)`);
// Log top 3 results with detailed score breakdown for debugging
- const top3 = filteredResults.slice(0, 3);
+ const top3 = rankedResults.slice(0, 3);
if (top3.length > 0) {
console.log(`[AudiobookSearch] ==================== RANKING DEBUG ====================`);
console.log(`[AudiobookSearch] Requested Title: "${title}"`);
console.log(`[AudiobookSearch] Requested Author: "${author}"`);
- console.log(`[AudiobookSearch] Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
+ console.log(`[AudiobookSearch] Top ${top3.length} results (out of ${rankedResults.length} total):`);
console.log(`[AudiobookSearch] --------------------------------------------------------`);
top3.forEach((result, index) => {
console.log(`[AudiobookSearch] ${index + 1}. "${result.title}"`);
console.log(`[AudiobookSearch] Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
console.log(`[AudiobookSearch] `);
console.log(`[AudiobookSearch] Base Score: ${result.score.toFixed(1)}/100`);
- console.log(`[AudiobookSearch] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/50`);
+ console.log(`[AudiobookSearch] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`);
console.log(`[AudiobookSearch] - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`);
- console.log(`[AudiobookSearch] - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`);
- console.log(`[AudiobookSearch] - Size Score: ${result.breakdown.sizeScore.toFixed(1)}/10 (${(result.size / (1024 ** 3)).toFixed(2)} GB)`);
+ console.log(`[AudiobookSearch] - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`);
console.log(`[AudiobookSearch] `);
console.log(`[AudiobookSearch] Bonus Points: +${result.bonusPoints.toFixed(1)}`);
if (result.bonusModifiers.length > 0) {
@@ -141,7 +128,7 @@ export async function POST(request: NextRequest) {
}
// Add rank position to each result
- const resultsWithRank = filteredResults.map((result, index) => ({
+ const resultsWithRank = rankedResults.map((result, index) => ({
...result,
rank: index + 1,
}));
@@ -149,9 +136,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
results: resultsWithRank,
- message: filteredResults.length > 0
- ? `Found ${filteredResults.length} quality matches`
- : 'No quality matches found',
+ message: rankedResults.length > 0
+ ? `Found ${rankedResults.length} results`
+ : 'No results found',
});
} catch (error) {
console.error('Failed to search for torrents:', error);
diff --git a/src/app/api/requests/[id]/interactive-search/route.ts b/src/app/api/requests/[id]/interactive-search/route.ts
index 8ec7683..79f9696 100644
--- a/src/app/api/requests/[id]/interactive-search/route.ts
+++ b/src/app/api/requests/[id]/interactive-search/route.ts
@@ -141,10 +141,9 @@ export async function POST(
console.log(`[InteractiveSearch] Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
console.log(`[InteractiveSearch] `);
console.log(`[InteractiveSearch] Base Score: ${result.score.toFixed(1)}/100`);
- console.log(`[InteractiveSearch] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/50`);
+ console.log(`[InteractiveSearch] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`);
console.log(`[InteractiveSearch] - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`);
console.log(`[InteractiveSearch] - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`);
- console.log(`[InteractiveSearch] - Size Score: ${result.breakdown.sizeScore.toFixed(1)}/10 (${(result.size / (1024 ** 3)).toFixed(2)} GB)`);
console.log(`[InteractiveSearch] `);
console.log(`[InteractiveSearch] Bonus Points: +${result.bonusPoints.toFixed(1)}`);
if (result.bonusModifiers.length > 0) {
diff --git a/src/app/api/setup/complete/route.ts b/src/app/api/setup/complete/route.ts
index 7fc9026..251b11f 100644
--- a/src/app/api/setup/complete/route.ts
+++ b/src/app/api/setup/complete/route.ts
@@ -412,6 +412,18 @@ export async function POST(request: NextRequest) {
},
});
+ // Chapter merging configuration
+ await prisma.configuration.upsert({
+ where: { key: 'chapter_merging_enabled' },
+ update: { value: String(paths.chapter_merging_enabled ?? false) },
+ create: {
+ key: 'chapter_merging_enabled',
+ value: String(paths.chapter_merging_enabled ?? false),
+ category: 'automation',
+ description: 'Automatically merge multi-file chapter downloads into single M4B with chapter markers'
+ },
+ });
+
// BookDate configuration (optional, global for all users)
// Note: libraryScope and customPrompt are now per-user settings, not required here
if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model) {
diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx
index 74e413c..a7c965a 100644
--- a/src/app/setup/page.tsx
+++ b/src/app/setup/page.tsx
@@ -84,6 +84,7 @@ interface SetupState {
downloadDir: string;
mediaDir: string;
metadataTaggingEnabled: boolean;
+ chapterMergingEnabled: boolean;
bookdateProvider: string;
bookdateApiKey: string;
bookdateModel: string;
@@ -153,6 +154,7 @@ export default function SetupWizard() {
downloadDir: '/downloads',
mediaDir: '/media/audiobooks',
metadataTaggingEnabled: true,
+ chapterMergingEnabled: false,
bookdateProvider: 'openai',
bookdateApiKey: '',
bookdateModel: '',
@@ -228,6 +230,7 @@ export default function SetupWizard() {
download_dir: state.downloadDir,
media_dir: state.mediaDir,
metadata_tagging_enabled: state.metadataTaggingEnabled,
+ chapter_merging_enabled: state.chapterMergingEnabled,
},
bookdate: state.bookdateConfigured ? {
provider: state.bookdateProvider,
@@ -525,6 +528,7 @@ export default function SetupWizard() {
downloadDir={state.downloadDir}
mediaDir={state.mediaDir}
metadataTaggingEnabled={state.metadataTaggingEnabled}
+ chapterMergingEnabled={state.chapterMergingEnabled}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
diff --git a/src/app/setup/steps/PathsStep.tsx b/src/app/setup/steps/PathsStep.tsx
index 309563f..46a8285 100644
--- a/src/app/setup/steps/PathsStep.tsx
+++ b/src/app/setup/steps/PathsStep.tsx
@@ -13,6 +13,7 @@ interface PathsStepProps {
downloadDir: string;
mediaDir: string;
metadataTaggingEnabled: boolean;
+ chapterMergingEnabled: boolean;
onUpdate: (field: string, value: string | boolean) => void;
onNext: () => void;
onBack: () => void;
@@ -22,6 +23,7 @@ export function PathsStep({
downloadDir,
mediaDir,
metadataTaggingEnabled,
+ chapterMergingEnabled,
onUpdate,
onNext,
onBack,
@@ -235,6 +237,31 @@ export function PathsStep({
+ {/* Chapter Merging Toggle */}
+
+
+
onUpdate('chapterMergingEnabled', e.target.checked)}
+ className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
+ />
+
+
+ Auto-merge chapters to M4B
+
+
+ Automatically merge multi-file chapter downloads into a single M4B audiobook with chapter
+ markers. Improves playback experience and library organization.
+
+
+
+
+
{
+ // Custom serializer to handle arrays correctly for Prowlarr API
+ // indexerIds=[1,2,3] should become indexerIds=1&indexerIds=2&indexerIds=3
+ const parts: string[] = [];
+ for (const [key, value] of Object.entries(params)) {
+ if (Array.isArray(value)) {
+ value.forEach(v => parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`));
+ } else if (value !== undefined && value !== null) {
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
+ }
+ }
+ return parts.join('&');
+ },
},
});
+
+ // Debug interceptor to log actual outgoing requests
+ this.client.interceptors.request.use((config) => {
+ console.log(`[Prowlarr] Actual request: ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`);
+ console.log(`[Prowlarr] Request params:`, JSON.stringify(config.params));
+ return config;
+ });
}
/**
@@ -91,25 +111,44 @@ export class ProwlarrService {
filters?: SearchFilters
): Promise {
try {
+ // Get configured download client type to determine if we should filter by category
+ const { getConfigService } = await import('../services/config.service');
+ const configService = getConfigService();
+ const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
+
const params: Record = {
query,
- categories: filters?.category?.toString() || this.defaultCategory.toString(),
type: 'search',
+ limit: 100, // Maximum results to return from Prowlarr
extended: 1, // Enable searching in tags, labels, and metadata
+ categories: filters?.category?.toString() || this.defaultCategory.toString(), // 3030 = Audiobooks (standard Newznab category)
};
// Filter by specific indexers if provided
- // Pass array directly - axios will serialize as indexerIds=1&indexerIds=2&indexerIds=3
if (filters?.indexerIds && filters.indexerIds.length > 0) {
params.indexerIds = filters.indexerIds;
}
const response = await this.client.get('/search', { params });
+ console.log(`[Prowlarr] Raw API response: ${response.data.length} results`);
- // Debug: Log first raw result to see structure (debug mode only)
+ // Debug: Log first raw result to see structure and protocol field
+ if (response.data.length > 0) {
+ const firstResult = response.data[0];
+ console.log(`[Prowlarr] First raw result - protocol: "${firstResult.protocol}", indexer: "${firstResult.indexer}", title: "${firstResult.title?.substring(0, 50)}..."`);
+
+ // Check protocol distribution in raw results
+ const rawProtocols = response.data.reduce((acc: Record, r: any) => {
+ const proto = r.protocol || 'missing';
+ acc[proto] = (acc[proto] || 0) + 1;
+ return acc;
+ }, {});
+ console.log(`[Prowlarr] Raw protocol distribution:`, JSON.stringify(rawProtocols));
+ }
+
+ // Debug: Log first raw result full structure (debug mode only)
if (process.env.LOG_LEVEL === 'debug' && response.data.length > 0) {
console.log('[Prowlarr] Sample raw result from API:', JSON.stringify(response.data[0], null, 2));
- console.log(`[Prowlarr] Received ${response.data.length} total results from API`);
}
// Transform Prowlarr results to our format
@@ -130,7 +169,12 @@ export class ProwlarrService {
// Apply additional filters
if (filters?.minSeeders) {
- filtered = filtered.filter((r) => r.seeders >= (filters.minSeeders || 0));
+ // Only apply seeder filter to torrent results (NZB results don't have seeders)
+ filtered = filtered.filter((r) => {
+ // Skip filter for NZB results (undefined seeders)
+ if (r.seeders === undefined) return true;
+ return r.seeders >= (filters.minSeeders || 0);
+ });
}
if (filters?.maxResults) {
@@ -318,6 +362,26 @@ export class ProwlarrService {
const config = await getConfigService();
const clientType = (await config.get('download_client_type')) || 'qbittorrent';
+ // Debug: Log protocol distribution
+ const protocolCounts = results.reduce((acc, r) => {
+ const proto = r.protocol || 'unknown';
+ acc[proto] = (acc[proto] || 0) + 1;
+ return acc;
+ }, {} as Record);
+ console.log(`[Prowlarr] Protocol distribution in ${results.length} results:`, JSON.stringify(protocolCounts));
+
+ // Debug: Log first few results to see their protocols
+ if (results.length > 0 && results.length <= 5) {
+ results.forEach((r, i) => {
+ console.log(`[Prowlarr] Result ${i + 1}: protocol="${r.protocol || 'undefined'}", url="${r.downloadUrl.substring(0, 80)}..."`);
+ });
+ } else if (results.length > 5) {
+ console.log(`[Prowlarr] First 3 results:`);
+ results.slice(0, 3).forEach((r, i) => {
+ console.log(`[Prowlarr] ${i + 1}: protocol="${r.protocol || 'undefined'}", isNZB=${ProwlarrService.isNZBResult(r)}`);
+ });
+ }
+
if (clientType === 'sabnzbd') {
// Filter for NZB results only
const filtered = results.filter(result => ProwlarrService.isNZBResult(result));
@@ -340,6 +404,12 @@ export class ProwlarrService {
* Static method for protocol detection
*/
static isNZBResult(result: TorrentResult): boolean {
+ // Check protocol field first (most reliable - provided by Prowlarr API)
+ if (result.protocol) {
+ return result.protocol.toLowerCase() === 'usenet';
+ }
+
+ // Fallback to URL pattern detection if protocol not provided
const url = result.downloadUrl.toLowerCase();
// Check file extension
@@ -347,14 +417,11 @@ export class ProwlarrService {
return true;
}
- // Check URL path
- if (url.includes('/nzb/') || url.includes('&t=get')) {
+ // Check URL path patterns common in Newznab APIs
+ if (url.includes('/nzb/') || url.includes('&t=get') || url.includes('/getnzb')) {
return true;
}
- // Check categories (3030 is audiobooks, but some indexers use Usenet-specific codes)
- // Note: This is less reliable, so we prioritize URL patterns
-
return false;
}
@@ -394,6 +461,7 @@ export class ProwlarrService {
bitrate: metadata.bitrate,
hasChapters: metadata.hasChapters,
flags: flags.length > 0 ? flags : undefined,
+ protocol: result.protocol, // 'torrent' or 'usenet'
};
} catch (error) {
console.error('Failed to transform result:', result, error);
diff --git a/src/lib/integrations/sabnzbd.service.ts b/src/lib/integrations/sabnzbd.service.ts
index 4920d5f..3b3be4b 100644
--- a/src/lib/integrations/sabnzbd.service.ts
+++ b/src/lib/integrations/sabnzbd.service.ts
@@ -267,6 +267,8 @@ export class SABnzbdService {
* Returns the NZB ID
*/
async addNZB(url: string, options?: AddNZBOptions): Promise {
+ console.log(`[SABnzbd] Adding NZB from URL: ${url.substring(0, 150)}...`);
+
const response = await this.client.get('/api', {
params: {
mode: 'addurl',
diff --git a/src/lib/processors/download-torrent.processor.ts b/src/lib/processors/download-torrent.processor.ts
index a3e4d6a..2679fd4 100644
--- a/src/lib/processors/download-torrent.processor.ts
+++ b/src/lib/processors/download-torrent.processor.ts
@@ -132,7 +132,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
torrentSizeBytes: torrent.size,
torrentUrl: torrent.guid,
magnetLink: torrent.downloadUrl,
- seeders: torrent.seeders,
+ seeders: torrent.seeders || 0,
leechers: torrent.leechers || 0,
downloadStatus: 'downloading',
selected: true,
@@ -163,7 +163,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
torrent: {
title: torrent.title,
size: torrent.size,
- seeders: torrent.seeders,
+ seeders: torrent.seeders || 0,
format: torrent.format,
},
};
diff --git a/src/lib/processors/search-indexers.processor.ts b/src/lib/processors/search-indexers.processor.ts
index 60b5cb8..1282c74 100644
--- a/src/lib/processors/search-indexers.processor.ts
+++ b/src/lib/processors/search-indexers.processor.ts
@@ -104,7 +104,6 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
const rankedResults = ranker.rankTorrents(searchResults, {
title: audiobook.title,
author: audiobook.author,
- durationMinutes: undefined, // We don't have duration from Audible
}, indexerPriorities, flagConfigs);
// Dual threshold filtering:
@@ -160,10 +159,9 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
await logger?.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
await logger?.info(``);
await logger?.info(` Base Score: ${result.score.toFixed(1)}/100`);
- await logger?.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/50`);
+ await logger?.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`);
await logger?.info(` - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`);
- await logger?.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`);
- await logger?.info(` - Size Score: ${result.breakdown.sizeScore.toFixed(1)}/10`);
+ await logger?.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`);
await logger?.info(``);
await logger?.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`);
if (result.bonusModifiers.length > 0) {
@@ -199,7 +197,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
selectedTorrent: {
title: bestResult.title,
score: bestResult.score,
- seeders: bestResult.seeders,
+ seeders: bestResult.seeders || 0,
format: bestResult.format,
},
};
diff --git a/src/lib/services/ebook-scraper.ts b/src/lib/services/ebook-scraper.ts
index 787b8c6..8604810 100644
--- a/src/lib/services/ebook-scraper.ts
+++ b/src/lib/services/ebook-scraper.ts
@@ -344,11 +344,18 @@ async function searchByAsin(
const html = await fetchHtml(searchUrl, flaresolverrUrl, logger);
const $ = cheerio.load(html);
- // Exclude MD5 links from "Recent downloads" banner (they're in .js-recent-downloads-container)
+ // Exclude MD5 links from "Recent downloads" banner and "Partial matches" section
// Only look for actual search result links
const searchResultLinks = $('a[href*="/md5/"]').filter((i, elem) => {
// Exclude links inside the recent downloads banner
- return $(elem).closest('.js-recent-downloads-container').length === 0;
+ if ($(elem).closest('.js-recent-downloads-container').length > 0) {
+ return false;
+ }
+ // Exclude links inside the partial matches section
+ if ($(elem).closest('.js-partial-matches-show').length > 0) {
+ return false;
+ }
+ return true;
});
if (DEBUG_ENABLED) {
@@ -451,9 +458,17 @@ async function searchByTitle(
const html = await fetchHtml(searchUrl, flaresolverrUrl, logger);
const $ = cheerio.load(html);
- // Exclude MD5 links from "Recent downloads" banner (they're in .js-recent-downloads-container)
+ // Exclude MD5 links from "Recent downloads" banner and "Partial matches" section
const searchResultLinks = $('a[href*="/md5/"]').filter((i, elem) => {
- return $(elem).closest('.js-recent-downloads-container').length === 0;
+ // Exclude links inside the recent downloads banner
+ if ($(elem).closest('.js-recent-downloads-container').length > 0) {
+ return false;
+ }
+ // Exclude links inside the partial matches section
+ if ($(elem).closest('.js-partial-matches-show').length > 0) {
+ return false;
+ }
+ return true;
});
if (DEBUG_ENABLED) {
diff --git a/src/lib/utils/chapter-merger.ts b/src/lib/utils/chapter-merger.ts
new file mode 100644
index 0000000..fad061b
--- /dev/null
+++ b/src/lib/utils/chapter-merger.ts
@@ -0,0 +1,547 @@
+/**
+ * Component: Chapter Merger Utility
+ * Documentation: documentation/features/chapter-merging.md
+ *
+ * Merges multi-file audiobook chapter downloads into a single M4B file
+ * with proper chapter markers.
+ */
+
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import path from 'path';
+import fs from 'fs/promises';
+import { JobLogger } from './job-logger';
+
+const execPromise = promisify(exec);
+
+// Supported audio formats for chapter merging
+const SUPPORTED_FORMATS = ['.mp3', '.m4a', '.m4b', '.mp4', '.aac'];
+
+// Patterns that indicate chapter-based files
+const CHAPTER_PATTERNS = [
+ /^(\d{1,3})[\s._-]/, // "01 - Title.mp3", "1.mp3", "001_chapter.mp3"
+ /chapter\s*(\d+)/i, // "Chapter 1.mp3", "chapter01.mp3"
+ /ch\s*(\d+)/i, // "Ch1.mp3", "ch 01.mp3"
+ /part\s*(\d+)/i, // "Part 1.mp3"
+ /disc\s*(\d+)/i, // "Disc 1.mp3"
+ /track\s*(\d+)/i, // "Track 1.mp3"
+];
+
+// Generic title patterns to ignore when extracting chapter names
+const GENERIC_TITLE_PATTERNS = [
+ /^track\s*\d+$/i,
+ /^chapter\s*\d+$/i,
+ /^\d+$/,
+ /^part\s*\d+$/i,
+];
+
+export interface ChapterFile {
+ path: string;
+ filename: string;
+ duration: number; // milliseconds
+ bitrate?: number; // kbps
+ trackNumber?: number; // from metadata
+ titleMetadata?: string; // from metadata
+ chapterTitle: string; // final computed title
+}
+
+export interface AudioProbeResult {
+ duration: number; // milliseconds
+ bitrate?: number; // kbps
+ trackNumber?: number;
+ title?: string;
+ format: string;
+}
+
+export interface MergeOptions {
+ title: string;
+ author: string;
+ narrator?: string;
+ year?: number;
+ asin?: string;
+ outputPath: string;
+}
+
+export interface MergeResult {
+ success: boolean;
+ outputPath?: string;
+ chapterCount?: number;
+ totalDuration?: number; // milliseconds
+ error?: string;
+}
+
+/**
+ * Detect if the given files appear to be chapter files that should be merged
+ */
+export async function detectChapterFiles(files: string[]): Promise {
+ // Need at least 2 files to merge
+ if (files.length < 2) {
+ return false;
+ }
+
+ // All files must have same audio format
+ const extensions = new Set(files.map(f => path.extname(f).toLowerCase()));
+ if (extensions.size > 1) {
+ return false;
+ }
+
+ // Must be a supported format
+ const ext = [...extensions][0];
+ if (!SUPPORTED_FORMATS.includes(ext)) {
+ 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;
+}
+
+/**
+ * Probe an audio file to extract duration and metadata
+ */
+export async function probeAudioFile(filePath: string): Promise {
+ const command = `ffprobe -v quiet -print_format json -show_format -show_streams "${filePath}"`;
+
+ try {
+ const { stdout } = await execPromise(command, { timeout: 30000 });
+ const data = JSON.parse(stdout);
+
+ const format = data.format || {};
+ const tags = format.tags || {};
+
+ // Duration in milliseconds
+ const duration = Math.round((parseFloat(format.duration) || 0) * 1000);
+
+ // Bitrate in kbps
+ const bitrate = format.bit_rate ? Math.round(parseInt(format.bit_rate) / 1000) : undefined;
+
+ // Track number (various possible tag names)
+ let trackNumber: number | undefined;
+ const trackStr = tags.track || tags.TRACK || tags['track-number'];
+ if (trackStr) {
+ // Handle "1/10" format
+ const match = String(trackStr).match(/^(\d+)/);
+ if (match) {
+ trackNumber = parseInt(match[1]);
+ }
+ }
+
+ // Title
+ const title = tags.title || tags.TITLE || undefined;
+
+ // File extension as format indicator
+ const fileFormat = path.extname(filePath).toLowerCase().slice(1);
+
+ return {
+ duration,
+ bitrate,
+ trackNumber,
+ title,
+ format: fileFormat,
+ };
+ } catch (error) {
+ throw new Error(`Failed to probe audio file: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+}
+
+/**
+ * Natural sort comparison for filenames
+ * Handles numeric sequences correctly: ch1, ch2, ch10 (not ch1, ch10, ch2)
+ */
+function naturalSortCompare(a: string, b: string): number {
+ const aParts = a.split(/(\d+)/);
+ const bParts = b.split(/(\d+)/);
+
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
+ const aPart = aParts[i] || '';
+ const bPart = bParts[i] || '';
+
+ // Check if both parts are numeric
+ const aNum = parseInt(aPart);
+ const bNum = parseInt(bPart);
+
+ if (!isNaN(aNum) && !isNaN(bNum)) {
+ if (aNum !== bNum) return aNum - bNum;
+ } else {
+ const cmp = aPart.localeCompare(bPart, undefined, { sensitivity: 'base' });
+ if (cmp !== 0) return cmp;
+ }
+ }
+
+ return 0;
+}
+
+/**
+ * Check if a title is generic (should be ignored)
+ */
+function isGenericTitle(title: string): boolean {
+ return GENERIC_TITLE_PATTERNS.some(pattern => pattern.test(title.trim()));
+}
+
+/**
+ * Extract chapter name from filename
+ */
+function extractChapterNameFromFilename(filename: string): string | null {
+ const basename = path.basename(filename, path.extname(filename));
+
+ // Try to extract meaningful name after chapter indicator
+ // "01 - The Beginning" -> "The Beginning"
+ // "Chapter 1 - Introduction" -> "Introduction"
+ const patterns = [
+ /^\d+[\s._-]+(.+)$/, // "01 - Title" or "01_Title"
+ /^chapter\s*\d+[\s._-]+(.+)$/i, // "Chapter 1 - Title"
+ /^ch\s*\d+[\s._-]+(.+)$/i, // "Ch1 - Title"
+ /^part\s*\d+[\s._-]+(.+)$/i, // "Part 1 - Title"
+ ];
+
+ for (const pattern of patterns) {
+ const match = basename.match(pattern);
+ if (match && match[1]) {
+ const extracted = match[1].trim();
+ if (extracted.length > 0 && !isGenericTitle(extracted)) {
+ return extracted;
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Get chapter title with priority: metadata > filename > fallback
+ */
+function getChapterTitle(file: ChapterFile, index: number): string {
+ // Priority 1: Title metadata (if meaningful)
+ if (file.titleMetadata && !isGenericTitle(file.titleMetadata)) {
+ return file.titleMetadata;
+ }
+
+ // Priority 2: Extract from filename
+ const extracted = extractChapterNameFromFilename(file.filename);
+ if (extracted) {
+ return extracted;
+ }
+
+ // Priority 3: Fallback to "Chapter X"
+ return `Chapter ${index + 1}`;
+}
+
+/**
+ * Analyze and order chapter files
+ * Returns files in correct order with metadata populated
+ */
+export async function analyzeChapterFiles(
+ filePaths: string[],
+ logger?: JobLogger
+): Promise {
+ // Probe all files in parallel
+ const probePromises = filePaths.map(async (filePath) => {
+ const probe = await probeAudioFile(filePath);
+ return {
+ path: filePath,
+ filename: path.basename(filePath),
+ duration: probe.duration,
+ bitrate: probe.bitrate,
+ trackNumber: probe.trackNumber,
+ titleMetadata: probe.title,
+ chapterTitle: '', // Will be computed after ordering
+ };
+ });
+
+ const files = await Promise.all(probePromises);
+
+ // Create filename-based order (natural sort)
+ const filenameOrder = [...files].sort((a, b) =>
+ naturalSortCompare(a.filename, b.filename)
+ );
+
+ // Check if metadata order is available and valid
+ const hasAllTrackNumbers = files.every(f => f.trackNumber !== undefined && f.trackNumber > 0);
+ let useMetadataOrder = false;
+ let metadataOrder: ChapterFile[] = [];
+
+ if (hasAllTrackNumbers) {
+ metadataOrder = [...files].sort((a, b) => (a.trackNumber || 0) - (b.trackNumber || 0));
+
+ // Check if track numbers are sequential
+ const isSequential = metadataOrder.every((f, i) => {
+ const expectedTrack = i + 1;
+ return f.trackNumber === expectedTrack;
+ });
+
+ if (isSequential) {
+ // Compare orders
+ 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');
+ } else {
+ await logger?.warn('Chapter ordering: filename order differs from metadata - using metadata order (more reliable)');
+ useMetadataOrder = true;
+ }
+ } else {
+ await logger?.warn('Chapter ordering: metadata track numbers not sequential - using filename order');
+ }
+ } else {
+ await logger?.info('Chapter ordering: incomplete metadata track numbers - using filename order');
+ }
+
+ // Use the determined order
+ const orderedFiles = useMetadataOrder ? metadataOrder : filenameOrder;
+
+ // Compute chapter titles
+ for (let i = 0; i < orderedFiles.length; i++) {
+ orderedFiles[i].chapterTitle = getChapterTitle(orderedFiles[i], i);
+ }
+
+ return orderedFiles;
+}
+
+/**
+ * Generate FFMETADATA1 format chapter metadata
+ */
+function generateChapterMetadata(chapters: ChapterFile[]): string {
+ let metadata = ';FFMETADATA1\n';
+
+ let currentTime = 0; // milliseconds
+
+ for (const chapter of chapters) {
+ const startTime = currentTime;
+ const endTime = currentTime + chapter.duration;
+
+ // Escape special characters in title
+ const escapedTitle = chapter.chapterTitle
+ .replace(/\\/g, '\\\\')
+ .replace(/=/g, '\\=')
+ .replace(/;/g, '\\;')
+ .replace(/#/g, '\\#')
+ .replace(/\n/g, '');
+
+ metadata += '\n[CHAPTER]\n';
+ metadata += 'TIMEBASE=1/1000\n';
+ metadata += `START=${startTime}\n`;
+ metadata += `END=${endTime}\n`;
+ metadata += `title=${escapedTitle}\n`;
+
+ currentTime = endTime;
+ }
+
+ return metadata;
+}
+
+/**
+ * Determine optimal bitrate for MP3 conversion
+ * Uses source bitrate if < 128kbps, otherwise 128k
+ */
+function determineOutputBitrate(chapters: ChapterFile[]): string {
+ // Find minimum bitrate across all files
+ const bitrates = chapters
+ .filter(c => c.bitrate !== undefined)
+ .map(c => c.bitrate as number);
+
+ if (bitrates.length === 0) {
+ return '128k';
+ }
+
+ const minBitrate = Math.min(...bitrates);
+
+ // Use source bitrate if lower than 128k, otherwise cap at 128k
+ if (minBitrate < 128) {
+ return `${minBitrate}k`;
+ }
+
+ return '128k';
+}
+
+/**
+ * Merge chapter files into a single M4B with chapter markers
+ */
+export async function mergeChapters(
+ chapters: ChapterFile[],
+ options: MergeOptions,
+ logger?: JobLogger
+): Promise {
+ if (chapters.length === 0) {
+ return { success: false, error: 'No chapters to merge' };
+ }
+
+ const tempDir = path.dirname(options.outputPath);
+ const concatFile = path.join(tempDir, `concat_${Date.now()}.txt`);
+ const metadataFile = path.join(tempDir, `chapters_${Date.now()}.txt`);
+
+ try {
+ // Ensure temp directory exists
+ await fs.mkdir(tempDir, { recursive: true });
+
+ // Create concat file
+ const concatContent = chapters
+ .map(c => `file '${c.path.replace(/'/g, "'\\''")}'`)
+ .join('\n');
+ await fs.writeFile(concatFile, concatContent);
+
+ // Create chapter metadata file
+ const chapterMetadata = generateChapterMetadata(chapters);
+ await fs.writeFile(metadataFile, chapterMetadata);
+
+ // Determine if we need to re-encode (MP3 input requires conversion to AAC)
+ const inputFormat = path.extname(chapters[0].path).toLowerCase();
+ const needsReencode = inputFormat === '.mp3';
+
+ // Build ffmpeg command
+ const args: string[] = [
+ 'ffmpeg',
+ '-y',
+ '-f', 'concat',
+ '-safe', '0',
+ '-i', `"${concatFile}"`,
+ '-i', `"${metadataFile}"`,
+ '-map_metadata', '1',
+ ];
+
+ 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}`);
+ } else {
+ // M4A/M4B -> M4B can use codec copy (fast, lossless)
+ args.push('-codec', 'copy');
+ await logger?.info('Using codec copy (no re-encoding)');
+ }
+
+ // Add book metadata
+ const escapeMetadata = (val: string): string =>
+ val.replace(/"/g, '\\"').replace(/'/g, "\\'");
+
+ args.push('-metadata', `title="${escapeMetadata(options.title)}"`);
+ args.push('-metadata', `album="${escapeMetadata(options.title)}"`);
+ args.push('-metadata', `album_artist="${escapeMetadata(options.author)}"`);
+ args.push('-metadata', `artist="${escapeMetadata(options.author)}"`);
+
+ if (options.narrator) {
+ args.push('-metadata', `composer="${escapeMetadata(options.narrator)}"`);
+ }
+
+ if (options.year) {
+ args.push('-metadata', `date="${options.year}"`);
+ }
+
+ if (options.asin) {
+ // Custom iTunes tag for ASIN
+ args.push('-metadata', `----:com.apple.iTunes:ASIN="${escapeMetadata(options.asin)}"`);
+ }
+
+ // Output format
+ args.push('-f', 'mp4');
+ args.push(`"${options.outputPath}"`);
+
+ const command = args.join(' ');
+
+ // Calculate timeout: base 5 minutes + 30 seconds per chapter
+ const timeout = (5 * 60 * 1000) + (chapters.length * 30 * 1000);
+
+ await logger?.info(`Merging ${chapters.length} chapters...`);
+
+ try {
+ await execPromise(command, { timeout });
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
+ throw new Error(`FFmpeg merge failed: ${errorMsg}`);
+ }
+
+ // Verify output file exists
+ try {
+ await fs.access(options.outputPath);
+ } catch {
+ throw new Error('Merged file not created');
+ }
+
+ // Calculate total duration
+ const totalDuration = chapters.reduce((sum, c) => sum + c.duration, 0);
+
+ await logger?.info(`Merge complete: ${chapters.length} chapters, ${formatDuration(totalDuration)}`);
+
+ return {
+ success: true,
+ outputPath: options.outputPath,
+ chapterCount: chapters.length,
+ totalDuration,
+ };
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
+ return { success: false, error: errorMsg };
+ } finally {
+ // Clean up temp files
+ try {
+ await fs.unlink(concatFile);
+ } catch {
+ // Ignore cleanup errors
+ }
+ try {
+ await fs.unlink(metadataFile);
+ } catch {
+ // Ignore cleanup errors
+ }
+ }
+}
+
+/**
+ * Format duration in milliseconds to human readable string
+ */
+export function formatDuration(ms: number): string {
+ const seconds = Math.floor(ms / 1000);
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}h ${minutes}m ${secs}s`;
+ } else if (minutes > 0) {
+ return `${minutes}m ${secs}s`;
+ }
+ return `${secs}s`;
+}
+
+/**
+ * Check available disk space in directory
+ * Returns available bytes, or null if unable to determine
+ */
+export async function checkDiskSpace(directory: string): Promise {
+ try {
+ // Use df on Unix-like systems
+ const { stdout } = await execPromise(`df -k "${directory}" | tail -1 | awk '{print $4}'`);
+ const availableKb = parseInt(stdout.trim());
+ if (!isNaN(availableKb)) {
+ return availableKb * 1024; // Convert to bytes
+ }
+ } catch {
+ // df not available (Windows) or other error
+ }
+
+ return null;
+}
+
+/**
+ * Estimate output file size (sum of inputs + 10% overhead)
+ */
+export async function estimateOutputSize(filePaths: string[]): Promise {
+ let totalSize = 0;
+
+ for (const filePath of filePaths) {
+ try {
+ const stats = await fs.stat(filePath);
+ totalSize += stats.size;
+ } catch {
+ // Ignore errors, estimate conservatively
+ }
+ }
+
+ // Add 10% overhead for metadata and format differences
+ return Math.ceil(totalSize * 1.1);
+}
diff --git a/src/lib/utils/file-organizer.ts b/src/lib/utils/file-organizer.ts
index b65b542..9ad1e9d 100644
--- a/src/lib/utils/file-organizer.ts
+++ b/src/lib/utils/file-organizer.ts
@@ -8,6 +8,14 @@ import path from 'path';
import axios from 'axios';
import { createJobLogger, JobLogger } from './job-logger';
import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger';
+import {
+ detectChapterFiles,
+ analyzeChapterFiles,
+ mergeChapters,
+ formatDuration,
+ estimateOutputSize,
+ checkDiskSpace,
+} from './chapter-merger';
import { prisma } from '../db';
import { downloadEbook } from '../services/ebook-scraper';
@@ -83,6 +91,86 @@ export class FileOrganizer {
// Determine base path for source files
const baseSourcePath = isFile ? path.dirname(downloadPath) : downloadPath;
+ // Track if we created a merged file that needs cleanup
+ let tempMergedFile: string | null = null;
+
+ // Check for chapter merging if multiple files
+ if (audioFiles.length > 1) {
+ try {
+ const chapterMergingConfig = await prisma.configuration.findUnique({
+ where: { key: 'chapter_merging_enabled' },
+ });
+
+ const chapterMergingEnabled = chapterMergingConfig?.value === 'true';
+
+ if (chapterMergingEnabled) {
+ // Build full paths to source files
+ const sourceFilePaths = audioFiles.map((audioFile) =>
+ isFile ? downloadPath : path.join(downloadPath, audioFile)
+ );
+
+ const isChapterDownload = await detectChapterFiles(sourceFilePaths);
+
+ 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.`);
+ } else {
+ // 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);
+
+ // 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)}`
+ );
+
+ // Replace audioFiles array with single merged file
+ audioFiles.length = 0;
+ audioFiles.push(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
+ }
+ }
+ }
+ }
+ } 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'}`);
+ // Continue with original audioFiles array
+ }
+ }
+
// Tag metadata BEFORE moving files (prevents Plex race condition)
// Map from original file path to tagged file path (for successful tags)
const taggedFileMap = new Map();
@@ -103,8 +191,13 @@ export class FileOrganizer {
await logger?.info(`Tagging ${audioFiles.length} audio files with metadata (before move)...`);
// Build full paths to source files for tagging
+ // Handle merged files (absolute paths) vs original files (relative paths)
const sourceFilePaths = audioFiles.map((audioFile) =>
- isFile ? downloadPath : path.join(downloadPath, audioFile)
+ path.isAbsolute(audioFile)
+ ? audioFile // Merged file - use path directly
+ : isFile
+ ? downloadPath
+ : path.join(downloadPath, audioFile)
);
const taggingResults = await tagMultipleFiles(sourceFilePaths, {
@@ -167,7 +260,13 @@ export class FileOrganizer {
// Copy audio files (do NOT delete originals - needed for seeding)
for (const audioFile of audioFiles) {
- const originalSourcePath = isFile ? downloadPath : path.join(downloadPath, audioFile);
+ // Handle merged files (absolute paths) vs original files (relative paths)
+ const isAbsolutePath = path.isAbsolute(audioFile);
+ const originalSourcePath = isAbsolutePath
+ ? audioFile // Merged file - use path directly
+ : isFile
+ ? downloadPath
+ : path.join(downloadPath, audioFile);
const filename = path.basename(audioFile);
const targetFilePath = path.join(targetPath, filename);
@@ -234,6 +333,16 @@ export class FileOrganizer {
}
}
+ // Clean up temp merged file after successful copy
+ if (tempMergedFile) {
+ try {
+ await fs.unlink(tempMergedFile);
+ await logger?.info(`Cleaned up temp merged file: ${path.basename(tempMergedFile)}`);
+ } catch (cleanupError) {
+ await logger?.warn(`Failed to clean up temp merged file: ${path.basename(tempMergedFile)}`);
+ }
+ }
+
// Handle cover art
if (coverFile) {
const sourcePath = path.join(baseSourcePath, coverFile);
diff --git a/src/lib/utils/ranking-algorithm.ts b/src/lib/utils/ranking-algorithm.ts
index f84758a..b7a4505 100644
--- a/src/lib/utils/ranking-algorithm.ts
+++ b/src/lib/utils/ranking-algorithm.ts
@@ -10,8 +10,8 @@ export interface TorrentResult {
indexerId?: number;
title: string;
size: number;
- seeders: number;
- leechers: number;
+ seeders?: number; // Optional for NZB/Usenet results (no seeders concept)
+ leechers?: number; // Optional for NZB/Usenet results (no leechers concept)
publishDate: Date;
downloadUrl: string;
infoUrl?: string; // Link to indexer's info page (for user reference)
@@ -21,6 +21,7 @@ export interface TorrentResult {
bitrate?: string;
hasChapters?: boolean;
flags?: string[]; // Indexer flags like "Freeleech", "Internal", etc.
+ protocol?: string; // 'torrent' or 'usenet' - from Prowlarr API
}
export interface AudiobookRequest {
@@ -45,7 +46,6 @@ export interface BonusModifier {
export interface ScoreBreakdown {
formatScore: number;
seederScore: number;
- sizeScore: number;
matchScore: number;
totalScore: number;
notes: string[];
@@ -78,10 +78,9 @@ export class RankingAlgorithm {
// Calculate base scores (0-100)
const formatScore = this.scoreFormat(torrent);
const seederScore = this.scoreSeeders(torrent.seeders);
- const sizeScore = this.scoreSize(torrent.size, audiobook.durationMinutes);
const matchScore = this.scoreMatch(torrent, audiobook);
- const baseScore = formatScore + seederScore + sizeScore + matchScore;
+ const baseScore = formatScore + seederScore + matchScore;
// Calculate bonus modifiers
const bonusModifiers: BonusModifier[] = [];
@@ -138,13 +137,11 @@ export class RankingAlgorithm {
breakdown: {
formatScore,
seederScore,
- sizeScore,
matchScore,
totalScore: baseScore,
notes: this.generateNotes(torrent, {
formatScore,
seederScore,
- sizeScore,
matchScore,
totalScore: baseScore,
notes: [],
@@ -180,20 +177,17 @@ export class RankingAlgorithm {
): ScoreBreakdown {
const formatScore = this.scoreFormat(torrent);
const seederScore = this.scoreSeeders(torrent.seeders);
- const sizeScore = this.scoreSize(torrent.size, audiobook.durationMinutes);
const matchScore = this.scoreMatch(torrent, audiobook);
- const totalScore = formatScore + seederScore + sizeScore + matchScore;
+ const totalScore = formatScore + seederScore + matchScore;
return {
formatScore,
seederScore,
- sizeScore,
matchScore,
totalScore,
notes: this.generateNotes(torrent, {
formatScore,
seederScore,
- sizeScore,
matchScore,
totalScore,
notes: [],
@@ -231,43 +225,23 @@ export class RankingAlgorithm {
* 10 seeders: 6 points
* 100 seeders: 12 points
* 1000+ seeders: 15 points
+ *
+ * Note: NZB/Usenet results don't have seeders concept - centralized servers provide guaranteed availability
*/
- private scoreSeeders(seeders: number): number {
+ private scoreSeeders(seeders: number | undefined): number {
+ // Handle undefined/null (NZB results) - give full score since Usenet has centralized availability
+ if (seeders === undefined || seeders === null || isNaN(seeders)) {
+ return 15; // Full score - Usenet doesn't need seeders, content is on centralized servers
+ }
+
if (seeders === 0) return 0;
return Math.min(15, Math.log10(seeders + 1) * 6);
}
- /**
- * Score size reasonableness (10 points max)
- * Expected: 1-2 MB per minute (64-128 kbps)
- * Perfect match: 10 points
- * Too small/large: Reduced points
- */
- private scoreSize(size: number, durationMinutes?: number): number {
- if (!durationMinutes) {
- return 5; // Neutral score if duration unknown
- }
-
- // Expected size: 1-2 MB per minute
- const minExpected = durationMinutes * 1024 * 1024; // 1 MB/min
- const maxExpected = durationMinutes * 2 * 1024 * 1024; // 2 MB/min
-
- if (size >= minExpected && size <= maxExpected) {
- return 10; // Perfect size
- }
-
- // Calculate deviation penalty
- const deviation =
- size < minExpected
- ? (minExpected - size) / minExpected
- : (size - maxExpected) / maxExpected;
-
- return Math.max(0, 10 - deviation * 10);
- }
/**
- * Score title/author match quality (50 points max)
- * Title similarity: 0-35 points (heavily weighted!)
+ * Score title/author match quality (60 points max)
+ * Title similarity: 0-45 points (heavily weighted!)
* Author presence: 0-15 points
*/
private scoreMatch(
@@ -392,7 +366,7 @@ export class RankingAlgorithm {
if (isCompleteTitle) {
// Complete title match → full points
- titleScore = 35;
+ titleScore = 45;
bestMatch = true;
break; // Found a good match, stop trying
}
@@ -403,7 +377,7 @@ export class RankingAlgorithm {
// No complete match found, use fuzzy similarity as fallback
// Try against full title first, then required title
const fuzzyScores = titlesToTry.map(title => compareTwoStrings(title, torrentTitle));
- titleScore = Math.max(...fuzzyScores) * 35;
+ titleScore = Math.max(...fuzzyScores) * 45;
}
// ========== STAGE 3: AUTHOR MATCHING (0-15 points) ==========
@@ -427,7 +401,7 @@ export class RankingAlgorithm {
authorScore = compareTwoStrings(requestAuthor, torrentTitle) * 15;
}
- return Math.min(50, titleScore + authorScore);
+ return Math.min(60, titleScore + authorScore);
}
/**
@@ -474,26 +448,23 @@ export class RankingAlgorithm {
notes.push('Unknown or uncommon format');
}
- // Seeder notes
- if (torrent.seeders === 0) {
- notes.push('⚠️ No seeders available');
- } else if (torrent.seeders < 5) {
- notes.push(`Low seeders (${torrent.seeders})`);
- } else if (torrent.seeders >= 50) {
- notes.push(`Excellent availability (${torrent.seeders} seeders)`);
+ // Seeder notes (skip for NZB/Usenet results which don't have seeders)
+ if (torrent.seeders !== undefined && torrent.seeders !== null && !isNaN(torrent.seeders)) {
+ if (torrent.seeders === 0) {
+ notes.push('⚠️ No seeders available');
+ } else if (torrent.seeders < 5) {
+ notes.push(`Low seeders (${torrent.seeders})`);
+ } else if (torrent.seeders >= 50) {
+ notes.push(`Excellent availability (${torrent.seeders} seeders)`);
+ }
}
- // Size notes
- if (breakdown.sizeScore < 5) {
- notes.push('⚠️ Unusual file size');
- }
-
- // Match notes (now worth 50 points!)
- if (breakdown.matchScore < 20) {
+ // Match notes (now worth 60 points!)
+ if (breakdown.matchScore < 24) {
notes.push('⚠️ Poor title/author match');
- } else if (breakdown.matchScore < 35) {
+ } else if (breakdown.matchScore < 42) {
notes.push('⚠️ Weak title/author match');
- } else if (breakdown.matchScore >= 45) {
+ } else if (breakdown.matchScore >= 54) {
notes.push('✓ Excellent title/author match');
}