mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user