Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
+733
View File
@@ -0,0 +1,733 @@
/**
* BookDate: Helper Functions for Recommendations
* Documentation: documentation/features/bookdate-prd.md
*/
import { prisma } from '@/lib/db';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { getConfigService } from '@/lib/services/config.service';
import { AudibleService } from '@/lib/integrations/audible.service';
import { getPlexService } from '@/lib/integrations/plex.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
export interface LibraryBook {
title: string;
author: string;
narrator?: string | null;
rating?: number | null;
}
interface CachedLibraryBook {
title: string;
author: string;
narrator: string | null;
plexGuid: string;
plexRatingKey: string | null;
userRating?: any; // Admin's cached rating
}
export interface SwipeHistory {
title: string;
author: string;
action: string;
markedAsKnown: boolean;
}
export interface AIRecommendation {
title: string;
author: string;
reason: string;
}
/**
* Enrich cached library books with user's personal ratings from Plex
* @param userId - User ID (to fetch their Plex token)
* @param cachedBooks - Books from PlexLibrary table cache
* @returns Books enriched with user's personal ratings
*/
async function enrichWithUserRatings(
userId: string,
cachedBooks: CachedLibraryBook[]
): Promise<LibraryBook[]> {
try {
// Get user's Plex token, plexId, and role
const user = await prisma.user.findUnique({
where: { id: userId },
select: { authToken: true, plexId: true, role: true },
});
if (!user) {
console.warn('[BookDate] User not found');
return cachedBooks.map(book => ({
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
rating: undefined,
}));
}
// Local admin users: Use cached ratings (from system Plex token)
// Local admins authenticate with username/password, not Plex OAuth
if (user.plexId.startsWith('local-')) {
console.log('[BookDate] User is local admin, using cached ratings (from system Plex token)');
return cachedBooks.map(book => ({
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
rating: book.userRating ? parseFloat(book.userRating.toString()) : undefined,
}));
}
// Plex-authenticated users (including admins): Fetch library with their token to get personal ratings
// Note: /library/sections/{id}/all returns items with the authenticated user's ratings
console.log('[BookDate] User is Plex-authenticated, fetching library with user token to get personal ratings');
if (!user.authToken) {
console.warn('[BookDate] User has no Plex auth token');
return cachedBooks.map(book => ({
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
rating: undefined,
}));
}
// Get Plex configuration
const configService = getConfigService();
const plexConfig = await configService.getPlexConfig();
if (!plexConfig.serverUrl || !plexConfig.libraryId) {
console.warn('[BookDate] No Plex server URL or library ID configured');
return cachedBooks.map(book => ({
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
rating: undefined,
}));
}
// Decrypt user's plex.tv OAuth token
let userPlexToken: string;
const encryptionService = getEncryptionService();
try {
userPlexToken = encryptionService.decrypt(user.authToken);
} catch (decryptError) {
// Token might be stored as plain text (from before encryption or different implementation)
// Try using it as-is
console.warn('[BookDate] Failed to decrypt user Plex token, trying as plain text');
userPlexToken = user.authToken;
}
try {
// Get server-specific access token
// Per Plex API: plex.tv OAuth tokens are for plex.tv, but we need
// server-specific access tokens from /api/v2/resources to talk to PMS
const plexService = getPlexService();
// Get server machine ID from stored config (no need to access system token)
if (!plexConfig.machineIdentifier) {
console.error('[BookDate] Server machine identifier not configured');
return cachedBooks.map(book => ({
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
rating: undefined,
}));
}
const serverMachineId = plexConfig.machineIdentifier;
const serverAccessToken = await plexService.getServerAccessToken(
serverMachineId,
userPlexToken
);
if (!serverAccessToken) {
console.warn('[BookDate] Could not get server access token for user (may not have server access)');
return cachedBooks.map(book => ({
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
rating: undefined,
}));
}
console.log('[BookDate] Successfully obtained server access token for user');
// Fetch library content with user's SERVER access token to get their personal ratings
const userLibrary = await plexService.getLibraryContent(
plexConfig.serverUrl,
serverAccessToken,
plexConfig.libraryId
);
console.log(`[BookDate] Fetched ${userLibrary.length} items from Plex with user's token`);
// Create a map of guid/ratingKey -> userRating for quick lookup
const ratingsMap = new Map<string, number>();
userLibrary.forEach(item => {
if (item.userRating) {
// Try to match by guid first (most reliable)
if (item.guid) {
ratingsMap.set(item.guid, item.userRating);
}
// Also store by ratingKey as fallback
if (item.ratingKey) {
ratingsMap.set(item.ratingKey, item.userRating);
}
}
});
console.log(`[BookDate] Found ${ratingsMap.size} rated items for non-admin user`);
// Enrich cached books with user's ratings from the fetched library
return cachedBooks.map(book => {
// Try to find rating by guid first (most reliable), then ratingKey
let rating: number | undefined;
if (book.plexGuid) {
rating = ratingsMap.get(book.plexGuid);
}
if (!rating && book.plexRatingKey) {
rating = ratingsMap.get(book.plexRatingKey);
}
return {
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
rating: rating,
};
});
} catch (fetchError: any) {
if (fetchError?.response?.status === 401 || fetchError?.message?.includes('401')) {
console.warn('[BookDate] User token unauthorized for library access (shared users may not have direct API access)');
console.warn('[BookDate] Falling back to recommendations without user ratings');
} else {
console.error('[BookDate] Failed to fetch library with user token:', fetchError);
}
// Fallback: return books without ratings
return cachedBooks.map(book => ({
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
rating: undefined,
}));
}
} catch (error) {
console.error('[BookDate] Error enriching books with user ratings:', error);
// Fallback: return books without ratings on error
return cachedBooks.map(book => ({
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
rating: undefined,
}));
}
}
/**
* Get user's Plex library books based on scope
* @param userId - User ID
* @param scope - 'full' | 'listened' | 'rated'
* @returns Array of library books (max 40)
*/
export async function getUserLibraryBooks(
userId: string,
scope: 'full' | 'listened' | 'rated'
): Promise<LibraryBook[]> {
try {
// Get user's Plex library configuration
const configService = getConfigService();
const plexConfig = await configService.getPlexConfig();
if (!plexConfig.libraryId) {
console.warn('[BookDate] No Plex library ID configured');
return [];
}
const plexLibraryId = plexConfig.libraryId;
// Check user type to determine query strategy for 'rated' scope
const user = await prisma.user.findUnique({
where: { id: userId },
select: { plexId: true },
});
const isLocalAdmin = user?.plexId.startsWith('local-') ?? false;
// Build query filters based on scope and user type
let whereClause: any = { plexLibraryId };
let takeLimit = 40;
if (scope === 'rated') {
if (isLocalAdmin) {
// Local admin: Filter by cached ratings (these are their ratings)
whereClause.userRating = { not: null };
} else {
// Plex-authenticated: Fetch more books to ensure we get 40 rated ones
// Don't filter by cached ratings - user's ratings may differ from system token
takeLimit = 100;
}
}
// Query Plex library from database (cached structure, includes system token's cached ratings)
let cachedBooks = await prisma.plexLibrary.findMany({
where: whereClause,
orderBy: {
addedAt: 'desc',
},
take: takeLimit,
select: {
title: true,
author: true,
narrator: true,
plexGuid: true,
plexRatingKey: true,
userRating: true, // System token's cached ratings from scan
},
});
// Enrich with user's personal ratings from Plex
const enrichedBooks = await enrichWithUserRatings(userId, cachedBooks);
// If scope is 'rated', filter to only books the user has actually rated
if (scope === 'rated') {
const ratedBooks = enrichedBooks.filter(book => book.rating != null);
// Limit to 40 for Plex users (local admin already limited in query)
return isLocalAdmin ? ratedBooks : ratedBooks.slice(0, 40);
}
return enrichedBooks;
} catch (error) {
console.error('[BookDate] Error fetching library books:', error);
return [];
}
}
/**
* Get user's recent swipes
* Prioritizes non-dismiss actions (likes/requests/dislikes) over dismissals
* @param userId - User ID
* @param limit - Max number of swipes to return
* @returns Array of recent swipes (prioritized: non-dismiss first, then dismissals)
*/
export async function getUserRecentSwipes(
userId: string,
limit: number = 10
): Promise<SwipeHistory[]> {
try {
// First, get the most recent non-dismiss swipes (left=reject, right=like/request)
// These are most informative for AI recommendations
const nonDismissSwipes = await prisma.bookDateSwipe.findMany({
where: {
userId,
action: { in: ['left', 'right'] },
},
orderBy: { createdAt: 'desc' },
take: limit,
select: {
bookTitle: true,
bookAuthor: true,
action: true,
markedAsKnown: true,
createdAt: true,
},
});
// Calculate remaining slots for dismissals
const remainingSlots = limit - nonDismissSwipes.length;
// If we have remaining slots, fill with dismiss swipes (up=dismiss)
let dismissSwipes: typeof nonDismissSwipes = [];
if (remainingSlots > 0) {
dismissSwipes = await prisma.bookDateSwipe.findMany({
where: {
userId,
action: 'up',
},
orderBy: { createdAt: 'desc' },
take: remainingSlots,
select: {
bookTitle: true,
bookAuthor: true,
action: true,
markedAsKnown: true,
createdAt: true,
},
});
}
// Combine both lists, maintaining chronological order (most recent first)
const allSwipes = [...nonDismissSwipes, ...dismissSwipes].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
);
console.log(
`[BookDate] Fetched ${allSwipes.length} swipes: ${nonDismissSwipes.length} non-dismiss, ${dismissSwipes.length} dismiss`
);
return allSwipes.map((s) => ({
title: s.bookTitle,
author: s.bookAuthor,
action: s.action,
markedAsKnown: s.markedAsKnown,
}));
} catch (error) {
console.error('[BookDate] Error fetching swipe history:', error);
return [];
}
}
/**
* Build AI prompt for recommendations
* @param userId - User ID
* @param config - User's BookDate configuration
* @returns JSON string prompt for AI
*/
export async function buildAIPrompt(
userId: string,
config: { libraryScope: string; customPrompt?: string | null }
): Promise<string> {
const libraryBooks = await getUserLibraryBooks(
userId,
config.libraryScope as 'full' | 'listened' | 'rated'
);
const swipeHistory = await getUserRecentSwipes(userId, 10);
console.log('[BookDate] Building AI prompt with context:');
console.log(`[BookDate] - Library books: ${libraryBooks.length}`);
console.log(`[BookDate] - Swipe history: ${swipeHistory.length}`);
console.log(`[BookDate] - Custom prompt: ${config.customPrompt ? 'Yes' : 'No'}`);
console.log(`[BookDate] - Library scope: ${config.libraryScope}`);
const prompt = {
task: 'recommend_audiobooks',
user_context: {
library_books: libraryBooks.slice(0, 40),
swipe_history: swipeHistory.map(s => ({
title: s.title,
author: s.author,
user_action: s.action === 'right'
? (s.markedAsKnown ? 'marked_as_liked' : 'requested')
: s.action === 'left' ? 'rejected' : 'dismissed',
})),
custom_preferences: config.customPrompt || null,
},
instructions:
'Based on the user\'s library and swipe history, recommend 20 audiobooks they would enjoy. ' +
'Important rules:\n' +
'1. DO NOT recommend any books already in the user\'s library\n' +
'2. DO NOT recommend any books from the swipe history (whether requested, rejected, dismissed, or marked_as_liked)\n' +
'3. Focus on variety and quality\n' +
'4. Consider user ratings if available (0-10 scale, higher = liked more)\n' +
'5. Learn from rejected books to avoid similar recommendations\n' +
'6. Learn from requested books to find similar ones\n' +
'7. Pay special attention to "marked_as_liked" books - these are books the user has already read/listened to elsewhere and enjoyed. Find similar books to these.\n' +
'Return ONLY valid JSON with no additional text or formatting.',
response_format: {
recommendations: [
{
title: 'string',
author: 'string',
reason: '1-2 sentence explanation',
},
],
},
};
const promptString = JSON.stringify(prompt);
console.log('[BookDate] Full AI prompt:', promptString);
return promptString;
}
/**
* Call AI API to get recommendations
* @param provider - 'openai' | 'claude'
* @param model - Model ID
* @param encryptedApiKey - Encrypted API key
* @param prompt - JSON prompt string
* @returns Parsed AI response with recommendations
*/
export async function callAI(
provider: string,
model: string,
encryptedApiKey: string,
prompt: string
): Promise<{ recommendations: AIRecommendation[] }> {
const encryptionService = getEncryptionService();
const apiKey = encryptionService.decrypt(encryptedApiKey);
console.log(`[BookDate] Calling AI provider: ${provider}, model: ${model}`);
if (provider === 'openai') {
const systemMessage = 'You are an expert audiobook recommender. Analyze user preferences and suggest audiobooks they will love. Return ONLY valid JSON.';
const requestBody = {
model,
response_format: { type: 'json_object' },
messages: [
{
role: 'system',
content: systemMessage,
},
{
role: 'user',
content: prompt,
},
],
};
console.log('[BookDate] OpenAI request body:', JSON.stringify(requestBody, null, 2));
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[BookDate] OpenAI API error:', response.status, errorText);
throw new Error(`OpenAI API error: ${response.status} ${errorText}`);
}
const data = await response.json();
const content = data.choices[0].message.content;
console.log('[BookDate] OpenAI response:', content);
return JSON.parse(content);
} else if (provider === 'claude') {
const userMessage = `${prompt}\n\nReturn ONLY valid JSON with no additional text or formatting.`;
const requestBody = {
model,
max_tokens: 4096,
messages: [
{
role: 'user',
content: userMessage,
},
],
};
console.log('[BookDate] Claude request body:', JSON.stringify(requestBody, null, 2));
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[BookDate] Claude API error:', response.status, errorText);
throw new Error(`Claude API error: ${response.status} ${errorText}`);
}
const data = await response.json();
const content = data.content[0].text;
console.log('[BookDate] Claude raw response:', content);
// Claude sometimes wraps JSON in markdown code blocks, so clean it
const cleanedContent = content
.replace(/^```json\s*/i, '')
.replace(/\s*```$/i, '')
.trim();
console.log('[BookDate] Claude cleaned response:', cleanedContent);
return JSON.parse(cleanedContent);
} else {
throw new Error(`Invalid provider: ${provider}`);
}
}
/**
* Match AI recommendation to Audnexus
* @param title - Book title
* @param author - Book author
* @returns Matched metadata or null
*/
export async function matchToAudnexus(
title: string,
author: string
): Promise<{
asin: string;
title: string;
author: string;
narrator: string | null;
rating: number | null;
description: string | null;
coverUrl: string | null;
} | null> {
try {
// Step 1: Search in Audible cache first (fastest)
const cached = await prisma.audibleCache.findFirst({
where: {
OR: [
{
title: {
contains: title,
mode: 'insensitive',
},
author: {
contains: author,
mode: 'insensitive',
},
},
],
},
select: {
asin: true,
title: true,
author: true,
narrator: true,
rating: true,
description: true,
coverArtUrl: true,
},
});
if (cached) {
console.log(`[BookDate] Found in cache: "${cached.title}" by ${cached.author}`);
return {
asin: cached.asin,
title: cached.title,
author: cached.author,
narrator: cached.narrator,
rating: cached.rating ? parseFloat(cached.rating.toString()) : null,
description: cached.description,
coverUrl: cached.coverArtUrl,
};
}
// Step 2: Search Audible.com for the book
console.log(`[BookDate] Not in cache, searching Audible for "${title}" by ${author}...`);
const audibleService = new AudibleService();
const searchQuery = `${title} ${author}`;
const searchResults = await audibleService.search(searchQuery, 1);
if (!searchResults.results || searchResults.results.length === 0) {
console.warn(`[BookDate] No Audible search results for "${title}" by ${author}`);
return null;
}
// Take the first result (best match)
const firstResult = searchResults.results[0];
console.log(`[BookDate] Found on Audible: "${firstResult.title}" (ASIN: ${firstResult.asin})`);
// Step 3: Use ASIN to fetch full details from Audnexus (or Audible as fallback)
const details = await audibleService.getAudiobookDetails(firstResult.asin);
if (!details) {
console.warn(`[BookDate] Could not fetch details for ASIN ${firstResult.asin}`);
return null;
}
console.log(`[BookDate] Successfully matched "${title}" to ASIN ${details.asin}`);
return {
asin: details.asin,
title: details.title,
author: details.author,
narrator: details.narrator || null,
rating: details.rating || null,
description: details.description || null,
coverUrl: details.coverArtUrl || null,
};
} catch (error) {
console.error(`[BookDate] Audnexus matching error for "${title}":`, error);
return null;
}
}
/**
* Check if book is already in user's library
* Uses the same matching algorithm as homepage (audiobook-matcher.ts)
* @param userId - User ID
* @param title - Book title
* @param author - Book author
* @param asin - Optional ASIN for exact matching
* @returns true if book is in library
*/
export async function isInLibrary(
userId: string,
title: string,
author: string,
asin?: string
): Promise<boolean> {
try {
// Use the centralized matching algorithm from audiobook-matcher.ts
// This ensures consistent matching behavior across the application
const match = await findPlexMatch({
asin: asin || '', // Empty ASIN will skip exact ASIN matching but still do fuzzy matching
title,
author,
});
if (match) {
console.log(`[BookDate] Book "${title}" by ${author} found in library (matched to: "${match.title}")`);
}
return !!match;
} catch (error) {
console.error(`[BookDate] Error checking library for "${title}":`, error);
return false;
}
}
/**
* Check if book has already been requested
* @param userId - User ID
* @param asin - Audible ASIN
* @returns true if book is already requested
*/
export async function isAlreadyRequested(
userId: string,
asin: string
): Promise<boolean> {
const request = await prisma.request.findFirst({
where: {
userId,
audiobook: {
audibleAsin: asin,
},
},
});
return !!request;
}
/**
* Check if book has already been swiped
* @param userId - User ID
* @param title - Book title
* @param author - Book author
* @returns true if book has been swiped
*/
export async function isAlreadySwiped(
userId: string,
title: string,
author: string
): Promise<boolean> {
const swipe = await prisma.bookDateSwipe.findFirst({
where: {
userId,
bookTitle: title,
bookAuthor: author,
},
});
return !!swipe;
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Component: Database Client
* Documentation: documentation/backend/database.md
*/
import { PrismaClient } from '@/generated/prisma/client';
// Prevent multiple instances of Prisma Client in development
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// Graceful shutdown
process.on('beforeExit', async () => {
await prisma.$disconnect();
});
export default prisma;
+86
View File
@@ -0,0 +1,86 @@
/**
* Component: Audiobooks Fetching Hook
* Documentation: documentation/frontend/components.md
*/
'use client';
import useSWR from 'swr';
import { authenticatedFetcher } from '@/lib/utils/api';
export interface Audiobook {
asin: string;
title: string;
author: string;
narrator?: string;
description?: string;
coverArtUrl?: string;
durationMinutes?: number;
releaseDate?: string;
rating?: number;
genres?: string[];
isAvailable?: boolean; // Set by real-time matching against plex_library
plexGuid?: string | null;
dbId?: string | null;
isRequested?: boolean; // Set if ANY user has requested this audiobook
requestStatus?: string | null; // Status of request (if any)
requestId?: string | null; // ID of request (if any)
requestedByUsername?: string | null; // Username who requested (only if not current user)
}
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) {
const endpoint =
type === 'popular'
? `/api/audiobooks/popular?page=${page}&limit=${limit}`
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}`;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 60000, // Cache for 1 minute
});
return {
audiobooks: data?.audiobooks || [],
totalCount: data?.totalCount || 0,
totalPages: data?.totalPages || 0,
currentPage: data?.page || page,
hasMore: data?.hasMore || false,
message: data?.message || null,
isLoading,
error,
};
}
export function useSearch(query: string, page: number = 1) {
const shouldFetch = query && query.length > 0;
const endpoint = shouldFetch ? `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${page}` : null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 30000, // Cache for 30 seconds
});
return {
results: data?.results || [],
totalResults: data?.totalResults || 0,
hasMore: data?.hasMore || false,
isLoading: shouldFetch && isLoading,
error,
};
}
export function useAudiobookDetails(asin: string | null) {
const endpoint = asin ? `/api/audiobooks/${asin}` : null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 300000, // Cache for 5 minutes
});
return {
audiobook: data?.audiobook || null,
isLoading,
error,
};
}
+292
View File
@@ -0,0 +1,292 @@
/**
* Component: Requests Management Hook
* Documentation: documentation/frontend/components.md
*/
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
import { Audiobook } from './useAudiobooks';
export interface Request {
id: string;
status: string;
progress: number;
errorMessage?: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
audiobook: {
id: string;
title: string;
author: string;
coverArtUrl?: string;
};
user: {
id: string;
plexUsername: string;
};
}
const fetcher = (url: string) =>
fetchWithAuth(url).then((res) => res.json());
export function useRequests(status?: string, limit: number = 50, myOnly: boolean = false) {
const { accessToken } = useAuth();
const params = new URLSearchParams();
if (status) params.append('status', status);
if (limit) params.append('limit', limit.toString());
if (myOnly) params.append('myOnly', 'true');
const endpoint = accessToken ? `/api/requests?${params.toString()}` : null;
const { data, error, isLoading } = useSWR(
endpoint,
fetcher,
{
refreshInterval: 5000, // Refresh every 5 seconds for real-time updates
}
);
return {
requests: data?.requests || [],
isLoading,
error,
};
}
export function useRequest(requestId: string) {
const { accessToken } = useAuth();
const endpoint = accessToken && requestId ? `/api/requests/${requestId}` : null;
const { data, error, isLoading } = useSWR(
endpoint,
fetcher,
{
refreshInterval: 3000, // Refresh every 3 seconds for progress updates
}
);
return {
request: data?.request || null,
isLoading,
error,
};
}
export function useCreateRequest() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createRequest = async (audiobook: Audiobook) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth('/api/requests', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ audiobook }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to create request');
}
// Revalidate requests list
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
// Revalidate audiobook lists to update button states
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
return data.request;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { createRequest, isLoading, error };
}
export function useCancelRequest() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const cancelRequest = async (requestId: string) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/requests/${requestId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ action: 'cancel' }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to cancel request');
}
// Revalidate requests
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
return data.request;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { cancelRequest, isLoading, error };
}
export function useManualSearch() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const triggerManualSearch = async (requestId: string) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/requests/${requestId}/manual-search`, {
method: 'POST',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to trigger manual search');
}
// Revalidate requests
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
return data.request;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { triggerManualSearch, isLoading, error };
}
export function useInteractiveSearch() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const searchTorrents = async (requestId: string) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/requests/${requestId}/interactive-search`, {
method: 'POST',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to search for torrents');
}
return data.results || [];
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { searchTorrents, isLoading, error };
}
export function useSelectTorrent() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectTorrent = async (requestId: string, torrent: any) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/requests/${requestId}/select-torrent`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ torrent }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to download torrent');
}
// Revalidate requests
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
return data.request;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { selectTorrent, isLoading, error };
}
+719
View File
@@ -0,0 +1,719 @@
/**
* Component: Audible Integration Service (Web Scraping)
* Documentation: documentation/integrations/audible.md
*/
import axios, { AxiosInstance } from 'axios';
import * as cheerio from 'cheerio';
export interface AudibleAudiobook {
asin: string;
title: string;
author: string;
narrator?: string;
description?: string;
coverArtUrl?: string;
durationMinutes?: number;
releaseDate?: string;
rating?: number;
genres?: string[];
}
export interface AudibleSearchResult {
query: string;
results: AudibleAudiobook[];
totalResults: number;
page: number;
hasMore: boolean;
}
export class AudibleService {
private client: AxiosInstance;
private readonly baseUrl = 'https://www.audible.com';
constructor() {
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
},
});
}
/**
* Get popular audiobooks from best sellers (with pagination support)
*/
async getPopularAudiobooks(limit: number = 20): Promise<AudibleAudiobook[]> {
try {
console.log(`[Audible] Fetching popular audiobooks (limit: ${limit})...`);
const audiobooks: AudibleAudiobook[] = [];
let page = 1;
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page
while (audiobooks.length < limit && page <= maxPages) {
console.log(`[Audible] Fetching page ${page}/${maxPages}...`);
const response = await this.client.get('/adblbestsellers', {
params: page > 1 ? { page } : {},
});
const $ = cheerio.load(response.data);
let foundOnPage = 0;
// Parse audiobook items from best sellers page
$('.productListItem').each((index, element) => {
if (audiobooks.length >= limit) return false;
const $el = $(element);
// Extract ASIN from data attribute or link
const asin = $el.find('li').attr('data-asin') ||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!asin) return;
// Skip duplicates
if (audiobooks.some(book => book.asin === asin)) return;
const title = $el.find('h3 a').text().trim() ||
$el.find('.bc-heading a').text().trim();
const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
const narratorText = $el.find('.narratorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').eq(1).text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
const ratingText = $el.find('.ratingsLabel').text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
audiobooks.push({
asin,
title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
rating,
});
foundOnPage++;
});
console.log(`[Audible] Found ${foundOnPage} audiobooks on page ${page}`);
// If we got fewer than expected, probably no more pages
if (foundOnPage < 10) {
console.log(`[Audible] Reached end of available pages`);
break;
}
page++;
// Add delay between pages to respect rate limiting
if (page <= maxPages && audiobooks.length < limit) {
await this.delay(1500);
}
}
console.log(`[Audible] Found ${audiobooks.length} popular audiobooks across ${page} pages`);
return audiobooks;
} catch (error) {
console.error('[Audible] Failed to fetch popular audiobooks:', error);
return [];
}
}
/**
* Get new release audiobooks (with pagination support)
*/
async getNewReleases(limit: number = 20): Promise<AudibleAudiobook[]> {
try {
console.log(`[Audible] Fetching new releases (limit: ${limit})...`);
const audiobooks: AudibleAudiobook[] = [];
let page = 1;
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page
while (audiobooks.length < limit && page <= maxPages) {
console.log(`[Audible] Fetching page ${page}/${maxPages}...`);
const response = await this.client.get('/newreleases', {
params: page > 1 ? { page } : {},
});
const $ = cheerio.load(response.data);
let foundOnPage = 0;
// Parse audiobook items from new releases page
$('.productListItem').each((index, element) => {
if (audiobooks.length >= limit) return false;
const $el = $(element);
const asin = $el.find('li').attr('data-asin') ||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!asin) return;
// Skip duplicates
if (audiobooks.some(book => book.asin === asin)) return;
const title = $el.find('h3 a').text().trim() ||
$el.find('.bc-heading a').text().trim();
const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
const narratorText = $el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
audiobooks.push({
asin,
title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
});
foundOnPage++;
});
console.log(`[Audible] Found ${foundOnPage} audiobooks on page ${page}`);
// If we got fewer than expected, probably no more pages
if (foundOnPage < 10) {
console.log(`[Audible] Reached end of available pages`);
break;
}
page++;
// Add delay between pages to respect rate limiting
if (page <= maxPages && audiobooks.length < limit) {
await this.delay(1500);
}
}
console.log(`[Audible] Found ${audiobooks.length} new releases across ${page} pages`);
return audiobooks;
} catch (error) {
console.error('[Audible] Failed to fetch new releases:', error);
return [];
}
}
/**
* Search for audiobooks
*/
async search(query: string, page: number = 1): Promise<AudibleSearchResult> {
try {
console.log(`[Audible] Searching for "${query}"...`);
const response = await this.client.get('/search', {
params: {
keywords: query,
page,
},
});
const $ = cheerio.load(response.data);
const audiobooks: AudibleAudiobook[] = [];
// Parse search results
$('.productListItem').each((index, element) => {
const $el = $(element);
const asin = $el.find('li').attr('data-asin') ||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!asin) return;
const title = $el.find('h3 a').text().trim() ||
$el.find('.bc-heading a').text().trim();
const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
const narratorText = $el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
const runtimeText = $el.find('.runtimeLabel').text().trim();
const durationMinutes = this.parseRuntime(runtimeText);
audiobooks.push({
asin,
title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes,
});
});
// Try to extract total results count
const resultsText = $('.resultsInfo').text().trim();
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
console.log(`[Audible] Found ${audiobooks.length} results for "${query}"`);
return {
query,
results: audiobooks,
totalResults,
page,
hasMore: audiobooks.length > 0 && totalResults > page * 20,
};
} catch (error) {
console.error('[Audible] Search failed:', error);
return {
query,
results: [],
totalResults: 0,
page,
hasMore: false,
};
}
}
/**
* Get detailed audiobook information
* Primary: Audnexus API (reliable, structured data)
* Fallback: Audible scraping
*/
async getAudiobookDetails(asin: string): Promise<AudibleAudiobook | null> {
try {
console.log(`[Audible] Fetching details for ASIN ${asin}...`);
// Try Audnexus first (more reliable)
const audnexusData = await this.fetchFromAudnexus(asin);
if (audnexusData) {
console.log(`[Audible] Successfully fetched from Audnexus for "${audnexusData.title}"`);
return audnexusData;
}
console.log(`[Audible] Audnexus failed, falling back to Audible scraping...`);
// Fallback to Audible scraping
return await this.scrapeAudibleDetails(asin);
} catch (error) {
console.error(`[Audible] Failed to fetch details for ${asin}:`, error);
return null;
}
}
/**
* Fetch audiobook details from Audnexus API
*/
private async fetchFromAudnexus(asin: string): Promise<AudibleAudiobook | null> {
try {
console.log(`[Audnexus] Fetching ASIN ${asin}...`);
const response = await axios.get(`https://api.audnex.us/books/${asin}`, {
timeout: 10000,
headers: {
'User-Agent': 'ReadMeABook/1.0',
},
});
const data = response.data;
// Build result from Audnexus data
const result: AudibleAudiobook = {
asin,
title: data.title || '',
author: data.authors?.map((a: any) => a.name).join(', ') || '',
narrator: data.narrators?.map((n: any) => n.name).join(', ') || '',
description: data.description || data.summary || '',
coverArtUrl: data.image || '',
durationMinutes: data.runtimeLengthMin ? parseInt(data.runtimeLengthMin) : undefined,
releaseDate: data.releaseDate || undefined,
rating: data.rating ? parseFloat(data.rating) : undefined,
genres: data.genres?.map((g: any) => typeof g === 'string' ? g : g.name).slice(0, 5) || undefined,
};
// Ensure cover art URL is high quality
if (result.coverArtUrl && !result.coverArtUrl.includes('_SL500_')) {
result.coverArtUrl = result.coverArtUrl.replace(/\._.*_\./, '._SL500_.');
}
console.log(`[Audnexus] Success:`, JSON.stringify({
title: result.title,
author: result.author,
narrator: result.narrator,
descLength: result.description?.length || 0,
duration: result.durationMinutes,
rating: result.rating,
genres: result.genres?.length || 0
}));
return result;
} catch (error: any) {
if (error.response?.status === 404) {
console.log(`[Audnexus] Book not found (404) for ASIN ${asin}`);
} else {
console.log(`[Audnexus] Error fetching ASIN ${asin}:`, error.message);
}
return null;
}
}
/**
* Scrape audiobook details from Audible (fallback method)
*/
private async scrapeAudibleDetails(asin: string): Promise<AudibleAudiobook | null> {
try {
const response = await this.client.get(`/pd/${asin}`);
const $ = cheerio.load(response.data);
// Initialize result object
let result: AudibleAudiobook = {
asin,
title: '',
author: '',
narrator: '',
description: '',
coverArtUrl: '',
};
// Debug: Save HTML in development
const isDev = process.env.NODE_ENV === 'development';
if (isDev) {
const fs = require('fs');
const path = require('path');
const debugPath = path.join('/tmp', `audible-${asin}.html`);
fs.writeFileSync(debugPath, response.data);
console.log(`[Audible] Saved HTML to ${debugPath} for debugging`);
}
// Try to extract JSON-LD structured data first
const jsonLdScripts = $('script[type="application/ld+json"]');
console.log(`[Audible] Found ${jsonLdScripts.length} JSON-LD script tags`);
jsonLdScripts.each((i, elem) => {
try {
const jsonData = JSON.parse($(elem).html() || '{}');
console.log(`[Audible] JSON-LD ${i} type:`, jsonData['@type']);
if (jsonData['@type'] === 'Book' || jsonData['@type'] === 'Audiobook' || jsonData['@type'] === 'Product') {
console.log('[Audible] Found valid JSON-LD structured data');
if (jsonData.name) result.title = jsonData.name;
if (jsonData.author) {
result.author = Array.isArray(jsonData.author)
? jsonData.author.map((a: any) => a.name || a).join(', ')
: jsonData.author?.name || jsonData.author || '';
}
if (jsonData.readBy) {
result.narrator = Array.isArray(jsonData.readBy)
? jsonData.readBy.map((n: any) => n.name || n).join(', ')
: jsonData.readBy?.name || jsonData.readBy || '';
}
if (jsonData.description) result.description = jsonData.description;
if (jsonData.image) result.coverArtUrl = jsonData.image;
if (jsonData.aggregateRating?.ratingValue) result.rating = jsonData.aggregateRating.ratingValue;
if (jsonData.datePublished) result.releaseDate = jsonData.datePublished;
if (jsonData.duration) {
const durationMatch = jsonData.duration.match(/PT(\d+)H(\d+)M/);
if (durationMatch) {
result.durationMinutes = parseInt(durationMatch[1]) * 60 + parseInt(durationMatch[2]);
}
}
}
} catch (e) {
console.log(`[Audible] JSON-LD ${i} parsing failed:`, e);
}
});
// Fallback to HTML parsing for any missing fields
// Title - try multiple selectors
if (!result.title) {
result.title = $('h1.bc-heading').first().text().trim() ||
$('h1[class*="heading"]').first().text().trim() ||
$('.bc-container h1').first().text().trim() ||
$('h1').first().text().trim();
console.log(`[Audible] Title from HTML: "${result.title}"`);
}
// Author - try multiple approaches (only in product details area)
if (!result.author) {
// Look specifically in the product details section, not the whole page
const productSection = $('.bc-section, .product-top-section, [class*="product"]').first();
const authors: string[] = [];
// First try labeled author sections
productSection.find('li.authorLabel a, span.authorLabel a, .authorLabel a').each((_, elem) => {
const text = $(elem).text().trim();
if (text && text.length > 0 && text.length < 80) {
authors.push(text);
}
});
// If no labeled authors, look for author links near the title (first 3 only to avoid recommendations)
if (authors.length === 0) {
$('a[href*="/author/"]').slice(0, 3).each((_, elem) => {
const text = $(elem).text().trim();
// Filter out navigation breadcrumbs and promotional text
if (text && text.length > 1 && text.length < 80 &&
!text.includes('') && !text.includes('...') &&
!text.toLowerCase().includes('more') && !text.toLowerCase().includes('see all')) {
authors.push(text);
}
});
}
if (authors.length > 0) {
// Deduplicate and limit to max 3 authors
result.author = [...new Set(authors)].slice(0, 3).join(', ');
}
result.author = result.author.replace(/^By:\s*/i, '').replace(/^Written by:\s*/i, '').trim();
console.log(`[Audible] Author from HTML: "${result.author}"`);
}
// Narrator - try multiple approaches (only in product details area)
if (!result.narrator) {
// Look specifically in the product details section
const productSection = $('.bc-section, .product-top-section, [class*="product"]').first();
const narrators: string[] = [];
// First try labeled narrator sections
productSection.find('li.narratorLabel a, span.narratorLabel a, .narratorLabel a').each((_, elem) => {
const text = $(elem).text().trim();
if (text && text.length > 0 && text.length < 80) {
narrators.push(text);
}
});
// If no labeled narrators, look for narrator links (first 5 only)
if (narrators.length === 0) {
$('a[href*="/narrator/"]').slice(0, 5).each((_, elem) => {
const text = $(elem).text().trim();
if (text && text.length > 1 && text.length < 80 &&
!text.includes('') && !text.includes('...')) {
narrators.push(text);
}
});
}
if (narrators.length > 0) {
// Deduplicate and limit to reasonable count
result.narrator = [...new Set(narrators)].slice(0, 5).join(', ');
}
if (result.narrator) {
result.narrator = result.narrator.replace(/^Narrated by:\s*/i, '').trim();
}
console.log(`[Audible] Narrator from HTML: "${result.narrator || ''}"`);
}
// Description - try multiple approaches with strict filtering
if (!result.description) {
const excludePatterns = [
/\$\d+\.\d+/, // Price patterns
/cancel anytime/i,
/free trial/i,
/membership/i,
/subscribe/i,
/offer.*ends/i,
/^\s*by\s+[\w\s,]+$/i, // Just author names
];
const isValidDescription = (text: string): boolean => {
if (!text || text.length < 50 || text.length > 5000) return false;
// Reject if it contains promotional patterns
for (const pattern of excludePatterns) {
if (pattern.test(text)) return false;
}
return true;
};
// Try specific description selectors first
const candidates = [
$('.bc-expander-content').first().text().trim(),
$('[class*="productPublisherSummary"]').first().text().trim(),
$('[data-widget="publisherSummary"]').first().text().trim(),
$('.bc-section p').first().text().trim(),
];
// Find first valid candidate
for (const candidate of candidates) {
if (isValidDescription(candidate)) {
result.description = candidate;
break;
}
}
// If still no description, search for valid paragraphs
if (!result.description) {
$('p, div[class*="description"]').each((_, elem) => {
const text = $(elem).text().trim();
if (isValidDescription(text) && text.length > (result.description?.length || 0)) {
result.description = text;
}
});
}
console.log(`[Audible] Description length: ${result.description?.length || 0} chars`);
}
// Cover art - try multiple selectors
if (!result.coverArtUrl) {
result.coverArtUrl = $('img.bc-image-inset-border').attr('src') ||
$('img[class*="product-image"]').first().attr('src') ||
$('img[class*="cover"]').first().attr('src') ||
$('.bc-pub-detail-image img').attr('src') ||
$('img[src*="images-na.ssl-images-amazon.com"]').first().attr('src') ||
$('img[src*="m.media-amazon.com"]').first().attr('src') ||
'';
if (result.coverArtUrl) {
result.coverArtUrl = result.coverArtUrl.replace(/\._.*_\./, '._SL500_.');
}
}
// Runtime/Duration - try multiple approaches
if (!result.durationMinutes) {
// Look for runtime text in various places
const runtimeText =
$('li.runtimeLabel span').text().trim() ||
$('.runtimeLabel').text().trim() ||
$('span:contains("Length:")').parent().text().trim() ||
$('li:contains("Length:")').text().trim() ||
(() => {
// Look for any text matching duration pattern
let found = '';
$('li, span, div').each((_, elem) => {
const text = $(elem).text().trim();
if (text.match(/\d+\s*(hr|hour|h)\s*\d*\s*(min|minute|m)?/i) && text.length < 100) {
found = text;
return false; // break
}
});
return found;
})();
result.durationMinutes = this.parseRuntime(runtimeText);
console.log(`[Audible] Duration from "${runtimeText}": ${result.durationMinutes} minutes`);
}
// Rating - try multiple approaches
if (!result.rating) {
const ratingText =
$('.ratingsLabel').text().trim() ||
$('[class*="rating"]').first().text().trim() ||
$('span:contains("out of 5 stars")').parent().text().trim() ||
(() => {
// Look for rating pattern
let found = '';
$('span, div').each((_, elem) => {
const text = $(elem).text().trim();
if (text.match(/\d+\.?\d*\s*out of\s*5/i) && text.length < 50) {
found = text;
return false;
}
});
return found;
})();
if (ratingText) {
const ratingMatch = ratingText.match(/(\d+\.?\d*)\s*out of/i);
result.rating = ratingMatch ? parseFloat(ratingMatch[1]) : undefined;
}
console.log(`[Audible] Rating from "${ratingText}": ${result.rating}`);
}
// Release date - try multiple selectors
if (!result.releaseDate) {
const releaseDateText =
$('li:contains("Release date:")').text().trim() ||
$('span:contains("Release date:")').parent().text().trim() ||
$('[class*="release"]').text().trim();
const dateMatch = releaseDateText.match(/Release date:\s*(.+)/i) ||
releaseDateText.match(/(\w+ \d{1,2},? \d{4})/);
if (dateMatch) {
result.releaseDate = dateMatch[1].trim();
}
console.log(`[Audible] Release date from "${releaseDateText}": ${result.releaseDate}`);
}
// Genres - try to extract categories
const genres: string[] = [];
$('a[href*="/cat/"]').each((_, el) => {
const genre = $(el).text().trim();
if (genre && !genres.includes(genre) && genre.length < 50 && genre.length > 2) {
genres.push(genre);
}
});
if (genres.length > 0) {
result.genres = genres.slice(0, 5); // Limit to 5 genres
console.log(`[Audible] Genres: ${result.genres.join(', ')}`);
}
console.log(`[Audible] Successfully fetched details for "${result.title}"`);
console.log(`[Audible] Final result:`, JSON.stringify({
title: result.title,
author: result.author,
narrator: result.narrator,
descLength: result.description?.length || 0,
duration: result.durationMinutes,
rating: result.rating,
genres: result.genres?.length || 0
}));
return result;
} catch (error) {
console.error(`[Audible] Failed to fetch details for ${asin}:`, error);
return null;
}
}
/**
* Parse runtime text to minutes
*/
private parseRuntime(runtimeText: string): number | undefined {
if (!runtimeText) return undefined;
const hoursMatch = runtimeText.match(/(\d+)\s*hrs?/i);
const minutesMatch = runtimeText.match(/(\d+)\s*mins?/i);
let totalMinutes = 0;
if (hoursMatch) {
totalMinutes += parseInt(hoursMatch[1]) * 60;
}
if (minutesMatch) {
totalMinutes += parseInt(minutesMatch[1]);
}
return totalMinutes > 0 ? totalMinutes : undefined;
}
/**
* Add delay between requests to respect rate limits
*/
private async delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Singleton instance
let audibleService: AudibleService | null = null;
export function getAudibleService(): AudibleService {
if (!audibleService) {
audibleService = new AudibleService();
}
return audibleService;
}
+986
View File
@@ -0,0 +1,986 @@
/**
* Component: Plex Media Server Integration Service
* Documentation: documentation/integrations/plex.md
*/
import axios, { AxiosInstance } from 'axios';
import { parseStringPromise } from 'xml2js';
const PLEX_TV_API_BASE = 'https://plex.tv/api/v2';
const PLEX_CLIENT_IDENTIFIER = process.env.PLEX_CLIENT_IDENTIFIER || 'readmeabook-unique-client-id';
const PLEX_PRODUCT_NAME = process.env.PLEX_PRODUCT_NAME || 'ReadMeABook';
export interface PlexPin {
id: number;
code: string;
authToken?: string;
}
export interface PlexUser {
id: number;
username: string;
email?: string;
thumb?: string;
authToken: string;
}
export interface PlexLibrary {
id: string;
title: string;
type: string;
language: string;
scanner: string;
agent: string;
locations: string[];
itemCount?: number;
}
export interface PlexAudiobook {
ratingKey: string;
guid: string;
title: string;
author?: string;
narrator?: string;
duration?: number;
year?: number;
userRating?: number;
summary?: string;
thumb?: string;
addedAt: number;
updatedAt: number;
filePath?: string;
}
export interface PlexServerInfo {
machineIdentifier: string;
version: string;
platform: string;
platformVersion?: string;
}
export interface PlexHomeUser {
id: string;
uuid: string;
title: string;
friendlyName: string;
username: string;
email: string;
thumb: string;
hasPassword: boolean;
restricted: boolean;
admin: boolean;
guest: boolean;
protected: boolean;
}
export class PlexService {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
timeout: 10000,
});
}
/**
* Request a new PIN for OAuth authentication
*/
async requestPin(): Promise<PlexPin> {
try {
const response = await this.client.post(
`${PLEX_TV_API_BASE}/pins`,
{
strong: true,
},
{
headers: {
'Accept': 'application/json',
'X-Plex-Client-Identifier': PLEX_CLIENT_IDENTIFIER,
'X-Plex-Product': PLEX_PRODUCT_NAME,
},
}
);
return {
id: response.data.id,
code: response.data.code,
};
} catch (error) {
console.error('Failed to request Plex PIN:', error);
throw new Error('Failed to request authentication PIN from Plex');
}
}
/**
* Check PIN status (poll until user authorizes)
*/
async checkPin(pinId: number): Promise<string | null> {
try {
const response = await this.client.get(`${PLEX_TV_API_BASE}/pins/${pinId}`, {
headers: {
'Accept': 'application/json',
'X-Plex-Client-Identifier': PLEX_CLIENT_IDENTIFIER,
},
});
return response.data.authToken || null;
} catch (error) {
console.error('Failed to check Plex PIN:', error);
return null;
}
}
/**
* Get user information using auth token
*/
async getUserInfo(authToken: string): Promise<PlexUser> {
try {
const response = await this.client.get('https://plex.tv/users/account', {
headers: {
'Accept': 'application/json',
'X-Plex-Token': authToken,
},
});
let userData: any;
// Handle different response formats from Plex
if (typeof response.data === 'string') {
// XML response - parse it
console.log('[Plex] Received XML response, parsing...');
const parsed = await parseStringPromise(response.data);
// XML attributes are in user.$
if (parsed.user && parsed.user.$) {
userData = parsed.user.$;
} else {
console.error('[Plex] Unexpected XML structure:', parsed);
throw new Error('Unexpected XML structure in Plex response');
}
} else if (response.data && typeof response.data === 'object') {
// JSON response
console.log('[Plex] Received JSON response');
userData = response.data;
} else {
console.error('[Plex] Unexpected response type:', typeof response.data);
throw new Error('Unexpected response format from Plex');
}
console.log('[Plex] Parsed user data:', JSON.stringify(userData, null, 2));
// Validate required fields
if (!userData.id) {
console.error('[Plex] User ID missing from parsed data:', userData);
throw new Error('User ID missing from Plex response');
}
const username = userData.username || userData.title;
if (!username) {
console.error('[Plex] Username missing from parsed data:', userData);
throw new Error('Username missing from Plex response');
}
return {
id: parseInt(userData.id, 10),
username,
email: userData.email || undefined,
thumb: userData.thumb || undefined,
authToken,
};
} catch (error) {
console.error('Failed to get Plex user info:', error);
if (error instanceof Error) {
throw error; // Re-throw our custom errors
}
throw new Error('Failed to retrieve user information from Plex');
}
}
/**
* Generate Plex OAuth URL
*/
getOAuthUrl(pinCode: string, pinId: number, baseCallbackUrl?: string): string {
// Use provided callback URL, or fall back to env var, or localhost
const callbackBase = baseCallbackUrl || process.env.PLEX_OAUTH_CALLBACK_URL || 'http://localhost:3030/api/auth/plex/callback';
const callbackUrl = encodeURIComponent(`${callbackBase}?pinId=${pinId}`);
return `https://app.plex.tv/auth#?clientID=${PLEX_CLIENT_IDENTIFIER}&code=${pinCode}&context[device][product]=${PLEX_PRODUCT_NAME}&forwardUrl=${callbackUrl}`;
}
/**
* Test connection to Plex server
*/
async testConnection(serverUrl: string, authToken: string): Promise<{ success: boolean; message: string; info?: PlexServerInfo }> {
try {
const response = await this.client.get(`${serverUrl}/identity`, {
headers: {
'X-Plex-Token': authToken,
'Accept': 'application/json',
},
});
let data = response.data;
// Handle different response formats from Plex
if (typeof data === 'string') {
// XML response - parse it
const parsed = await parseStringPromise(data);
// XML attributes are in MediaContainer.$
data = parsed.MediaContainer && parsed.MediaContainer.$
? parsed.MediaContainer.$
: parsed.MediaContainer || {};
} else if (data && typeof data === 'object') {
// JSON response - could be direct object or wrapped in MediaContainer
if (data.MediaContainer) {
// If wrapped, extract the MediaContainer object
data = data.MediaContainer;
}
// else data is already the right format
}
console.log('[Plex] Identity response:', JSON.stringify(data, null, 2));
const info: PlexServerInfo = {
machineIdentifier: data.machineIdentifier || 'unknown',
version: data.version || 'unknown',
platform: data.platform || 'Plex Server',
platformVersion: data.platformVersion,
};
return {
success: true,
message: `Connected to Plex server (${info.platform} v${info.version})`,
info,
};
} catch (error) {
console.error('Plex connection test failed:', error);
return {
success: false,
message: 'Could not connect to Plex server. Check server URL and token.',
};
}
}
/**
* Get server-specific access token for a user
*
* Per Plex API docs: plex.tv OAuth tokens are for talking to plex.tv,
* but you need server-specific access tokens from /api/v2/resources to talk to PMS.
*
* @param serverMachineId - The machine identifier of the PMS
* @param userPlexToken - The user's plex.tv OAuth token
* @returns The server-specific access token, or null if not found/no access
*/
async getServerAccessToken(
serverMachineId: string,
userPlexToken: string
): Promise<string | null> {
try {
console.log('[Plex] Fetching server access token for machineId:', serverMachineId);
// Get the list of servers/resources the user has access to
const response = await this.client.get('https://plex.tv/api/v2/resources', {
headers: {
'X-Plex-Token': userPlexToken,
'X-Plex-Client-Identifier': PLEX_CLIENT_IDENTIFIER,
'Accept': 'application/json',
},
params: {
includeHttps: 1,
includeRelay: 1,
},
timeout: 10000,
});
const resources = response.data || [];
// Find the server resource matching the machine ID
const serverResource = resources.find((r: any) => {
const resourceId = r.clientIdentifier || r.machineIdentifier;
return resourceId === serverMachineId;
});
if (!serverResource) {
console.warn('[Plex] User does not have access to server:', serverMachineId);
return null;
}
if (!serverResource.accessToken) {
console.error('[Plex] Server resource found but no accessToken provided');
return null;
}
console.log('[Plex] Found server access token for:', serverResource.name);
return serverResource.accessToken;
} catch (error) {
console.error('[Plex] Failed to fetch server access token:', error);
return null;
}
}
/**
* Verify user has access to the configured Plex server
* Returns true if user can access the server, false otherwise
*
* This checks if the server appears in the user's list of accessible servers
* from plex.tv, which properly validates shared access permissions.
*/
async verifyServerAccess(serverUrl: string, serverMachineId: string, userToken: string): Promise<boolean> {
try {
console.log('[Plex] Verifying server access for machineId:', serverMachineId);
// Get the list of servers/resources the user has access to
const response = await this.client.get('https://plex.tv/api/v2/resources', {
headers: {
'X-Plex-Token': userToken,
'X-Plex-Client-Identifier': PLEX_CLIENT_IDENTIFIER,
'Accept': 'application/json',
},
params: {
includeHttps: 1,
includeRelay: 1,
},
timeout: 10000,
});
const resources = response.data || [];
console.log('[Plex] User has access to', resources.length, 'resources');
// Log all resources for debugging
console.log('[Plex] User accessible resources:', JSON.stringify(
resources.map((r: any) => ({
name: r.name,
product: r.product,
provides: r.provides,
clientIdentifier: r.clientIdentifier,
machineIdentifier: r.machineIdentifier,
owned: r.owned,
})),
null,
2
));
// Filter to only server resources (not clients like apps)
const servers = resources.filter((r: any) =>
r.provides === 'server' ||
r.product === 'Plex Media Server' ||
(r.provides && r.provides.includes && r.provides.includes('server'))
);
console.log('[Plex] Found', servers.length, 'server resources');
// Check if our server is in the list of accessible resources
const hasAccess = servers.some((resource: any) => {
const resourceId = resource.clientIdentifier || resource.machineIdentifier;
const match = resourceId === serverMachineId;
console.log('[Plex] Comparing:', {
resourceId,
serverMachineId,
match,
name: resource.name,
});
if (match) {
console.log('[Plex] ✓ Found matching server:', {
name: resource.name,
machineId: resourceId,
owned: resource.owned,
});
}
return match;
});
if (!hasAccess) {
console.warn('[Plex] ✗ Server not found in user\'s accessible resources');
console.warn('[Plex] Looking for machineId:', serverMachineId);
console.warn('[Plex] User has access to servers:',
servers.map((r: any) => ({
name: r.name,
clientId: r.clientIdentifier,
machineId: r.machineIdentifier,
}))
);
}
return hasAccess;
} catch (error: any) {
console.error('[Plex] Failed to verify server access:', error.response?.status || error.message);
if (error.response?.data) {
console.error('[Plex] Error response:', error.response.data);
}
return false;
}
}
/**
* Get all libraries from Plex server
*/
async getLibraries(serverUrl: string, authToken: string): Promise<PlexLibrary[]> {
try {
const response = await this.client.get(`${serverUrl}/library/sections`, {
headers: {
'X-Plex-Token': authToken,
'Accept': 'application/json',
},
});
let data = response.data;
// Handle different response formats from Plex
if (typeof data === 'string') {
// XML response - parse it
const parsed = await parseStringPromise(data);
data = parsed.MediaContainer;
} else if (data && typeof data === 'object') {
// JSON response - could be wrapped in MediaContainer
if (data.MediaContainer) {
data = data.MediaContainer;
}
}
const directories = data.Directory || [];
const libraries = directories.map((dir: any) => ({
id: (dir.key || dir.$?.key || '').toString(),
title: dir.title || dir.$?.title || 'Unknown Library',
type: dir.type || dir.$?.type || 'unknown',
language: dir.language || dir.$?.language || 'en',
scanner: dir.scanner || dir.$?.scanner || '',
agent: dir.agent || dir.$?.agent || '',
locations: Array.isArray(dir.Location)
? dir.Location.map((loc: any) => loc.path || loc.$?.path || '')
: [],
}));
return libraries;
} catch (error) {
console.error('Failed to get Plex libraries:', error);
throw new Error('Failed to retrieve libraries from Plex server');
}
}
/**
* Get recently added items from a library (lightweight polling method)
* Uses sort by addedAt descending with pagination
*/
async getRecentlyAdded(
serverUrl: string,
authToken: string,
libraryId: string,
limit: number = 10
): Promise<PlexAudiobook[]> {
try {
const response = await this.client.get(
`${serverUrl}/library/sections/${libraryId}/all`,
{
params: {
type: 9, // Type 9 = Albums (books in audiobook context)
sort: 'addedAt:desc',
'X-Plex-Container-Start': 0,
'X-Plex-Container-Size': limit,
},
headers: {
'X-Plex-Token': authToken,
'Accept': 'application/json',
},
}
);
console.log('[Plex] Recently added response type:', typeof response.data);
// Handle XML response
let data = response.data;
if (typeof data === 'string') {
console.log('[Plex] Parsing XML response...');
const parsed = await parseStringPromise(data);
data = parsed.MediaContainer;
} else if (data && typeof data === 'object') {
// JSON response - could be wrapped in MediaContainer
if (data.MediaContainer) {
console.log('[Plex] Extracting from MediaContainer wrapper');
data = data.MediaContainer;
}
}
const tracks = data.Metadata || data.Track || data.Directory || data.Album || [];
console.log('[Plex] Found', Array.isArray(tracks) ? tracks.length : '(not an array)', 'recently added items');
if (!Array.isArray(tracks)) {
console.warn('[Plex] tracks is not an array:', tracks);
return [];
}
return tracks.map((item: any) => ({
ratingKey: item.ratingKey || item.$?.ratingKey,
guid: item.guid || item.$?.guid || '',
title: item.title || item.$?.title, // Album title (book name)
author: item.parentTitle || item.$?.parentTitle || item.originalTitle, // Artist name (author)
narrator: item.writer || item.$?.writer,
duration: item.duration ? parseInt(item.duration) : undefined,
year: item.year ? parseInt(item.year) : undefined,
summary: item.summary || item.$?.summary,
thumb: item.thumb || item.$?.thumb,
addedAt: item.addedAt ? parseInt(item.addedAt) : Date.now(),
updatedAt: item.updatedAt ? parseInt(item.updatedAt) : Date.now(),
userRating: item.userRating ? parseFloat(item.userRating) : (item.$?.userRating ? parseFloat(item.$?.userRating) : undefined),
}));
} catch (error) {
console.error('Failed to get recently added content:', error);
throw new Error('Failed to retrieve recently added content from Plex library');
}
}
/**
* Get all items from a library
*/
async getLibraryContent(
serverUrl: string,
authToken: string,
libraryId: string
): Promise<PlexAudiobook[]> {
try {
const response = await this.client.get(
`${serverUrl}/library/sections/${libraryId}/all`,
{
params: {
type: 9, // Type 9 = Albums (books in audiobook context)
},
headers: {
'X-Plex-Token': authToken,
'Accept': 'application/json',
},
}
);
console.log('[Plex] Library content response type:', typeof response.data);
// Handle XML response
let data = response.data;
if (typeof data === 'string') {
console.log('[Plex] Parsing XML response...');
const parsed = await parseStringPromise(data);
data = parsed.MediaContainer;
} else if (data && typeof data === 'object') {
// JSON response - could be wrapped in MediaContainer
if (data.MediaContainer) {
console.log('[Plex] Extracting from MediaContainer wrapper');
data = data.MediaContainer;
}
}
console.log('[Plex] Data structure keys:', Object.keys(data || {}));
console.log('[Plex] Looking for content in: Metadata, Track, Directory, Album');
const tracks = data.Metadata || data.Track || data.Directory || data.Album || [];
console.log('[Plex] Found', Array.isArray(tracks) ? tracks.length : '(not an array)', 'items');
if (!Array.isArray(tracks)) {
console.warn('[Plex] tracks is not an array:', tracks);
return [];
}
return tracks.map((item: any) => ({
ratingKey: item.ratingKey || item.$?.ratingKey,
guid: item.guid || item.$?.guid || '',
title: item.title || item.$?.title, // Album title (book name)
author: item.parentTitle || item.$?.parentTitle || item.originalTitle, // Artist name (author)
narrator: item.writer || item.$?.writer,
duration: item.duration ? parseInt(item.duration) : undefined,
year: item.year ? parseInt(item.year) : undefined,
summary: item.summary || item.$?.summary,
thumb: item.thumb || item.$?.thumb,
addedAt: item.addedAt ? parseInt(item.addedAt) : Date.now(),
updatedAt: item.updatedAt ? parseInt(item.updatedAt) : Date.now(),
userRating: item.userRating ? parseFloat(item.userRating) : (item.$?.userRating ? parseFloat(item.$?.userRating) : undefined),
}));
} catch (error: any) {
if (error?.response?.status === 401) {
console.error('[Plex] 401 Unauthorized when fetching library content - token may not have server access permissions');
} else {
console.error('[Plex] Failed to get library content:', error);
}
throw new Error('Failed to retrieve content from Plex library');
}
}
/**
* Trigger library scan
*/
async scanLibrary(serverUrl: string, authToken: string, libraryId: string): Promise<void> {
try {
await this.client.get(`${serverUrl}/library/sections/${libraryId}/refresh`, {
headers: {
'X-Plex-Token': authToken,
},
});
console.log(`Triggered Plex library scan for library ${libraryId}`);
} catch (error) {
console.error('Failed to trigger Plex scan:', error);
throw new Error('Failed to trigger Plex library scan');
}
}
/**
* Search library for specific title
*/
async searchLibrary(
serverUrl: string,
authToken: string,
libraryId: string,
query: string
): Promise<PlexAudiobook[]> {
try {
const response = await this.client.get(
`${serverUrl}/library/sections/${libraryId}/search`,
{
params: { title: query },
headers: {
'X-Plex-Token': authToken,
'Accept': 'application/json',
},
}
);
// Handle XML response
let data = response.data;
if (typeof data === 'string') {
const parsed = await parseStringPromise(data);
data = parsed.MediaContainer;
}
const items = data.Metadata || [];
return items.map((item: any) => ({
ratingKey: item.ratingKey || item.$.ratingKey,
guid: item.guid || item.$.guid || '',
title: item.title || item.$.title,
author: item.grandparentTitle || item.$.grandparentTitle,
duration: item.duration ? parseInt(item.duration) : undefined,
summary: item.summary || item.$.summary,
thumb: item.thumb || item.$.thumb,
addedAt: item.addedAt ? parseInt(item.addedAt) : Date.now(),
updatedAt: item.updatedAt ? parseInt(item.updatedAt) : Date.now(),
}));
} catch (error) {
console.error('Failed to search Plex library:', error);
return [];
}
}
/**
* Get metadata for a specific item (by ratingKey) with user's personal rating
* This fetches the item with the user's auth token, which includes their personal rating
*/
async getItemMetadata(
serverUrl: string,
authToken: string,
ratingKey: string
): Promise<{ userRating?: number } | null> {
try {
const response = await this.client.get(
`${serverUrl}/library/metadata/${ratingKey}`,
{
headers: {
'X-Plex-Token': authToken,
'Accept': 'application/json',
},
}
);
let data = response.data;
// Handle different response formats
if (typeof data === 'string') {
const parsed = await parseStringPromise(data);
data = parsed.MediaContainer;
} else if (data && typeof data === 'object') {
if (data.MediaContainer) {
data = data.MediaContainer;
}
}
// Extract first metadata item
const items = data.Metadata || [];
if (!Array.isArray(items) || items.length === 0) {
return null;
}
const item = items[0];
return {
userRating: item.userRating
? parseFloat(item.userRating)
: (item.$?.userRating ? parseFloat(item.$?.userRating) : undefined),
};
} catch (error: any) {
// Handle 401 specifically (expired or invalid token)
if (error.response?.status === 401) {
console.warn(`[Plex] User token unauthorized for ratingKey ${ratingKey} (token may be expired or invalid)`);
return null;
}
// Handle 404 (item not found or user doesn't have access)
if (error.response?.status === 404) {
console.warn(`[Plex] Item not found or no access: ratingKey ${ratingKey}`);
return null;
}
console.error(`[Plex] Failed to get metadata for ratingKey ${ratingKey}:`, error.message || error);
return null;
}
}
/**
* Batch fetch ratings for multiple items using user's token
* Returns a map of ratingKey -> userRating
*/
async batchGetUserRatings(
serverUrl: string,
authToken: string,
ratingKeys: string[]
): Promise<Map<string, number>> {
const ratingsMap = new Map<string, number>();
let unauthorizedCount = 0;
// Fetch ratings in parallel (limit concurrency to avoid overwhelming Plex)
const BATCH_SIZE = 10;
for (let i = 0; i < ratingKeys.length; i += BATCH_SIZE) {
const batch = ratingKeys.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(ratingKey => this.getItemMetadata(serverUrl, authToken, ratingKey))
);
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value?.userRating) {
const ratingKey = batch[index];
ratingsMap.set(ratingKey, result.value.userRating);
} else if (result.status === 'rejected') {
// Count authorization failures
if (result.reason?.response?.status === 401) {
unauthorizedCount++;
}
}
});
}
// If we got many 401s, log a warning about token issues
if (unauthorizedCount > 0) {
console.warn(`[Plex] ${unauthorizedCount} of ${ratingKeys.length} items returned 401 (user token may be expired or invalid)`);
if (unauthorizedCount === ratingKeys.length) {
console.error('[Plex] All rating requests failed with 401 - user needs to re-authenticate with Plex');
}
}
return ratingsMap;
}
/**
* Get list of Plex Home users/profiles
* Returns all managed users and home members for the authenticated account
*/
async getHomeUsers(authToken: string): Promise<PlexHomeUser[]> {
try {
console.log('[Plex] Fetching home users from plex.tv/api/home/users');
const response = await this.client.get(
'https://plex.tv/api/home/users',
{
headers: {
'Accept': 'application/json',
'X-Plex-Token': authToken,
'X-Plex-Client-Identifier': PLEX_CLIENT_IDENTIFIER,
},
}
);
console.log('[Plex] Home users API response status:', response.status);
console.log('[Plex] Home users API response type:', typeof response.data);
// Handle XML response
let data = response.data;
if (typeof data === 'string') {
console.log('[Plex] Response is XML string, parsing...');
const parsed = await parseStringPromise(data);
data = parsed;
console.log('[Plex] Parsed XML structure:', JSON.stringify(data, null, 2));
} else {
console.log('[Plex] Response is JSON, structure:', JSON.stringify(data, null, 2));
}
// Extract users from response
// Response structure: { home: { users: [{ user: {...} }] } } or similar
const users: any[] = [];
console.log('[Plex] Checking for users in response...');
console.log('[Plex] data.MediaContainer exists?', !!data.MediaContainer);
console.log('[Plex] data.MediaContainer?.User exists?', !!data.MediaContainer?.User);
console.log('[Plex] data.home exists?', !!data.home);
console.log('[Plex] data.home?.users exists?', !!data.home?.users);
console.log('[Plex] data.users exists?', !!data.users);
// Check for users in MediaContainer.User (XML response structure)
if (data.MediaContainer?.User) {
console.log('[Plex] Found users in data.MediaContainer.User');
const usersList = Array.isArray(data.MediaContainer.User) ? data.MediaContainer.User : [data.MediaContainer.User];
console.log('[Plex] usersList length:', usersList.length);
usersList.forEach((item: any) => {
// XML parsed data has attributes in the $ property
if (item.$) {
users.push(item.$);
} else {
users.push(item);
}
});
} else if (data.home?.users) {
console.log('[Plex] Found users in data.home.users');
const usersList = Array.isArray(data.home.users) ? data.home.users : [data.home.users];
console.log('[Plex] usersList length:', usersList.length);
usersList.forEach((item: any) => {
if (item.user) {
users.push(item.user);
} else if (item.$) {
users.push(item.$);
} else {
users.push(item);
}
});
} else if (data.users) {
console.log('[Plex] Found users in data.users');
const usersList = Array.isArray(data.users) ? data.users : [data.users];
console.log('[Plex] usersList length:', usersList.length);
usersList.forEach((item: any) => {
if (item.user) {
users.push(item.user);
} else if (item.$) {
users.push(item.$);
} else {
users.push(item);
}
});
} else {
console.log('[Plex] No users found in expected locations. Full data structure:');
console.log(JSON.stringify(data, null, 2));
}
console.log('[Plex] Extracted', users.length, 'users from response');
if (users.length === 0) {
console.warn('[Plex] No home users found - this account may not have a Plex Home setup');
return [];
}
return users.map((user: any) => {
// Handle both direct properties and $ properties (from XML parsing)
const id = user.id || '';
const uuid = user.uuid || '';
const title = user.title || '';
const username = user.username || '';
const email = user.email || '';
const thumb = user.thumb || '';
const hasPassword = user.hasPassword === '1' || user.hasPassword === 'true' || user.hasPassword === true;
const restricted = user.restricted === '1' || user.restricted === 'true' || user.restricted === true;
const admin = user.admin === '1' || user.admin === 'true' || user.admin === true;
const guest = user.guest === '1' || user.guest === 'true' || user.guest === true;
const protectedUser = user.protected === '1' || user.protected === 'true' || user.protected === true;
return {
id,
uuid,
title,
friendlyName: title, // In Plex Home API, 'title' is the friendly display name
username,
email,
thumb,
hasPassword,
restricted,
admin,
guest,
protected: protectedUser,
};
});
} catch (error: any) {
console.error('[Plex] Failed to get home users:', error.message || error);
if (error.response) {
console.error('[Plex] Error response status:', error.response.status);
console.error('[Plex] Error response data:', error.response.data);
}
// Return empty array if no home users (not an error condition)
return [];
}
}
/**
* Switch to a specific Plex Home user/profile
* Returns the authentication token for the selected profile
*/
async switchHomeUser(
userId: string,
authToken: string,
pin?: string
): Promise<string | null> {
try {
const params: any = {};
if (pin) {
params.pin = pin;
}
const response = await this.client.post(
`https://plex.tv/api/home/users/${userId}/switch`,
null,
{
params,
headers: {
'Accept': 'application/json',
'X-Plex-Token': authToken,
'X-Plex-Client-Identifier': PLEX_CLIENT_IDENTIFIER,
},
}
);
// Handle XML response
let data = response.data;
if (typeof data === 'string') {
const parsed = await parseStringPromise(data);
data = parsed;
}
// Extract authenticationToken from response
// Response structure varies: could be in root, in user object, or in attributes
let authenticationToken: string | null = null;
if (data.authenticationToken) {
authenticationToken = data.authenticationToken;
} else if (data.user?.authenticationToken) {
authenticationToken = data.user.authenticationToken;
} else if (data.$?.authenticationToken) {
authenticationToken = data.$?.authenticationToken;
} else if (data.user?.$?.authenticationToken) {
authenticationToken = data.user.$?.authenticationToken;
}
if (!authenticationToken) {
console.error('[Plex] No authenticationToken found in switch response:', JSON.stringify(data, null, 2));
return null;
}
return authenticationToken;
} catch (error: any) {
// Handle PIN errors specifically
if (error.response?.status === 401) {
console.error('[Plex] Invalid PIN for profile');
throw new Error('Invalid PIN');
}
console.error('[Plex] Failed to switch home user:', error);
throw new Error('Failed to switch to selected profile');
}
}
}
// Singleton instance
let plexService: PlexService | null = null;
export function getPlexService(): PlexService {
if (!plexService) {
plexService = new PlexService();
}
return plexService;
}
+355
View File
@@ -0,0 +1,355 @@
/**
* Component: Prowlarr Integration Service
* Documentation: documentation/phase3/prowlarr.md
*/
import axios, { AxiosInstance } from 'axios';
import { XMLParser } from 'fast-xml-parser';
import { TorrentResult } from '../utils/ranking-algorithm';
export interface SearchFilters {
category?: number;
minSeeders?: number;
maxResults?: number;
}
export interface Indexer {
id: number;
name: string;
enable: boolean;
protocol: string;
priority: number;
capabilities?: {
supportsRss?: boolean;
};
fields?: Array<{
name: string;
value: any;
}>;
}
export interface IndexerStats {
indexers: Array<{
indexerId: number;
indexerName: string;
numberOfQueries: number;
numberOfGrabs: number;
numberOfFailedQueries: number;
averageResponseTime: number;
}>;
}
interface ProwlarrSearchResult {
guid: string;
indexer: string;
title: string;
size: number;
seeders: number;
leechers: number;
publishDate: string;
downloadUrl: string;
infoHash?: string;
categories?: number[];
}
export class ProwlarrService {
private client: AxiosInstance;
private baseUrl: string;
private apiKey: string;
private defaultCategory = 3030; // Audiobooks category
constructor(baseUrl: string, apiKey: string) {
this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash
this.apiKey = apiKey;
this.client = axios.create({
baseURL: `${this.baseUrl}/api/v1`,
headers: {
'X-Api-Key': this.apiKey,
},
timeout: 30000, // 30 seconds
});
}
/**
* Search for audiobooks across all configured indexers
*/
async search(
query: string,
filters?: SearchFilters
): Promise<TorrentResult[]> {
try {
const params: Record<string, any> = {
query,
categories: filters?.category?.toString() || this.defaultCategory.toString(),
type: 'search',
extended: 1, // Enable searching in tags, labels, and metadata
};
const response = await this.client.get('/search', { params });
// Transform Prowlarr results to our format
const results = response.data
.map((result: ProwlarrSearchResult) => this.transformResult(result))
.filter((result: TorrentResult | null) => result !== null) as TorrentResult[];
// Apply filters
let filtered = results;
if (filters?.minSeeders) {
filtered = filtered.filter((r) => r.seeders >= (filters.minSeeders || 0));
}
if (filters?.maxResults) {
filtered = filtered.slice(0, filters.maxResults);
}
console.log(`Prowlarr search for "${query}" returned ${filtered.length} results`);
return filtered;
} catch (error) {
console.error('Prowlarr search failed:', error);
throw new Error(
`Failed to search Prowlarr: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Get list of configured indexers
*/
async getIndexers(): Promise<Indexer[]> {
try {
const response = await this.client.get('/indexer');
return response.data;
} catch (error) {
console.error('Failed to get Prowlarr indexers:', error);
throw new Error('Failed to get indexers from Prowlarr');
}
}
/**
* Test connection to Prowlarr
*/
async testConnection(): Promise<boolean> {
try {
await this.client.get('/health');
return true;
} catch (error) {
console.error('Prowlarr connection test failed:', error);
return false;
}
}
/**
* Get indexer statistics
*/
async getStats(): Promise<IndexerStats> {
try {
const response = await this.client.get('/indexerstats');
return response.data;
} catch (error) {
console.error('Failed to get Prowlarr stats:', error);
throw new Error('Failed to get indexer statistics');
}
}
/**
* Get RSS feed for a specific indexer
* Returns recent releases from the indexer's RSS feed
* Uses true RSS feed endpoint to avoid burdening indexers with searches
*/
async getRssFeed(indexerId: number): Promise<TorrentResult[]> {
try {
// Prowlarr RSS endpoint: /{indexerId}/api?apikey={key}&t=search&cat=3030
const rssUrl = `${this.baseUrl}/${indexerId}/api`;
const response = await axios.get(rssUrl, {
params: {
apikey: this.apiKey,
t: 'search',
cat: this.defaultCategory.toString(),
limit: 100,
extended: 1,
},
timeout: 30000,
responseType: 'text', // Get XML as text
});
// Parse XML RSS feed
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
});
const parsed = parser.parse(response.data);
// Extract items from RSS feed
const items = parsed?.rss?.channel?.item || [];
const itemsArray = Array.isArray(items) ? items : [items];
// Transform RSS items to TorrentResult format
const results: TorrentResult[] = [];
for (const item of itemsArray) {
if (!item) continue;
try {
// Extract torznab attributes
const attrs = Array.isArray(item['torznab:attr']) ? item['torznab:attr'] : [item['torznab:attr']];
const getAttr = (name: string) => {
const attr = attrs.find((a: any) => a?.['@_name'] === name);
return attr?.['@_value'];
};
const seeders = parseInt(getAttr('seeders') || '0', 10);
const peers = parseInt(getAttr('peers') || '0', 10);
const leechers = Math.max(0, peers - seeders);
// Extract metadata from title
const metadata = this.extractMetadata(item.title || '');
const result: TorrentResult = {
indexer: item.prowlarrindexer?.['#text'] || item.prowlarrindexer || 'Unknown',
title: item.title || '',
size: parseInt(item.size || '0', 10),
seeders,
leechers,
publishDate: item.pubDate ? new Date(item.pubDate) : new Date(),
downloadUrl: item.link || item.enclosure?.['@_url'] || '',
infoHash: getAttr('infohash'),
guid: item.guid || '',
format: metadata.format,
bitrate: metadata.bitrate,
hasChapters: metadata.hasChapters,
};
results.push(result);
} catch (error) {
console.error('Failed to parse RSS item:', error);
// Continue with other items
}
}
console.log(`RSS feed for indexer ${indexerId} returned ${results.length} results`);
return results;
} catch (error) {
console.error(`Failed to get RSS feed for indexer ${indexerId}:`, error);
throw new Error(`Failed to get RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get RSS feeds from all enabled indexers
*/
async getAllRssFeeds(indexerIds: number[]): Promise<TorrentResult[]> {
const allResults: TorrentResult[] = [];
for (const indexerId of indexerIds) {
try {
const results = await this.getRssFeed(indexerId);
allResults.push(...results);
} catch (error) {
console.error(`Failed to get RSS feed for indexer ${indexerId}:`, error);
// Continue with other indexers even if one fails
}
}
console.log(`RSS feeds from ${indexerIds.length} indexers returned ${allResults.length} total results`);
return allResults;
}
/**
* Transform Prowlarr result to our TorrentResult format
*/
private transformResult(result: ProwlarrSearchResult): TorrentResult | null {
try {
// Extract metadata from title
const metadata = this.extractMetadata(result.title);
return {
indexer: result.indexer,
title: result.title,
size: result.size,
seeders: result.seeders,
leechers: result.leechers,
publishDate: new Date(result.publishDate),
downloadUrl: result.downloadUrl,
infoHash: result.infoHash,
guid: result.guid,
format: metadata.format,
bitrate: metadata.bitrate,
hasChapters: metadata.hasChapters,
};
} catch (error) {
console.error('Failed to transform result:', result, error);
return null;
}
}
/**
* Extract audiobook metadata from torrent title
*/
private extractMetadata(title: string): {
format?: 'M4B' | 'M4A' | 'MP3';
bitrate?: string;
hasChapters?: boolean;
} {
const upperTitle = title.toUpperCase();
// Detect format
let format: 'M4B' | 'M4A' | 'MP3' | undefined;
if (upperTitle.includes('M4B')) {
format = 'M4B';
} else if (upperTitle.includes('M4A')) {
format = 'M4A';
} else if (upperTitle.includes('MP3')) {
format = 'MP3';
}
// Detect bitrate (e.g., "64kbps", "128 KBPS")
const bitrateMatch = title.match(/(\d+)\s*kbps/i);
const bitrate = bitrateMatch ? `${bitrateMatch[1]}kbps` : undefined;
// M4B typically has chapters
const hasChapters = format === 'M4B' ? true : undefined;
return {
format,
bitrate,
hasChapters,
};
}
}
// Singleton instance
let prowlarrService: ProwlarrService | null = null;
export async function getProwlarrService(): Promise<ProwlarrService> {
if (!prowlarrService) {
// Get configuration from database
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const config = await configService.getMany(['prowlarr_url', 'prowlarr_api_key']);
const baseUrl = config.prowlarr_url || process.env.PROWLARR_URL || 'http://prowlarr:9696';
const apiKey = config.prowlarr_api_key || process.env.PROWLARR_API_KEY;
if (!apiKey) {
throw new Error('Prowlarr API key not configured');
}
prowlarrService = new ProwlarrService(baseUrl, apiKey);
// Test connection
const isConnected = await prowlarrService.testConnection();
if (!isConnected) {
console.warn('Warning: Prowlarr connection test failed');
}
}
return prowlarrService;
}
+774
View File
@@ -0,0 +1,774 @@
/**
* Component: qBittorrent Integration Service
* Documentation: documentation/phase3/qbittorrent.md
*/
import axios, { AxiosInstance } from 'axios';
import * as parseTorrentModule from 'parse-torrent';
import FormData from 'form-data';
// Handle both ESM and CommonJS imports
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
export interface AddTorrentOptions {
savePath?: string;
category?: string;
tags?: string[];
paused?: boolean;
skipChecking?: boolean;
sequentialDownload?: boolean;
}
export interface TorrentInfo {
hash: string;
name: string;
size: number;
progress: number; // 0.0 to 1.0
dlspeed: number; // Bytes per second
upspeed: number;
downloaded: number;
uploaded: number;
eta: number; // Seconds remaining
state: TorrentState;
category: string;
tags: string;
save_path: string;
completion_on: number; // Unix timestamp
added_on: number;
seeding_time?: number; // Seconds spent seeding
ratio?: number; // Upload/download ratio
}
export type TorrentState =
| 'downloading'
| 'uploading'
| 'stalledDL'
| 'stalledUP'
| 'pausedDL'
| 'pausedUP'
| 'queuedDL'
| 'queuedUP'
| 'checkingDL'
| 'checkingUP'
| 'error'
| 'missingFiles'
| 'allocating';
export interface TorrentFile {
name: string;
size: number;
progress: number;
priority: number;
index: number;
}
export interface DownloadProgress {
percent: number;
bytesDownloaded: number;
bytesTotal: number;
speed: number;
eta: number;
state: string;
}
export class QBittorrentService {
private client: AxiosInstance;
private baseUrl: string;
private username: string;
private password: string;
private cookie?: string;
private defaultSavePath: string;
private defaultCategory: string;
constructor(
baseUrl: string,
username: string,
password: string,
defaultSavePath: string = '/downloads',
defaultCategory: string = 'readmeabook'
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.username = username;
this.password = password;
this.defaultSavePath = defaultSavePath;
this.defaultCategory = defaultCategory;
this.client = axios.create({
baseURL: `${this.baseUrl}/api/v2`,
timeout: 30000,
});
}
/**
* Authenticate and establish session
*/
async login(): Promise<void> {
try {
const response = await axios.post(
`${this.baseUrl}/api/v2/auth/login`,
new URLSearchParams({
username: this.username,
password: this.password,
}),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}
);
// Extract cookie from response
const cookies = response.headers['set-cookie'];
if (cookies && cookies.length > 0) {
this.cookie = cookies[0].split(';')[0];
}
if (!this.cookie) {
throw new Error('Failed to authenticate with qBittorrent');
}
console.log('Successfully authenticated with qBittorrent');
} catch (error) {
console.error('qBittorrent login failed:', error);
throw new Error('Failed to authenticate with qBittorrent');
}
}
/**
* Add torrent (magnet link or file URL) - Enterprise Implementation
*/
async addTorrent(url: string, options?: AddTorrentOptions): Promise<string> {
// Ensure we're authenticated
if (!this.cookie) {
await this.login();
}
try {
const category = options?.category || this.defaultCategory;
// Ensure category exists
await this.ensureCategory(category);
// Determine if this is a magnet link or .torrent file URL
if (url.startsWith('magnet:')) {
console.log('[qBittorrent] Detected magnet link');
return await this.addMagnetLink(url, category, options);
} else {
console.log('[qBittorrent] Detected .torrent file URL');
return await this.addTorrentFile(url, category, options);
}
} catch (error) {
// Try re-authenticating if we get a 403
if (axios.isAxiosError(error) && error.response?.status === 403) {
console.log('[qBittorrent] Session expired, re-authenticating...');
await this.login();
return this.addTorrent(url, options); // Retry once
}
console.error('[qBittorrent] Failed to add torrent:', error);
throw new Error('Failed to add torrent to qBittorrent');
}
}
/**
* Add magnet link - hash is extractable from URI (deterministic)
*/
private async addMagnetLink(
magnetUrl: string,
category: string,
options?: AddTorrentOptions
): Promise<string> {
// Extract info_hash from magnet link (deterministic)
const infoHash = this.extractHashFromMagnet(magnetUrl);
if (!infoHash) {
throw new Error('Invalid magnet link - could not extract info_hash');
}
console.log(`[qBittorrent] Extracted info_hash from magnet: ${infoHash}`);
// Check for duplicates
try {
const existing = await this.getTorrent(infoHash);
console.log(`[qBittorrent] Torrent ${infoHash} already exists (duplicate), returning existing hash`);
return infoHash;
} catch {
// Torrent doesn't exist, continue with adding
}
// Upload via 'urls' parameter
const form = new URLSearchParams({
urls: magnetUrl,
savepath: options?.savePath || this.defaultSavePath,
category,
paused: options?.paused ? 'true' : 'false',
sequentialDownload: (options?.sequentialDownload !== false).toString(),
});
if (options?.tags) {
form.append('tags', options.tags.join(','));
}
console.log('[qBittorrent] Uploading magnet link...');
const response = await this.client.post('/torrents/add', form, {
headers: {
Cookie: this.cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
if (response.data !== 'Ok.') {
throw new Error(`qBittorrent rejected magnet link: ${response.data}`);
}
console.log(`[qBittorrent] Successfully added magnet link: ${infoHash}`);
return infoHash;
}
/**
* Add .torrent file - download, parse, extract hash, upload content (deterministic)
*/
private async addTorrentFile(
torrentUrl: string,
category: string,
options?: AddTorrentOptions
): Promise<string> {
console.log(`[qBittorrent] Downloading .torrent file from: ${torrentUrl}`);
// Make initial request with maxRedirects: 0 to intercept redirects
// Some Prowlarr indexers return HTTP URLs that redirect to magnet: links
let torrentResponse;
try {
torrentResponse = await axios.get(torrentUrl, {
responseType: 'arraybuffer',
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 300, // Only 2xx is success
timeout: 10000,
});
console.log(`[qBittorrent] Got 2xx response, size=${torrentResponse.data.length} bytes`);
// Check if response body contains a magnet link
if (torrentResponse.data.length > 0) {
const responseText = torrentResponse.data.toString();
const magnetMatch = responseText.match(/^magnet:\?[^\s]+$/);
if (magnetMatch) {
console.log(`[qBittorrent] Response body is a magnet link`);
return await this.addMagnetLink(magnetMatch[0], category, options);
}
}
// Got valid torrent data (or will be validated below)
} catch (error) {
if (!axios.isAxiosError(error) || !error.response) {
// Not an axios error or no response - re-throw
console.error(`[qBittorrent] Request failed:`, error);
throw error;
}
const status = error.response.status;
// Handle 3xx redirects
if (status >= 300 && status < 400) {
const location = error.response.headers['location'];
console.log(`[qBittorrent] Got ${status} redirect to: ${location}`);
// Check if redirect target is a magnet link
if (location && location.startsWith('magnet:')) {
console.log(`[qBittorrent] Redirect target is magnet link`);
return await this.addMagnetLink(location, category, options);
}
// Regular HTTP redirect - follow it manually
if (location && (location.startsWith('http://') || location.startsWith('https://'))) {
console.log(`[qBittorrent] Following HTTP redirect...`);
try {
torrentResponse = await axios.get(location, {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 5,
});
console.log(`[qBittorrent] After following redirect: size=${torrentResponse.data.length} bytes`);
} catch (redirectError) {
console.error(`[qBittorrent] Failed to follow redirect:`, redirectError);
throw new Error('Failed to download torrent file after redirect');
}
} else {
throw new Error(`Invalid redirect location: ${location}`);
}
} else {
// Non-redirect error (4xx, 5xx)
console.error(`[qBittorrent] HTTP error ${status}:`, error.message);
throw new Error(`Failed to download torrent: HTTP ${status}`);
}
}
const torrentBuffer = Buffer.from(torrentResponse.data);
console.log(`[qBittorrent] Processing torrent file: ${torrentBuffer.length} bytes`);
// Parse .torrent file to extract info_hash (deterministic)
let parsedTorrent: any;
try {
parsedTorrent = await parseTorrent(torrentBuffer);
} catch (error) {
console.error('[qBittorrent] Failed to parse .torrent file:', error);
throw new Error('Invalid .torrent file - failed to parse');
}
const infoHash = parsedTorrent.infoHash;
if (!infoHash) {
throw new Error('Failed to extract info_hash from .torrent file');
}
console.log(`[qBittorrent] Extracted info_hash: ${infoHash}`);
console.log(`[qBittorrent] Torrent name: ${parsedTorrent.name || 'Unknown'}`);
// Check for duplicates
try {
const existing = await this.getTorrent(infoHash);
console.log(`[qBittorrent] Torrent ${infoHash} already exists (duplicate), returning existing hash`);
return infoHash;
} catch {
// Torrent doesn't exist, continue with adding
}
// Upload .torrent file content via multipart/form-data
const formData = new FormData();
const filename = parsedTorrent.name ? `${parsedTorrent.name}.torrent` : 'torrent.torrent';
formData.append('torrents', torrentBuffer, {
filename,
contentType: 'application/x-bittorrent',
});
formData.append('savepath', options?.savePath || this.defaultSavePath);
formData.append('category', category);
formData.append('paused', options?.paused ? 'true' : 'false');
formData.append('sequentialDownload', (options?.sequentialDownload !== false).toString());
if (options?.tags) {
formData.append('tags', options.tags.join(','));
}
console.log('[qBittorrent] Uploading .torrent file content...');
const response = await this.client.post('/torrents/add', formData, {
headers: {
Cookie: this.cookie,
...formData.getHeaders(),
},
maxBodyLength: Infinity,
maxContentLength: Infinity,
});
if (response.data !== 'Ok.') {
throw new Error(`qBittorrent rejected .torrent file: ${response.data}`);
}
console.log(`[qBittorrent] Successfully added torrent: ${infoHash}`);
return infoHash;
}
/**
* Ensure category exists in qBittorrent
*/
private async ensureCategory(category: string): Promise<void> {
if (!this.cookie) {
await this.login();
}
try {
// Create category (this is idempotent - won't fail if it already exists)
await this.client.post(
'/torrents/createCategory',
new URLSearchParams({
category,
savePath: this.defaultSavePath,
}),
{
headers: {
Cookie: this.cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
console.log(`[qBittorrent] Category "${category}" ensured`);
} catch (error) {
// Ignore errors - category might already exist
console.log(`[qBittorrent] Category creation returned:`, error);
}
}
/**
* Get torrent status and progress
*/
async getTorrent(hash: string): Promise<TorrentInfo> {
if (!this.cookie) {
await this.login();
}
try {
const response = await this.client.get('/torrents/info', {
headers: { Cookie: this.cookie },
params: { hashes: hash },
});
const torrents = response.data;
if (!torrents || torrents.length === 0) {
throw new Error(`Torrent ${hash} not found`);
}
return torrents[0];
} catch (error) {
// Don't log error here - caller handles it (e.g., duplicate checking)
throw error;
}
}
/**
* Get all torrents (optionally filtered by category)
*/
async getTorrents(category?: string): Promise<TorrentInfo[]> {
if (!this.cookie) {
await this.login();
}
try {
const params: Record<string, string> = {};
if (category) {
params.category = category;
}
const response = await this.client.get('/torrents/info', {
headers: { Cookie: this.cookie },
params,
});
return response.data;
} catch (error) {
console.error('Failed to get torrents:', error);
throw new Error('Failed to get torrents from qBittorrent');
}
}
/**
* Pause torrent
*/
async pauseTorrent(hash: string): Promise<void> {
if (!this.cookie) {
await this.login();
}
try {
await this.client.post(
'/torrents/pause',
new URLSearchParams({ hashes: hash }),
{
headers: {
Cookie: this.cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
console.log(`Paused torrent: ${hash}`);
} catch (error) {
console.error('Failed to pause torrent:', error);
throw new Error('Failed to pause torrent');
}
}
/**
* Resume torrent
*/
async resumeTorrent(hash: string): Promise<void> {
if (!this.cookie) {
await this.login();
}
try {
await this.client.post(
'/torrents/resume',
new URLSearchParams({ hashes: hash }),
{
headers: {
Cookie: this.cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
console.log(`Resumed torrent: ${hash}`);
} catch (error) {
console.error('Failed to resume torrent:', error);
throw new Error('Failed to resume torrent');
}
}
/**
* Delete torrent
*/
async deleteTorrent(hash: string, deleteFiles: boolean = false): Promise<void> {
if (!this.cookie) {
await this.login();
}
try {
await this.client.post(
'/torrents/delete',
new URLSearchParams({
hashes: hash,
deleteFiles: deleteFiles.toString(),
}),
{
headers: {
Cookie: this.cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
console.log(`Deleted torrent: ${hash}`);
} catch (error) {
console.error('Failed to delete torrent:', error);
throw new Error('Failed to delete torrent');
}
}
/**
* Get files in torrent
*/
async getFiles(hash: string): Promise<TorrentFile[]> {
if (!this.cookie) {
await this.login();
}
try {
const response = await this.client.get('/torrents/files', {
headers: { Cookie: this.cookie },
params: { hash },
});
return response.data;
} catch (error) {
console.error('Failed to get torrent files:', error);
throw new Error('Failed to get torrent files');
}
}
/**
* Set category for torrent
*/
async setCategory(hash: string, category: string): Promise<void> {
if (!this.cookie) {
await this.login();
}
try {
await this.client.post(
'/torrents/setCategory',
new URLSearchParams({
hashes: hash,
category,
}),
{
headers: {
Cookie: this.cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
console.log(`Set category for torrent ${hash}: ${category}`);
} catch (error) {
console.error('Failed to set category:', error);
throw new Error('Failed to set torrent category');
}
}
/**
* Test connection to qBittorrent
*/
async testConnection(): Promise<boolean> {
try {
await this.login();
return true;
} catch (error) {
console.error('qBittorrent connection test failed:', error);
return false;
}
}
/**
* Static method to test connection with custom credentials (for setup wizard)
*/
static async testConnectionWithCredentials(
url: string,
username: string,
password: string
): Promise<string> {
const baseUrl = url.replace(/\/$/, '');
try {
const response = await axios.post(
`${baseUrl}/api/v2/auth/login`,
new URLSearchParams({ username, password }),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}
);
// Get version to confirm connection
const cookies = response.headers['set-cookie'];
if (!cookies || cookies.length === 0) {
throw new Error('Failed to authenticate');
}
const cookie = cookies[0].split(';')[0];
const versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, {
headers: { Cookie: cookie },
});
return versionResponse.data || 'Connected';
} catch (error) {
console.error('qBittorrent connection test failed:', error);
throw new Error('Failed to connect to qBittorrent');
}
}
/**
* Get download progress details
*/
getDownloadProgress(torrent: TorrentInfo): DownloadProgress {
return {
percent: Math.round(torrent.progress * 100),
bytesDownloaded: torrent.downloaded,
bytesTotal: torrent.size,
speed: torrent.dlspeed,
eta: torrent.eta,
state: this.mapState(torrent.state),
};
}
/**
* Map qBittorrent state to our simplified state
*/
private mapState(state: TorrentState): string {
const stateMap: Record<TorrentState, string> = {
downloading: 'downloading',
uploading: 'completed',
stalledDL: 'downloading',
stalledUP: 'completed',
pausedDL: 'paused',
pausedUP: 'paused',
queuedDL: 'queued',
queuedUP: 'completed',
checkingDL: 'checking',
checkingUP: 'checking',
error: 'failed',
missingFiles: 'failed',
allocating: 'downloading',
};
return stateMap[state] || 'unknown';
}
/**
* Extract info_hash from magnet link
*/
private extractHashFromMagnet(magnetUrl: string): string | null {
// Extract hash from magnet:?xt=urn:btih:HASH
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
if (match) {
return match[1].toLowerCase();
}
return null;
}
}
// Singleton instance
let qbittorrentService: QBittorrentService | null = null;
let configLoaded = false;
export async function getQBittorrentService(): Promise<QBittorrentService> {
// Always recreate if config hasn't been loaded successfully
if (!qbittorrentService || !configLoaded) {
try {
// Get configuration from database ONLY (no env var fallback)
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
console.log('[qBittorrent] Loading configuration from database...');
const config = await configService.getMany([
'download_client_url',
'download_client_username',
'download_client_password',
'download_dir',
]);
console.log('[qBittorrent] Config loaded:', {
hasUrl: !!config.download_client_url,
hasUsername: !!config.download_client_username,
hasPassword: !!config.download_client_password,
hasPath: !!config.download_dir,
});
// Validate all required fields are present (no env var fallback)
const missingFields: string[] = [];
if (!config.download_client_url) {
missingFields.push('qBittorrent URL');
}
if (!config.download_client_username) {
missingFields.push('qBittorrent username');
}
if (!config.download_client_password) {
missingFields.push('qBittorrent password');
}
if (!config.download_dir) {
missingFields.push('Download path');
}
if (missingFields.length > 0) {
const errorMsg = `qBittorrent is not fully configured. Missing: ${missingFields.join(', ')}. Please configure qBittorrent in the admin settings.`;
console.error('[qBittorrent]', errorMsg);
throw new Error(errorMsg);
}
// TypeScript type narrowing: at this point we know all values are non-null
const url = config.download_client_url as string;
const username = config.download_client_username as string;
const password = config.download_client_password as string;
const savePath = config.download_dir as string;
console.log('[qBittorrent] Creating service instance...');
qbittorrentService = new QBittorrentService(
url,
username,
password,
savePath,
'readmeabook'
);
// Test connection
console.log('[qBittorrent] Testing connection...');
const isConnected = await qbittorrentService.testConnection();
if (!isConnected) {
console.warn('[qBittorrent] Connection test failed');
throw new Error('qBittorrent connection test failed. Please check your configuration in admin settings.');
} else {
console.log('[qBittorrent] Connection test successful');
configLoaded = true; // Mark as successfully loaded
}
} catch (error) {
console.error('[qBittorrent] Failed to initialize service:', error);
qbittorrentService = null; // Reset service on error
configLoaded = false;
throw error;
}
}
return qbittorrentService;
}
+219
View File
@@ -0,0 +1,219 @@
/**
* Component: Authentication Middleware
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
import { prisma } from '../db';
export interface AuthenticatedRequest extends NextRequest {
user?: TokenPayload & { id: string };
}
/**
* Extract token from Authorization header
*/
function extractToken(request: NextRequest): string | null {
const authHeader = request.headers.get('authorization');
if (!authHeader) {
return null;
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return null;
}
return parts[1];
}
/**
* Middleware: Require authentication
* Verifies JWT token and adds user to request
*/
export async function requireAuth(
request: NextRequest,
handler: (request: AuthenticatedRequest) => Promise<NextResponse>
): Promise<NextResponse> {
const token = extractToken(request);
if (!token) {
console.error('[Auth Middleware] No token provided');
return NextResponse.json(
{
error: 'Unauthorized',
message: 'No authentication token provided',
},
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
console.error('[Auth Middleware] Token verification failed');
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Invalid or expired token',
},
{ status: 401 }
);
}
// Verify user still exists in database
const user = await prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user) {
console.error('[Auth Middleware] User not found in database:', payload.sub);
return NextResponse.json(
{
error: 'Unauthorized',
message: 'User not found',
},
{ status: 401 }
);
}
// Add user to request
const authenticatedRequest = request as AuthenticatedRequest;
authenticatedRequest.user = {
...payload,
id: user.id,
};
return handler(authenticatedRequest);
}
/**
* Middleware: Require admin role
* Must be chained after requireAuth
*/
export async function requireAdmin(
request: AuthenticatedRequest,
handler: (request: AuthenticatedRequest) => Promise<NextResponse>
): Promise<NextResponse> {
if (!request.user) {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Authentication required',
},
{ status: 401 }
);
}
if (request.user.role !== 'admin') {
return NextResponse.json(
{
error: 'Forbidden',
message: 'Admin access required',
},
{ status: 403 }
);
}
return handler(request);
}
/**
* Helper: Get current user from request (for use in API routes)
*/
export function getCurrentUser(request: NextRequest): TokenPayload | null {
const token = extractToken(request);
if (!token) return null;
return verifyAccessToken(token);
}
/**
* Helper: Check if user is admin
*/
export function isAdmin(user: TokenPayload | null): boolean {
return user?.role === 'admin';
}
/**
* Middleware: Require local admin (setup admin)
* Must be chained after requireAuth
* Only allows local admin users (created during setup with username/password)
*/
export async function requireLocalAdmin(
request: AuthenticatedRequest,
handler: (request: AuthenticatedRequest) => Promise<NextResponse>
): Promise<NextResponse> {
if (!request.user) {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Authentication required',
},
{ status: 401 }
);
}
// Verify user is admin
if (request.user.role !== 'admin') {
return NextResponse.json(
{
error: 'Forbidden',
message: 'Admin access required',
},
{ status: 403 }
);
}
// Fetch user from database to check isSetupAdmin flag
const user = await prisma.user.findUnique({
where: { id: request.user.id },
select: {
isSetupAdmin: true,
plexId: true,
},
});
if (!user) {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'User not found',
},
{ status: 401 }
);
}
// Check if user is local admin (setup admin with local authentication)
const isLocalAdmin = user.isSetupAdmin && user.plexId.startsWith('local-');
if (!isLocalAdmin) {
return NextResponse.json(
{
error: 'Forbidden',
message: 'This action is only available to the local admin account',
},
{ status: 403 }
);
}
return handler(request);
}
/**
* Helper: Check if user is local admin (setup admin with local authentication)
*/
export async function isLocalAdmin(userId: string): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
isSetupAdmin: true,
plexId: true,
},
});
if (!user) return false;
return user.isSetupAdmin && user.plexId.startsWith('local-');
}
@@ -0,0 +1,178 @@
/**
* Component: Audible Refresh Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Fetches popular and new release audiobooks from Audible and caches them
*/
import { prisma } from '../db';
import { createJobLogger } from '../utils/job-logger';
export interface AudibleRefreshPayload {
jobId?: string;
scheduledJobId?: string;
}
export async function processAudibleRefresh(payload: AudibleRefreshPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'AudibleRefresh') : null;
await logger?.info('Starting Audible data refresh...');
const { getAudibleService } = await import('../integrations/audible.service');
const { getThumbnailCacheService } = await import('../services/thumbnail-cache.service');
const audibleService = getAudibleService();
const thumbnailCache = getThumbnailCacheService();
try {
// Clear previous popular/new-release flags for fresh data
await prisma.audibleCache.updateMany({
where: {
OR: [
{ isPopular: true },
{ isNewRelease: true },
],
},
data: {
isPopular: false,
isNewRelease: false,
popularRank: null,
newReleaseRank: null,
},
});
await logger?.info('Cleared previous popular/new-release flags in audible_cache');
// Fetch popular and new releases - 200 items each
const popular = await audibleService.getPopularAudiobooks(200);
const newReleases = await audibleService.getNewReleases(200);
await logger?.info(`Fetched ${popular.length} popular, ${newReleases.length} new releases from Audible`);
// Persist to audible_cache
let popularSaved = 0;
let newReleasesSaved = 0;
const syncTime = new Date();
for (let i = 0; i < popular.length; i++) {
const audiobook = popular[i];
try {
// Cache thumbnail if coverArtUrl exists
let cachedCoverPath: string | null = null;
if (audiobook.coverArtUrl) {
cachedCoverPath = await thumbnailCache.cacheThumbnail(audiobook.asin, audiobook.coverArtUrl);
}
await prisma.audibleCache.upsert({
where: { asin: audiobook.asin },
create: {
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
cachedCoverPath: cachedCoverPath,
durationMinutes: audiobook.durationMinutes,
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
rating: audiobook.rating ? audiobook.rating : null,
genres: audiobook.genres || [],
isPopular: true,
popularRank: i + 1,
lastSyncedAt: syncTime,
},
update: {
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
cachedCoverPath: cachedCoverPath,
durationMinutes: audiobook.durationMinutes,
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
rating: audiobook.rating ? audiobook.rating : null,
genres: audiobook.genres || [],
isPopular: true,
popularRank: i + 1,
lastSyncedAt: syncTime,
},
});
popularSaved++;
} catch (error) {
await logger?.error(`Failed to save popular audiobook ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
for (let i = 0; i < newReleases.length; i++) {
const audiobook = newReleases[i];
try {
// Cache thumbnail if coverArtUrl exists
let cachedCoverPath: string | null = null;
if (audiobook.coverArtUrl) {
cachedCoverPath = await thumbnailCache.cacheThumbnail(audiobook.asin, audiobook.coverArtUrl);
}
await prisma.audibleCache.upsert({
where: { asin: audiobook.asin },
create: {
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
cachedCoverPath: cachedCoverPath,
durationMinutes: audiobook.durationMinutes,
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
rating: audiobook.rating ? audiobook.rating : null,
genres: audiobook.genres || [],
isNewRelease: true,
newReleaseRank: i + 1,
lastSyncedAt: syncTime,
},
update: {
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
cachedCoverPath: cachedCoverPath,
durationMinutes: audiobook.durationMinutes,
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
rating: audiobook.rating ? audiobook.rating : null,
genres: audiobook.genres || [],
isNewRelease: true,
newReleaseRank: i + 1,
lastSyncedAt: syncTime,
},
});
newReleasesSaved++;
} catch (error) {
await logger?.error(`Failed to save new release ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
await logger?.info(`Saved ${popularSaved} popular and ${newReleasesSaved} new releases to audible_cache`);
// Cleanup unused thumbnails
await logger?.info('Cleaning up unused thumbnails...');
const allActiveAsins = await prisma.audibleCache.findMany({
select: { asin: true },
});
const activeAsinSet = new Set(allActiveAsins.map(item => item.asin));
const deletedCount = await thumbnailCache.cleanupUnusedThumbnails(activeAsinSet);
await logger?.info(`Cleanup complete: ${deletedCount} unused thumbnails removed`);
return {
success: true,
message: 'Audible refresh completed',
popularSaved,
newReleasesSaved,
thumbnailsDeleted: deletedCount,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
@@ -0,0 +1,146 @@
/**
* Component: Cleanup Seeded Torrents Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Cleans up torrents that have met their seeding requirements
*/
import { prisma } from '../db';
import { createJobLogger } from '../utils/job-logger';
export interface CleanupSeededTorrentsPayload {
jobId?: string;
scheduledJobId?: string;
}
export async function processCleanupSeededTorrents(payload: CleanupSeededTorrentsPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'CleanupSeededTorrents') : null;
await logger?.info('Starting cleanup job for seeded torrents...');
try {
// Get indexer configuration with per-indexer seeding times
const { getConfigService } = await import('../services/config.service');
const configService = getConfigService();
const indexersConfigStr = await configService.get('prowlarr_indexers');
if (!indexersConfigStr) {
await logger?.warn('No indexer configuration found, skipping');
return {
success: false,
message: 'No indexer configuration',
skipped: true,
};
}
const indexersConfig = JSON.parse(indexersConfigStr);
// Create a map of indexer name to config for quick lookup
const indexerConfigMap = new Map<string, any>();
for (const indexer of indexersConfig) {
indexerConfigMap.set(indexer.name, indexer);
}
await logger?.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
// Find all completed requests that have download history
const completedRequests = await prisma.request.findMany({
where: {
status: { in: ['available', 'downloaded'] },
},
include: {
downloadHistory: {
where: {
selected: true,
downloadStatus: 'completed',
},
orderBy: { completedAt: 'desc' },
take: 1,
},
},
take: 100, // Limit to 100 requests per run
});
await logger?.info(`Found ${completedRequests.length} completed requests to check`);
let cleaned = 0;
let skipped = 0;
let noConfig = 0;
for (const request of completedRequests) {
try {
const downloadHistory = request.downloadHistory[0];
if (!downloadHistory || !downloadHistory.downloadClientId || !downloadHistory.indexerName) {
continue;
}
// Get the indexer name from download history
const indexerName = downloadHistory.indexerName;
// Find matching indexer configuration by name
const seedingConfig = indexerConfigMap.get(indexerName);
// If no config found or seeding time is 0 (unlimited), skip
if (!seedingConfig) {
noConfig++;
continue;
}
if (seedingConfig.seedingTimeMinutes === 0) {
noConfig++;
continue;
}
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
// Get torrent info from qBittorrent to check seeding time
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
let torrent;
try {
torrent = await qbt.getTorrent(downloadHistory.downloadClientId);
} catch (error) {
// Torrent might already be deleted, skip
continue;
}
// Check if seeding time requirement is met
const actualSeedingTime = torrent.seeding_time || 0;
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
if (!hasMetRequirement) {
const remaining = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
skipped++;
continue;
}
await logger?.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
// Delete torrent and files from qBittorrent
await qbt.deleteTorrent(downloadHistory.downloadClientId, true); // true = delete files
await logger?.info(`Deleted torrent and files for request ${request.id}`);
cleaned++;
} catch (error) {
await logger?.error(`Failed to cleanup request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
await logger?.info(`Cleanup complete: ${cleaned} torrents cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
return {
success: true,
message: 'Cleanup seeded torrents completed',
totalChecked: completedRequests.length,
cleaned,
skipped,
unlimited: noConfig,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
@@ -0,0 +1,119 @@
/**
* Component: Download Torrent Job Processor
* Documentation: documentation/phase3/README.md
*/
import { DownloadTorrentPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getQBittorrentService } from '../integrations/qbittorrent.service';
import { createJobLogger } from '../utils/job-logger';
/**
* Process download torrent job
* Adds selected torrent to download client and starts monitoring
*/
export async function processDownloadTorrent(payload: DownloadTorrentPayload): Promise<any> {
const { requestId, audiobook, torrent, jobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'DownloadTorrent') : null;
await logger?.info(`Processing request ${requestId} for "${audiobook.title}"`);
await logger?.info(`Selected torrent: ${torrent.title}`, {
size: torrent.size,
seeders: torrent.seeders,
format: torrent.format,
indexer: torrent.indexer,
});
try {
// Update request status to downloading
await prisma.request.update({
where: { id: requestId },
data: {
status: 'downloading',
progress: 0,
updatedAt: new Date(),
},
});
// Get qBittorrent service
const qbt = await getQBittorrentService();
// Add torrent to qBittorrent
await logger?.info(`Adding torrent to qBittorrent`);
const torrentHash = await qbt.addTorrent(torrent.downloadUrl, {
category: 'readmeabook',
tags: [
'audiobook',
`request-${requestId}`,
`audiobook-${audiobook.id}`,
],
sequentialDownload: true, // Download in order for potential streaming
paused: false, // Start immediately
});
await logger?.info(`Torrent added with hash: ${torrentHash}`);
// Create DownloadHistory record
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
indexerName: torrent.indexer,
downloadClient: 'qbittorrent',
downloadClientId: torrentHash,
torrentName: torrent.title,
torrentHash: torrent.infoHash || torrentHash,
torrentSizeBytes: torrent.size,
seeders: torrent.seeders,
leechers: torrent.leechers || 0,
downloadStatus: 'downloading',
selected: true,
startedAt: new Date(),
},
});
await logger?.info(`Created download history record: ${downloadHistory.id}`);
// Trigger monitor download job with initial delay
// qBittorrent needs a few seconds to process the torrent before it's available via API
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId,
downloadHistory.id,
torrentHash,
'qbittorrent',
3 // Wait 3 seconds before first check to avoid race condition
);
await logger?.info(`Started monitoring job for request ${requestId} (3s initial delay)`);
return {
success: true,
message: 'Torrent added to download client and monitoring started',
requestId,
downloadHistoryId: downloadHistory.id,
torrentHash,
torrent: {
title: torrent.title,
size: torrent.size,
seeders: torrent.seeders,
format: torrent.format,
},
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Update request status to failed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Failed to add torrent to download client',
updatedAt: new Date(),
},
});
throw error;
}
}
+191
View File
@@ -0,0 +1,191 @@
/**
* Component: Match Library Job Processor
* Documentation: documentation/phase3/README.md
*
* DEPRECATED: This processor is deprecated. Matching is now handled by scan_library job.
* Kept for backwards compatibility but should not be used in new code.
*/
import { MatchPlexPayload } from '../services/job-queue.service';
import { prisma } from '../db';
import { getLibraryService } from '../services/library';
import { compareTwoStrings } from 'string-similarity';
import { getConfigService } from '../services/config.service';
import { createJobLogger } from '../utils/job-logger';
/**
* Process match library job (DEPRECATED - use scan_library instead)
* Fuzzy matches requested audiobook to library item and updates status
*/
export async function processMatchPlex(payload: MatchPlexPayload): Promise<any> {
const { requestId, audiobookId, title, author, jobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'MatchLibrary') : null;
await logger?.warn('DEPRECATED: match_plex job is deprecated. Use scan_plex instead.');
await logger?.info(`Matching "${title}" by ${author} in library`);
try {
// Get library service and configuration
const configService = getConfigService();
const libraryService = await getLibraryService();
const backendMode = await configService.getBackendMode();
await logger?.info(`Backend mode: ${backendMode}`);
// Get configured library ID
const libraryId = backendMode === 'audiobookshelf'
? await configService.get('audiobookshelf.library_id')
: (await configService.getPlexConfig()).libraryId;
if (!libraryId) {
throw new Error(`${backendMode} library not configured`);
}
// Search library using abstraction layer
const searchResults = await libraryService.searchItems(libraryId, title);
await logger?.info(`Found ${searchResults.length} results in library`);
if (searchResults.length === 0) {
await logger?.warn(`No matches found in library for "${title}"`);
// Mark as completed anyway - the file is there, library just needs time to scan
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: true,
message: 'No library match found yet, but request completed',
requestId,
matched: false,
note: 'Library may need time to scan the new files',
};
}
// Fuzzy match against results
const matches = searchResults.map((item) => {
const titleScore = compareTwoStrings(title.toLowerCase(), (item.title || '').toLowerCase());
const authorScore = author
? compareTwoStrings(author.toLowerCase(), (item.author || '').toLowerCase())
: 0.5;
// Weighted average: title is more important
const overallScore = titleScore * 0.7 + authorScore * 0.3;
return {
item,
score: overallScore,
titleScore,
authorScore,
};
});
// Sort by score
matches.sort((a, b) => b.score - a.score);
const bestMatch = matches[0];
await logger?.info(`Best match: "${bestMatch.item.title}" by ${bestMatch.item.author || 'Unknown'}`, {
score: Math.round(bestMatch.score * 100),
titleScore: Math.round(bestMatch.titleScore * 100),
authorScore: Math.round(bestMatch.authorScore * 100),
});
// Accept match if score >= 70%
if (bestMatch.score >= 0.7) {
await logger?.info(`Match accepted!`);
// Update audiobook with library item ID
const updateData: any = {
completedAt: new Date(),
updatedAt: new Date(),
};
if (backendMode === 'audiobookshelf') {
updateData.absItemId = bestMatch.item.externalId;
} else {
updateData.plexGuid = bestMatch.item.externalId;
}
await prisma.audiobook.update({
where: { id: audiobookId },
data: updateData,
});
// Ensure request is marked as completed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: true,
message: `Successfully matched audiobook in library (${backendMode})`,
backendMode,
requestId,
matched: true,
matchScore: bestMatch.score,
libraryItem: {
title: bestMatch.item.title,
author: bestMatch.item.author,
id: bestMatch.item.id,
externalId: bestMatch.item.externalId,
},
};
} else {
await logger?.warn(`Match score too low (${Math.round(bestMatch.score * 100)}%), but marking as completed anyway`);
// Mark as completed even if match is poor
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: true,
message: 'Request completed, but library match uncertain',
requestId,
matched: false,
matchScore: bestMatch.score,
note: `Low match score: ${Math.round(bestMatch.score * 100)}%`,
};
}
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Don't fail the request - the files are organized correctly
// Just log the error and mark as completed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
errorMessage: `Library matching failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: false,
message: 'Request completed despite library matching error',
requestId,
matched: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -0,0 +1,225 @@
/**
* Component: Monitor Download Job Processor
* Documentation: documentation/phase3/README.md
*/
import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getQBittorrentService } from '../integrations/qbittorrent.service';
import { createJobLogger, JobLogger } from '../utils/job-logger';
/**
* Helper function to retry getTorrent with exponential backoff
* Handles race condition where torrent isn't immediately available after adding
*/
async function getTorrentWithRetry(
qbt: any,
hash: string,
logger: JobLogger | null,
maxRetries: number = 3,
initialDelayMs: number = 500
): Promise<any> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await qbt.getTorrent(hash);
} catch (error) {
lastError = error as Error;
// If this is the last attempt, throw the error
if (attempt === maxRetries - 1) {
break;
}
// Exponential backoff: 500ms, 1000ms, 2000ms
const delayMs = initialDelayMs * Math.pow(2, attempt);
await logger?.warn(`Torrent ${hash} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
// All retries failed
throw lastError || new Error('Failed to get torrent after retries');
}
/**
* Process monitor download job
* Checks download progress from download client and updates request status
* Re-schedules itself if download is still in progress
*/
export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> {
const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'MonitorDownload') : null;
try {
// Get download client service (currently only qBittorrent supported)
if (downloadClient !== 'qbittorrent') {
throw new Error(`Download client ${downloadClient} not yet supported`);
}
const qbt = await getQBittorrentService();
// Get torrent status with retry logic (handles race condition)
const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger);
const progress = qbt.getDownloadProgress(torrent);
// Update request progress
await prisma.request.update({
where: { id: requestId },
data: {
progress: progress.percent,
updatedAt: new Date(),
},
});
// Update download history
await prisma.downloadHistory.update({
where: { id: downloadHistoryId },
data: {
downloadStatus: progress.state,
},
});
// Check download state
if (progress.state === 'completed') {
await logger?.info(`Download completed for request ${requestId}`);
// Get torrent files to find download path
const files = await qbt.getFiles(downloadClientId);
const downloadPath = torrent.save_path;
await logger?.info(`Downloaded to: ${downloadPath}`, {
filesCount: files.length,
torrentName: torrent.name,
});
// Update download history to completed
await prisma.downloadHistory.update({
where: { id: downloadHistoryId },
data: {
downloadStatus: 'completed',
completedAt: new Date(),
},
});
// Get request with audiobook details
const request = await prisma.request.findUnique({
where: { id: requestId },
include: {
audiobook: true,
},
});
if (!request || !request.audiobook) {
throw new Error('Request or audiobook not found');
}
// Trigger organize files job
const jobQueue = getJobQueueService();
await jobQueue.addOrganizeJob(
requestId,
request.audiobook.id,
`${downloadPath}/${torrent.name}`,
`/media/audiobooks/${request.audiobook.author}/${request.audiobook.title}`
);
await logger?.info(`Triggered organize_files job for request ${requestId}`);
return {
success: true,
completed: true,
message: 'Download completed, organizing files',
requestId,
progress: 100,
downloadPath,
};
} else if (progress.state === 'failed') {
await logger?.error(`Download failed for request ${requestId}`);
// Update request to failed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: 'Download failed in qBittorrent',
updatedAt: new Date(),
},
});
// Update download history
await prisma.downloadHistory.update({
where: { id: downloadHistoryId },
data: {
downloadStatus: 'failed',
downloadError: 'Download failed in qBittorrent',
},
});
return {
success: false,
completed: true,
message: 'Download failed',
requestId,
progress: progress.percent,
};
} else {
// Still downloading - schedule another check in 10 seconds
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId,
downloadHistoryId,
downloadClientId,
downloadClient,
10 // Delay 10 seconds between checks
);
// Only log every 5% progress to reduce log spam
const shouldLog = progress.percent % 5 === 0 || progress.percent < 5;
if (shouldLog) {
await logger?.info(`Request ${requestId}: ${progress.percent}% complete (${progress.state})`, {
speed: progress.speed,
eta: progress.eta,
});
}
return {
success: true,
completed: false,
message: 'Download in progress, monitoring continues',
requestId,
progress: progress.percent,
speed: progress.speed,
eta: progress.eta,
state: progress.state,
};
}
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Check if this is a transient "torrent not found" error
const errorMessage = error instanceof Error ? error.message : '';
const isTorrentNotFound = errorMessage.includes('not found') || errorMessage.includes('Torrent') && errorMessage.includes('not found');
if (isTorrentNotFound) {
// Transient error - don't mark request as failed, let Bull retry
// The request stays in 'downloading' status until Bull exhausts all retries
await logger?.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
} else {
// Permanent error - mark request as failed immediately
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: errorMessage || 'Monitor download failed',
updatedAt: new Date(),
},
});
}
// Rethrow to trigger Bull's retry mechanism
throw error;
}
}
@@ -0,0 +1,122 @@
/**
* Component: Monitor RSS Feeds Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Monitors RSS feeds for new audiobook releases and matches against missing requests
*/
import { prisma } from '../db';
import { createJobLogger } from '../utils/job-logger';
import { getJobQueueService } from '../services/job-queue.service';
export interface MonitorRssFeedsPayload {
jobId?: string;
scheduledJobId?: string;
}
export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'MonitorRssFeeds') : null;
await logger?.info(`Starting RSS feed monitoring...`);
// Get indexer configuration
const { getConfigService } = await import('../services/config.service');
const configService = getConfigService();
const indexersConfigStr = await configService.get('prowlarr_indexers');
if (!indexersConfigStr) {
await logger?.warn(`No indexers configured, skipping`);
return { success: false, message: 'No indexers configured', skipped: true };
}
const indexersConfig = JSON.parse(indexersConfigStr);
// Filter indexers that have RSS enabled
const rssEnabledIndexers = indexersConfig.filter(
(indexer: any) => indexer.rssEnabled === true
);
if (rssEnabledIndexers.length === 0) {
await logger?.warn(`No indexers with RSS enabled, skipping`);
return { success: false, message: 'No RSS-enabled indexers', skipped: true };
}
await logger?.info(`Monitoring ${rssEnabledIndexers.length} RSS-enabled indexers`);
// Get RSS feeds from all enabled indexers
const { getProwlarrService } = await import('../integrations/prowlarr.service');
const prowlarrService = await getProwlarrService();
const indexerIds = rssEnabledIndexers.map((i: any) => i.id);
const rssResults = await prowlarrService.getAllRssFeeds(indexerIds);
await logger?.info(`Retrieved ${rssResults.length} items from RSS feeds`);
if (rssResults.length === 0) {
return { success: true, message: 'No RSS results', matched: 0 };
}
// Get all requests awaiting search (missing audiobooks)
const missingRequests = await prisma.request.findMany({
where: { status: 'awaiting_search' },
include: { audiobook: true },
take: 100,
});
await logger?.info(`Found ${missingRequests.length} requests awaiting search`);
if (missingRequests.length === 0) {
return { success: true, message: 'No missing requests', matched: 0 };
}
// Match RSS results against missing audiobooks
let matched = 0;
const jobQueue = getJobQueueService();
for (const request of missingRequests) {
const audiobook = request.audiobook;
// Simple fuzzy matching: check if torrent title contains author and partial title
const authorWords = audiobook.author.toLowerCase().split(' ');
const titleWords = audiobook.title.toLowerCase().split(' ').slice(0, 3);
for (const torrent of rssResults) {
const torrentTitle = torrent.title.toLowerCase();
// Check if torrent contains author name and at least 2 title words
const hasAuthor = authorWords.some(word => word.length > 2 && torrentTitle.includes(word));
const titleMatchCount = titleWords.filter(word => word.length > 2 && torrentTitle.includes(word)).length;
if (hasAuthor && titleMatchCount >= 2) {
await logger?.info(`Match found! "${audiobook.title}" by ${audiobook.author} matches torrent: ${torrent.title}`);
// Trigger search job to process this request
try {
await jobQueue.addSearchJob(request.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
});
matched++;
await logger?.info(`Triggered search job for request ${request.id}`);
} catch (error) {
await logger?.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Only trigger once per request
break;
}
}
}
await logger?.info(`RSS monitoring complete: ${matched} matches found and queued for processing`);
return {
success: true,
message: 'RSS monitoring completed',
matched,
totalFeeds: rssResults.length,
totalMissing: missingRequests.length,
};
}
@@ -0,0 +1,191 @@
/**
* Component: Organize Files Job Processor
* Documentation: documentation/phase3/README.md
*/
import { OrganizeFilesPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getFileOrganizer } from '../utils/file-organizer';
import { createJobLogger } from '../utils/job-logger';
/**
* Process organize files job
* Moves completed downloads to media library in proper directory structure
*/
export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> {
const { requestId, audiobookId, downloadPath, jobId } = payload;
// Create logger (fallback to console-only if jobId not provided)
const logger = jobId ? createJobLogger(jobId, 'OrganizeFiles') : null;
await logger?.info(`Processing request ${requestId}`);
await logger?.info(`Download path: ${downloadPath}`);
try {
// Update request status to processing
await prisma.request.update({
where: { id: requestId },
data: {
status: 'processing',
progress: 100, // Download is complete, now organizing
updatedAt: new Date(),
},
});
// Get audiobook details
const audiobook = await prisma.audiobook.findUnique({
where: { id: audiobookId },
});
if (!audiobook) {
throw new Error(`Audiobook ${audiobookId} not found`);
}
await logger?.info(`Organizing: ${audiobook.title} by ${audiobook.author}`);
// Get file organizer
const organizer = getFileOrganizer();
// Organize files (pass logger to file organizer)
const result = await organizer.organize(
downloadPath,
{
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator || undefined,
coverArtUrl: audiobook.coverArtUrl || undefined,
},
jobId ? { jobId, context: 'FileOrganizer' } : undefined
);
if (!result.success) {
throw new Error(`File organization failed: ${result.errors.join(', ')}`);
}
await logger?.info(`Successfully moved ${result.filesMovedCount} files to ${result.targetPath}`);
// Update audiobook record with file path and status
await prisma.audiobook.update({
where: { id: audiobookId },
data: {
filePath: result.targetPath,
status: 'completed',
completedAt: new Date(),
updatedAt: new Date(),
},
});
// Update request to downloaded (green status, waiting for Plex scan)
await prisma.request.update({
where: { id: requestId },
data: {
status: 'downloaded',
progress: 100,
completedAt: new Date(),
updatedAt: new Date(),
},
});
await logger?.info(`Request ${requestId} completed successfully - status: downloaded`, {
success: true,
message: 'Files organized successfully',
requestId,
audiobookId,
targetPath: result.targetPath,
filesCount: result.filesMovedCount,
audioFiles: result.audioFiles,
coverArt: result.coverArtFile,
errors: result.errors,
});
return {
success: true,
message: 'Files organized successfully',
requestId,
audiobookId,
targetPath: result.targetPath,
filesCount: result.filesMovedCount,
audioFiles: result.audioFiles,
coverArt: result.coverArtFile,
errors: result.errors,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
const errorMessage = error instanceof Error ? error.message : 'File organization failed';
// Check if this is a "no files found" error that should be retried
const isNoFilesError = errorMessage.includes('No audiobook files found');
if (isNoFilesError) {
// Get current request to check retry count
const currentRequest = await prisma.request.findUnique({
where: { id: requestId },
select: { importAttempts: true, maxImportRetries: true },
});
if (!currentRequest) {
throw new Error('Request not found');
}
const newAttempts = currentRequest.importAttempts + 1;
if (newAttempts < currentRequest.maxImportRetries) {
// Still have retries left - queue for re-import
await logger?.warn(`No files found for request ${requestId}, queueing for retry (attempt ${newAttempts}/${currentRequest.maxImportRetries})`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'awaiting_import',
importAttempts: newAttempts,
lastImportAt: new Date(),
errorMessage: `${errorMessage}. Retry ${newAttempts}/${currentRequest.maxImportRetries}`,
updatedAt: new Date(),
},
});
return {
success: false,
message: 'No audiobook files found, queued for re-import',
requestId,
attempts: newAttempts,
maxRetries: currentRequest.maxImportRetries,
};
} else {
// Max retries exceeded - move to warn status
await logger?.warn(`Max retries (${currentRequest.maxImportRetries}) exceeded for request ${requestId}, moving to warn status`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'warn',
importAttempts: newAttempts,
errorMessage: `${errorMessage}. Max retries (${currentRequest.maxImportRetries}) exceeded. Manual retry available.`,
updatedAt: new Date(),
},
});
return {
success: false,
message: 'Max import retries exceeded, manual intervention required',
requestId,
attempts: newAttempts,
maxRetries: currentRequest.maxImportRetries,
};
}
} else {
// Other error - fail immediately
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage,
updatedAt: new Date(),
},
});
throw error;
}
}
}
@@ -0,0 +1,198 @@
/**
* Component: Library Recently Added Check Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Lightweight polling for new library items (Plex or Audiobookshelf)
*/
import { prisma } from '../db';
import { createJobLogger } from '../utils/job-logger';
import { getLibraryService } from '../services/library';
export interface PlexRecentlyAddedPayload {
jobId?: string;
scheduledJobId?: string;
}
export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'RecentlyAdded') : null;
const { getConfigService } = await import('../services/config.service');
const configService = getConfigService();
// Get backend mode
const backendMode = await configService.getBackendMode();
await logger?.info(`Backend mode: ${backendMode}`);
// Validate configuration based on backend mode
if (backendMode === 'audiobookshelf') {
const absConfig = await configService.getMany([
'audiobookshelf.server_url',
'audiobookshelf.api_token',
'audiobookshelf.library_id',
]);
const missingFields: string[] = [];
if (!absConfig['audiobookshelf.server_url']) missingFields.push('Audiobookshelf server URL');
if (!absConfig['audiobookshelf.api_token']) missingFields.push('Audiobookshelf API token');
if (!absConfig['audiobookshelf.library_id']) missingFields.push('Audiobookshelf library ID');
if (missingFields.length > 0) {
const errorMsg = `Audiobookshelf is not configured. Missing: ${missingFields.join(', ')}`;
await logger?.warn(errorMsg);
return { success: false, message: errorMsg, skipped: true };
}
} else {
const plexConfig = await configService.getMany([
'plex_url',
'plex_token',
'plex_audiobook_library_id',
]);
const missingFields: string[] = [];
if (!plexConfig.plex_url) missingFields.push('Plex server URL');
if (!plexConfig.plex_token) missingFields.push('Plex auth token');
if (!plexConfig.plex_audiobook_library_id) missingFields.push('Plex audiobook library ID');
if (missingFields.length > 0) {
const errorMsg = `Plex is not configured. Missing: ${missingFields.join(', ')}`;
await logger?.warn(errorMsg);
return { success: false, message: errorMsg, skipped: true };
}
}
await logger?.info(`Starting recently added check...`);
// Get library service (automatically selects Plex or Audiobookshelf)
const libraryService = await getLibraryService();
try {
// Get configured library ID
const libraryId = backendMode === 'audiobookshelf'
? await configService.get('audiobookshelf.library_id')
: await configService.get('plex_audiobook_library_id');
// Fetch top 10 recently added items using abstraction layer
const recentItems = await libraryService.getRecentlyAdded(libraryId!, 10);
await logger?.info(`Found ${recentItems.length} recently added items`);
if (recentItems.length === 0) {
return { success: true, message: 'No recent items', newCount: 0, updatedCount: 0, matchedDownloads: 0 };
}
// Check for new items not in database
let newCount = 0;
let updatedCount = 0;
let matchedDownloads = 0;
for (const item of recentItems) {
const existing = await prisma.plexLibrary.findUnique({
where: { plexGuid: item.externalId },
});
if (!existing) {
await prisma.plexLibrary.create({
data: {
plexGuid: item.externalId,
plexRatingKey: item.id,
title: item.title,
author: item.author || 'Unknown Author',
narrator: item.narrator,
summary: item.description,
duration: item.duration ? item.duration * 1000 : null, // Convert seconds to milliseconds
year: item.year,
thumbUrl: item.coverUrl,
plexLibraryId: libraryId!,
addedAt: item.addedAt,
lastScannedAt: new Date(),
},
});
newCount++;
await logger?.info(`New item added: ${item.title} by ${item.author}`);
} else {
await prisma.plexLibrary.update({
where: { plexGuid: item.externalId },
data: {
title: item.title,
author: item.author || existing.author,
narrator: item.narrator || existing.narrator,
summary: item.description || existing.summary,
duration: item.duration ? item.duration * 1000 : existing.duration,
year: item.year || existing.year,
thumbUrl: item.coverUrl || existing.thumbUrl,
lastScannedAt: new Date(),
},
});
updatedCount++;
}
}
// Check for downloaded requests to match
const downloadedRequests = await prisma.request.findMany({
where: { status: 'downloaded' },
include: { audiobook: true },
take: 50,
});
if (downloadedRequests.length > 0) {
await logger?.info(`Checking ${downloadedRequests.length} downloaded requests for matches`);
const { findPlexMatch } = await import('../utils/audiobook-matcher');
for (const request of downloadedRequests) {
try {
const audiobook = request.audiobook;
const match = await findPlexMatch({
asin: audiobook.audibleAsin || '',
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator || undefined,
});
if (match) {
await logger?.info(`Match found: "${audiobook.title}" → "${match.title}"`);
// Update audiobook with matched library item ID
const updateData: any = { updatedAt: new Date() };
if (backendMode === 'audiobookshelf') {
updateData.absItemId = match.plexGuid; // plexGuid field stores the externalId from either backend
} else {
updateData.plexGuid = match.plexGuid;
}
await prisma.audiobook.update({
where: { id: audiobook.id },
data: updateData,
});
await prisma.request.update({
where: { id: request.id },
data: { status: 'available', completedAt: new Date(), updatedAt: new Date() },
});
matchedDownloads++;
}
} catch (error) {
await logger?.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
await logger?.info(`Complete: ${newCount} new, ${updatedCount} updated, ${matchedDownloads} matched downloads`);
return {
success: true,
message: `Recently added check completed (${backendMode})`,
backendMode,
newCount,
updatedCount,
matchedDownloads,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
@@ -0,0 +1,99 @@
/**
* Component: Retry Failed Imports Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Retries file organization for requests that are awaiting import
*/
import { prisma } from '../db';
import { createJobLogger } from '../utils/job-logger';
import { getJobQueueService } from '../services/job-queue.service';
export interface RetryFailedImportsPayload {
jobId?: string;
scheduledJobId?: string;
}
export async function processRetryFailedImports(payload: RetryFailedImportsPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'RetryFailedImports') : null;
await logger?.info('Starting retry job for requests awaiting import...');
try {
// Find all requests in awaiting_import status
const requests = await prisma.request.findMany({
where: {
status: 'awaiting_import',
},
include: {
audiobook: true,
downloadHistory: {
where: { selected: true },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
take: 50, // Limit to 50 requests per run
});
await logger?.info(`Found ${requests.length} requests awaiting import`);
if (requests.length === 0) {
return {
success: true,
message: 'No requests awaiting import',
triggered: 0,
};
}
// Trigger organize job for each request
const jobQueue = getJobQueueService();
let triggered = 0;
let skipped = 0;
for (const request of requests) {
try {
// Get the download path from the most recent download history
const downloadHistory = request.downloadHistory[0];
if (!downloadHistory || !downloadHistory.downloadClientId) {
await logger?.warn(`No download history found for request ${request.id}, skipping`);
skipped++;
continue;
}
// Get download path from qBittorrent
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
const torrent = await qbt.getTorrent(downloadHistory.downloadClientId);
const downloadPath = `${torrent.save_path}/${torrent.name}`;
await jobQueue.addOrganizeJob(
request.id,
request.audiobook.id,
downloadPath,
`/media/audiobooks/${request.audiobook.author}/${request.audiobook.title}`
);
triggered++;
await logger?.info(`Triggered organize job for request ${request.id}: ${request.audiobook.title}`);
} catch (error) {
await logger?.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++;
}
}
await logger?.info(`Triggered ${triggered}/${requests.length} organize jobs (${skipped} skipped)`);
return {
success: true,
message: 'Retry failed imports completed',
totalRequests: requests.length,
triggered,
skipped,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
@@ -0,0 +1,75 @@
/**
* Component: Retry Missing Torrents Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Retries search for requests that are awaiting torrent search
*/
import { prisma } from '../db';
import { createJobLogger } from '../utils/job-logger';
import { getJobQueueService } from '../services/job-queue.service';
export interface RetryMissingTorrentsPayload {
jobId?: string;
scheduledJobId?: string;
}
export async function processRetryMissingTorrents(payload: RetryMissingTorrentsPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'RetryMissingTorrents') : null;
await logger?.info('Starting retry job for requests awaiting search...');
try {
// Find all requests in awaiting_search status
const requests = await prisma.request.findMany({
where: {
status: 'awaiting_search',
},
include: {
audiobook: true,
},
take: 50, // Limit to 50 requests per run
});
await logger?.info(`Found ${requests.length} requests awaiting search`);
if (requests.length === 0) {
return {
success: true,
message: 'No requests awaiting search',
triggered: 0,
};
}
// Trigger search job for each request
const jobQueue = getJobQueueService();
let triggered = 0;
for (const request of requests) {
try {
await jobQueue.addSearchJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
});
triggered++;
await logger?.info(`Triggered search for request ${request.id}: ${request.audiobook.title}`);
} catch (error) {
await logger?.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
await logger?.info(`Triggered ${triggered}/${requests.length} search jobs`);
return {
success: true,
message: 'Retry missing torrents completed',
totalRequests: requests.length,
triggered,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Component: Library Scan Job Processor
* Documentation: documentation/backend/services/jobs.md
*
* Scans library (Plex or Audiobookshelf) and populates plex_library table with all audiobooks.
* Works with both Plex and Audiobookshelf backends via abstraction layer.
*/
import { ScanPlexPayload } from '../services/job-queue.service';
import { prisma } from '../db';
import { getLibraryService } from '../services/library';
import { getConfigService } from '../services/config.service';
import { createJobLogger } from '../utils/job-logger';
/**
* Process library scan job
* Scans library and updates plex_library table (works for both Plex and Audiobookshelf)
*/
export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
const { libraryId, partial, path, jobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'ScanLibrary') : null;
await logger?.info(`Scanning library ${libraryId || 'default'}${partial ? ' (partial)' : ''}`);
try {
// 1. Get library service (automatically selects Plex or Audiobookshelf based on config)
const libraryService = await getLibraryService();
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
await logger?.info(`Backend mode: ${backendMode}`);
// 2. Get configured library ID
let targetLibraryId = libraryId;
if (!targetLibraryId) {
if (backendMode === 'audiobookshelf') {
const absLibraryId = await configService.get('audiobookshelf.library_id');
if (!absLibraryId) {
throw new Error('Audiobookshelf library not configured');
}
targetLibraryId = absLibraryId;
} else {
const plexConfig = await configService.getPlexConfig();
if (!plexConfig.libraryId) {
throw new Error('Plex audiobook library not configured');
}
targetLibraryId = plexConfig.libraryId;
}
}
await logger?.info(`Fetching content from library ${targetLibraryId}`);
// 3. Get all audiobooks from library using abstraction layer
const libraryItems = await libraryService.getLibraryItems(targetLibraryId);
await logger?.info(`Found ${libraryItems.length} items in library`);
let newCount = 0;
let updatedCount = 0;
let skippedCount = 0;
const results: any[] = [];
// 4. Process each library item - populate plex_library table
// Note: Table is still called plex_library for backwards compatibility, but now stores items from any backend
for (const item of libraryItems) {
if (!item.title || !item.externalId) {
skippedCount++;
continue;
}
try {
// Check if this audiobook already exists in plex_library by externalId (plexGuid or abs_item_id)
const existing = await prisma.plexLibrary.findFirst({
where: { plexGuid: item.externalId },
});
if (existing) {
// Update existing record with latest data
await prisma.plexLibrary.update({
where: { id: existing.id },
data: {
title: item.title,
author: item.author || existing.author,
narrator: item.narrator || existing.narrator,
summary: item.description || existing.summary,
duration: item.duration ? item.duration * 1000 : existing.duration, // Convert seconds to milliseconds
year: item.year || existing.year,
thumbUrl: item.coverUrl || existing.thumbUrl,
plexLibraryId: targetLibraryId,
plexRatingKey: item.id || existing.plexRatingKey,
lastScannedAt: new Date(),
updatedAt: new Date(),
},
});
updatedCount++;
} else {
// Create new plex_library entry
const newLibraryItem = await prisma.plexLibrary.create({
data: {
plexGuid: item.externalId,
plexRatingKey: item.id,
title: item.title,
author: item.author || 'Unknown Author',
narrator: item.narrator,
summary: item.description,
duration: item.duration ? item.duration * 1000 : null, // Convert seconds to milliseconds
year: item.year,
thumbUrl: item.coverUrl,
plexLibraryId: targetLibraryId,
addedAt: item.addedAt,
lastScannedAt: new Date(),
},
});
newCount++;
await logger?.info(`Added new: "${item.title}" by ${item.author}`);
results.push({
id: newLibraryItem.id,
plexGuid: newLibraryItem.plexGuid,
title: item.title,
author: item.author,
});
}
} catch (error) {
await logger?.error(`Failed to process "${item.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
skippedCount++;
}
}
await logger?.info(`Scan complete: ${libraryItems.length} items scanned, ${newCount} new, ${updatedCount} updated, ${skippedCount} skipped`);
// 5. Match downloaded requests against library
await logger?.info(`Checking for downloaded requests to match...`);
const downloadedRequests = await prisma.request.findMany({
where: { status: 'downloaded' },
include: { audiobook: true },
take: 50, // Limit to prevent overwhelming
});
await logger?.info(`Found ${downloadedRequests.length} downloaded requests to match`);
let matchedCount = 0;
const { findPlexMatch } = await import('../utils/audiobook-matcher');
for (const request of downloadedRequests) {
try {
const audiobook = request.audiobook;
// Use the centralized matcher (handles ASIN matching, title normalization, narrator matching, etc.)
// Works for both Plex and Audiobookshelf backends
const match = await findPlexMatch({
asin: audiobook.audibleAsin || '',
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator || undefined,
});
if (match) {
await logger?.info(`Match found! "${audiobook.title}" -> "${match.title}"`);
// Update audiobook with matched library item ID (plexGuid or abs_item_id)
const updateData: any = { updatedAt: new Date() };
if (backendMode === 'audiobookshelf') {
updateData.absItemId = match.plexGuid; // plexGuid field stores the externalId from either backend
} else {
updateData.plexGuid = match.plexGuid;
}
await prisma.audiobook.update({
where: { id: audiobook.id },
data: updateData,
});
// Update request to available
await prisma.request.update({
where: { id: request.id },
data: {
status: 'available',
completedAt: new Date(),
updatedAt: new Date(),
},
});
matchedCount++;
}
} catch (error) {
await logger?.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
await logger?.info(`Matched ${matchedCount}/${downloadedRequests.length} downloaded requests`, {
totalScanned: libraryItems.length,
newCount,
updatedCount,
skippedCount,
matchedDownloads: matchedCount,
});
return {
success: true,
message: `Library scan completed successfully (${backendMode})`,
backendMode,
libraryId: targetLibraryId,
totalScanned: libraryItems.length,
newCount,
updatedCount,
skippedCount,
newAudiobooks: results,
matchedDownloads: matchedCount,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
@@ -0,0 +1,133 @@
/**
* Component: Search Indexers Job Processor
* Documentation: documentation/phase3/README.md
*/
import { SearchIndexersPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getProwlarrService } from '../integrations/prowlarr.service';
import { getRankingAlgorithm } from '../utils/ranking-algorithm';
import { createJobLogger } from '../utils/job-logger';
/**
* Process search indexers job
* Searches configured indexers for audiobook torrents
*/
export async function processSearchIndexers(payload: SearchIndexersPayload): Promise<any> {
const { requestId, audiobook, jobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'SearchIndexers') : null;
await logger?.info(`Processing request ${requestId} for "${audiobook.title}"`);
try {
// Update request status to searching
await prisma.request.update({
where: { id: requestId },
data: {
status: 'searching',
searchAttempts: { increment: 1 },
updatedAt: new Date(),
},
});
// Get Prowlarr service
const prowlarr = await getProwlarrService();
// Build search query (title + author for better results)
const searchQuery = `${audiobook.title} ${audiobook.author}`;
await logger?.info(`Searching for: "${searchQuery}"`);
// Search indexers
const searchResults = await prowlarr.search(searchQuery, {
category: 3030, // Audiobooks
minSeeders: 1, // Only torrents with at least 1 seeder
maxResults: 50, // Limit results
});
await logger?.info(`Found ${searchResults.length} results`);
if (searchResults.length === 0) {
// No results found - queue for re-search instead of failing
await logger?.warn(`No torrents found for request ${requestId}, marking as awaiting_search`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'awaiting_search',
errorMessage: 'No torrents found. Will retry automatically.',
lastSearchAt: new Date(),
updatedAt: new Date(),
},
});
return {
success: false,
message: 'No torrents found, queued for re-search',
requestId,
};
}
// Get ranking algorithm
const ranker = getRankingAlgorithm();
// Rank results
const rankedResults = ranker.rankTorrents(searchResults, {
title: audiobook.title,
author: audiobook.author,
durationMinutes: undefined, // We don't have duration from Audible
});
await logger?.info(`Ranked ${rankedResults.length} results`);
// Select best result
const bestResult = rankedResults[0];
// Log top 3 results
const top3 = rankedResults.slice(0, 3).map((r, i) => ({
rank: i + 1,
title: r.title,
score: r.score,
breakdown: r.breakdown,
}));
await logger?.info(`Best result: ${bestResult.title} (score: ${bestResult.score})`, {
top3Results: top3,
});
// Trigger download job with best result
const jobQueue = getJobQueueService();
await jobQueue.addDownloadJob(requestId, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
}, bestResult);
return {
success: true,
message: `Found ${searchResults.length} results, selected best torrent`,
requestId,
resultsCount: searchResults.length,
selectedTorrent: {
title: bestResult.title,
score: bestResult.score,
seeders: bestResult.seeders,
format: bestResult.format,
},
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Unknown error during search',
updatedAt: new Date(),
},
});
throw error;
}
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Component: Audiobookshelf API Client
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { getConfigService } from '../config.service';
interface ABSRequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: any;
}
/**
* Make a request to the Audiobookshelf API
*/
export async function absRequest<T>(endpoint: string, options: ABSRequestOptions = {}): Promise<T> {
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${endpoint}`;
const response = await fetch(url, {
method: options.method || 'GET',
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
throw new Error(`ABS API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
/**
* Get Audiobookshelf server status/info
*/
export async function getABSServerInfo() {
return absRequest<{ version: string; name: string }>('/status');
}
/**
* Get all libraries from Audiobookshelf
*/
export async function getABSLibraries() {
const result = await absRequest<{ libraries: any[] }>('/libraries');
return result.libraries;
}
/**
* Get all items in a library
*/
export async function getABSLibraryItems(libraryId: string) {
const result = await absRequest<{ results: any[] }>(`/libraries/${libraryId}/items`);
return result.results;
}
/**
* Get recently added items in a library
*/
export async function getABSRecentItems(libraryId: string, limit: number) {
const result = await absRequest<{ results: any[] }>(
`/libraries/${libraryId}/items?sort=addedAt&desc=1&limit=${limit}`
);
return result.results;
}
/**
* Get a single item by ID
*/
export async function getABSItem(itemId: string) {
return absRequest<any>(`/items/${itemId}`);
}
/**
* Search for items in a library
*/
export async function searchABSItems(libraryId: string, query: string) {
const result = await absRequest<{ book: any[] }>(
`/libraries/${libraryId}/search?q=${encodeURIComponent(query)}`
);
return result.book || [];
}
/**
* Trigger a library scan
*/
export async function triggerABSScan(libraryId: string) {
await absRequest(`/libraries/${libraryId}/scan`, { method: 'POST' });
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Component: Audiobookshelf Type Definitions
* Documentation: documentation/features/audiobookshelf-integration.md
*/
export interface ABSLibrary {
id: string;
name: string;
mediaType: 'book' | 'podcast';
folders: { id: string; fullPath: string }[];
stats?: {
totalItems: number;
};
}
export interface ABSBookMetadata {
title: string;
subtitle?: string;
authorName: string;
authorNameLF?: string;
narratorName?: string;
seriesName?: string;
genres: string[];
publishedYear?: string;
description?: string;
isbn?: string;
asin?: string;
language?: string;
explicit: boolean;
}
export interface ABSAudioFile {
index: number;
ino: string;
metadata: {
filename: string;
ext: string;
path: string;
size: number;
mtimeMs: number;
};
duration: number;
}
export interface ABSLibraryItem {
id: string;
ino: string;
libraryId: string;
folderId: string;
path: string;
relPath: string;
isFile: boolean;
mtimeMs: number;
ctimeMs: number;
birthtimeMs: number;
addedAt: number;
updatedAt: number;
isMissing: boolean;
isInvalid: boolean;
mediaType: 'book';
media: {
metadata: ABSBookMetadata;
coverPath?: string;
audioFiles: ABSAudioFile[];
duration: number;
size: number;
numTracks: number;
numAudioFiles: number;
};
numFiles: number;
size: number;
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Auth Provider Interface
* Documentation: documentation/features/audiobookshelf-integration.md
*/
export interface UserInfo {
id: string; // User UUID
plexId?: string; // Plex ID, OIDC subject, or local username
username: string;
email?: string;
avatarUrl?: string;
role?: string; // 'admin' | 'user'
isAdmin?: boolean; // Deprecated: use role instead
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface LoginInitiation {
redirectUrl?: string; // For OAuth/OIDC flows
pinId?: string; // For Plex PIN flow
state?: string; // CSRF state token
}
export interface CallbackParams {
code?: string; // Authorization code
state?: string; // CSRF state
pinId?: string; // Plex PIN
error?: string;
[key: string]: any; // Allow additional params like username, password
}
export interface AuthResult {
success: boolean;
user?: UserInfo;
tokens?: AuthTokens;
error?: string;
requiresApproval?: boolean; // For pending approval flow
requiresProfileSelection?: boolean; // For Plex Home
profiles?: any[]; // Plex Home profiles
isFirstLogin?: boolean; // First user login (initial jobs will run)
}
export interface IAuthProvider {
type: 'plex' | 'oidc' | 'local';
// Auth initiation
initiateLogin(): Promise<LoginInitiation>;
// Auth completion
handleCallback(params: CallbackParams): Promise<AuthResult>;
// Token refresh
refreshToken(refreshToken: string): Promise<AuthTokens | null>;
// Validation
validateAccess(userInfo: UserInfo): Promise<boolean>;
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Local Auth Provider (Username/Password)
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import bcrypt from 'bcrypt';
import {
IAuthProvider,
LoginInitiation,
CallbackParams,
AuthResult,
UserInfo,
AuthTokens,
} from './IAuthProvider';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getConfigService } from '@/lib/services/config.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { prisma } from '@/lib/db';
interface LocalLoginParams extends CallbackParams {
username: string;
password: string;
}
interface RegisterParams {
username: string;
password: string;
}
export class LocalAuthProvider implements IAuthProvider {
type: 'local' = 'local';
private configService = getConfigService();
private encryptionService = getEncryptionService();
/**
* Initiate login (no-op for local auth)
*/
async initiateLogin(): Promise<LoginInitiation> {
// Local auth doesn't need initiation - return empty
return {};
}
/**
* Handle login with username/password
*/
async handleCallback(params: CallbackParams): Promise<AuthResult> {
try {
const { username, password } = params as LocalLoginParams;
if (!username || !password) {
return { success: false, error: 'Username and password required' };
}
// Find user
const user = await prisma.user.findFirst({
where: {
plexUsername: username,
authProvider: 'local',
},
});
if (!user) {
return { success: false, error: 'Invalid username or password' };
}
// Check registration status
if (user.registrationStatus === 'pending_approval') {
return {
success: false,
requiresApproval: true,
};
}
if (user.registrationStatus === 'rejected') {
return { success: false, error: 'Account has been rejected' };
}
// Verify password
let passwordValid = false;
try {
// Decrypt the stored hash
const decryptedHash = this.encryptionService.decrypt(user.authToken || '');
passwordValid = await bcrypt.compare(password, decryptedHash);
} catch (error) {
console.error('[LocalAuthProvider] Password verification failed:', error);
return { success: false, error: 'Invalid username or password' };
}
if (!passwordValid) {
return { success: false, error: 'Invalid username or password' };
}
// Update last login
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
// Generate tokens
console.log('[LocalAuthProvider] Generating tokens for user:', {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
authProvider: user.authProvider,
});
const tokens = await this.generateTokens({
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
isAdmin: user.role === 'admin',
});
console.log('[LocalAuthProvider] Tokens generated, returning user data');
return {
success: true,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
},
tokens,
};
} catch (error) {
console.error('[LocalAuthProvider] Login failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Authentication failed',
};
}
}
/**
* Register a new user
*/
async register(params: RegisterParams): Promise<AuthResult> {
try {
const { username, password } = params;
// Validate
if (!username || username.length < 3) {
return { success: false, error: 'Username must be at least 3 characters' };
}
if (!password || password.length < 8) {
return { success: false, error: 'Password must be at least 8 characters' };
}
// Check if registration is enabled
const registrationEnabled = await this.configService.get('auth.registration_enabled');
if (registrationEnabled !== 'true') {
return { success: false, error: 'Registration is disabled' };
}
// Check username uniqueness
const existing = await prisma.user.findFirst({
where: {
plexUsername: username,
authProvider: 'local',
},
});
if (existing) {
return { success: false, error: 'Username already taken' };
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Encrypt the hash before storing
const encryptedHash = this.encryptionService.encrypt(passwordHash);
// Determine registration status
const requireApproval = (await this.configService.get('auth.require_admin_approval')) === 'true';
const registrationStatus = requireApproval ? 'pending_approval' : 'approved';
// Check if first user (make admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
// Create user
const user = await prisma.user.create({
data: {
plexId: `local-${username}`,
plexUsername: username,
authToken: encryptedHash,
authProvider: 'local',
role: isFirstUser ? 'admin' : 'user',
isSetupAdmin: isFirstUser,
registrationStatus: isFirstUser ? 'approved' : registrationStatus,
lastLoginAt: new Date(),
},
});
// If requires approval and not first user, return pending status
if (requireApproval && !isFirstUser) {
return {
success: false,
requiresApproval: true,
};
}
// Generate tokens for immediate login
const tokens = await this.generateTokens({
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
isAdmin: user.role === 'admin',
});
return {
success: true,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
},
tokens,
};
} catch (error) {
console.error('[LocalAuthProvider] Registration failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Registration failed',
};
}
}
/**
* Generate JWT access and refresh tokens
*/
private async generateTokens(userInfo: UserInfo & { plexId: string }): Promise<AuthTokens> {
const tokenPayload = {
sub: userInfo.id,
plexId: userInfo.plexId,
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
};
console.log('[LocalAuthProvider] JWT token payload:', tokenPayload);
const accessToken = generateAccessToken(tokenPayload);
const refreshToken = generateRefreshToken(userInfo.id);
return {
accessToken,
refreshToken,
};
}
/**
* Refresh JWT tokens
*/
async refreshToken(refreshToken: string): Promise<AuthTokens | null> {
// JWT refresh is handled by existing JWT utilities
// This method is a placeholder for future implementation
return null;
}
/**
* Validate user has access
*/
async validateAccess(userInfo: UserInfo): Promise<boolean> {
try {
// Check if user exists and is approved
const user = await prisma.user.findUnique({
where: { id: userInfo.id },
});
if (!user || user.authProvider !== 'local') {
return false;
}
if (user.registrationStatus === 'pending_approval' || user.registrationStatus === 'rejected') {
return false;
}
return true;
} catch (error) {
console.error('[LocalAuthProvider] Access validation failed:', error);
return false;
}
}
}
+568
View File
@@ -0,0 +1,568 @@
/**
* OIDC Auth Provider Implementation
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { Issuer, Client, generators } from 'openid-client';
import {
IAuthProvider,
UserInfo,
AuthTokens,
LoginInitiation,
CallbackParams,
AuthResult,
} from './IAuthProvider';
import { getConfigService } from '@/lib/services/config.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getBaseUrl } from '@/lib/utils/url';
import { getSchedulerService } from '@/lib/services/scheduler.service';
import { prisma } from '@/lib/db';
// In-memory storage for OIDC flow state (temporary until callback completes)
// In production, this could be replaced with Redis for multi-instance support
interface OIDCFlowState {
state: string;
nonce: string;
codeVerifier: string;
timestamp: number;
}
const flowStateCache = new Map<string, OIDCFlowState>();
const FLOW_STATE_TTL = 10 * 60 * 1000; // 10 minutes
export class OIDCAuthProvider implements IAuthProvider {
type: 'oidc' = 'oidc';
private configService = getConfigService();
private encryptionService = getEncryptionService();
private client: Client | null = null;
/**
* Get or create OIDC client
*/
private async getClient(): Promise<Client> {
if (this.client) return this.client;
const issuerUrl = await this.configService.get('oidc.issuer_url');
const clientId = await this.configService.get('oidc.client_id');
const clientSecret = await this.configService.get('oidc.client_secret');
if (!issuerUrl || !clientId || !clientSecret) {
throw new Error('OIDC is not fully configured');
}
// Discover OIDC endpoints
const issuer = await Issuer.discover(issuerUrl);
// Create client
this.client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [await this.getRedirectUri()],
response_types: ['code'],
});
return this.client;
}
/**
* Get redirect URI for OAuth callback
*/
private async getRedirectUri(): Promise<string> {
const baseUrl = getBaseUrl();
return `${baseUrl}/api/auth/oidc/callback`;
}
/**
* Initiate OIDC login flow
*/
async initiateLogin(): Promise<LoginInitiation> {
try {
const client = await this.getClient();
const state = generators.state();
const nonce = generators.nonce();
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
// Store state in memory cache
flowStateCache.set(state, {
state,
nonce,
codeVerifier,
timestamp: Date.now(),
});
// Clean up expired states
this.cleanupExpiredStates();
// Generate authorization URL
const redirectUrl = client.authorizationUrl({
scope: 'openid profile email groups',
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return {
redirectUrl,
state,
};
} catch (error) {
console.error('[OIDCAuthProvider] Failed to initiate login:', error);
throw new Error('Failed to initiate OIDC authentication');
}
}
/**
* Handle OIDC callback
*/
async handleCallback(params: CallbackParams): Promise<AuthResult> {
try {
const { code, state, error } = params;
if (error) {
return {
success: false,
error: `OIDC provider error: ${error}`,
};
}
if (!code || !state) {
return {
success: false,
error: 'Missing authorization code or state',
};
}
// Retrieve stored flow state
const flowState = flowStateCache.get(state);
if (!flowState) {
return {
success: false,
error: 'Invalid or expired state parameter',
};
}
// Clean up state after retrieval
flowStateCache.delete(state);
const client = await this.getClient();
const redirectUri = await this.getRedirectUri();
if (process.env.LOG_LEVEL === 'debug') {
console.debug('[OIDCAuthProvider] Exchanging code for tokens', {
redirectUri,
hasCode: !!code,
hasState: !!state,
stateMatches: state === flowState.state,
});
}
// Exchange code for tokens
const tokenSet = await client.callback(
redirectUri,
{ code, state },
{
code_verifier: flowState.codeVerifier,
nonce: flowState.nonce,
state: flowState.state,
}
);
if (!tokenSet.access_token) {
return {
success: false,
error: 'Failed to obtain access token',
};
}
// Get user info from OIDC provider
const userinfo = await client.userinfo(tokenSet.access_token);
if (!userinfo.sub) {
return {
success: false,
error: 'Invalid user info from OIDC provider',
};
}
// Check access control
const hasAccess = await this.checkAccessControl(userinfo);
if (!hasAccess) {
return {
success: false,
error: 'You do not have access to this application',
};
}
// Map OIDC claims to UserInfo
const username = (userinfo.preferred_username || userinfo.email || userinfo.sub) as string;
const email = userinfo.email as string | undefined;
const avatarUrl = userinfo.picture as string | undefined;
// Check admin role from claims
const isAdminFromClaim = await this.checkAdminClaim(userinfo);
// Check if admin approval required
const accessMethod = await this.configService.get('oidc.access_control_method');
if (accessMethod === 'admin_approval') {
const existingUser = await this.findUserByOIDCSubject(userinfo.sub);
if (!existingUser) {
// Create pending user
await this.createPendingUser(userinfo.sub, username, email, avatarUrl);
return {
success: false,
requiresApproval: true,
};
}
if (existingUser.registrationStatus === 'pending_approval') {
return {
success: false,
requiresApproval: true,
};
}
if (existingUser.registrationStatus === 'rejected') {
return {
success: false,
error: 'Your account has been rejected by an administrator',
};
}
}
// Create or update user
const result = await this.createOrUpdateUser(
userinfo.sub,
username,
email,
avatarUrl,
isAdminFromClaim
);
// Generate JWT tokens
const tokens = await this.generateTokens(result.userInfo);
return {
success: true,
user: result.userInfo,
tokens,
isFirstLogin: result.isFirstLogin,
};
} catch (error) {
console.error('[OIDCAuthProvider] Callback failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Authentication failed',
};
}
}
/**
* Check if user has access to the application
*/
private async checkAccessControl(userinfo: any): Promise<boolean> {
const method = await this.configService.get('oidc.access_control_method');
switch (method) {
case 'open':
return true;
case 'group_claim': {
const claimName = (await this.configService.get('oidc.access_group_claim')) || 'groups';
const requiredGroup = await this.configService.get('oidc.access_group_value');
if (!requiredGroup) {
console.error('[OIDCAuthProvider] Group claim access control enabled but no required group configured');
return false;
}
const userGroups = userinfo[claimName] || [];
if (Array.isArray(userGroups)) {
return userGroups.includes(requiredGroup);
}
return userGroups === requiredGroup;
}
case 'allowed_list': {
const allowedEmailsStr = await this.configService.get('oidc.allowed_emails');
const allowedUsernamesStr = await this.configService.get('oidc.allowed_usernames');
const allowedEmails = allowedEmailsStr ? JSON.parse(allowedEmailsStr) : [];
const allowedUsernames = allowedUsernamesStr ? JSON.parse(allowedUsernamesStr) : [];
return (
allowedEmails.includes(userinfo.email) ||
allowedUsernames.includes(userinfo.preferred_username)
);
}
case 'admin_approval':
// Admin approval check happens in handleCallback
return true;
default:
// If no method specified, default to open access
return true;
}
}
/**
* Check if user should be granted admin role from OIDC claims
*/
private async checkAdminClaim(userinfo: any): Promise<boolean> {
const enabled = await this.configService.get('oidc.admin_claim_enabled');
if (enabled !== 'true') {
return false;
}
const claimName = (await this.configService.get('oidc.admin_claim_name')) || 'groups';
const claimValue = await this.configService.get('oidc.admin_claim_value');
if (!claimValue) {
return false;
}
const userClaims = userinfo[claimName] || [];
if (Array.isArray(userClaims)) {
return userClaims.includes(claimValue);
}
return userClaims === claimValue;
}
/**
* Find user by OIDC subject
*/
private async findUserByOIDCSubject(oidcSubject: string) {
return await prisma.user.findFirst({
where: {
oidcSubject,
authProvider: 'oidc',
},
});
}
/**
* Create pending user (for admin approval flow)
*/
private async createPendingUser(
oidcSubject: string,
username: string,
email: string | undefined,
avatarUrl: string | undefined
) {
const providerName = await this.configService.get('oidc.provider_name');
await prisma.user.create({
data: {
plexId: oidcSubject, // Use oidcSubject as unique identifier
plexUsername: username,
plexEmail: email || null,
role: 'user',
isSetupAdmin: false,
avatarUrl: avatarUrl || null,
authProvider: 'oidc',
oidcSubject,
oidcProvider: providerName || 'unknown',
registrationStatus: 'pending_approval',
lastLoginAt: new Date(),
},
});
}
/**
* Create or update user in database
*/
private async createOrUpdateUser(
oidcSubject: string,
username: string,
email: string | undefined,
avatarUrl: string | undefined,
isAdminFromClaim: boolean
): Promise<{ userInfo: UserInfo; isFirstLogin: boolean }> {
const providerName = await this.configService.get('oidc.provider_name');
// Check if this is the first user (should be promoted to admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
const role = isFirstUser || isAdminFromClaim ? 'admin' : 'user';
// Create or update user
const user = await prisma.user.upsert({
where: { plexId: oidcSubject },
create: {
plexId: oidcSubject, // Use oidcSubject as plexId for unique constraint
plexUsername: username,
plexEmail: email || null,
role,
isSetupAdmin: isFirstUser,
avatarUrl: avatarUrl || null,
authProvider: 'oidc',
oidcSubject,
oidcProvider: providerName || 'unknown',
registrationStatus: 'approved',
lastLoginAt: new Date(),
},
update: {
plexUsername: username,
plexEmail: email || null,
avatarUrl: avatarUrl || null,
oidcProvider: providerName || 'unknown',
registrationStatus: 'approved',
lastLoginAt: new Date(),
// Update role if admin claim is present
...(isAdminFromClaim && { role: 'admin' }),
},
});
// Track if we need to trigger initial jobs
let shouldTriggerJobs = false;
// If this is the first user, trigger initial jobs (Audible refresh + Library scan)
// This happens after OIDC-only setup where no admin was created during wizard
if (isFirstUser) {
console.log('[OIDCAuthProvider] First OIDC user created - triggering initial jobs');
// Check if initial jobs have already been run (avoid duplicate runs)
const initialJobsRun = await this.configService.get('system.initial_jobs_run');
if (initialJobsRun !== 'true') {
shouldTriggerJobs = true;
// Trigger jobs in background (don't block authentication)
this.triggerInitialJobs().catch(err => {
console.error('[OIDCAuthProvider] Failed to trigger initial jobs:', err);
});
}
}
return {
userInfo: {
id: user.id,
username: user.plexUsername,
email: user.plexEmail || undefined,
avatarUrl: user.avatarUrl || undefined,
isAdmin: user.role === 'admin',
},
isFirstLogin: isFirstUser && shouldTriggerJobs,
};
}
/**
* Trigger initial jobs (Audible refresh + Library scan) after first OIDC login
* This is called automatically when the first user logs in via OIDC after setup
*/
private async triggerInitialJobs(): Promise<void> {
try {
const schedulerService = getSchedulerService();
// Get scheduled jobs by type
const audibleJob = await prisma.scheduledJob.findFirst({
where: { type: 'audible_refresh' },
});
const libraryJob = await prisma.scheduledJob.findFirst({
where: { type: 'plex_library_scan' },
});
console.log('[OIDCAuthProvider] Triggering initial jobs...');
// Trigger Audible refresh
if (audibleJob) {
await schedulerService.triggerJobNow(audibleJob.id);
console.log('[OIDCAuthProvider] Triggered Audible refresh job');
} else {
console.warn('[OIDCAuthProvider] Audible refresh job not found');
}
// Trigger Library scan
if (libraryJob) {
await schedulerService.triggerJobNow(libraryJob.id);
console.log('[OIDCAuthProvider] Triggered Library scan job');
} else {
console.warn('[OIDCAuthProvider] Library scan job not found');
}
// Mark initial jobs as run
await prisma.configuration.upsert({
where: { key: 'system.initial_jobs_run' },
update: { value: 'true' },
create: { key: 'system.initial_jobs_run', value: 'true' },
});
console.log('[OIDCAuthProvider] Initial jobs triggered successfully');
} catch (error) {
console.error('[OIDCAuthProvider] Error triggering initial jobs:', error);
throw error;
}
}
/**
* Generate JWT access and refresh tokens
*/
private async generateTokens(userInfo: UserInfo): Promise<AuthTokens> {
const accessToken = generateAccessToken({
sub: userInfo.id,
plexId: userInfo.id, // For backwards compatibility
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
});
const refreshToken = generateRefreshToken(userInfo.id);
return {
accessToken,
refreshToken,
};
}
/**
* Refresh JWT tokens
*/
async refreshToken(refreshToken: string): Promise<AuthTokens | null> {
// JWT refresh is handled by existing JWT utilities
// This method is a placeholder for future implementation
return null;
}
/**
* Validate user has access
*/
async validateAccess(userInfo: UserInfo): Promise<boolean> {
try {
// Check if user exists and is approved
const user = await prisma.user.findUnique({
where: { id: userInfo.id },
});
if (!user || user.authProvider !== 'oidc') {
return false;
}
if (user.registrationStatus === 'pending_approval' || user.registrationStatus === 'rejected') {
return false;
}
return true;
} catch (error) {
console.error('[OIDCAuthProvider] Access validation failed:', error);
return false;
}
}
/**
* Clean up expired flow states
*/
private cleanupExpiredStates(): void {
const now = Date.now();
for (const [state, flowState] of flowStateCache.entries()) {
if (now - flowState.timestamp > FLOW_STATE_TTL) {
flowStateCache.delete(state);
}
}
}
}
+261
View File
@@ -0,0 +1,261 @@
/**
* Plex Auth Provider Implementation
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import {
IAuthProvider,
UserInfo,
AuthTokens,
LoginInitiation,
CallbackParams,
AuthResult,
} from './IAuthProvider';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getConfigService } from '@/lib/services/config.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getBaseUrl } from '@/lib/utils/url';
import { prisma } from '@/lib/db';
export class PlexAuthProvider implements IAuthProvider {
type: 'plex' = 'plex';
private plexService = getPlexService();
private configService = getConfigService();
private encryptionService = getEncryptionService();
/**
* Initiate Plex OAuth login flow
*/
async initiateLogin(): Promise<LoginInitiation> {
try {
// Request a PIN from Plex
const pin = await this.plexService.requestPin();
// Generate OAuth URL
const baseCallbackUrl = process.env.PLEX_OAUTH_CALLBACK_URL ||
`${getBaseUrl()}/api/auth/plex/callback`;
const oauthUrl = this.plexService.getOAuthUrl(pin.code, pin.id, baseCallbackUrl);
return {
redirectUrl: oauthUrl,
pinId: pin.id.toString(),
};
} catch (error) {
console.error('[PlexAuthProvider] Failed to initiate login:', error);
throw new Error('Failed to initiate Plex authentication');
}
}
/**
* Handle OAuth callback - check PIN status and complete authentication
*/
async handleCallback(params: CallbackParams): Promise<AuthResult> {
try {
const { pinId } = params;
if (!pinId) {
return {
success: false,
error: 'Missing PIN ID',
};
}
// Check PIN status
const authToken = await this.plexService.checkPin(parseInt(pinId, 10));
if (!authToken) {
// Still waiting for user authorization
return {
success: false,
error: 'Waiting for user authorization',
};
}
// Get user info from Plex
const plexUser = await this.plexService.getUserInfo(authToken);
if (!plexUser || !plexUser.id || !plexUser.username) {
return {
success: false,
error: 'Failed to get user information from Plex',
};
}
// Verify user has access to configured server
const plexConfig = await this.configService.getPlexConfig();
if (!plexConfig.serverUrl || !plexConfig.machineIdentifier) {
return {
success: false,
error: 'Plex server is not configured',
};
}
const hasAccess = await this.plexService.verifyServerAccess(
plexConfig.serverUrl,
plexConfig.machineIdentifier,
authToken
);
if (!hasAccess) {
return {
success: false,
error: 'You do not have access to this Plex server',
};
}
// Check for Plex Home profiles
const homeUsers = await this.plexService.getHomeUsers(authToken);
if (homeUsers.length > 1) {
// Multiple profiles - need profile selection
return {
success: true,
requiresProfileSelection: true,
profiles: homeUsers,
};
}
// No additional profiles - create/update user with main account
const userInfo = await this.createOrUpdateUser(
plexUser.id.toString(),
plexUser.username,
plexUser.email,
plexUser.thumb,
authToken,
null // No home profile
);
// Generate JWT tokens
const tokens = await this.generateTokens(userInfo);
return {
success: true,
user: userInfo,
tokens,
};
} catch (error) {
console.error('[PlexAuthProvider] Callback failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Authentication failed',
};
}
}
/**
* Refresh JWT tokens
*/
async refreshToken(refreshToken: string): Promise<AuthTokens | null> {
// JWT refresh is handled by existing JWT utilities
// This method is a placeholder for future implementation
return null;
}
/**
* Validate user has access to the server
*/
async validateAccess(userInfo: UserInfo): Promise<boolean> {
try {
const plexConfig = await this.configService.getPlexConfig();
if (!plexConfig.serverUrl || !plexConfig.machineIdentifier) {
return false;
}
// Get user's Plex token from database
const user = await prisma.user.findUnique({
where: { id: userInfo.id },
});
if (!user || !user.authToken) {
return false;
}
// Decrypt token
const decryptedToken = this.encryptionService.decrypt(user.authToken);
// Verify server access
return await this.plexService.verifyServerAccess(
plexConfig.serverUrl,
plexConfig.machineIdentifier,
decryptedToken
);
} catch (error) {
console.error('[PlexAuthProvider] Access validation failed:', error);
return false;
}
}
/**
* Create or update user in database
*/
private async createOrUpdateUser(
plexId: string,
username: string,
email: string | undefined,
avatarUrl: string | undefined,
authToken: string,
homeUserId: string | null
): Promise<UserInfo> {
// Check if this is the first user (should be promoted to admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
const role = isFirstUser ? 'admin' : 'user';
// Create or update user in database
const user = await prisma.user.upsert({
where: { plexId },
create: {
plexId,
plexUsername: username,
plexEmail: email || null,
role,
isSetupAdmin: isFirstUser,
avatarUrl: avatarUrl || null,
authToken: this.encryptionService.encrypt(authToken),
authProvider: 'plex',
plexHomeUserId: homeUserId,
lastLoginAt: new Date(),
},
update: {
plexUsername: username,
plexEmail: email || null,
avatarUrl: avatarUrl || null,
authToken: this.encryptionService.encrypt(authToken),
authProvider: 'plex',
plexHomeUserId: homeUserId,
lastLoginAt: new Date(),
},
});
return {
id: user.id,
username: user.plexUsername,
email: user.plexEmail || undefined,
avatarUrl: user.avatarUrl || undefined,
isAdmin: user.role === 'admin',
};
}
/**
* Generate JWT access and refresh tokens
*/
private async generateTokens(userInfo: UserInfo): Promise<AuthTokens> {
const accessToken = generateAccessToken({
sub: userInfo.id,
plexId: userInfo.id, // For backwards compatibility
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
});
const refreshToken = generateRefreshToken(userInfo.id);
return {
accessToken,
refreshToken,
};
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Auth Provider Factory
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { IAuthProvider } from './IAuthProvider';
import { PlexAuthProvider } from './PlexAuthProvider';
import { OIDCAuthProvider } from './OIDCAuthProvider'; // Phase 3
import { LocalAuthProvider } from './LocalAuthProvider'; // Phase 4
import { getConfigService } from '@/lib/services/config.service';
export type AuthMethod = 'plex' | 'oidc' | 'local';
/**
* Get the appropriate auth provider based on backend mode and auth method
* @param method - Optional override for auth method (useful for multi-auth scenarios)
*/
export async function getAuthProvider(method?: AuthMethod): Promise<IAuthProvider> {
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
// Plex mode always uses Plex OAuth
if (backendMode === 'plex') {
return new PlexAuthProvider();
}
// Audiobookshelf mode - determine auth method
if (method) {
// Explicit method provided
if (method === 'oidc') {
return new OIDCAuthProvider();
} else if (method === 'local') {
return new LocalAuthProvider();
}
}
// Auto-detect from configuration
const oidcEnabled = (await configService.get('oidc.enabled')) === 'true';
const registrationEnabled = (await configService.get('auth.registration_enabled')) === 'true';
if (oidcEnabled) {
return new OIDCAuthProvider();
} else if (registrationEnabled) {
return new LocalAuthProvider();
}
// Fallback to Plex (shouldn't happen in normal flow)
return new PlexAuthProvider();
}
// Re-export types
export * from './IAuthProvider';
export { PlexAuthProvider } from './PlexAuthProvider';
export { OIDCAuthProvider } from './OIDCAuthProvider';
export { LocalAuthProvider } from './LocalAuthProvider';
+250
View File
@@ -0,0 +1,250 @@
/**
* Component: Configuration Service
* Documentation: documentation/backend/services/config.md
*/
import { prisma } from '@/lib/db';
import { getEncryptionService } from './encryption.service';
/**
* Configuration update payload
*/
export interface ConfigUpdate {
key: string;
value: string;
encrypted?: boolean;
category?: string;
description?: string;
}
/**
* Plex configuration structure
*/
export interface PlexConfig {
serverUrl: string | null;
authToken: string | null;
libraryId: string | null;
machineIdentifier: string | null;
}
/**
* Configuration service for reading settings from database
*/
export class ConfigurationService {
private cache: Map<string, string> = new Map();
private cacheExpiry: Map<string, number> = new Map();
private readonly CACHE_TTL = 60000; // 1 minute
/**
* Get a configuration value by key (decrypted if encrypted)
*/
async get(key: string): Promise<string | null> {
// Check cache first
const cached = this.cache.get(key);
const expiry = this.cacheExpiry.get(key);
if (cached && expiry && Date.now() < expiry) {
return cached;
}
// Fetch from database
try {
const config = await prisma.configuration.findUnique({
where: { key },
});
if (config && config.value) {
let value = config.value;
// Decrypt if encrypted
if (config.encrypted) {
const encryptionService = getEncryptionService();
value = encryptionService.decrypt(config.value);
}
// Cache the decrypted value
this.cache.set(key, value);
this.cacheExpiry.set(key, Date.now() + this.CACHE_TTL);
return value;
}
return null;
} catch (error) {
console.error(`[Config] Failed to get config key "${key}":`, error);
return null;
}
}
/**
* Get multiple configuration values
*/
async getMany(keys: string[]): Promise<Record<string, string | null>> {
const result: Record<string, string | null> = {};
await Promise.all(
keys.map(async (key) => {
result[key] = await this.get(key);
})
);
return result;
}
/**
* Get all configuration items for a specific category
*/
async getCategory(category: string): Promise<Record<string, any>> {
try {
const configs = await prisma.configuration.findMany({
where: { category },
});
const result: Record<string, any> = {};
for (const config of configs) {
let value = config.value;
// Decrypt if encrypted
if (config.encrypted && value) {
const encryptionService = getEncryptionService();
value = encryptionService.decrypt(value);
}
result[config.key] = {
value,
encrypted: config.encrypted,
description: config.description,
};
}
return result;
} catch (error) {
console.error(`[Config] Failed to get category "${category}":`, error);
return {};
}
}
/**
* Get all configuration items (with masked sensitive values)
*/
async getAll(): Promise<Record<string, any>> {
try {
const configs = await prisma.configuration.findMany();
const result: Record<string, any> = {};
for (const config of configs) {
result[config.key] = {
value: config.encrypted ? '***ENCRYPTED***' : config.value,
encrypted: config.encrypted,
category: config.category,
description: config.description,
};
}
return result;
} catch (error) {
console.error('[Config] Failed to get all configuration:', error);
return {};
}
}
/**
* Set multiple configuration values (encrypts if needed)
*/
async setMany(updates: ConfigUpdate[]): Promise<void> {
try {
const encryptionService = getEncryptionService();
for (const update of updates) {
let value = update.value;
// Encrypt if needed
if (update.encrypted) {
value = encryptionService.encrypt(value);
}
// Upsert configuration
await prisma.configuration.upsert({
where: { key: update.key },
create: {
key: update.key,
value,
encrypted: update.encrypted || false,
category: update.category,
description: update.description,
},
update: {
value,
encrypted: update.encrypted || false,
category: update.category,
description: update.description,
},
});
// Clear cache for this key
this.clearCache(update.key);
}
} catch (error) {
console.error('[Config] Failed to set configuration:', error);
throw error;
}
}
/**
* Get Plex-specific configuration
*/
async getPlexConfig(): Promise<PlexConfig> {
const config = await this.getMany([
'plex_url',
'plex_token',
'plex_audiobook_library_id',
'plex_machine_identifier',
]);
return {
serverUrl: config.plex_url,
authToken: config.plex_token,
libraryId: config.plex_audiobook_library_id,
machineIdentifier: config.plex_machine_identifier || null,
};
}
/**
* Get backend mode (Plex or Audiobookshelf)
*/
async getBackendMode(): Promise<'plex' | 'audiobookshelf'> {
const mode = await this.get('system.backend_mode');
return (mode as 'plex' | 'audiobookshelf') || 'plex';
}
/**
* Check if Audiobookshelf mode is enabled
*/
async isAudiobookshelfMode(): Promise<boolean> {
return (await this.getBackendMode()) === 'audiobookshelf';
}
/**
* Clear the cache for a specific key or all keys
*/
clearCache(key?: string): void {
if (key) {
this.cache.delete(key);
this.cacheExpiry.delete(key);
} else {
this.cache.clear();
this.cacheExpiry.clear();
}
}
}
// Singleton instance
let configService: ConfigurationService | null = null;
export function getConfigService(): ConfigurationService {
if (!configService) {
configService = new ConfigurationService();
}
return configService;
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Component: Encryption Service
* Documentation: documentation/backend/services/config.md
*/
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const KEY_LENGTH = 32;
export class EncryptionService {
private key: Buffer;
constructor() {
const encryptionKey = process.env.CONFIG_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error('CONFIG_ENCRYPTION_KEY environment variable is not set');
}
// Ensure key is exactly 32 bytes
if (encryptionKey.length < KEY_LENGTH) {
// Pad with zeros if too short
this.key = Buffer.alloc(KEY_LENGTH);
Buffer.from(encryptionKey).copy(this.key);
} else if (encryptionKey.length > KEY_LENGTH) {
// Truncate if too long
this.key = Buffer.from(encryptionKey).subarray(0, KEY_LENGTH);
} else {
this.key = Buffer.from(encryptionKey);
}
}
/**
* Encrypt a plaintext string
* @param plaintext - The string to encrypt
* @returns Base64-encoded string in format: iv:authTag:encryptedData
*/
encrypt(plaintext: string): string {
try {
// Generate random IV for this encryption
const iv = crypto.randomBytes(IV_LENGTH);
// Create cipher
const cipher = crypto.createCipheriv(ALGORITHM, this.key, iv);
// Encrypt data
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
encrypted += cipher.final('base64');
// Get auth tag
const authTag = cipher.getAuthTag();
// Combine IV, auth tag, and encrypted data
const result = `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
return result;
} catch (error) {
throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Decrypt an encrypted string
* @param encryptedData - Base64-encoded string in format: iv:authTag:encryptedData
* @returns Decrypted plaintext string
*/
decrypt(encryptedData: string): string {
try {
// Split the encrypted data
const parts = encryptedData.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted data format');
}
const [ivBase64, authTagBase64, encrypted] = parts;
// Decode components
const iv = Buffer.from(ivBase64, 'base64');
const authTag = Buffer.from(authTagBase64, 'base64');
// Create decipher
const decipher = crypto.createDecipheriv(ALGORITHM, this.key, iv);
decipher.setAuthTag(authTag);
// Decrypt data
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Generate a random encryption key (32 bytes)
* @returns Base64-encoded random key
*/
static generateKey(): string {
return crypto.randomBytes(KEY_LENGTH).toString('base64');
}
}
// Singleton instance
let encryptionService: EncryptionService | null = null;
export function getEncryptionService(): EncryptionService {
if (!encryptionService) {
encryptionService = new EncryptionService();
}
return encryptionService;
}
+845
View File
@@ -0,0 +1,845 @@
/**
* Component: Job Queue Service
* Documentation: documentation/backend/services/jobs.md
*/
import Queue, { Job as BullJob, JobOptions } from 'bull';
import Redis from 'ioredis';
import { prisma } from '../db';
import { TorrentResult } from '../utils/ranking-algorithm';
export type JobType =
| 'search_indexers'
| 'download_torrent'
| 'monitor_download'
| 'organize_files'
| 'scan_plex'
| 'match_plex'
| 'plex_library_scan'
| 'plex_recently_added_check'
| 'audible_refresh'
| 'retry_missing_torrents'
| 'retry_failed_imports'
| 'cleanup_seeded_torrents'
| 'monitor_rss_feeds';
export interface JobPayload {
jobId?: string; // Database job ID (added automatically by addJob)
[key: string]: any;
}
export interface SearchIndexersPayload extends JobPayload {
requestId: string;
audiobook: {
id: string;
title: string;
author: string;
};
}
export interface DownloadTorrentPayload extends JobPayload {
requestId: string;
audiobook: {
id: string;
title: string;
author: string;
};
torrent: TorrentResult;
}
export interface MonitorDownloadPayload extends JobPayload {
requestId: string;
downloadHistoryId: string;
downloadClientId: string;
downloadClient: 'qbittorrent' | 'transmission';
}
export interface OrganizeFilesPayload extends JobPayload {
requestId: string;
audiobookId: string;
downloadPath: string;
targetPath: string;
}
export interface ScanPlexPayload extends JobPayload {
libraryId?: string;
partial?: boolean;
path?: string;
}
export interface MatchPlexPayload extends JobPayload {
requestId: string;
audiobookId: string;
title: string;
author: string;
}
export interface PlexRecentlyAddedPayload extends JobPayload {
scheduledJobId?: string;
}
export interface MonitorRssFeedsPayload extends JobPayload {
scheduledJobId?: string;
}
export interface AudibleRefreshPayload extends JobPayload {
scheduledJobId?: string;
}
export interface RetryMissingTorrentsPayload extends JobPayload {
scheduledJobId?: string;
}
export interface RetryFailedImportsPayload extends JobPayload {
scheduledJobId?: string;
}
export interface CleanupSeededTorrentsPayload extends JobPayload {
scheduledJobId?: string;
}
export interface QueueStats {
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
}
export class JobQueueService {
private queue: Queue.Queue;
private redis: Redis;
constructor() {
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
// Create Redis client
this.redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
});
// Increase max listeners to accommodate all job processors (12 total)
this.redis.setMaxListeners(20);
// Create Bull queue
this.queue = new Queue('audiobook-jobs', redisUrl, {
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: 100,
removeOnFail: 200,
},
});
// Increase max listeners to accommodate all job processors (12 total)
this.queue.setMaxListeners(20);
this.setupEventHandlers();
this.startProcessors();
}
/**
* Setup event handlers for job lifecycle
*/
private setupEventHandlers(): void {
this.queue.on('completed', async (job: BullJob, result: any) => {
console.log(`Job ${job.id} completed:`, result);
await this.updateJobInDatabase(job.id as string, 'completed', result);
});
this.queue.on('failed', async (job: BullJob, error: Error) => {
console.error(`Job ${job.id} failed:`, error.message);
await this.updateJobInDatabase(
job.id as string,
'failed',
null,
error.message,
error.stack
);
// Handle permanent failures for specific job types after all retries exhausted
if (job.name === 'monitor_download' && job.data) {
const payload = job.data as MonitorDownloadPayload;
console.error(`[MonitorDownload] Job permanently failed for request ${payload.requestId} after ${job.attemptsMade} attempts`);
// Update request status to failed (only happens after all retries exhausted)
try {
await prisma.request.update({
where: { id: payload.requestId },
data: {
status: 'failed',
errorMessage: error.message || 'Failed to monitor download after multiple retries',
updatedAt: new Date(),
},
});
// Update download history
if (payload.downloadHistoryId) {
await prisma.downloadHistory.update({
where: { id: payload.downloadHistoryId },
data: {
downloadStatus: 'failed',
downloadError: error.message || 'Failed to monitor download',
},
});
}
} catch (updateError) {
console.error('[MonitorDownload] Failed to update request/download status:', updateError);
}
}
});
this.queue.on('stalled', async (job: BullJob) => {
console.warn(`Job ${job.id} stalled`);
await this.updateJobInDatabase(job.id as string, 'stuck');
});
this.queue.on('active', async (job: BullJob) => {
await this.updateJobInDatabase(job.id as string, 'active');
});
this.queue.on('error', (error: Error) => {
console.error('Queue error:', error);
});
}
/**
* Start job processors for each job type
*/
private startProcessors(): void {
// Search indexers processor
this.queue.process('search_indexers', 3, async (job: BullJob<SearchIndexersPayload>) => {
const { processSearchIndexers } = await import('../processors/search-indexers.processor');
return await processSearchIndexers(job.data);
});
// Download torrent processor
this.queue.process('download_torrent', 3, async (job: BullJob<DownloadTorrentPayload>) => {
const { processDownloadTorrent } = await import('../processors/download-torrent.processor');
return await processDownloadTorrent(job.data);
});
// Monitor download processor
this.queue.process('monitor_download', 5, async (job: BullJob<MonitorDownloadPayload>) => {
const { processMonitorDownload } = await import('../processors/monitor-download.processor');
return await processMonitorDownload(job.data);
});
// Organize files processor
this.queue.process('organize_files', 2, async (job: BullJob<OrganizeFilesPayload>) => {
const { processOrganizeFiles } = await import('../processors/organize-files.processor');
return await processOrganizeFiles(job.data);
});
// Scan Plex processor
this.queue.process('scan_plex', 1, async (job: BullJob<ScanPlexPayload>) => {
const { processScanPlex } = await import('../processors/scan-plex.processor');
return await processScanPlex(job.data);
});
// Match Plex processor
this.queue.process('match_plex', 3, async (job: BullJob<MatchPlexPayload>) => {
const { processMatchPlex } = await import('../processors/match-plex.processor');
return await processMatchPlex(job.data);
});
// Scheduled job processors
this.queue.process('plex_library_scan', 1, async (job: BullJob) => {
// plex_library_scan is just an alias for scan_plex
const { processScanPlex } = await import('../processors/scan-plex.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'plex_library_scan');
return await processScanPlex(payloadWithJobId);
});
this.queue.process('plex_recently_added_check', 1, async (job: BullJob<PlexRecentlyAddedPayload>) => {
const { processPlexRecentlyAddedCheck } = await import('../processors/plex-recently-added.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'plex_recently_added_check');
return await processPlexRecentlyAddedCheck(payloadWithJobId);
});
this.queue.process('monitor_rss_feeds', 1, async (job: BullJob<MonitorRssFeedsPayload>) => {
const { processMonitorRssFeeds } = await import('../processors/monitor-rss-feeds.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'monitor_rss_feeds');
return await processMonitorRssFeeds(payloadWithJobId);
});
this.queue.process('audible_refresh', 1, async (job: BullJob<AudibleRefreshPayload>) => {
const { processAudibleRefresh } = await import('../processors/audible-refresh.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'audible_refresh');
return await processAudibleRefresh(payloadWithJobId);
});
this.queue.process('retry_missing_torrents', 1, async (job: BullJob<RetryMissingTorrentsPayload>) => {
const { processRetryMissingTorrents } = await import('../processors/retry-missing-torrents.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'retry_missing_torrents');
return await processRetryMissingTorrents(payloadWithJobId);
});
this.queue.process('retry_failed_imports', 1, async (job: BullJob<RetryFailedImportsPayload>) => {
const { processRetryFailedImports } = await import('../processors/retry-failed-imports.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'retry_failed_imports');
return await processRetryFailedImports(payloadWithJobId);
});
this.queue.process('cleanup_seeded_torrents', 1, async (job: BullJob<CleanupSeededTorrentsPayload>) => {
const { processCleanupSeededTorrents } = await import('../processors/cleanup-seeded-torrents.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'cleanup_seeded_torrents');
return await processCleanupSeededTorrents(payloadWithJobId);
});
}
/**
* Ensure a database Job record exists for scheduled jobs
* If jobId is already in payload (manual trigger), return as-is
* Otherwise, create a Job record for timer-triggered scheduled jobs
* Also updates the lastRun timestamp for timer-triggered scheduled jobs
*/
private async ensureJobRecord(job: BullJob, jobType: JobType): Promise<any> {
const payload = job.data;
// If jobId already exists (manual trigger via addJob), return payload as-is
if (payload.jobId) {
return payload;
}
// Check if a Job record already exists for this Bull job
const existingJob = await prisma.job.findFirst({
where: { bullJobId: job.id as string },
});
if (existingJob) {
// Update lastRun for the scheduled job if this is a timer-triggered job
if (payload.scheduledJobId) {
await prisma.scheduledJob.update({
where: { id: payload.scheduledJobId },
data: { lastRun: new Date() },
}).catch(err => {
console.error(`[JobQueue] Failed to update lastRun for scheduled job ${payload.scheduledJobId}:`, err);
});
}
return { ...payload, jobId: existingJob.id };
}
// Create a new Job record for this scheduled job
const dbJob = await prisma.job.create({
data: {
bullJobId: job.id as string,
requestId: payload.requestId || null,
type: jobType,
status: 'pending',
priority: 0,
payload,
maxAttempts: 3,
},
});
// Update lastRun for the scheduled job if this is a timer-triggered job
if (payload.scheduledJobId) {
await prisma.scheduledJob.update({
where: { id: payload.scheduledJobId },
data: { lastRun: new Date() },
}).catch(err => {
console.error(`[JobQueue] Failed to update lastRun for scheduled job ${payload.scheduledJobId}:`, err);
});
}
return { ...payload, jobId: dbJob.id };
}
/**
* Update job status in database
*/
private async updateJobInDatabase(
bullJobId: string,
status: string,
result?: any,
errorMessage?: string,
stackTrace?: string
): Promise<void> {
try {
const updateData: any = {
status,
updatedAt: new Date(),
};
if (status === 'active') {
updateData.startedAt = new Date();
}
if (status === 'completed' || status === 'failed') {
updateData.completedAt = new Date();
}
if (result) {
updateData.result = result;
}
if (errorMessage) {
updateData.errorMessage = errorMessage;
}
if (stackTrace) {
updateData.stackTrace = stackTrace;
}
await prisma.job.updateMany({
where: { bullJobId },
data: updateData,
});
} catch (error) {
console.error('Failed to update job in database:', error);
}
}
/**
* Add a job to the queue
*/
private async addJob(
type: JobType,
payload: JobPayload,
options?: JobOptions
): Promise<string> {
// First create the database job record
const dbJob = await prisma.job.create({
data: {
bullJobId: null, // Will be updated after Bull job is created
requestId: payload.requestId || null,
type,
status: 'pending',
priority: options?.priority || 0,
payload,
maxAttempts: options?.attempts || 3,
},
});
// Add jobId to payload so processors can access it
const payloadWithJobId = { ...payload, jobId: dbJob.id };
// Create Bull job
const bullJob = await this.queue.add(type, payloadWithJobId, options);
// Update database job with Bull job ID
await prisma.job.update({
where: { id: dbJob.id },
data: { bullJobId: bullJob.id as string },
});
return dbJob.id;
}
/**
* Add search indexers job
*/
async addSearchJob(requestId: string, audiobook: { id: string; title: string; author: string }): Promise<string> {
return await this.addJob(
'search_indexers',
{
requestId,
audiobook,
} as SearchIndexersPayload,
{
priority: 10, // High priority for user-initiated requests
}
);
}
/**
* Add download torrent job
*/
async addDownloadJob(
requestId: string,
audiobook: { id: string; title: string; author: string },
torrent: TorrentResult
): Promise<string> {
return await this.addJob(
'download_torrent',
{
requestId,
audiobook,
torrent,
} as DownloadTorrentPayload,
{
priority: 9, // High priority - download selected torrent
}
);
}
/**
* Add monitor download job
*/
async addMonitorJob(
requestId: string,
downloadHistoryId: string,
downloadClientId: string,
downloadClient: 'qbittorrent' | 'transmission',
delaySeconds: number = 0
): Promise<string> {
return await this.addJob(
'monitor_download',
{
requestId,
downloadHistoryId,
downloadClientId,
downloadClient,
} as MonitorDownloadPayload,
{
priority: 5, // Medium priority
delay: delaySeconds * 1000, // Convert seconds to milliseconds
}
);
}
/**
* Add organize files job
*/
async addOrganizeJob(
requestId: string,
audiobookId: string,
downloadPath: string,
targetPath: string
): Promise<string> {
return await this.addJob(
'organize_files',
{
requestId,
audiobookId,
downloadPath,
targetPath,
} as OrganizeFilesPayload,
{
priority: 8,
}
);
}
/**
* Add Plex scan job
*/
async addPlexScanJob(libraryId: string, partial?: boolean, path?: string): Promise<string> {
return await this.addJob(
'scan_plex',
{
libraryId,
partial,
path,
} as ScanPlexPayload,
{
priority: 7,
}
);
}
/**
* Add Plex match job
*/
async addPlexMatchJob(
requestId: string,
audiobookId: string,
title: string,
author: string
): Promise<string> {
return await this.addJob(
'match_plex',
{
requestId,
audiobookId,
title,
author,
} as MatchPlexPayload,
{
priority: 6,
}
);
}
/**
* Add Plex recently added check job
*/
async addPlexRecentlyAddedJob(scheduledJobId?: string): Promise<string> {
return await this.addJob(
'plex_recently_added_check',
{
scheduledJobId,
} as PlexRecentlyAddedPayload,
{
priority: 8,
}
);
}
/**
* Add RSS feed monitoring job
*/
async addMonitorRssFeedsJob(scheduledJobId?: string): Promise<string> {
return await this.addJob(
'monitor_rss_feeds',
{
scheduledJobId,
} as MonitorRssFeedsPayload,
{
priority: 8,
}
);
}
/**
* Add Audible refresh job
*/
async addAudibleRefreshJob(scheduledJobId?: string): Promise<string> {
return await this.addJob(
'audible_refresh',
{
scheduledJobId,
} as AudibleRefreshPayload,
{
priority: 9,
}
);
}
/**
* Add retry missing torrents job
*/
async addRetryMissingTorrentsJob(scheduledJobId?: string): Promise<string> {
return await this.addJob(
'retry_missing_torrents',
{
scheduledJobId,
} as RetryMissingTorrentsPayload,
{
priority: 7,
}
);
}
/**
* Add retry failed imports job
*/
async addRetryFailedImportsJob(scheduledJobId?: string): Promise<string> {
return await this.addJob(
'retry_failed_imports',
{
scheduledJobId,
} as RetryFailedImportsPayload,
{
priority: 7,
}
);
}
/**
* Add cleanup seeded torrents job
*/
async addCleanupSeededTorrentsJob(scheduledJobId?: string): Promise<string> {
return await this.addJob(
'cleanup_seeded_torrents',
{
scheduledJobId,
} as CleanupSeededTorrentsPayload,
{
priority: 10,
}
);
}
/**
* Get job by ID
*/
async getJob(jobId: string): Promise<any | null> {
return await prisma.job.findUnique({
where: { id: jobId },
});
}
/**
* Get all jobs for a request
*/
async getJobsByRequest(requestId: string): Promise<any[]> {
return await prisma.job.findMany({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
}
/**
* Get queue statistics
*/
async getQueueStats(): Promise<QueueStats> {
const counts = await this.queue.getJobCounts();
return {
waiting: counts.waiting || 0,
active: counts.active || 0,
completed: counts.completed || 0,
failed: counts.failed || 0,
delayed: counts.delayed || 0,
};
}
/**
* Get active jobs
*/
async getActiveJobs(): Promise<any[]> {
const bullJobs = await this.queue.getActive();
const jobIds = bullJobs.map((j) => j.id as string);
return await prisma.job.findMany({
where: {
bullJobId: { in: jobIds },
},
});
}
/**
* Get failed jobs
*/
async getFailedJobs(limit: number = 50): Promise<any[]> {
return await prisma.job.findMany({
where: { status: 'failed' },
orderBy: { updatedAt: 'desc' },
take: limit,
});
}
/**
* Retry a failed job
*/
async retryJob(jobId: string): Promise<void> {
const job = await prisma.job.findUnique({
where: { id: jobId },
});
if (!job) {
throw new Error('Job not found');
}
if (job.bullJobId) {
const bullJob = await this.queue.getJob(job.bullJobId);
if (bullJob) {
await bullJob.retry();
}
}
await prisma.job.update({
where: { id: jobId },
data: {
status: 'pending',
attempts: 0,
errorMessage: null,
stackTrace: null,
},
});
}
/**
* Cancel a job
*/
async cancelJob(jobId: string): Promise<void> {
const job = await prisma.job.findUnique({
where: { id: jobId },
});
if (!job) {
throw new Error('Job not found');
}
if (job.bullJobId) {
const bullJob = await this.queue.getJob(job.bullJobId);
if (bullJob) {
await bullJob.remove();
}
}
await prisma.job.update({
where: { id: jobId },
data: { status: 'cancelled' },
});
}
/**
* Pause the queue
*/
async pauseQueue(): Promise<void> {
await this.queue.pause();
}
/**
* Resume the queue
*/
async resumeQueue(): Promise<void> {
await this.queue.resume();
}
/**
* Close queue connection (for graceful shutdown)
*/
async close(): Promise<void> {
await this.queue.close();
this.redis.disconnect();
}
/**
* Add a repeatable job with cron schedule
*/
async addRepeatableJob(
jobType: string,
payload: JobPayload,
cronExpression: string,
jobId: string
): Promise<void> {
await this.queue.add(jobType, payload, {
repeat: {
cron: cronExpression,
},
jobId,
});
console.log(`[JobQueue] Added repeatable job: ${jobType} with cron ${cronExpression}`);
}
/**
* Remove a repeatable job
*/
async removeRepeatableJob(
jobType: string,
cronExpression: string,
jobId: string
): Promise<void> {
await this.queue.removeRepeatable(jobType, {
cron: cronExpression,
jobId,
});
console.log(`[JobQueue] Removed repeatable job: ${jobType}`);
}
/**
* Get all repeatable jobs
*/
async getRepeatableJobs(): Promise<any[]> {
return await this.queue.getRepeatableJobs();
}
}
// Singleton instance
let jobQueueService: JobQueueService | null = null;
export function getJobQueueService(): JobQueueService {
if (!jobQueueService) {
jobQueueService = new JobQueueService();
}
return jobQueueService;
}
// Graceful shutdown
process.on('SIGTERM', async () => {
if (jobQueueService) {
console.log('Closing job queue...');
await jobQueueService.close();
}
});
@@ -0,0 +1,108 @@
/**
* Component: Audiobookshelf Library Service
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import {
ILibraryService,
LibraryConnectionResult,
ServerInfo,
Library,
LibraryItem,
} from './ILibraryService';
import {
getABSServerInfo,
getABSLibraries,
getABSLibraryItems,
getABSRecentItems,
getABSItem,
searchABSItems,
triggerABSScan,
} from '../audiobookshelf/api';
import { ABSLibraryItem } from '../audiobookshelf/types';
export class AudiobookshelfLibraryService implements ILibraryService {
async testConnection(): Promise<LibraryConnectionResult> {
try {
const serverInfo = await this.getServerInfo();
return {
success: true,
serverInfo,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async getServerInfo(): Promise<ServerInfo> {
const info = await getABSServerInfo();
return {
name: info.name || 'Audiobookshelf',
version: info.version,
identifier: info.name, // ABS doesn't have unique identifier like Plex
};
}
async getLibraries(): Promise<Library[]> {
const libraries = await getABSLibraries();
return libraries
.filter((lib: any) => lib.mediaType === 'book') // Only audiobook libraries
.map((lib: any) => ({
id: lib.id,
name: lib.name,
type: lib.mediaType,
itemCount: lib.stats?.totalItems,
}));
}
async getLibraryItems(libraryId: string): Promise<LibraryItem[]> {
const items = await getABSLibraryItems(libraryId);
return items.map(this.mapABSItemToLibraryItem);
}
async getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]> {
const items = await getABSRecentItems(libraryId, limit);
return items.map(this.mapABSItemToLibraryItem);
}
async getItem(itemId: string): Promise<LibraryItem | null> {
try {
const item = await getABSItem(itemId);
return this.mapABSItemToLibraryItem(item);
} catch {
return null;
}
}
async searchItems(libraryId: string, query: string): Promise<LibraryItem[]> {
const items = await searchABSItems(libraryId, query);
return items.map((result: any) => this.mapABSItemToLibraryItem(result.libraryItem));
}
async triggerLibraryScan(libraryId: string): Promise<void> {
await triggerABSScan(libraryId);
}
private mapABSItemToLibraryItem(item: ABSLibraryItem): LibraryItem {
const metadata = item.media.metadata;
return {
id: item.id,
externalId: item.id, // ABS item ID is the external ID
title: metadata.title,
author: metadata.authorName,
narrator: metadata.narratorName,
description: metadata.description,
coverUrl: item.media.coverPath ? `/api/items/${item.id}/cover` : undefined,
duration: item.media.duration,
asin: metadata.asin,
isbn: metadata.isbn,
year: metadata.publishedYear ? parseInt(metadata.publishedYear) : undefined,
addedAt: new Date(item.addedAt),
updatedAt: new Date(item.updatedAt),
};
}
}
@@ -0,0 +1,58 @@
/**
* Library Service Interface
* Documentation: documentation/features/audiobookshelf-integration.md
*/
export interface ServerInfo {
name: string;
version: string;
platform?: string;
identifier: string; // machineIdentifier (Plex) or serverId (ABS)
}
export interface Library {
id: string;
name: string;
type: string;
itemCount?: number;
}
export interface LibraryItem {
id: string; // ratingKey (Plex) or item id (ABS)
externalId: string; // plexGuid or abs_item_id
title: string;
author: string;
narrator?: string;
description?: string;
coverUrl?: string;
duration?: number; // seconds
asin?: string;
isbn?: string;
year?: number;
addedAt: Date;
updatedAt: Date;
}
export interface LibraryConnectionResult {
success: boolean;
serverInfo?: ServerInfo;
error?: string;
}
export interface ILibraryService {
// Connection
testConnection(): Promise<LibraryConnectionResult>;
getServerInfo(): Promise<ServerInfo>;
// Libraries
getLibraries(): Promise<Library[]>;
getLibraryItems(libraryId: string): Promise<LibraryItem[]>;
getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]>;
// Items
getItem(itemId: string): Promise<LibraryItem | null>;
searchItems(libraryId: string, query: string): Promise<LibraryItem[]>;
// Scanning
triggerLibraryScan(libraryId: string): Promise<void>;
}
@@ -0,0 +1,261 @@
/**
* Plex Library Service Implementation
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import {
ILibraryService,
ServerInfo,
Library,
LibraryItem,
LibraryConnectionResult,
} from './ILibraryService';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getConfigService } from '@/lib/services/config.service';
export class PlexLibraryService implements ILibraryService {
private plexService = getPlexService();
private configService = getConfigService();
/**
* Test connection to Plex server
*/
async testConnection(): Promise<LibraryConnectionResult> {
try {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
return {
success: false,
error: 'Plex server configuration is incomplete',
};
}
const result = await this.plexService.testConnection(
config.serverUrl,
config.authToken
);
if (!result.success) {
return {
success: false,
error: result.message,
};
}
return {
success: true,
serverInfo: result.info ? {
name: result.info.platform || 'Plex Media Server',
version: result.info.version,
platform: result.info.platform,
identifier: result.info.machineIdentifier,
} : undefined,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Connection failed',
};
}
}
/**
* Get Plex server information
*/
async getServerInfo(): Promise<ServerInfo> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
const result = await this.plexService.testConnection(
config.serverUrl,
config.authToken
);
if (!result.success || !result.info) {
throw new Error('Failed to get server information');
}
return {
name: result.info.platform || 'Plex Media Server',
version: result.info.version,
platform: result.info.platform,
identifier: result.info.machineIdentifier,
};
}
/**
* Get all libraries from Plex server
*/
async getLibraries(): Promise<Library[]> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
const libraries = await this.plexService.getLibraries(
config.serverUrl,
config.authToken
);
return libraries.map(lib => ({
id: lib.id,
name: lib.title,
type: lib.type,
itemCount: lib.itemCount,
}));
}
/**
* Get all items from a library
*/
async getLibraryItems(libraryId: string): Promise<LibraryItem[]> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
const items = await this.plexService.getLibraryContent(
config.serverUrl,
config.authToken,
libraryId
);
return items.map(item => this.mapPlexItemToLibraryItem(item));
}
/**
* Get recently added items from a library
*/
async getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
const items = await this.plexService.getRecentlyAdded(
config.serverUrl,
config.authToken,
libraryId,
limit
);
return items.map(item => this.mapPlexItemToLibraryItem(item));
}
/**
* Get a single item by its rating key
*/
async getItem(itemId: string): Promise<LibraryItem | null> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
try {
const metadata = await this.plexService.getItemMetadata(
config.serverUrl,
config.authToken,
itemId
);
if (!metadata) {
return null;
}
// Note: getItemMetadata only returns partial data (userRating)
// For full item data, we would need to fetch from library content
// This is a simplified implementation
return null;
} catch (error) {
console.error('[PlexLibraryService] Failed to get item:', error);
return null;
}
}
/**
* Search library for items matching query
*/
async searchItems(libraryId: string, query: string): Promise<LibraryItem[]> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
const items = await this.plexService.searchLibrary(
config.serverUrl,
config.authToken,
libraryId,
query
);
return items.map(item => this.mapPlexItemToLibraryItem(item));
}
/**
* Trigger library scan
*/
async triggerLibraryScan(libraryId: string): Promise<void> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
await this.plexService.scanLibrary(
config.serverUrl,
config.authToken,
libraryId
);
}
/**
* Map Plex audiobook to generic LibraryItem interface
*/
private mapPlexItemToLibraryItem(plexItem: any): LibraryItem {
// Extract ASIN from plexGuid if present
const asin = this.extractAsinFromGuid(plexItem.guid);
return {
id: plexItem.ratingKey,
externalId: plexItem.guid,
title: plexItem.title,
author: plexItem.author || '',
narrator: plexItem.narrator,
description: plexItem.summary,
coverUrl: plexItem.thumb,
duration: plexItem.duration ? Math.floor(plexItem.duration / 1000) : undefined, // Convert ms to seconds
asin,
isbn: undefined, // Plex doesn't typically store ISBN
year: plexItem.year,
addedAt: new Date(plexItem.addedAt * 1000), // Convert Unix timestamp to Date
updatedAt: new Date(plexItem.updatedAt * 1000),
};
}
/**
* Extract ASIN from Plex GUID
* Plex GUIDs can contain ASIN in formats like:
* - com.plexapp.agents.audible://B00ABC123?lang=en
* - plex://album/5d07bcfe403c64002036d1af
*/
private extractAsinFromGuid(guid: string): string | undefined {
if (!guid) return undefined;
// Match ASIN pattern in Audible agent GUIDs
const asinMatch = guid.match(/audible:\/\/([A-Z0-9]{10})/i);
if (asinMatch && asinMatch[1]) {
return asinMatch[1];
}
return undefined;
}
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Library Service Factory
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { ILibraryService } from './ILibraryService';
import { PlexLibraryService } from './PlexLibraryService';
import { AudiobookshelfLibraryService } from './AudiobookshelfLibraryService';
import { getConfigService } from '@/lib/services/config.service';
let cachedService: ILibraryService | null = null;
let cachedMode: 'plex' | 'audiobookshelf' | null = null;
/**
* Get the appropriate library service based on backend mode
* Returns cached instance if mode hasn't changed
*/
export async function getLibraryService(): Promise<ILibraryService> {
const configService = getConfigService();
const mode = await configService.getBackendMode();
// Return cached instance if mode hasn't changed
if (cachedService && cachedMode === mode) {
return cachedService;
}
// Create new instance based on mode
if (mode === 'audiobookshelf') {
cachedService = new AudiobookshelfLibraryService();
} else {
cachedService = new PlexLibraryService();
}
cachedMode = mode;
return cachedService;
}
/**
* Clear cached service instance (useful for testing or mode changes)
*/
export function clearLibraryServiceCache(): void {
cachedService = null;
cachedMode = null;
}
// Re-export types
export * from './ILibraryService';
export { PlexLibraryService } from './PlexLibraryService';
export { AudiobookshelfLibraryService } from './AudiobookshelfLibraryService';
+588
View File
@@ -0,0 +1,588 @@
/**
* Component: Recurring Jobs Scheduler Service
* Documentation: documentation/backend/services/scheduler.md
*/
import { getJobQueueService, ScanPlexPayload } from './job-queue.service';
import { prisma } from '../db';
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds';
export interface ScheduledJob {
id: string;
name: string;
type: string; // Changed from ScheduledJobType to string for Prisma compatibility
schedule: string; // Cron expression
enabled: boolean;
payload: any;
createdAt: Date;
updatedAt: Date;
lastRun: Date | null;
lastRunJobId: string | null; // Bull queue job ID of most recent execution
nextRun: Date | null;
}
export interface CreateScheduledJobDto {
name: string;
type: ScheduledJobType;
schedule: string;
enabled?: boolean;
payload?: any;
}
export interface UpdateScheduledJobDto {
name?: string;
schedule?: string;
enabled?: boolean;
payload?: any;
}
export class SchedulerService {
private jobQueue = getJobQueueService();
/**
* Initialize scheduler and set up default jobs if they don't exist
*/
async start(): Promise<void> {
console.log('[Scheduler] Initializing scheduler service...');
// Create default jobs if they don't exist
await this.ensureDefaultJobs();
// Load and schedule all enabled jobs
await this.scheduleAllJobs();
// Check and trigger overdue jobs
await this.triggerOverdueJobs();
console.log('[Scheduler] Scheduler service started');
}
/**
* Ensure default jobs exist in database
*/
private async ensureDefaultJobs(): Promise<void> {
const defaults = [
{
name: 'Library Scan',
type: 'plex_library_scan' as ScheduledJobType,
schedule: '0 */6 * * *', // Every 6 hours
enabled: false, // Start disabled until first setup is complete
payload: {},
},
{
name: 'Recently Added Check',
type: 'plex_recently_added_check' as ScheduledJobType,
schedule: '*/5 * * * *', // Every 5 minutes
enabled: true, // Enable by default for quick detection
payload: {},
},
{
name: 'Audible Data Refresh',
type: 'audible_refresh' as ScheduledJobType,
schedule: '0 0 * * *', // Daily at midnight
enabled: false, // Start disabled until first setup is complete
payload: {},
},
{
name: 'Retry Missing Torrents Search',
type: 'retry_missing_torrents' as ScheduledJobType,
schedule: '0 0 * * *', // Daily at midnight
enabled: true, // Enable by default
payload: {},
},
{
name: 'Retry Failed Imports',
type: 'retry_failed_imports' as ScheduledJobType,
schedule: '0 */6 * * *', // Every 6 hours
enabled: true, // Enable by default
payload: {},
},
{
name: 'Cleanup Seeded Torrents',
type: 'cleanup_seeded_torrents' as ScheduledJobType,
schedule: '*/30 * * * *', // Every 30 minutes
enabled: true, // Enable by default
payload: {},
},
{
name: 'Monitor RSS Feeds',
type: 'monitor_rss_feeds' as ScheduledJobType,
schedule: '*/15 * * * *', // Every 15 minutes
enabled: true, // Enable by default
payload: {},
},
];
for (const defaultJob of defaults) {
const existing = await prisma.scheduledJob.findFirst({
where: { type: defaultJob.type },
});
if (!existing) {
await prisma.scheduledJob.create({
data: defaultJob,
});
console.log(`[Scheduler] Created default job: ${defaultJob.name} (disabled by default)`);
}
}
}
/**
* Schedule all enabled jobs
*/
private async scheduleAllJobs(): Promise<void> {
const jobs = await prisma.scheduledJob.findMany({
where: { enabled: true },
});
for (const job of jobs) {
await this.scheduleJob(job);
}
console.log(`[Scheduler] Scheduled ${jobs.length} jobs`);
}
/**
* Schedule a single job using Bull's repeatable jobs
*/
private async scheduleJob(job: any): Promise<void> {
try {
await this.jobQueue.addRepeatableJob(
job.type,
{ scheduledJobId: job.id },
job.schedule,
`scheduled-${job.id}`
);
console.log(`[Scheduler] Job scheduled: ${job.name} (${job.schedule})`);
} catch (error) {
console.error(`[Scheduler] Failed to schedule job ${job.name}:`, error);
throw error;
}
}
/**
* Unschedule a job by removing it from Bull's repeatable jobs
*/
private async unscheduleJob(job: any): Promise<void> {
try {
await this.jobQueue.removeRepeatableJob(
job.type,
job.schedule,
`scheduled-${job.id}`
);
console.log(`[Scheduler] Job unscheduled: ${job.name}`);
} catch (error) {
console.error(`[Scheduler] Failed to unschedule job ${job.name}:`, error);
// Don't throw - job might not exist in Bull yet
}
}
/**
* Get all scheduled jobs
*/
async getScheduledJobs(): Promise<ScheduledJob[]> {
return await prisma.scheduledJob.findMany({
orderBy: { name: 'asc' },
});
}
/**
* Get single scheduled job by ID
*/
async getScheduledJob(id: string): Promise<ScheduledJob | null> {
return await prisma.scheduledJob.findUnique({
where: { id },
});
}
/**
* Create new scheduled job
*/
async createScheduledJob(dto: CreateScheduledJobDto): Promise<ScheduledJob> {
// Validate cron expression
this.validateCronExpression(dto.schedule);
const job = await prisma.scheduledJob.create({
data: {
name: dto.name,
type: dto.type,
schedule: dto.schedule,
enabled: dto.enabled ?? true,
payload: dto.payload || {},
},
});
if (job.enabled) {
await this.scheduleJob(job);
}
return job;
}
/**
* Update scheduled job
*/
async updateScheduledJob(
id: string,
dto: UpdateScheduledJobDto
): Promise<ScheduledJob> {
if (dto.schedule) {
this.validateCronExpression(dto.schedule);
}
// Get the old job to unschedule it
const oldJob = await prisma.scheduledJob.findUnique({
where: { id },
});
if (oldJob && oldJob.enabled) {
await this.unscheduleJob(oldJob);
}
const job = await prisma.scheduledJob.update({
where: { id },
data: {
...(dto.name && { name: dto.name }),
...(dto.schedule && { schedule: dto.schedule }),
...(dto.enabled !== undefined && { enabled: dto.enabled }),
...(dto.payload && { payload: dto.payload }),
updatedAt: new Date(),
},
});
// Reschedule if enabled
if (job.enabled) {
await this.scheduleJob(job);
}
return job;
}
/**
* Delete scheduled job
*/
async deleteScheduledJob(id: string): Promise<void> {
const job = await prisma.scheduledJob.findUnique({
where: { id },
});
if (job && job.enabled) {
await this.unscheduleJob(job);
}
await prisma.scheduledJob.delete({
where: { id },
});
}
/**
* Manually trigger a job to run immediately
*/
async triggerJobNow(id: string): Promise<string> {
const job = await this.getScheduledJob(id);
if (!job) {
throw new Error('Scheduled job not found');
}
// Trigger the appropriate job type
let bullJobId: string;
switch (job.type) {
case 'plex_library_scan':
bullJobId = await this.triggerPlexScan(job);
break;
case 'plex_recently_added_check':
bullJobId = await this.triggerPlexRecentlyAddedCheck(job);
break;
case 'audible_refresh':
bullJobId = await this.triggerAudibleRefresh(job);
break;
case 'retry_missing_torrents':
bullJobId = await this.triggerRetryMissingTorrents(job);
break;
case 'retry_failed_imports':
bullJobId = await this.triggerRetryFailedImports(job);
break;
case 'cleanup_seeded_torrents':
bullJobId = await this.triggerCleanupSeededTorrents(job);
break;
case 'monitor_rss_feeds':
bullJobId = await this.triggerMonitorRssFeeds(job);
break;
default:
throw new Error(`Unknown job type: ${job.type}`);
}
// Update last run time and store Bull job ID
await prisma.scheduledJob.update({
where: { id },
data: {
lastRun: new Date(),
lastRunJobId: bullJobId,
},
});
console.log(`[Scheduler] Job "${job.name}" triggered with Bull job ID: ${bullJobId}`);
return bullJobId;
}
/**
* Trigger library scan (Plex or Audiobookshelf based on backend mode)
*/
private async triggerPlexScan(job: any): Promise<string> {
const { getConfigService } = await import('./config.service');
const configService = getConfigService();
// Check backend mode
const backendMode = await configService.getBackendMode();
// Validate configuration based on backend mode
let libraryId: string | null = null;
const missingFields: string[] = [];
if (backendMode === 'audiobookshelf') {
const absConfig = await configService.getMany([
'audiobookshelf.server_url',
'audiobookshelf.api_token',
'audiobookshelf.library_id',
]);
if (!absConfig['audiobookshelf.server_url']) {
missingFields.push('Audiobookshelf server URL');
}
if (!absConfig['audiobookshelf.api_token']) {
missingFields.push('Audiobookshelf API token');
}
if (!absConfig['audiobookshelf.library_id']) {
missingFields.push('Audiobookshelf library ID');
}
if (missingFields.length > 0) {
const errorMsg = `Audiobookshelf is not configured. Missing: ${missingFields.join(', ')}. Please configure Audiobookshelf in the admin settings before running library scans.`;
console.error('[ScanLibrary] Error:', errorMsg);
throw new Error(errorMsg);
}
libraryId = job.payload?.libraryId || absConfig['audiobookshelf.library_id'];
} else {
const plexConfig = await configService.getMany([
'plex_url',
'plex_token',
'plex_audiobook_library_id',
]);
if (!plexConfig.plex_url) {
missingFields.push('Plex server URL');
}
if (!plexConfig.plex_token) {
missingFields.push('Plex auth token');
}
if (!plexConfig.plex_audiobook_library_id) {
missingFields.push('Plex audiobook library ID');
}
if (missingFields.length > 0) {
const errorMsg = `Plex is not configured. Missing: ${missingFields.join(', ')}. Please configure Plex in the admin settings before running library scans.`;
console.error('[ScanLibrary] Error:', errorMsg);
throw new Error(errorMsg);
}
libraryId = job.payload?.libraryId || plexConfig.plex_audiobook_library_id;
}
console.log(`[ScanLibrary] Triggering ${backendMode} library scan for library: ${libraryId}`);
return await this.jobQueue.addPlexScanJob(
libraryId || '',
job.payload?.partial,
job.payload?.path
);
}
/**
* Trigger Plex recently added check (lightweight polling)
*/
private async triggerPlexRecentlyAddedCheck(job: any): Promise<string> {
return await this.jobQueue.addPlexRecentlyAddedJob(job.id);
}
/**
* Trigger Audible data refresh
* Populates audible_cache table with popular/new-release audiobooks
* Caches cover thumbnails locally
* NO matching logic - that happens at query time
*/
private async triggerAudibleRefresh(job: any): Promise<string> {
return await this.jobQueue.addAudibleRefreshJob(job.id);
}
/**
* Enable a scheduled job
*/
async enableJob(id: string): Promise<void> {
await this.updateScheduledJob(id, { enabled: true });
}
/**
* Disable a scheduled job
*/
async disableJob(id: string): Promise<void> {
await this.updateScheduledJob(id, { enabled: false });
}
/**
* Check for overdue jobs and trigger them
*/
private async triggerOverdueJobs(): Promise<void> {
console.log('[Scheduler] Checking for overdue jobs...');
const jobs = await prisma.scheduledJob.findMany({
where: { enabled: true },
});
for (const job of jobs) {
try {
if (this.isJobOverdue(job)) {
console.log(`[Scheduler] Job "${job.name}" is overdue, triggering now...`);
await this.triggerJobNow(job.id);
}
} catch (error) {
console.error(`[Scheduler] Failed to trigger overdue job "${job.name}":`, error);
}
}
}
/**
* Check if a job is overdue based on its schedule and last run time
*/
private isJobOverdue(job: any): boolean {
// If never run, consider it overdue
if (!job.lastRun) {
return true;
}
// Parse cron expression to get interval in milliseconds
const intervalMs = this.getIntervalFromCron(job.schedule);
if (!intervalMs) {
console.warn(`[Scheduler] Could not parse interval for job "${job.name}", skipping`);
return false;
}
// Calculate time since last run
const timeSinceLastRun = Date.now() - new Date(job.lastRun).getTime();
// Job is overdue if time since last run exceeds the interval
return timeSinceLastRun >= intervalMs;
}
/**
* Get interval in milliseconds from cron expression
* Supports common patterns like "0 * * * *" (hourly), "0 *\/6 * * *" (every 6 hours), etc.
*/
private getIntervalFromCron(cronExpression: string): number | null {
const parts = cronExpression.split(' ');
if (parts.length < 5) {
return null;
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Every N hours: "0 */N * * *"
const hourMatch = hour.match(/^\*\/(\d+)$/);
if (minute === '0' && hourMatch && dayOfMonth === '*' && month === '*') {
const hours = parseInt(hourMatch[1], 10);
return hours * 60 * 60 * 1000;
}
// Hourly: "0 * * * *"
if (minute === '0' && hour === '*' && dayOfMonth === '*' && month === '*') {
return 60 * 60 * 1000; // 1 hour
}
// Every N minutes: "*/N * * * *"
const minuteMatch = minute.match(/^\*\/(\d+)$/);
if (minuteMatch && hour === '*' && dayOfMonth === '*' && month === '*') {
const minutes = parseInt(minuteMatch[1], 10);
return minutes * 60 * 1000;
}
// Weekly: "M H * * D" where D is day of week (0-7)
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
const dayNum = parseInt(dayOfWeek, 10);
if (!isNaN(hourNum) && !isNaN(minuteNum) && !isNaN(dayNum)) {
return 7 * 24 * 60 * 60 * 1000; // 7 days
}
}
// Daily at specific time: "M H * * *" where H is 0-23, M is 0-59
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
if (!isNaN(hourNum) && !isNaN(minuteNum) && hourNum >= 0 && hourNum <= 23 && minuteNum >= 0 && minuteNum <= 59) {
return 24 * 60 * 60 * 1000; // 24 hours
}
}
// For other patterns, return a conservative default (24 hours)
console.warn(`[Scheduler] Unknown cron pattern "${cronExpression}", defaulting to 24 hours`);
return 24 * 60 * 60 * 1000;
}
/**
* Validate cron expression format
*/
private validateCronExpression(expression: string): void {
// Basic validation - check format
const parts = expression.split(' ');
if (parts.length < 5 || parts.length > 6) {
throw new Error('Invalid cron expression format');
}
// Additional validation could be added here
// For production, use a library like 'cron-parser'
}
/**
* Trigger retry for requests awaiting torrent search
*/
private async triggerRetryMissingTorrents(job: any): Promise<string> {
return await this.jobQueue.addRetryMissingTorrentsJob(job.id);
}
/**
* Trigger retry for requests awaiting import
*/
private async triggerRetryFailedImports(job: any): Promise<string> {
return await this.jobQueue.addRetryFailedImportsJob(job.id);
}
/**
* Trigger RSS feed monitoring
*/
private async triggerMonitorRssFeeds(job: any): Promise<string> {
return await this.jobQueue.addMonitorRssFeedsJob(job.id);
}
/**
* Trigger cleanup of torrents that have met seeding requirements
*/
private async triggerCleanupSeededTorrents(job: any): Promise<string> {
return await this.jobQueue.addCleanupSeededTorrentsJob(job.id);
}
}
// Singleton instance
let schedulerService: SchedulerService | null = null;
export function getSchedulerService(): SchedulerService {
if (!schedulerService) {
schedulerService = new SchedulerService();
}
return schedulerService;
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Component: Thumbnail Cache Service
* Documentation: documentation/integrations/audible.md
*/
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import axios from 'axios';
const CACHE_DIR = '/app/cache/thumbnails';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB max per image
const TIMEOUT_MS = 10000; // 10 second timeout for downloads
export class ThumbnailCacheService {
/**
* Ensure cache directory exists
*/
private async ensureCacheDir(): Promise<void> {
try {
await fs.mkdir(CACHE_DIR, { recursive: true });
} catch (error) {
console.error('[ThumbnailCache] Failed to create cache directory:', error);
throw error;
}
}
/**
* Generate a unique filename for a cached thumbnail
* @param asin - Audible ASIN
* @param url - Original URL (used for extension)
* @returns Filename for cached thumbnail
*/
private generateFilename(asin: string, url: string): string {
// Extract file extension from URL (default to .jpg if not found)
const urlPath = new URL(url).pathname;
const ext = path.extname(urlPath) || '.jpg';
// Use ASIN as filename for easy lookup and cleanup
return `${asin}${ext}`;
}
/**
* Download and cache a thumbnail from a URL
* @param asin - Audible ASIN
* @param url - URL of the thumbnail to download
* @returns Local file path of cached thumbnail, or null if failed
*/
async cacheThumbnail(asin: string, url: string): Promise<string | null> {
if (!url || !asin) {
return null;
}
try {
await this.ensureCacheDir();
const filename = this.generateFilename(asin, url);
const filePath = path.join(CACHE_DIR, filename);
// Check if file already exists
try {
await fs.access(filePath);
// File exists, return path
return filePath;
} catch {
// File doesn't exist, proceed with download
}
// Download image
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: TIMEOUT_MS,
maxContentLength: MAX_FILE_SIZE,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
});
// Verify content type is an image
const contentType = response.headers['content-type'];
if (!contentType || !contentType.startsWith('image/')) {
console.warn(`[ThumbnailCache] Invalid content type for ${asin}: ${contentType}`);
return null;
}
// Write to file
await fs.writeFile(filePath, Buffer.from(response.data));
console.log(`[ThumbnailCache] Cached thumbnail for ${asin}: ${filePath}`);
return filePath;
} catch (error) {
// Log error but don't throw - we'll fall back to the original URL
console.error(`[ThumbnailCache] Failed to cache thumbnail for ${asin}:`, error);
return null;
}
}
/**
* Delete a cached thumbnail
* @param asin - Audible ASIN
*/
async deleteThumbnail(asin: string): Promise<void> {
try {
// Find all files matching this ASIN (with any extension)
const files = await fs.readdir(CACHE_DIR);
const asinFiles = files.filter(f => f.startsWith(asin + '.'));
for (const file of asinFiles) {
const filePath = path.join(CACHE_DIR, file);
await fs.unlink(filePath);
console.log(`[ThumbnailCache] Deleted thumbnail: ${filePath}`);
}
} catch (error) {
console.error(`[ThumbnailCache] Failed to delete thumbnail for ${asin}:`, error);
}
}
/**
* Clean up thumbnails that are no longer referenced in the database
* @param activeAsins - Set of ASINs that should be kept
*/
async cleanupUnusedThumbnails(activeAsins: Set<string>): Promise<number> {
try {
await this.ensureCacheDir();
const files = await fs.readdir(CACHE_DIR);
let deletedCount = 0;
for (const file of files) {
// Extract ASIN from filename (remove extension)
const asin = path.parse(file).name;
// If ASIN is not in active set, delete the file
if (!activeAsins.has(asin)) {
const filePath = path.join(CACHE_DIR, file);
await fs.unlink(filePath);
deletedCount++;
console.log(`[ThumbnailCache] Deleted unused thumbnail: ${file}`);
}
}
console.log(`[ThumbnailCache] Cleanup complete: ${deletedCount} thumbnails deleted`);
return deletedCount;
} catch (error) {
console.error('[ThumbnailCache] Failed to cleanup thumbnails:', error);
return 0;
}
}
/**
* Get the cached path for a thumbnail
* @param cachedPath - Path from database
* @returns Path relative to app root for serving
*/
getCachedPath(cachedPath: string | null): string | null {
if (!cachedPath) {
return null;
}
// Return path relative to /app for serving
return cachedPath.replace('/app/', '/');
}
/**
* Get cache directory (for mounting in Docker)
*/
getCacheDirectory(): string {
return CACHE_DIR;
}
}
// Singleton instance
let thumbnailCacheService: ThumbnailCacheService | null = null;
export function getThumbnailCacheService(): ThumbnailCacheService {
if (!thumbnailCacheService) {
thumbnailCacheService = new ThumbnailCacheService();
}
return thumbnailCacheService;
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Component: API Utility Functions
* Documentation: documentation/frontend/utilities.md
*/
import { isTokenExpired } from './jwt-client';
let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;
/**
* Refresh the access token using the refresh token
*/
async function refreshAccessToken(): Promise<string | null> {
// If already refreshing, return the existing promise
if (isRefreshing && refreshPromise) {
console.log('[refreshAccessToken] Already refreshing, returning existing promise');
return refreshPromise;
}
isRefreshing = true;
refreshPromise = (async () => {
try {
const refreshToken = localStorage.getItem('refreshToken');
console.log('[refreshAccessToken] Has refresh token:', !!refreshToken);
if (!refreshToken) {
console.error('[refreshAccessToken] No refresh token found');
return null;
}
// Check if refresh token is expired
if (isTokenExpired(refreshToken)) {
console.error('[refreshAccessToken] Refresh token is expired');
return null;
}
console.log('[refreshAccessToken] Calling /api/auth/refresh');
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
console.log('[refreshAccessToken] Refresh response status:', response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('[refreshAccessToken] Refresh failed:', errorData);
return null;
}
const data = await response.json();
const newAccessToken = data.accessToken;
console.log('[refreshAccessToken] New access token received');
// Update localStorage
localStorage.setItem('accessToken', newAccessToken);
return newAccessToken;
} catch (error) {
console.error('[refreshAccessToken] Token refresh failed:', error);
return null;
} finally {
isRefreshing = false;
refreshPromise = null;
}
})();
return refreshPromise;
}
/**
* Logout user by clearing tokens and redirecting
*/
function performLogout() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
// Redirect to login if not already there
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`;
}
}
/**
* Make an authenticated API request with JWT token
* Automatically handles 401 errors by refreshing token and retrying
*/
export async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<Response> {
const token = localStorage.getItem('accessToken');
console.log('[fetchWithAuth] Making request to:', url);
console.log('[fetchWithAuth] Has token:', !!token);
const headers = {
...options.headers,
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
};
// Make initial request
let response = await fetch(url, {
...options,
headers,
});
console.log('[fetchWithAuth] Initial response status:', response.status);
// Handle 401 Unauthorized - attempt token refresh
if (response.status === 401) {
console.log('[fetchWithAuth] Got 401, attempting token refresh...');
const newAccessToken = await refreshAccessToken();
if (newAccessToken) {
console.log('[fetchWithAuth] Token refreshed successfully, retrying request');
// Retry request with new token
const newHeaders = {
...options.headers,
'Authorization': `Bearer ${newAccessToken}`,
};
response = await fetch(url, {
...options,
headers: newHeaders,
});
console.log('[fetchWithAuth] Retry response status:', response.status);
// If still 401, logout
if (response.status === 401) {
console.error('[fetchWithAuth] Still 401 after refresh, logging out');
performLogout();
}
} else {
// Refresh failed - logout
console.error('[fetchWithAuth] Token refresh failed, logging out');
performLogout();
}
}
return response;
}
/**
* Fetch JSON data with authentication
*/
export async function fetchJSON<T = any>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetchWithAuth(url, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
/**
* SWR fetcher with authentication
*/
export const authenticatedFetcher = (url: string) => fetchJSON(url);
+463
View File
@@ -0,0 +1,463 @@
/**
* Component: Audiobook Matching Utility
* Documentation: documentation/integrations/audible.md
*
* Real-time matching between Audible books and library backends (Plex or Audiobookshelf).
* Supports ASIN, ISBN, and fuzzy title/author matching.
*/
import { prisma } from '@/lib/db';
import { compareTwoStrings } from 'string-similarity';
import { LibraryItem } from '@/lib/services/library';
// Debug logging controlled by LOG_LEVEL environment variable
const DEBUG_ENABLED = process.env.LOG_LEVEL === 'debug';
export interface AudiobookMatchInput {
asin: string;
title: string;
author: string;
narrator?: string;
}
export interface AudiobookMatchResult {
plexGuid: string;
title: string;
author: string;
}
/**
* Normalize audiobook title for matching by removing common suffixes/prefixes
* that don't affect the core title identity.
*/
function normalizeTitle(title: string): string {
let normalized = title.toLowerCase().trim();
// Remove common parenthetical additions (case-insensitive)
normalized = normalized.replace(/\s*\(unabridged\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(abridged\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(full cast\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(full-cast edition\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(dramatized\)\s*/gi, ' ');
normalized = normalized.replace(/\s*\(narrated by[^)]*\)\s*/gi, ' ');
// Remove common subtitle patterns
normalized = normalized.replace(/:\s*a novel\s*$/gi, '');
normalized = normalized.replace(/:\s*a thriller\s*$/gi, '');
normalized = normalized.replace(/:\s*a memoir\s*$/gi, '');
// Remove book number suffixes (but keep them in main title if they're significant)
// Only remove if they're clearly series indicators at the end
normalized = normalized.replace(/,?\s*book\s+\d+\s*$/gi, '');
normalized = normalized.replace(/:\s*book\s+\d+\s*$/gi, '');
// Clean up extra whitespace
normalized = normalized.replace(/\s+/g, ' ').trim();
return normalized;
}
/**
* Find a matching audiobook in the Plex library for a given Audible audiobook.
*
* Matching logic (in order of priority):
* 1. **ASIN in plexGuid** - Check if any Plex book's GUID contains the Audible ASIN (100% match)
* 2. **Fuzzy matching** - Normalized title/author string similarity with 70% threshold
*
* @param audiobook - Audible audiobook to match
* @returns Matched Plex library item or null
*/
export async function findPlexMatch(
audiobook: AudiobookMatchInput
): Promise<AudiobookMatchResult | null> {
// Query plex_library for potential matches
// IMPORTANT: Search by TITLE ONLY (not author) because Plex often has narrator as author
const titleSearchLength = Math.min(20, audiobook.title.length);
const plexBooks = await prisma.plexLibrary.findMany({
where: {
title: {
contains: audiobook.title.substring(0, titleSearchLength),
mode: 'insensitive',
},
},
select: {
plexGuid: true,
title: true,
author: true,
},
take: 20,
});
// Build match result for logging
const matchResult: any = {
input: {
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator || null,
asin: audiobook.asin,
},
candidatesFound: plexBooks.length,
matchType: null,
matched: false,
result: null,
};
// If no candidates found, log and return null
if (plexBooks.length === 0) {
matchResult.matchType = 'no_candidates';
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
return null;
}
// PRIORITY 1: Check for EXACT ASIN match in plexGuid
for (const plexBook of plexBooks) {
if (plexBook.plexGuid && plexBook.plexGuid.includes(audiobook.asin)) {
matchResult.matchType = 'asin_exact';
matchResult.matched = true;
matchResult.result = {
plexGuid: plexBook.plexGuid,
plexTitle: plexBook.title,
plexAuthor: plexBook.author,
confidence: 100,
};
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
return plexBook;
}
}
// FILTER OUT candidates with wrong ASINs in plexGuid
const ASIN_PATTERN = /[A-Z0-9]{10}/g;
const rejectedAsins: string[] = [];
const validCandidates = plexBooks.filter((plexBook) => {
if (!plexBook.plexGuid) return true;
const asinsInGuid = plexBook.plexGuid.match(ASIN_PATTERN);
if (!asinsInGuid || asinsInGuid.length === 0) return true;
const hasOurAsin = asinsInGuid.some(asin => asin === audiobook.asin);
const hasOtherAsins = asinsInGuid.some(asin => asin !== audiobook.asin);
if (hasOtherAsins && !hasOurAsin) {
rejectedAsins.push(...asinsInGuid);
return false;
}
return true;
});
matchResult.asinFiltering = {
beforeCount: plexBooks.length,
afterCount: validCandidates.length,
rejectedAsins: rejectedAsins.length > 0 ? rejectedAsins : undefined,
};
if (validCandidates.length === 0) {
matchResult.matchType = 'asin_filtered_all';
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
return null;
}
// Normalize the Audible title
const normalizedAudibleTitle = normalizeTitle(audiobook.title);
// PRIORITY 2: Perform fuzzy matching
const candidates = validCandidates.map((plexBook) => {
const normalizedPlexTitle = normalizeTitle(plexBook.title);
const titleScore = compareTwoStrings(normalizedAudibleTitle, normalizedPlexTitle);
const authorScore = compareTwoStrings(
audiobook.author.toLowerCase(),
plexBook.author.toLowerCase()
);
let narratorScore = 0;
let usedNarratorMatch = false;
if (audiobook.narrator) {
narratorScore = compareTwoStrings(
audiobook.narrator.toLowerCase(),
plexBook.author.toLowerCase()
);
usedNarratorMatch = narratorScore > authorScore;
}
const personScore = usedNarratorMatch ? narratorScore : authorScore;
const overallScore = titleScore * 0.7 + personScore * 0.3;
return {
plexBook,
titleScore,
authorScore,
narratorScore,
usedNarratorMatch,
score: overallScore
};
});
// Sort by score descending
candidates.sort((a, b) => b.score - a.score);
const bestMatch = candidates[0];
// Add best match details to result
matchResult.bestCandidate = {
plexTitle: bestMatch.plexBook.title,
plexAuthor: bestMatch.plexBook.author,
plexGuid: bestMatch.plexBook.plexGuid,
scores: {
title: Math.round(bestMatch.titleScore * 100),
author: Math.round(bestMatch.authorScore * 100),
narrator: audiobook.narrator ? Math.round(bestMatch.narratorScore * 100) : null,
usedMatch: bestMatch.usedNarratorMatch ? 'narrator' : 'author',
overall: Math.round(bestMatch.score * 100),
},
threshold: 70,
};
// Accept match if score >= 70%
if (bestMatch && bestMatch.score >= 0.7) {
matchResult.matchType = 'fuzzy';
matchResult.matched = true;
matchResult.result = {
plexGuid: bestMatch.plexBook.plexGuid,
plexTitle: bestMatch.plexBook.title,
plexAuthor: bestMatch.plexBook.author,
confidence: Math.round(bestMatch.score * 100),
};
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
return bestMatch.plexBook;
}
// No match found
matchResult.matchType = 'fuzzy_below_threshold';
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
return null;
}
/**
* Enrich an Audible audiobook with Plex library match information.
* Used by API routes to add availability status to responses.
*/
export async function enrichAudiobookWithMatch(audiobook: AudiobookMatchInput & Record<string, any>) {
const match = await findPlexMatch(audiobook);
return {
...audiobook,
isAvailable: match !== null,
plexGuid: match?.plexGuid || null,
};
}
/**
* Batch enrich multiple audiobooks with match information.
* Processes in parallel for better performance.
*
* @param audiobooks - Audiobooks to enrich
* @param userId - Optional user ID to check request status
*/
export async function enrichAudiobooksWithMatches(
audiobooks: Array<AudiobookMatchInput & Record<string, any>>,
userId?: string
) {
const results = await Promise.all(audiobooks.map((book) => enrichAudiobookWithMatch(book)));
// 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
const audiobookRecords = await prisma.audiobook.findMany({
where: {
audibleAsin: { in: asins },
},
select: {
id: true,
audibleAsin: true,
requests: {
select: {
id: true,
status: true,
userId: true,
user: {
select: {
plexUsername: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
// Create a map of ASIN -> request info
const requestMap = new Map<string, {
requestId: string;
requestStatus: string;
requestedByUserId: string;
requestedByUsername: string;
}>();
for (const record of audiobookRecords) {
if (record.requests.length > 0 && record.audibleAsin) {
const request = record.requests[0];
requestMap.set(record.audibleAsin, {
requestId: request.id,
requestStatus: request.status,
requestedByUserId: request.userId,
requestedByUsername: request.user.plexUsername,
});
}
}
// Add request status to results
for (const result of results) {
const requestInfo = requestMap.get(result.asin);
const enrichedResult = result as any;
if (requestInfo) {
enrichedResult.isRequested = true;
enrichedResult.requestStatus = requestInfo.requestStatus;
enrichedResult.requestId = requestInfo.requestId;
enrichedResult.requestedByUserId = requestInfo.requestedByUserId;
// Only include username if it's not the current user
if (userId && requestInfo.requestedByUserId !== userId) {
enrichedResult.requestedByUsername = requestInfo.requestedByUsername;
}
} else {
enrichedResult.isRequested = false;
enrichedResult.requestStatus = null;
enrichedResult.requestId = null;
enrichedResult.requestedByUserId = null;
enrichedResult.requestedByUsername = null;
}
}
if (DEBUG_ENABLED) {
const summary = {
total: results.length,
available: results.filter(r => r.isAvailable).length,
notAvailable: results.filter(r => !r.isAvailable).length,
requested: userId ? results.filter(r => (r as any).isRequested).length : 'N/A',
};
console.log(JSON.stringify({ MATCHER_BATCH_SUMMARY: summary }));
}
return results;
}
/**
* Normalize ISBN for comparison (remove dashes and spaces)
*/
function normalizeISBN(isbn: string): string {
return isbn.replace(/[-\s]/g, '').toUpperCase();
}
/**
* Generic audiobook matching function that works with LibraryItem interface.
* Works with any library backend (Plex, Audiobookshelf, etc.)
*
* Matching priority:
* 1. Exact ASIN match (100% confidence)
* 2. Exact ISBN match (95% confidence)
* 3. Fuzzy title/author match (70%+ threshold)
*
* @param request - Audiobook request details
* @param libraryItems - Items from library backend
* @returns Matched LibraryItem or null
*/
export function matchAudiobook(
request: { title: string; author: string; asin?: string; isbn?: string },
libraryItems: LibraryItem[]
): LibraryItem | null {
// 1. Exact ASIN match (highest confidence)
if (request.asin) {
const asinMatch = libraryItems.find(item =>
item.asin?.toLowerCase() === request.asin?.toLowerCase()
);
if (asinMatch) {
if (DEBUG_ENABLED) {
console.log(JSON.stringify({
GENERIC_MATCHER: {
matchType: 'asin_exact',
input: { title: request.title, asin: request.asin },
matched: { title: asinMatch.title, asin: asinMatch.asin },
confidence: 100
}
}));
}
return asinMatch;
}
}
// 2. Exact ISBN match (normalize ISBNs by removing dashes)
if (request.isbn) {
const normalizedRequestISBN = normalizeISBN(request.isbn);
const isbnMatch = libraryItems.find(item =>
item.isbn && normalizeISBN(item.isbn) === normalizedRequestISBN
);
if (isbnMatch) {
if (DEBUG_ENABLED) {
console.log(JSON.stringify({
GENERIC_MATCHER: {
matchType: 'isbn_exact',
input: { title: request.title, isbn: request.isbn },
matched: { title: isbnMatch.title, isbn: isbnMatch.isbn },
confidence: 95
}
}));
}
return isbnMatch;
}
}
// 3. Fuzzy title/author match
const normalizedRequestTitle = normalizeTitle(request.title);
const normalizedRequestAuthor = request.author.toLowerCase();
const candidates = libraryItems.map(item => {
const normalizedItemTitle = normalizeTitle(item.title);
const normalizedItemAuthor = item.author.toLowerCase();
const titleScore = compareTwoStrings(normalizedRequestTitle, normalizedItemTitle);
const authorScore = compareTwoStrings(normalizedRequestAuthor, normalizedItemAuthor);
// Weighted average: title is more important
const overallScore = titleScore * 0.7 + authorScore * 0.3;
return { item, titleScore, authorScore, score: overallScore };
});
// Sort by score and get best match
candidates.sort((a, b) => b.score - a.score);
const bestMatch = candidates[0];
// Accept if score >= 70%
if (bestMatch && bestMatch.score >= 0.7) {
if (DEBUG_ENABLED) {
console.log(JSON.stringify({
GENERIC_MATCHER: {
matchType: 'fuzzy',
input: { title: request.title, author: request.author },
matched: { title: bestMatch.item.title, author: bestMatch.item.author },
scores: {
title: Math.round(bestMatch.titleScore * 100),
author: Math.round(bestMatch.authorScore * 100),
overall: Math.round(bestMatch.score * 100)
},
confidence: Math.round(bestMatch.score * 100)
}
}));
}
return bestMatch.item;
}
// No match found
if (DEBUG_ENABLED) {
console.log(JSON.stringify({
GENERIC_MATCHER: {
matchType: 'no_match',
input: { title: request.title, author: request.author },
bestScore: bestMatch ? Math.round(bestMatch.score * 100) : 0,
threshold: 70
}
}));
}
return null;
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Component: Class Name Utility
* Documentation: documentation/frontend/components.md
*/
import clsx, { ClassValue } from 'clsx';
/**
* Utility for merging Tailwind CSS classes
* Handles conditional classes and removes duplicates
*/
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
+283
View File
@@ -0,0 +1,283 @@
/**
* Component: Cron Utilities
* Documentation: documentation/backend/services/scheduler.md
*/
export interface SchedulePreset {
label: string;
cron: string;
description: string;
}
export const SCHEDULE_PRESETS: SchedulePreset[] = [
{ label: 'Every 15 minutes', cron: '*/15 * * * *', description: 'Runs 4 times per hour' },
{ label: 'Every 30 minutes', cron: '*/30 * * * *', description: 'Runs twice per hour' },
{ label: 'Every hour', cron: '0 * * * *', description: 'Runs at the start of every hour' },
{ label: 'Every 2 hours', cron: '0 */2 * * *', description: 'Runs 12 times per day' },
{ label: 'Every 3 hours', cron: '0 */3 * * *', description: 'Runs 8 times per day' },
{ label: 'Every 6 hours', cron: '0 */6 * * *', description: 'Runs 4 times per day' },
{ label: 'Every 12 hours', cron: '0 */12 * * *', description: 'Runs twice per day' },
{ label: 'Daily at midnight', cron: '0 0 * * *', description: 'Runs once per day at 12:00 AM' },
{ label: 'Daily at noon', cron: '0 12 * * *', description: 'Runs once per day at 12:00 PM' },
{ label: 'Daily at 6 AM', cron: '0 6 * * *', description: 'Runs once per day at 6:00 AM' },
{ label: 'Weekly (Sunday midnight)', cron: '0 0 * * 0', description: 'Runs once per week' },
{ label: 'Monthly (1st at midnight)', cron: '0 0 1 * *', description: 'Runs once per month' },
];
/**
* Converts a cron expression to a human-readable description
* @param cron - The cron expression (e.g., "0 *\/6 * * *")
* @returns Human-readable description (e.g., "Every 6 hours")
*/
export function cronToHuman(cron: string): string {
// Check if it matches a preset
const preset = SCHEDULE_PRESETS.find(p => p.cron === cron);
if (preset) {
return preset.label;
}
// Parse the cron expression
const parts = cron.trim().split(/\s+/);
if (parts.length < 5) {
return cron; // Invalid cron, return as-is
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Minutes pattern
if (minute.startsWith('*/')) {
const interval = parseInt(minute.substring(2), 10);
return `Every ${interval} minute${interval !== 1 ? 's' : ''}`;
}
// Hours pattern
if (hour.startsWith('*/') && minute === '0') {
const interval = parseInt(hour.substring(2), 10);
return `Every ${interval} hour${interval !== 1 ? 's' : ''}`;
}
// Daily pattern
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
if (hour === '*') {
if (minute === '0') {
return 'Every hour';
}
const minInterval = parseInt(minute.replace('*/', ''), 10);
return `Every ${minInterval} minutes`;
}
const hourNum = parseInt(hour, 10);
if (!isNaN(hourNum)) {
const minuteNum = parseInt(minute, 10);
const time = formatTime(hourNum, minuteNum);
return `Daily at ${time}`;
}
}
// Weekly pattern
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
const days = parseDayOfWeek(dayOfWeek);
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
const time = formatTime(hourNum, minuteNum);
return `Weekly on ${days} at ${time}`;
}
// Monthly pattern
if (month === '*' && dayOfWeek === '*' && dayOfMonth !== '*') {
const day = parseInt(dayOfMonth, 10);
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
const time = formatTime(hourNum, minuteNum);
return `Monthly on day ${day} at ${time}`;
}
// Fallback: return the cron expression
return cron;
}
/**
* Parse day of week number to name
*/
function parseDayOfWeek(dayOfWeek: string): string {
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const day = parseInt(dayOfWeek, 10);
if (!isNaN(day) && day >= 0 && day <= 6) {
return dayNames[day];
}
return dayOfWeek;
}
/**
* Format hour and minute to 12-hour time
*/
function formatTime(hour: number, minute: number): string {
const period = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
const displayMinute = minute.toString().padStart(2, '0');
return `${displayHour}:${displayMinute} ${period}`;
}
/**
* Validates a cron expression
* @param cron - The cron expression to validate
* @returns true if valid, false otherwise
*/
export function isValidCron(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
return false;
}
// Basic validation - each part should be valid
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
return (
isValidCronField(minute, 0, 59) &&
isValidCronField(hour, 0, 23) &&
isValidCronField(dayOfMonth, 1, 31) &&
isValidCronField(month, 1, 12) &&
isValidCronField(dayOfWeek, 0, 7)
);
}
/**
* Validates a single cron field
*/
function isValidCronField(field: string, min: number, max: number): boolean {
// Asterisk is always valid
if (field === '*') {
return true;
}
// Step values (*/n)
if (field.startsWith('*/')) {
const step = parseInt(field.substring(2), 10);
return !isNaN(step) && step > 0 && step <= max;
}
// Range (n-m)
if (field.includes('-')) {
const [start, end] = field.split('-').map(s => parseInt(s, 10));
return !isNaN(start) && !isNaN(end) && start >= min && end <= max && start < end;
}
// List (n,m,o)
if (field.includes(',')) {
const values = field.split(',').map(s => parseInt(s, 10));
return values.every(v => !isNaN(v) && v >= min && v <= max);
}
// Single value
const value = parseInt(field, 10);
return !isNaN(value) && value >= min && value <= max;
}
/**
* Custom schedule builder
*/
export interface CustomSchedule {
type: 'minutes' | 'hours' | 'daily' | 'weekly' | 'monthly' | 'custom';
interval?: number; // For minutes/hours
time?: { hour: number; minute: number }; // For daily/weekly/monthly
dayOfWeek?: number; // For weekly (0-6)
dayOfMonth?: number; // For monthly (1-31)
customCron?: string; // For custom
}
/**
* Converts custom schedule to cron expression
*/
export function customScheduleToCron(schedule: CustomSchedule): string {
switch (schedule.type) {
case 'minutes':
return `*/${schedule.interval || 15} * * * *`;
case 'hours':
const hourInterval = schedule.interval || 1;
// If interval is 24 or more hours, convert to daily at midnight
if (hourInterval >= 24) {
return `0 0 * * *`; // Daily at midnight
}
return `0 */${hourInterval} * * *`;
case 'daily':
const dailyHour = schedule.time?.hour || 0;
const dailyMinute = schedule.time?.minute || 0;
return `${dailyMinute} ${dailyHour} * * *`;
case 'weekly':
const weeklyHour = schedule.time?.hour || 0;
const weeklyMinute = schedule.time?.minute || 0;
const weeklyDay = schedule.dayOfWeek || 0;
return `${weeklyMinute} ${weeklyHour} * * ${weeklyDay}`;
case 'monthly':
const monthlyHour = schedule.time?.hour || 0;
const monthlyMinute = schedule.time?.minute || 0;
const monthlyDay = schedule.dayOfMonth || 1;
return `${monthlyMinute} ${monthlyHour} ${monthlyDay} * *`;
case 'custom':
return schedule.customCron || '0 * * * *';
default:
return '0 * * * *';
}
}
/**
* Attempts to parse a cron expression into a custom schedule
*/
export function cronToCustomSchedule(cron: string): CustomSchedule {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
return { type: 'custom', customCron: cron };
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Minutes pattern
if (minute.startsWith('*/') && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
const interval = parseInt(minute.substring(2), 10);
return { type: 'minutes', interval };
}
// Hours pattern
if (hour.startsWith('*/') && minute === '0' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
const interval = parseInt(hour.substring(2), 10);
return { type: 'hours', interval };
}
// Daily pattern
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*' && !hour.includes('*') && !minute.includes('*')) {
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
if (!isNaN(hourNum) && !isNaN(minuteNum)) {
return { type: 'daily', time: { hour: hourNum, minute: minuteNum } };
}
}
// Weekly pattern
if (dayOfMonth === '*' && month === '*' && !dayOfWeek.includes('*') && !hour.includes('*') && !minute.includes('*')) {
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
const dayNum = parseInt(dayOfWeek, 10);
if (!isNaN(hourNum) && !isNaN(minuteNum) && !isNaN(dayNum)) {
return { type: 'weekly', time: { hour: hourNum, minute: minuteNum }, dayOfWeek: dayNum };
}
}
// Monthly pattern
if (month === '*' && dayOfWeek === '*' && !dayOfMonth.includes('*') && !hour.includes('*') && !minute.includes('*')) {
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
const dayNum = parseInt(dayOfMonth, 10);
if (!isNaN(hourNum) && !isNaN(minuteNum) && !isNaN(dayNum)) {
return { type: 'monthly', time: { hour: hourNum, minute: minuteNum }, dayOfMonth: dayNum };
}
}
// Fallback to custom
return { type: 'custom', customCron: cron };
}
+479
View File
@@ -0,0 +1,479 @@
/**
* Component: File Organization System
* Documentation: documentation/phase3/file-organization.md
*/
import fs from 'fs/promises';
import path from 'path';
import axios from 'axios';
import { createJobLogger, JobLogger } from './job-logger';
import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger';
import { prisma } from '../db';
export interface AudiobookMetadata {
title: string;
author: string;
narrator?: string;
year?: number;
coverArtUrl?: string;
}
export interface OrganizationResult {
success: boolean;
targetPath: string;
filesMovedCount: number;
errors: string[];
audioFiles: string[];
coverArtFile?: string;
}
export interface ValidationResult {
isValid: boolean;
issues: string[];
path: string;
}
export interface LoggerConfig {
jobId: string;
context: string;
}
export class FileOrganizer {
private mediaDir: string;
private tempDir: string;
constructor(mediaDir: string = '/media/audiobooks', tempDir: string = '/tmp/readmeabook') {
this.mediaDir = mediaDir;
this.tempDir = tempDir;
}
/**
* Organize completed download into proper directory structure
*/
async organize(
downloadPath: string,
audiobook: AudiobookMetadata,
loggerConfig?: LoggerConfig
): Promise<OrganizationResult> {
// Create logger if config provided
const logger = loggerConfig ? createJobLogger(loggerConfig.jobId, loggerConfig.context) : null;
const result: OrganizationResult = {
success: false,
targetPath: '',
filesMovedCount: 0,
errors: [],
audioFiles: [],
};
try {
await logger?.info(`Organizing: ${downloadPath}`);
// Find audiobook files
const { audioFiles, coverFile, isFile } = await this.findAudiobookFiles(downloadPath);
if (audioFiles.length === 0) {
throw new Error('No audiobook files found in download');
}
await logger?.info(`Found ${audioFiles.length} audio files`);
// Determine base path for source files
const baseSourcePath = isFile ? path.dirname(downloadPath) : downloadPath;
// 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<string, string>();
try {
const config = await prisma.configuration.findUnique({
where: { key: 'metadata_tagging_enabled' },
});
const metadataTaggingEnabled = config?.value === 'true';
if (metadataTaggingEnabled && audioFiles.length > 0) {
await logger?.info(`Metadata tagging enabled, checking ffmpeg availability...`);
const ffmpegAvailable = await checkFfmpegAvailable();
if (ffmpegAvailable) {
await logger?.info(`Tagging ${audioFiles.length} audio files with metadata (before move)...`);
// Build full paths to source files for tagging
const sourceFilePaths = audioFiles.map((audioFile) =>
isFile ? downloadPath : path.join(downloadPath, audioFile)
);
const taggingResults = await tagMultipleFiles(sourceFilePaths, {
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
year: audiobook.year,
});
const successCount = taggingResults.filter((r) => r.success).length;
const failCount = taggingResults.filter((r) => !r.success).length;
if (successCount > 0) {
await logger?.info(`Successfully tagged ${successCount} file(s) with metadata`);
}
if (failCount > 0) {
await logger?.warn(`Failed to tag ${failCount} file(s): ${
taggingResults
.filter((r) => !r.success)
.map((r) => `${path.basename(r.filePath)}: ${r.error}`)
.join(', ')
}`);
result.errors.push(`Failed to tag ${failCount} file(s) with metadata`);
}
// Build map of successfully tagged files
for (const tagResult of taggingResults) {
if (tagResult.success && tagResult.taggedFilePath) {
taggedFileMap.set(tagResult.filePath, tagResult.taggedFilePath);
}
}
} else {
await logger?.warn(`Metadata tagging enabled but ffmpeg not available - skipping tagging`);
result.errors.push('Metadata tagging skipped: ffmpeg not available');
}
} else {
await logger?.info(`Metadata tagging disabled or no audio files to tag`);
}
} catch (error) {
await logger?.error(`Metadata tagging failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
result.errors.push(`Metadata tagging failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Don't fail the whole operation if metadata tagging fails - continue with copying files
}
// Build target directory
const targetPath = this.buildTargetPath(
this.mediaDir,
audiobook.author,
audiobook.title,
audiobook.year
);
await logger?.info(`Target path: ${targetPath}`);
// Create target directory
await fs.mkdir(targetPath, { recursive: true });
// Copy audio files (do NOT delete originals - needed for seeding)
for (const audioFile of audioFiles) {
const originalSourcePath = isFile ? downloadPath : path.join(downloadPath, audioFile);
const filename = path.basename(audioFile);
const targetFilePath = path.join(targetPath, filename);
// Check if we have a tagged version of this file
const taggedFilePath = taggedFileMap.get(originalSourcePath);
const sourcePath = taggedFilePath || originalSourcePath; // Use tagged version if available, otherwise use original
// Check if source exists
try {
await fs.access(sourcePath, fs.constants.R_OK);
} catch {
console.warn(`[FileOrganizer] Source file not found or not readable: ${sourcePath}`);
result.errors.push(`Source file not found: ${audioFile}`);
continue;
}
// Check if target already exists (skip if already copied)
try {
await fs.access(targetFilePath);
console.log(`[FileOrganizer] File already exists, skipping: ${filename}`);
result.audioFiles.push(targetFilePath);
// Clean up tagged temp file if it exists
if (taggedFilePath) {
try {
await fs.unlink(taggedFilePath);
await logger?.info(`Cleaned up temp file: ${path.basename(taggedFilePath)}`);
} catch {
// Ignore cleanup errors
}
}
continue;
} catch {
// File doesn't exist, continue with copy
}
// Copy file (do NOT delete original - needed for seeding)
try {
// Read source file (either tagged version or original)
const fileData = await fs.readFile(sourcePath);
// Write to target with explicit permissions
await fs.writeFile(targetFilePath, fileData, { mode: 0o644 });
result.audioFiles.push(targetFilePath);
result.filesMovedCount++;
if (taggedFilePath) {
await logger?.info(`Copied tagged file: ${filename}`);
// Clean up the tagged temp file after successful copy
try {
await fs.unlink(taggedFilePath);
await logger?.info(`Cleaned up temp file: ${path.basename(taggedFilePath)}`);
} catch (cleanupError) {
await logger?.warn(`Failed to clean up temp file: ${path.basename(taggedFilePath)}`);
}
} else {
await logger?.info(`Copied: ${filename}`);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await logger?.error(`Failed to copy ${filename}: ${errorMsg}`);
result.errors.push(`Failed to copy ${audioFile}: ${errorMsg}`);
// Continue with other files instead of throwing
}
}
// Handle cover art
if (coverFile) {
const sourcePath = path.join(baseSourcePath, coverFile);
const targetCoverPath = path.join(targetPath, 'cover.jpg');
try {
// Copy cover art (do NOT delete original)
const coverData = await fs.readFile(sourcePath);
await fs.writeFile(targetCoverPath, coverData, { mode: 0o644 });
result.coverArtFile = targetCoverPath;
result.filesMovedCount++;
await logger?.info(`Copied cover art`);
} catch (error) {
await logger?.warn(`Failed to copy cover art: ${error instanceof Error ? error.message : 'Unknown error'}`);
result.errors.push('Failed to copy cover art');
}
} else if (audiobook.coverArtUrl) {
// Download cover art from Audible if not in torrent
try {
await this.downloadCoverArt(audiobook.coverArtUrl, targetPath);
result.coverArtFile = path.join(targetPath, 'cover.jpg');
await logger?.info(`Downloaded cover art from Audible`);
} catch (error) {
await logger?.warn(`Failed to download cover art: ${error instanceof Error ? error.message : 'Unknown error'}`);
result.errors.push('Failed to download cover art');
}
}
result.targetPath = targetPath;
result.success = true;
// DO NOT clean up download directory - files needed for seeding
// Cleanup will be handled by the seeding cleanup job after seeding requirements are met
await logger?.info(`Organization complete: ${result.filesMovedCount} files copied (originals kept for seeding)`);
return result;
} catch (error) {
await logger?.error(`Organization failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
result.errors.push(error instanceof Error ? error.message : 'Unknown error');
return result;
}
}
/**
* Find audiobook files in download directory or single file
*/
private async findAudiobookFiles(
downloadPath: string
): Promise<{ audioFiles: string[]; coverFile?: string; isFile: boolean }> {
const audioExtensions = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
const coverPatterns = [
/cover\.(jpg|jpeg|png)$/i,
/folder\.(jpg|jpeg|png)$/i,
/art\.(jpg|jpeg|png)$/i,
];
const audioFiles: string[] = [];
let coverFile: string | undefined;
let isFile = false;
try {
// Check if downloadPath is a file or directory
const stats = await fs.stat(downloadPath);
if (stats.isFile()) {
// Handle single file case
isFile = true;
const ext = path.extname(downloadPath).toLowerCase();
if (audioExtensions.includes(ext)) {
// Return just the filename (not full path)
audioFiles.push(path.basename(downloadPath));
}
} else {
// Handle directory case
const files = await this.walkDirectory(downloadPath);
for (const file of files) {
const ext = path.extname(file).toLowerCase();
// Check if it's an audio file
if (audioExtensions.includes(ext)) {
audioFiles.push(file);
}
// Check if it's cover art
const basename = path.basename(file);
if (coverPatterns.some((pattern) => pattern.test(basename))) {
coverFile = file;
}
}
}
} catch (error) {
console.error('[FileOrganizer] Error reading directory:', error);
throw error;
}
return { audioFiles, coverFile, isFile };
}
/**
* Recursively walk directory to find all files
*/
private async walkDirectory(dir: string, baseDir: string = ''): Promise<string[]> {
const files: string[] = [];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = baseDir ? path.join(baseDir, entry.name) : entry.name;
if (entry.isDirectory()) {
const subFiles = await this.walkDirectory(fullPath, relativePath);
files.push(...subFiles);
} else {
files.push(relativePath);
}
}
} catch (error) {
console.error(`[FileOrganizer] Error reading directory ${dir}:`, error);
}
return files;
}
/**
* Build target path with sanitized names
*/
private buildTargetPath(
baseDir: string,
author: string,
title: string,
year?: number
): string {
const authorClean = this.sanitizePath(author);
const titleClean = this.sanitizePath(title);
const folderName = year ? `${titleClean} (${year})` : titleClean;
return path.join(baseDir, authorClean, folderName);
}
/**
* Sanitize path component (remove invalid characters)
*/
private sanitizePath(name: string): string {
return (
name
// Remove invalid filename characters
.replace(/[<>:"/\\|?*]/g, '')
// Remove leading/trailing dots and spaces
.trim()
.replace(/^\.+/, '')
.replace(/\.+$/, '')
// Collapse multiple spaces
.replace(/\s+/g, ' ')
// Limit length (255 chars max for most filesystems)
.slice(0, 200)
);
}
/**
* Download cover art from URL
*/
private async downloadCoverArt(url: string, targetDir: string): Promise<void> {
const targetPath = path.join(targetDir, 'cover.jpg');
try {
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000,
});
await fs.writeFile(targetPath, response.data);
} catch (error) {
console.error('[FileOrganizer] Failed to download cover art:', error);
throw error;
}
}
/**
* Clean up download directory
*/
async cleanup(downloadPath: string): Promise<void> {
try {
// Remove download directory and all remaining files
await fs.rm(downloadPath, { recursive: true, force: true });
console.log(`[FileOrganizer] Cleaned up: ${downloadPath}`);
} catch (error) {
console.error(`[FileOrganizer] Cleanup failed for ${downloadPath}:`, error);
// Don't throw - cleanup is non-critical
}
}
/**
* Validate directory structure
*/
async validate(basePath: string): Promise<ValidationResult> {
const result: ValidationResult = {
isValid: true,
issues: [],
path: basePath,
};
try {
// Check if base path exists
await fs.access(basePath);
// Check if it's a directory
const stats = await fs.stat(basePath);
if (!stats.isDirectory()) {
result.isValid = false;
result.issues.push('Path is not a directory');
}
// Check if writable
try {
const testFile = path.join(basePath, '.test-write');
await fs.writeFile(testFile, 'test');
await fs.unlink(testFile);
} catch {
result.isValid = false;
result.issues.push('Directory is not writable');
}
} catch (error) {
result.isValid = false;
result.issues.push(`Path does not exist or is not accessible: ${basePath}`);
}
return result;
}
}
// Singleton instance
let fileOrganizer: FileOrganizer | null = null;
export function getFileOrganizer(): FileOrganizer {
if (!fileOrganizer) {
const mediaDir = process.env.MEDIA_DIR || '/media/audiobooks';
const tempDir = process.env.TEMP_DIR || '/tmp/readmeabook';
fileOrganizer = new FileOrganizer(mediaDir, tempDir);
}
return fileOrganizer;
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Component: Job Logger Utility
* Documentation: documentation/backend/services/jobs.md
*
* Provides structured logging for job processors with database persistence
*/
import { prisma } from '../db';
export type LogLevel = 'info' | 'warn' | 'error';
export interface LogMetadata {
[key: string]: any;
}
/**
* Job Logger - Logs events to both console and database
*/
export class JobLogger {
private jobId: string;
private context: string;
constructor(jobId: string, context: string) {
this.jobId = jobId;
this.context = context;
}
/**
* Log info message
*/
async info(message: string, metadata?: LogMetadata): Promise<void> {
await this.log('info', message, metadata);
}
/**
* Log warning message
*/
async warn(message: string, metadata?: LogMetadata): Promise<void> {
await this.log('warn', message, metadata);
}
/**
* Log error message
*/
async error(message: string, metadata?: LogMetadata): Promise<void> {
await this.log('error', message, metadata);
}
/**
* Internal logging method
*/
private async log(level: LogLevel, message: string, metadata?: LogMetadata): Promise<void> {
// Log to console with timestamp (for Docker logs)
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
const consoleMessage = `[${this.context}] ${message}`;
switch (level) {
case 'info':
console.log(consoleMessage);
break;
case 'warn':
console.warn(consoleMessage);
break;
case 'error':
console.error(consoleMessage);
break;
}
// Log metadata if provided
if (metadata && Object.keys(metadata).length > 0) {
console.log(timestamp, JSON.stringify(metadata, null, 2));
}
// Persist to database (non-blocking, ignore errors to not break job execution)
try {
await prisma.jobEvent.create({
data: {
jobId: this.jobId,
level,
context: this.context,
message,
metadata: metadata ? JSON.parse(JSON.stringify(metadata)) : null,
},
});
} catch (error) {
console.error('[JobLogger] Failed to persist log to database:', error);
// Don't throw - logging failure should not break job execution
}
}
}
/**
* Create a job logger instance
*/
export function createJobLogger(jobId: string, context: string): JobLogger {
return new JobLogger(jobId, context);
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Component: Client-Side JWT Utilities
* Documentation: documentation/frontend/routing-auth.md
*/
interface JWTPayload {
sub: string;
plexId: string;
username: string;
role: string;
iat: number;
exp: number;
}
/**
* Decode JWT without verification (client-side only)
* Note: This does NOT verify the signature - only use for reading claims
*/
export function decodeJWT(token: string): JWTPayload | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const payload = parts[1];
const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
return decoded as JWTPayload;
} catch (error) {
console.error('Failed to decode JWT:', error);
return null;
}
}
/**
* Check if token is expired
*/
export function isTokenExpired(token: string): boolean {
const decoded = decodeJWT(token);
if (!decoded || !decoded.exp) {
return true;
}
const now = Math.floor(Date.now() / 1000);
return decoded.exp < now;
}
/**
* Get milliseconds until token expires
*/
export function getTokenExpiryMs(token: string): number | null {
const decoded = decodeJWT(token);
if (!decoded || !decoded.exp) {
return null;
}
const now = Math.floor(Date.now() / 1000);
const expiresIn = decoded.exp - now;
return expiresIn > 0 ? expiresIn * 1000 : 0;
}
/**
* Get milliseconds until token should be refreshed (5 mins before expiry)
*/
export function getRefreshTimeMs(token: string): number | null {
const expiryMs = getTokenExpiryMs(token);
if (expiryMs === null) {
return null;
}
const REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
const refreshTime = expiryMs - REFRESH_BUFFER_MS;
return refreshTime > 0 ? refreshTime : 0;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Component: JWT Token Utilities
* Documentation: documentation/backend/services/auth.md
*/
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'change-this-to-a-random-secret-key';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-this-to-another-random-secret-key';
const ACCESS_TOKEN_EXPIRY = '1h'; // 1 hour
const REFRESH_TOKEN_EXPIRY = '7d'; // 7 days
export interface TokenPayload {
sub: string; // User ID
plexId: string;
username: string;
role: string;
}
export interface RefreshTokenPayload {
sub: string;
type: 'refresh';
}
/**
* Generate access token (short-lived)
*/
export function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: ACCESS_TOKEN_EXPIRY,
});
}
/**
* Generate refresh token (long-lived)
*/
export function generateRefreshToken(userId: string): string {
const payload: RefreshTokenPayload = {
sub: userId,
type: 'refresh',
};
return jwt.sign(payload, JWT_REFRESH_SECRET, {
expiresIn: REFRESH_TOKEN_EXPIRY,
});
}
/**
* Verify access token
*/
export function verifyAccessToken(token: string): TokenPayload | null {
try {
const decoded = jwt.verify(token, JWT_SECRET) as TokenPayload;
return decoded;
} catch (error) {
console.error('[JWT] Access token verification failed:', error);
if (error instanceof Error) {
console.error('[JWT] Error details:', error.message);
}
return null;
}
}
/**
* Verify refresh token
*/
export function verifyRefreshToken(token: string): RefreshTokenPayload | null {
try {
const decoded = jwt.verify(token, JWT_REFRESH_SECRET) as RefreshTokenPayload;
if (decoded.type !== 'refresh') {
return null;
}
return decoded;
} catch (error) {
console.error('Refresh token verification failed:', error);
return null;
}
}
/**
* Decode token without verification (for debugging)
*/
export function decodeToken(token: string): any {
try {
return jwt.decode(token);
} catch (error) {
return null;
}
}
+178
View File
@@ -0,0 +1,178 @@
/**
* Component: Metadata Tagging Utility
* Documentation: documentation/phase3/file-organization.md
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
const execPromise = promisify(exec);
export interface MetadataTaggingOptions {
title: string;
author: string;
narrator?: string;
year?: number;
}
export interface TaggingResult {
success: boolean;
filePath: string; // Original file path
taggedFilePath?: string; // Path to tagged file (if successful)
error?: string;
}
/**
* Tag audio file metadata using ffmpeg
* Supports m4b and mp3 files
* Uses -codec copy for lossless operation (metadata only, no re-encoding)
*/
export async function tagAudioFileMetadata(
filePath: string,
metadata: MetadataTaggingOptions
): Promise<TaggingResult> {
try {
// Check if file exists
await fs.access(filePath);
const ext = path.extname(filePath).toLowerCase();
// Only process supported formats
if (!['.m4b', '.m4a', '.mp3', '.mp4'].includes(ext)) {
return {
success: false,
filePath,
error: `Unsupported file format: ${ext}`,
};
}
// Create temporary file path
const tempFile = `${filePath}.tmp`;
// Build ffmpeg command
const args: string[] = [
'ffmpeg',
'-i', `"${filePath}"`,
'-codec', 'copy', // No re-encoding, metadata only
];
// For m4b/m4a/mp4 files, use standard metadata tags
if (['.m4b', '.m4a', '.mp4'].includes(ext)) {
args.push(
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
'-metadata', `album="${escapeMetadata(metadata.title)}"`, // Book title in Album field (Plex uses this)
'-metadata', `album_artist="${escapeMetadata(metadata.author)}"`, // Author in Album Artist (PRIMARY for Plex)
'-metadata', `artist="${escapeMetadata(metadata.author)}"` // Fallback
);
if (metadata.narrator) {
args.push('-metadata', `composer="${escapeMetadata(metadata.narrator)}"`); // Narrator in Composer
}
if (metadata.year) {
args.push('-metadata', `date="${metadata.year}"`);
}
// Explicitly specify output format (fixes .tmp extension issue)
args.push('-f', 'mp4');
}
// For mp3 files, use ID3v2 tags
else if (ext === '.mp3') {
args.push(
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
'-metadata', `album="${escapeMetadata(metadata.title)}"`,
'-metadata', `album_artist="${escapeMetadata(metadata.author)}"`,
'-metadata', `artist="${escapeMetadata(metadata.author)}"`
);
if (metadata.narrator) {
// For MP3, composer is also used for narrator
args.push('-metadata', `composer="${escapeMetadata(metadata.narrator)}"`);
}
if (metadata.year) {
args.push('-metadata', `date="${metadata.year}"`);
}
// Explicitly specify output format (fixes .tmp extension issue)
args.push('-f', 'mp3');
}
// Output to temp file
args.push(`"${tempFile}"`);
// Execute ffmpeg command
const command = args.join(' ');
try {
await execPromise(command, { timeout: 120000 }); // 2 minute timeout
} catch (error) {
// Clean up temp file if it exists
try {
await fs.unlink(tempFile);
} catch {
// Ignore cleanup errors
}
throw new Error(`ffmpeg failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// DO NOT replace original file - return temp file path instead
// This preserves the original file for seeding
return {
success: true,
filePath,
taggedFilePath: tempFile,
};
} catch (error) {
return {
success: false,
filePath,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Tag multiple audio files with metadata
*/
export async function tagMultipleFiles(
filePaths: string[],
metadata: MetadataTaggingOptions
): Promise<TaggingResult[]> {
const results: TaggingResult[] = [];
for (const filePath of filePaths) {
const result = await tagAudioFileMetadata(filePath, metadata);
results.push(result);
}
return results;
}
/**
* Escape metadata values for shell command
* Removes quotes and special characters that could break the command
*/
function escapeMetadata(value: string): string {
return value
.replace(/"/g, '\\"') // Escape double quotes
.replace(/'/g, "\\'") // Escape single quotes
.replace(/`/g, '\\`') // Escape backticks
.replace(/\$/g, '\\$') // Escape dollar signs
.replace(/\\/g, '\\\\'); // Escape backslashes
}
/**
* Check if ffmpeg is available
*/
export async function checkFfmpegAvailable(): Promise<boolean> {
try {
await execPromise('ffmpeg -version');
return true;
} catch {
return false;
}
}
+311
View File
@@ -0,0 +1,311 @@
/**
* Component: Intelligent Ranking Algorithm
* Documentation: documentation/phase3/ranking-algorithm.md
*/
import { compareTwoStrings } from 'string-similarity';
export interface TorrentResult {
indexer: string;
title: string;
size: number;
seeders: number;
leechers: number;
publishDate: Date;
downloadUrl: string;
infoHash?: string;
guid: string;
format?: 'M4B' | 'M4A' | 'MP3' | 'OTHER';
bitrate?: string;
hasChapters?: boolean;
}
export interface AudiobookRequest {
title: string;
author: string;
narrator?: string;
durationMinutes?: number;
}
export interface ScoreBreakdown {
formatScore: number;
seederScore: number;
sizeScore: number;
matchScore: number;
totalScore: number;
notes: string[];
}
export interface RankedTorrent extends TorrentResult {
score: number;
rank: number;
breakdown: ScoreBreakdown;
}
export class RankingAlgorithm {
/**
* Rank all torrents and return sorted by score (best first)
*/
rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest
): RankedTorrent[] {
const ranked = torrents.map((torrent) => {
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;
return {
...torrent,
score: totalScore,
rank: 0, // Will be assigned after sorting
breakdown: {
formatScore,
seederScore,
sizeScore,
matchScore,
totalScore,
notes: this.generateNotes(torrent, {
formatScore,
seederScore,
sizeScore,
matchScore,
totalScore,
notes: [],
}),
},
};
});
// Sort by score descending (best first)
ranked.sort((a, b) => b.score - a.score);
// Assign ranks
ranked.forEach((r, index) => {
r.rank = index + 1;
});
return ranked;
}
/**
* Get detailed scoring breakdown for a torrent
*/
getScoreBreakdown(
torrent: TorrentResult,
audiobook: AudiobookRequest
): 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;
return {
formatScore,
seederScore,
sizeScore,
matchScore,
totalScore,
notes: this.generateNotes(torrent, {
formatScore,
seederScore,
sizeScore,
matchScore,
totalScore,
notes: [],
}),
};
}
/**
* Score format quality (40 points max)
* M4B with chapters: 40 pts
* M4B without chapters: 35 pts
* M4A: 25 pts
* MP3: 15 pts
* Other: 5 pts
*/
private scoreFormat(torrent: TorrentResult): number {
const format = this.detectFormat(torrent);
switch (format) {
case 'M4B':
return torrent.hasChapters !== false ? 40 : 35;
case 'M4A':
return 25;
case 'MP3':
return 15;
default:
return 5;
}
}
/**
* Score seeder count (25 points max)
* Logarithmic scaling:
* 1 seeder: 0 points
* 10 seeders: 10 points
* 100 seeders: 20 points
* 1000+ seeders: 25 points
*/
private scoreSeeders(seeders: number): number {
if (seeders === 0) return 0;
return Math.min(25, Math.log10(seeders + 1) * 10);
}
/**
* Score size reasonableness (20 points max)
* Expected: 1-2 MB per minute (64-128 kbps)
* Perfect match: 20 points
* Too small/large: Reduced points
*/
private scoreSize(size: number, durationMinutes?: number): number {
if (!durationMinutes) {
return 10; // 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 20; // Perfect size
}
// Calculate deviation penalty
const deviation =
size < minExpected
? (minExpected - size) / minExpected
: (size - maxExpected) / maxExpected;
return Math.max(0, 20 - deviation * 20);
}
/**
* Score title/author match quality (15 points max)
* Title similarity: 0-10 points
* Author presence: 0-5 points
*/
private scoreMatch(
torrent: TorrentResult,
audiobook: AudiobookRequest
): number {
const title = torrent.title.toLowerCase();
const requestTitle = audiobook.title.toLowerCase();
const requestAuthor = audiobook.author.toLowerCase();
// Title similarity (0-10 points)
const titleSimilarity = compareTwoStrings(requestTitle, title) * 10;
// Author presence (0-5 points)
const hasAuthor = title.includes(requestAuthor) ? 5 : 0;
return Math.min(15, titleSimilarity + hasAuthor);
}
/**
* Detect format from torrent title
*/
private detectFormat(torrent: TorrentResult): 'M4B' | 'M4A' | 'MP3' | 'OTHER' {
// Use explicit format if provided
if (torrent.format) {
return torrent.format;
}
const title = torrent.title.toUpperCase();
// Check for format keywords in title
if (title.includes('M4B')) return 'M4B';
if (title.includes('M4A')) return 'M4A';
if (title.includes('MP3')) return 'MP3';
// Default to OTHER if no format detected
return 'OTHER';
}
/**
* Generate human-readable notes about scoring
*/
private generateNotes(
torrent: TorrentResult,
breakdown: ScoreBreakdown
): string[] {
const notes: string[] = [];
// Format notes
const format = this.detectFormat(torrent);
if (format === 'M4B') {
notes.push('Excellent format (M4B)');
if (torrent.hasChapters !== false) {
notes.push('Has chapter markers');
}
} else if (format === 'M4A') {
notes.push('Good format (M4A)');
} else if (format === 'MP3') {
notes.push('Acceptable format (MP3)');
} else {
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)`);
}
// Size notes
if (breakdown.sizeScore < 10) {
notes.push('⚠️ Unusual file size');
}
// Match notes
if (breakdown.matchScore < 8) {
notes.push('⚠️ Title/author may not match well');
}
// Overall quality assessment
if (breakdown.totalScore >= 80) {
notes.push('✓ Excellent choice');
} else if (breakdown.totalScore >= 60) {
notes.push('✓ Good choice');
} else if (breakdown.totalScore < 40) {
notes.push('⚠️ Consider reviewing this choice');
}
return notes;
}
}
// Singleton instance
let ranker: RankingAlgorithm | null = null;
export function getRankingAlgorithm(): RankingAlgorithm {
if (!ranker) {
ranker = new RankingAlgorithm();
}
return ranker;
}
/**
* Helper function to rank torrents using the singleton instance
*/
export function rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest
): (TorrentResult & { qualityScore: number })[] {
const algorithm = getRankingAlgorithm();
const ranked = algorithm.rankTorrents(torrents, audiobook);
// Return torrents with qualityScore field for compatibility
return ranked.map((r) => ({
...r,
qualityScore: Math.round(r.score),
}));
}
+75
View File
@@ -0,0 +1,75 @@
/**
* URL Utilities for OAuth and Redirects
* Documentation: documentation/backend/services/environment.md
*/
/**
* Get application base URL for OAuth callbacks and redirects
*
* Priority order:
* 1. PUBLIC_URL - Primary documented environment variable
* 2. NEXTAUTH_URL - Legacy fallback for backward compatibility
* 3. BASE_URL - Alternative fallback
* 4. http://localhost:3030 - Development default
*
* @returns Normalized base URL (no trailing slash)
*
* @example
* // With PUBLIC_URL set
* process.env.PUBLIC_URL = 'https://example.com/'
* getBaseUrl() // Returns: 'https://example.com'
*
* // Without any env vars (development)
* getBaseUrl() // Returns: 'http://localhost:3030'
*/
export function getBaseUrl(): string {
const publicUrl = process.env.PUBLIC_URL?.trim();
const nextAuthUrl = process.env.NEXTAUTH_URL?.trim();
const baseUrl = process.env.BASE_URL?.trim();
// Priority: PUBLIC_URL > NEXTAUTH_URL > BASE_URL > localhost
let url = publicUrl || nextAuthUrl || baseUrl || 'http://localhost:3030';
// Normalize: remove trailing slash
url = url.replace(/\/$/, '');
// Validate URL format
if (!url.startsWith('http://') && !url.startsWith('https://')) {
console.warn(`[URL Utility] Invalid base URL format: ${url}. URLs must start with http:// or https://`);
}
// Production warning if using localhost
if (process.env.NODE_ENV === 'production' && url.includes('localhost')) {
console.warn('[URL Utility] ⚠️ WARNING: Using localhost URL in production. OAuth callbacks may fail. Set PUBLIC_URL environment variable.');
}
// Log which variable is being used (debug only)
if (process.env.LOG_LEVEL === 'debug') {
const source = publicUrl ? 'PUBLIC_URL' :
nextAuthUrl ? 'NEXTAUTH_URL' :
baseUrl ? 'BASE_URL' :
'default (localhost)';
console.debug(`[URL Utility] Using base URL from ${source}: ${url}`);
}
return url;
}
/**
* Build full OAuth callback URL
*
* @param path - Callback path (e.g., '/api/auth/oidc/callback')
* @returns Full callback URL
*
* @example
* getCallbackUrl('/api/auth/oidc/callback')
* // Returns: 'https://example.com/api/auth/oidc/callback'
*/
export function getCallbackUrl(path: string): string {
const baseUrl = getBaseUrl();
// Ensure path starts with /
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${baseUrl}${normalizedPath}`;
}