Add Transmission/NZBGet and per-client paths and much more

Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
kikootwo
2026-02-09 19:45:43 -05:00
parent d7acd67aa4
commit 4b90b35748
117 changed files with 9346 additions and 1488 deletions
+11 -8
View File
@@ -11,11 +11,12 @@ import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import { RMABLogger } from './logger';
import { CHAPTER_MERGE_FORMATS } from '../constants/audio-formats';
const execPromise = promisify(exec);
// Supported audio formats for chapter merging
const SUPPORTED_FORMATS = ['.mp3', '.m4a', '.m4b', '.mp4', '.aac'];
// Supported audio formats for chapter merging (from shared constants)
const SUPPORTED_FORMATS: readonly string[] = CHAPTER_MERGE_FORMATS;
// Patterns that indicate chapter-based files
const CHAPTER_PATTERNS = [
@@ -629,9 +630,9 @@ export async function mergeChapters(
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)
// Determine if we need to re-encode (non-AAC input requires conversion to AAC for M4B)
const inputFormat = path.extname(chapters[0].path).toLowerCase();
const needsReencode = inputFormat === '.mp3';
const needsReencode = inputFormat === '.mp3' || inputFormat === '.flac' || inputFormat === '.aac';
// Build ffmpeg command
const args: string[] = [
@@ -646,26 +647,28 @@ export async function mergeChapters(
];
if (needsReencode) {
// MP3 -> M4B requires re-encoding to AAC
// Non-AAC -> M4B requires re-encoding to AAC
const bitrate = determineOutputBitrate(chapters);
// Check for libfdk_aac (higher quality) or fall back to native aac
const hasFdkAac = await checkLibFdkAac();
const formatLabel = inputFormat.slice(1).toUpperCase(); // '.mp3' -> 'MP3', '.flac' -> 'FLAC'
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})`);
await logger?.info(`Merge strategy: Re-encoding ${formatLabel} → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`);
} else {
// Use VBR for better quality at same average bitrate
const vbrQuality = bitrateToVbrQuality(bitrate);
args.push('-c:a', 'aac');
args.push('-q:a', vbrQuality.toString());
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 VBR (quality ${vbrQuality}, target ~${bitrate})`);
await logger?.info(`Merge strategy: Re-encoding ${formatLabel} → AAC/M4B using native AAC VBR (quality ${vbrQuality}, target ~${bitrate})`);
}
} else {
// M4A/M4B -> M4B can use codec copy (fast, lossless)
// M4A/M4B/MP4 -> M4B can use codec copy (fast, lossless)
args.push('-c', 'copy');
await logger?.info(`Merge strategy: Codec copy (lossless, fast - no re-encoding needed for ${inputFormat} input)`);
}
+39 -2
View File
@@ -20,6 +20,7 @@ import {
} from './chapter-merger';
import { prisma } from '../db';
import { substituteTemplate, type TemplateVariables } from './path-template.util';
import { AUDIO_EXTENSIONS } from '../constants/audio-formats';
export interface AudiobookMetadata {
title: string;
@@ -362,6 +363,34 @@ export class FileOrganizer {
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await logger?.error(`Failed to copy ${filename}: ${errorMsg}`);
// If the tagged temp file failed to copy, clean it up and try the original untagged file
if (taggedFilePath) {
// Clean up the tagged temp file that failed to copy
try {
await fs.unlink(taggedFilePath);
await logger?.info(`Cleaned up temp file after copy failure: ${path.basename(taggedFilePath)}`);
} catch {
// Ignore cleanup errors
}
// Fallback: attempt to copy the original untagged file instead
await logger?.info(`Attempting fallback copy of original (untagged) file: ${filename}`);
try {
await fs.access(originalSourcePath, fs.constants.R_OK);
await fs.copyFile(originalSourcePath, targetFilePath);
await fs.chmod(targetFilePath, 0o644);
result.audioFiles.push(targetFilePath);
result.filesMovedCount++;
await logger?.info(`Fallback copy succeeded (without metadata tags): ${filename}`);
result.errors.push(`Tagged copy failed for ${filename}, copied original without metadata tags`);
continue;
} catch (fallbackError) {
const fallbackMsg = fallbackError instanceof Error ? fallbackError.message : 'Unknown error';
await logger?.error(`Fallback copy of original file also failed: ${fallbackMsg}`);
}
}
result.errors.push(`Failed to copy ${audioFile}: ${errorMsg}`);
// Continue with other files instead of throwing
}
@@ -411,7 +440,15 @@ export class FileOrganizer {
// This replaces the old inline ebook sidecar download that happened here.
result.targetPath = targetPath;
result.success = true;
// Only mark as success if at least one audio file was placed in the target directory
// (either freshly copied or already existed from a previous attempt)
if (result.audioFiles.length > 0) {
result.success = true;
} else {
result.errors.push('No audio files were successfully copied to the target directory');
await logger?.error(`Organization failed: no audio files copied despite ${audioFiles.length} file(s) found`);
}
// DO NOT clean up download directory - files needed for seeding
// Cleanup will be handled by the seeding cleanup job after seeding requirements are met
@@ -431,7 +468,7 @@ export class FileOrganizer {
private async findAudiobookFiles(
downloadPath: string
): Promise<{ audioFiles: string[]; coverFile?: string; isFile: boolean }> {
const audioExtensions = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
const audioExtensions: readonly string[] = AUDIO_EXTENSIONS;
const coverPatterns = [
/cover\.(jpg|jpeg|png)$/i,
/folder\.(jpg|jpeg|png)$/i,
+2 -6
View File
@@ -8,11 +8,7 @@
import crypto from 'crypto';
import path from 'path';
/**
* Supported audio file extensions for hash generation
*/
const AUDIO_EXTENSIONS = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
import { AUDIO_EXTENSIONS } from '../constants/audio-formats';
/**
* Generates a SHA256 hash of audio filenames for library matching.
@@ -51,7 +47,7 @@ export function generateFilesHash(filePaths: string[]): string {
})
.filter((basename) => {
const ext = path.extname(basename).toLowerCase();
return AUDIO_EXTENSIONS.includes(ext);
return (AUDIO_EXTENSIONS as readonly string[]).includes(ext);
})
.map((basename) => basename.toLowerCase()) // Normalize case
.sort(); // Sort alphabetically for deterministic hash
+33 -29
View File
@@ -5,6 +5,7 @@
* Groups indexers by their category configuration to minimize API calls.
* Indexers with identical categories are grouped together for a single search.
* Supports separate audiobook and ebook category configurations per indexer.
* Indexers with no categories for a given type are skipped (effectively disabled).
*/
export type CategoryType = 'audiobook' | 'ebook';
@@ -25,22 +26,33 @@ export interface IndexerGroup {
indexers: IndexerConfig[];
}
export interface GroupingResult {
groups: IndexerGroup[];
skippedIndexers: IndexerConfig[]; // Indexers skipped due to no categories for the type
}
/**
* Gets the appropriate categories from an indexer based on the category type.
*
* Returns empty array when the field is explicitly set to [] (user disabled this type).
* Falls back to defaults only when the field is undefined/missing (legacy configs).
*
* @param indexer - The indexer configuration
* @param type - The category type ('audiobook' or 'ebook')
* @returns Array of category IDs
* @returns Array of category IDs (empty = disabled for this type)
*/
export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType): number[] {
if (type === 'ebook') {
return indexer.ebookCategories && indexer.ebookCategories.length > 0
? indexer.ebookCategories
: [7020]; // Default ebook category
// Field exists (even if empty) — respect it
if (Array.isArray(indexer.ebookCategories)) {
return indexer.ebookCategories;
}
// Field missing — legacy config, use default
return [7020];
}
// Audiobook - check new field first, then legacy field
if (indexer.audiobookCategories && indexer.audiobookCategories.length > 0) {
// Audiobook check new field first, then legacy field
if (Array.isArray(indexer.audiobookCategories)) {
return indexer.audiobookCategories;
}
if (indexer.categories && indexer.categories.length > 0) {
@@ -52,57 +64,49 @@ export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType)
/**
* Groups indexers by their category configuration.
* Indexers with identical category arrays are grouped together.
* Indexers with no categories for the specified type are skipped.
*
* @param indexers - Array of indexer configurations
* @param type - The category type to group by ('audiobook' or 'ebook')
* @returns Array of groups, each containing indexers with matching categories
* @returns GroupingResult with groups and skipped indexers
*
* @example
* const indexers = [
* { id: 1, audiobookCategories: [3030], ebookCategories: [7020] },
* { id: 2, audiobookCategories: [3030], ebookCategories: [7020] },
* { id: 2, audiobookCategories: [3030], ebookCategories: [] },
* { id: 3, audiobookCategories: [3030, 3010], ebookCategories: [7020] },
* ];
*
* const audiobookGroups = groupIndexersByCategories(indexers, 'audiobook');
* // Result:
* // [
* // { categories: [3030], indexerIds: [1, 2], indexers: [...] },
* // { categories: [3030, 3010], indexerIds: [3], indexers: [...] }
* // ]
*
* const ebookGroups = groupIndexersByCategories(indexers, 'ebook');
* // Result:
* // [
* // { categories: [7020], indexerIds: [1, 2, 3], indexers: [...] }
* // ]
* const result = groupIndexersByCategories(indexers, 'ebook');
* // result.groups: [{ categories: [7020], indexerIds: [1, 3], indexers: [...] }]
* // result.skippedIndexers: [{ id: 2, ... }] (no ebook categories)
*/
export function groupIndexersByCategories(
indexers: IndexerConfig[],
type: CategoryType = 'audiobook'
): IndexerGroup[] {
// Map to track unique category combinations
// Key: sorted category IDs as string (e.g., "3030,3010")
// Value: array of indexers with those categories
): GroupingResult {
const groupMap = new Map<string, IndexerConfig[]>();
const skippedIndexers: IndexerConfig[] = [];
for (const indexer of indexers) {
// Get categories for the specified type
const categories = getCategoriesForType(indexer, type);
// Skip indexers with no categories for this type (effectively disabled)
if (categories.length === 0) {
skippedIndexers.push(indexer);
continue;
}
// Sort categories to ensure consistent grouping
// [3030, 3010] and [3010, 3030] should be the same group
const sortedCategories = [...categories].sort((a, b) => a - b);
const key = sortedCategories.join(',');
// Add indexer to group
if (!groupMap.has(key)) {
groupMap.set(key, []);
}
groupMap.get(key)!.push(indexer);
}
// Convert map to array of groups
const groups: IndexerGroup[] = [];
for (const [key, indexersInGroup] of groupMap.entries()) {
const categories = key.split(',').map(Number);
@@ -115,7 +119,7 @@ export function groupIndexersByCategories(
});
}
return groups;
return { groups, skippedIndexers };
}
/**
+28 -2
View File
@@ -7,6 +7,7 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import { METADATA_TAG_FORMATS, MP4_CONTAINER_FORMATS } from '../constants/audio-formats';
const execPromise = promisify(exec);
@@ -41,7 +42,7 @@ export async function tagAudioFileMetadata(
const ext = path.extname(filePath).toLowerCase();
// Only process supported formats
if (!['.m4b', '.m4a', '.mp3', '.mp4'].includes(ext)) {
if (!(METADATA_TAG_FORMATS as readonly string[]).includes(ext)) {
return {
success: false,
filePath,
@@ -61,7 +62,7 @@ export async function tagAudioFileMetadata(
];
// For m4b/m4a/mp4 files, use standard metadata tags
if (['.m4b', '.m4a', '.mp4'].includes(ext)) {
if ((MP4_CONTAINER_FORMATS as readonly string[]).includes(ext)) {
args.push(
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
'-metadata', `album="${escapeMetadata(metadata.title)}"`, // Book title in Album field (Plex uses this)
@@ -85,6 +86,31 @@ export async function tagAudioFileMetadata(
// Explicitly specify output format (fixes .tmp extension issue)
args.push('-f', 'mp4');
}
// For FLAC files, use Vorbis comment tags (native FLAC metadata)
else if (ext === '.flac') {
args.push(
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
'-metadata', `album="${escapeMetadata(metadata.title)}"`,
'-metadata', `albumartist="${escapeMetadata(metadata.author)}"`,
'-metadata', `artist="${escapeMetadata(metadata.author)}"`
);
if (metadata.narrator) {
args.push('-metadata', `composer="${escapeMetadata(metadata.narrator)}"`);
}
if (metadata.year) {
args.push('-metadata', `date="${metadata.year}"`);
}
if (metadata.asin) {
// FLAC supports arbitrary Vorbis comment tags
args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`);
}
// Explicitly specify output format
args.push('-f', 'flac');
}
// For mp3 files, use ID3v2 tags
else if (ext === '.mp3') {
args.push(
+57
View File
@@ -0,0 +1,57 @@
/**
* Utility: Permission Resolution
* Documentation: documentation/admin-dashboard.md
*
* Resolves effective user permissions from the tri-state pattern:
* admin → always granted
* per-user setting (true/false) → explicit override
* null → falls back to global setting
*/
import { prisma } from '@/lib/db';
/**
* Resolve a tri-state permission (admin → per-user → global fallback).
* @param userRole - 'admin' or 'user'
* @param userValue - per-user setting (true, false, or null)
* @param globalValue - global setting from Configuration table
* @returns effective boolean permission
*/
export function resolvePermission(
userRole: string,
userValue: boolean | null,
globalValue: boolean
): boolean {
if (userRole === 'admin') return true;
if (userValue === true) return true;
if (userValue === false) return false;
return globalValue;
}
/**
* Fetch a global boolean setting from the Configuration table.
* @param key - Configuration key
* @param defaultValue - Value to use if the key doesn't exist
*/
export async function getGlobalBooleanSetting(
key: string,
defaultValue: boolean = true
): Promise<boolean> {
const config = await prisma.configuration.findUnique({
where: { key },
});
return config == null ? defaultValue : config.value === 'true';
}
/**
* Resolve a user's effective interactive search access permission.
*/
export async function resolveInteractiveSearchAccess(
userRole: string,
userInteractiveSearchAccess: boolean | null
): Promise<boolean> {
if (userRole === 'admin') return true;
if (userInteractiveSearchAccess === true) return true;
if (userInteractiveSearchAccess === false) return false;
return getGlobalBooleanSetting('interactive_search_access', true);
}
+28 -8
View File
@@ -17,7 +17,7 @@ export interface TorrentResult {
infoUrl?: string; // Link to indexer's info page (for user reference)
infoHash?: string;
guid: string;
format?: 'M4B' | 'M4A' | 'MP3' | 'OTHER';
format?: 'M4B' | 'M4A' | 'MP3' | 'FLAC' | 'OTHER';
bitrate?: string;
hasChapters?: boolean;
flags?: string[]; // Indexer flags like "Freeleech", "Internal", etc.
@@ -254,6 +254,7 @@ export class RankingAlgorithm {
* Reduced from 25 to make room for data-driven size scoring
* M4B with chapters: 10 pts
* M4B without chapters: 9 pts
* FLAC: 7 pts (lossless audio, excellent quality)
* M4A: 6 pts
* MP3: 4 pts
* Other: 1 pt
@@ -264,6 +265,8 @@ export class RankingAlgorithm {
switch (format) {
case 'M4B':
return torrent.hasChapters !== false ? 10 : 9;
case 'FLAC':
return 7;
case 'M4A':
return 6;
case 'MP3':
@@ -395,11 +398,13 @@ export class RankingAlgorithm {
.filter(word => word.length > 0 && !stopList.includes(word));
};
// Separate required words (outside parentheses/brackets) from optional words (inside)
// This handles common patterns like "Title (Subtitle)" where subtitle may be omitted
// Note: Run on ORIGINAL title to preserve brackets, then normalize the result
// Separate required words (outside parentheses/brackets/colon subtitles) from optional words
// This handles common patterns like:
// "Title (Subtitle)" where subtitle may be omitted
// "Title: Series Name" where Audible appends series names after a colon
// Note: Run on ORIGINAL title to preserve brackets/colons, then normalize the result
const separateRequiredOptional = (title: string): { required: string; optional: string } => {
// Work with original title format for bracket detection
// Work with original title format for bracket/colon detection
const originalTitle = audiobook.title.toLowerCase();
// Extract content in parentheses/brackets as optional
@@ -411,8 +416,20 @@ export class RankingAlgorithm {
optionalMatches.push(match[1]);
}
// Remove parenthetical/bracketed content to get required portion
const requiredRaw = originalTitle.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
// Remove parenthetical/bracketed content to get the non-bracketed portion
let requiredRaw = originalTitle.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
// Treat content after a colon as optional (Audible commonly appends series names)
// e.g., "The Finest Edge of Twilight: Dungeons & Dragons" → required: title, optional: series
const colonIndex = requiredRaw.indexOf(':');
if (colonIndex > 0 && colonIndex < requiredRaw.length - 1) {
const afterColon = requiredRaw.substring(colonIndex + 1).trim();
if (afterColon.length > 0) {
optionalMatches.push(afterColon);
}
requiredRaw = requiredRaw.substring(0, colonIndex).trim();
}
// Normalize the required portion (handles CamelCase, punctuation)
const required = this.normalizeForMatching(requiredRaw);
const optional = optionalMatches.join(' ');
@@ -652,7 +669,7 @@ export class RankingAlgorithm {
/**
* Detect format from torrent title
*/
private detectFormat(torrent: TorrentResult): 'M4B' | 'M4A' | 'MP3' | 'OTHER' {
private detectFormat(torrent: TorrentResult): 'M4B' | 'M4A' | 'MP3' | 'FLAC' | 'OTHER' {
// Use explicit format if provided
if (torrent.format) {
return torrent.format;
@@ -664,6 +681,7 @@ export class RankingAlgorithm {
if (title.includes('M4B')) return 'M4B';
if (title.includes('M4A')) return 'M4A';
if (title.includes('MP3')) return 'MP3';
if (title.includes('FLAC')) return 'FLAC';
// Default to OTHER if no format detected
return 'OTHER';
@@ -686,6 +704,8 @@ export class RankingAlgorithm {
if (torrent.hasChapters !== false) {
notes.push('Has chapter markers');
}
} else if (format === 'FLAC') {
notes.push('Lossless format (FLAC)');
} else if (format === 'M4A') {
notes.push('Good format (M4A)');
} else if (format === 'MP3') {
+23
View File
@@ -80,3 +80,26 @@ export function isParentCategory(categoryId: number): boolean {
const category = TORRENT_CATEGORIES.find((cat) => cat.id === categoryId);
return !!category?.children && category.children.length > 0;
}
/**
* Get all standard category IDs (parents and children) from the predefined tree
*/
export function getAllStandardCategoryIds(): Set<number> {
const ids = new Set<number>();
for (const parent of TORRENT_CATEGORIES) {
ids.add(parent.id);
if (parent.children) {
for (const child of parent.children) {
ids.add(child.id);
}
}
}
return ids;
}
/**
* Check if a category ID exists in the predefined category tree
*/
export function isStandardCategory(categoryId: number): boolean {
return getAllStandardCategoryIds().has(categoryId);
}