Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
/**
* Component: Audiobookshelf API Client
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { getConfigService } from '../config.service';
interface ABSRequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: any;
}
/**
* Make a request to the Audiobookshelf API
*/
export async function absRequest<T>(endpoint: string, options: ABSRequestOptions = {}): Promise<T> {
const configService = getConfigService();
const serverUrl = await configService.get('audiobookshelf.server_url');
const apiToken = await configService.get('audiobookshelf.api_token');
if (!serverUrl || !apiToken) {
throw new Error('Audiobookshelf not configured');
}
const url = `${serverUrl.replace(/\/$/, '')}/api${endpoint}`;
const response = await fetch(url, {
method: options.method || 'GET',
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
throw new Error(`ABS API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
/**
* Get Audiobookshelf server status/info
*/
export async function getABSServerInfo() {
return absRequest<{ version: string; name: string }>('/status');
}
/**
* Get all libraries from Audiobookshelf
*/
export async function getABSLibraries() {
const result = await absRequest<{ libraries: any[] }>('/libraries');
return result.libraries;
}
/**
* Get all items in a library
*/
export async function getABSLibraryItems(libraryId: string) {
const result = await absRequest<{ results: any[] }>(`/libraries/${libraryId}/items`);
return result.results;
}
/**
* Get recently added items in a library
*/
export async function getABSRecentItems(libraryId: string, limit: number) {
const result = await absRequest<{ results: any[] }>(
`/libraries/${libraryId}/items?sort=addedAt&desc=1&limit=${limit}`
);
return result.results;
}
/**
* Get a single item by ID
*/
export async function getABSItem(itemId: string) {
return absRequest<any>(`/items/${itemId}`);
}
/**
* Search for items in a library
*/
export async function searchABSItems(libraryId: string, query: string) {
const result = await absRequest<{ book: any[] }>(
`/libraries/${libraryId}/search?q=${encodeURIComponent(query)}`
);
return result.book || [];
}
/**
* Trigger a library scan
*/
export async function triggerABSScan(libraryId: string) {
await absRequest(`/libraries/${libraryId}/scan`, { method: 'POST' });
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Component: Audiobookshelf Type Definitions
* Documentation: documentation/features/audiobookshelf-integration.md
*/
export interface ABSLibrary {
id: string;
name: string;
mediaType: 'book' | 'podcast';
folders: { id: string; fullPath: string }[];
stats?: {
totalItems: number;
};
}
export interface ABSBookMetadata {
title: string;
subtitle?: string;
authorName: string;
authorNameLF?: string;
narratorName?: string;
seriesName?: string;
genres: string[];
publishedYear?: string;
description?: string;
isbn?: string;
asin?: string;
language?: string;
explicit: boolean;
}
export interface ABSAudioFile {
index: number;
ino: string;
metadata: {
filename: string;
ext: string;
path: string;
size: number;
mtimeMs: number;
};
duration: number;
}
export interface ABSLibraryItem {
id: string;
ino: string;
libraryId: string;
folderId: string;
path: string;
relPath: string;
isFile: boolean;
mtimeMs: number;
ctimeMs: number;
birthtimeMs: number;
addedAt: number;
updatedAt: number;
isMissing: boolean;
isInvalid: boolean;
mediaType: 'book';
media: {
metadata: ABSBookMetadata;
coverPath?: string;
audioFiles: ABSAudioFile[];
duration: number;
size: number;
numTracks: number;
numAudioFiles: number;
};
numFiles: number;
size: number;
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Auth Provider Interface
* Documentation: documentation/features/audiobookshelf-integration.md
*/
export interface UserInfo {
id: string; // User UUID
plexId?: string; // Plex ID, OIDC subject, or local username
username: string;
email?: string;
avatarUrl?: string;
role?: string; // 'admin' | 'user'
isAdmin?: boolean; // Deprecated: use role instead
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface LoginInitiation {
redirectUrl?: string; // For OAuth/OIDC flows
pinId?: string; // For Plex PIN flow
state?: string; // CSRF state token
}
export interface CallbackParams {
code?: string; // Authorization code
state?: string; // CSRF state
pinId?: string; // Plex PIN
error?: string;
[key: string]: any; // Allow additional params like username, password
}
export interface AuthResult {
success: boolean;
user?: UserInfo;
tokens?: AuthTokens;
error?: string;
requiresApproval?: boolean; // For pending approval flow
requiresProfileSelection?: boolean; // For Plex Home
profiles?: any[]; // Plex Home profiles
isFirstLogin?: boolean; // First user login (initial jobs will run)
}
export interface IAuthProvider {
type: 'plex' | 'oidc' | 'local';
// Auth initiation
initiateLogin(): Promise<LoginInitiation>;
// Auth completion
handleCallback(params: CallbackParams): Promise<AuthResult>;
// Token refresh
refreshToken(refreshToken: string): Promise<AuthTokens | null>;
// Validation
validateAccess(userInfo: UserInfo): Promise<boolean>;
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Local Auth Provider (Username/Password)
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import bcrypt from 'bcrypt';
import {
IAuthProvider,
LoginInitiation,
CallbackParams,
AuthResult,
UserInfo,
AuthTokens,
} from './IAuthProvider';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getConfigService } from '@/lib/services/config.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { prisma } from '@/lib/db';
interface LocalLoginParams extends CallbackParams {
username: string;
password: string;
}
interface RegisterParams {
username: string;
password: string;
}
export class LocalAuthProvider implements IAuthProvider {
type: 'local' = 'local';
private configService = getConfigService();
private encryptionService = getEncryptionService();
/**
* Initiate login (no-op for local auth)
*/
async initiateLogin(): Promise<LoginInitiation> {
// Local auth doesn't need initiation - return empty
return {};
}
/**
* Handle login with username/password
*/
async handleCallback(params: CallbackParams): Promise<AuthResult> {
try {
const { username, password } = params as LocalLoginParams;
if (!username || !password) {
return { success: false, error: 'Username and password required' };
}
// Find user
const user = await prisma.user.findFirst({
where: {
plexUsername: username,
authProvider: 'local',
},
});
if (!user) {
return { success: false, error: 'Invalid username or password' };
}
// Check registration status
if (user.registrationStatus === 'pending_approval') {
return {
success: false,
requiresApproval: true,
};
}
if (user.registrationStatus === 'rejected') {
return { success: false, error: 'Account has been rejected' };
}
// Verify password
let passwordValid = false;
try {
// Decrypt the stored hash
const decryptedHash = this.encryptionService.decrypt(user.authToken || '');
passwordValid = await bcrypt.compare(password, decryptedHash);
} catch (error) {
console.error('[LocalAuthProvider] Password verification failed:', error);
return { success: false, error: 'Invalid username or password' };
}
if (!passwordValid) {
return { success: false, error: 'Invalid username or password' };
}
// Update last login
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
// Generate tokens
console.log('[LocalAuthProvider] Generating tokens for user:', {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
authProvider: user.authProvider,
});
const tokens = await this.generateTokens({
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
isAdmin: user.role === 'admin',
});
console.log('[LocalAuthProvider] Tokens generated, returning user data');
return {
success: true,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
},
tokens,
};
} catch (error) {
console.error('[LocalAuthProvider] Login failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Authentication failed',
};
}
}
/**
* Register a new user
*/
async register(params: RegisterParams): Promise<AuthResult> {
try {
const { username, password } = params;
// Validate
if (!username || username.length < 3) {
return { success: false, error: 'Username must be at least 3 characters' };
}
if (!password || password.length < 8) {
return { success: false, error: 'Password must be at least 8 characters' };
}
// Check if registration is enabled
const registrationEnabled = await this.configService.get('auth.registration_enabled');
if (registrationEnabled !== 'true') {
return { success: false, error: 'Registration is disabled' };
}
// Check username uniqueness
const existing = await prisma.user.findFirst({
where: {
plexUsername: username,
authProvider: 'local',
},
});
if (existing) {
return { success: false, error: 'Username already taken' };
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Encrypt the hash before storing
const encryptedHash = this.encryptionService.encrypt(passwordHash);
// Determine registration status
const requireApproval = (await this.configService.get('auth.require_admin_approval')) === 'true';
const registrationStatus = requireApproval ? 'pending_approval' : 'approved';
// Check if first user (make admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
// Create user
const user = await prisma.user.create({
data: {
plexId: `local-${username}`,
plexUsername: username,
authToken: encryptedHash,
authProvider: 'local',
role: isFirstUser ? 'admin' : 'user',
isSetupAdmin: isFirstUser,
registrationStatus: isFirstUser ? 'approved' : registrationStatus,
lastLoginAt: new Date(),
},
});
// If requires approval and not first user, return pending status
if (requireApproval && !isFirstUser) {
return {
success: false,
requiresApproval: true,
};
}
// Generate tokens for immediate login
const tokens = await this.generateTokens({
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
isAdmin: user.role === 'admin',
});
return {
success: true,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
},
tokens,
};
} catch (error) {
console.error('[LocalAuthProvider] Registration failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Registration failed',
};
}
}
/**
* Generate JWT access and refresh tokens
*/
private async generateTokens(userInfo: UserInfo & { plexId: string }): Promise<AuthTokens> {
const tokenPayload = {
sub: userInfo.id,
plexId: userInfo.plexId,
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
};
console.log('[LocalAuthProvider] JWT token payload:', tokenPayload);
const accessToken = generateAccessToken(tokenPayload);
const refreshToken = generateRefreshToken(userInfo.id);
return {
accessToken,
refreshToken,
};
}
/**
* Refresh JWT tokens
*/
async refreshToken(refreshToken: string): Promise<AuthTokens | null> {
// JWT refresh is handled by existing JWT utilities
// This method is a placeholder for future implementation
return null;
}
/**
* Validate user has access
*/
async validateAccess(userInfo: UserInfo): Promise<boolean> {
try {
// Check if user exists and is approved
const user = await prisma.user.findUnique({
where: { id: userInfo.id },
});
if (!user || user.authProvider !== 'local') {
return false;
}
if (user.registrationStatus === 'pending_approval' || user.registrationStatus === 'rejected') {
return false;
}
return true;
} catch (error) {
console.error('[LocalAuthProvider] Access validation failed:', error);
return false;
}
}
}
+568
View File
@@ -0,0 +1,568 @@
/**
* OIDC Auth Provider Implementation
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { Issuer, Client, generators } from 'openid-client';
import {
IAuthProvider,
UserInfo,
AuthTokens,
LoginInitiation,
CallbackParams,
AuthResult,
} from './IAuthProvider';
import { getConfigService } from '@/lib/services/config.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getBaseUrl } from '@/lib/utils/url';
import { getSchedulerService } from '@/lib/services/scheduler.service';
import { prisma } from '@/lib/db';
// In-memory storage for OIDC flow state (temporary until callback completes)
// In production, this could be replaced with Redis for multi-instance support
interface OIDCFlowState {
state: string;
nonce: string;
codeVerifier: string;
timestamp: number;
}
const flowStateCache = new Map<string, OIDCFlowState>();
const FLOW_STATE_TTL = 10 * 60 * 1000; // 10 minutes
export class OIDCAuthProvider implements IAuthProvider {
type: 'oidc' = 'oidc';
private configService = getConfigService();
private encryptionService = getEncryptionService();
private client: Client | null = null;
/**
* Get or create OIDC client
*/
private async getClient(): Promise<Client> {
if (this.client) return this.client;
const issuerUrl = await this.configService.get('oidc.issuer_url');
const clientId = await this.configService.get('oidc.client_id');
const clientSecret = await this.configService.get('oidc.client_secret');
if (!issuerUrl || !clientId || !clientSecret) {
throw new Error('OIDC is not fully configured');
}
// Discover OIDC endpoints
const issuer = await Issuer.discover(issuerUrl);
// Create client
this.client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [await this.getRedirectUri()],
response_types: ['code'],
});
return this.client;
}
/**
* Get redirect URI for OAuth callback
*/
private async getRedirectUri(): Promise<string> {
const baseUrl = getBaseUrl();
return `${baseUrl}/api/auth/oidc/callback`;
}
/**
* Initiate OIDC login flow
*/
async initiateLogin(): Promise<LoginInitiation> {
try {
const client = await this.getClient();
const state = generators.state();
const nonce = generators.nonce();
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
// Store state in memory cache
flowStateCache.set(state, {
state,
nonce,
codeVerifier,
timestamp: Date.now(),
});
// Clean up expired states
this.cleanupExpiredStates();
// Generate authorization URL
const redirectUrl = client.authorizationUrl({
scope: 'openid profile email groups',
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return {
redirectUrl,
state,
};
} catch (error) {
console.error('[OIDCAuthProvider] Failed to initiate login:', error);
throw new Error('Failed to initiate OIDC authentication');
}
}
/**
* Handle OIDC callback
*/
async handleCallback(params: CallbackParams): Promise<AuthResult> {
try {
const { code, state, error } = params;
if (error) {
return {
success: false,
error: `OIDC provider error: ${error}`,
};
}
if (!code || !state) {
return {
success: false,
error: 'Missing authorization code or state',
};
}
// Retrieve stored flow state
const flowState = flowStateCache.get(state);
if (!flowState) {
return {
success: false,
error: 'Invalid or expired state parameter',
};
}
// Clean up state after retrieval
flowStateCache.delete(state);
const client = await this.getClient();
const redirectUri = await this.getRedirectUri();
if (process.env.LOG_LEVEL === 'debug') {
console.debug('[OIDCAuthProvider] Exchanging code for tokens', {
redirectUri,
hasCode: !!code,
hasState: !!state,
stateMatches: state === flowState.state,
});
}
// Exchange code for tokens
const tokenSet = await client.callback(
redirectUri,
{ code, state },
{
code_verifier: flowState.codeVerifier,
nonce: flowState.nonce,
state: flowState.state,
}
);
if (!tokenSet.access_token) {
return {
success: false,
error: 'Failed to obtain access token',
};
}
// Get user info from OIDC provider
const userinfo = await client.userinfo(tokenSet.access_token);
if (!userinfo.sub) {
return {
success: false,
error: 'Invalid user info from OIDC provider',
};
}
// Check access control
const hasAccess = await this.checkAccessControl(userinfo);
if (!hasAccess) {
return {
success: false,
error: 'You do not have access to this application',
};
}
// Map OIDC claims to UserInfo
const username = (userinfo.preferred_username || userinfo.email || userinfo.sub) as string;
const email = userinfo.email as string | undefined;
const avatarUrl = userinfo.picture as string | undefined;
// Check admin role from claims
const isAdminFromClaim = await this.checkAdminClaim(userinfo);
// Check if admin approval required
const accessMethod = await this.configService.get('oidc.access_control_method');
if (accessMethod === 'admin_approval') {
const existingUser = await this.findUserByOIDCSubject(userinfo.sub);
if (!existingUser) {
// Create pending user
await this.createPendingUser(userinfo.sub, username, email, avatarUrl);
return {
success: false,
requiresApproval: true,
};
}
if (existingUser.registrationStatus === 'pending_approval') {
return {
success: false,
requiresApproval: true,
};
}
if (existingUser.registrationStatus === 'rejected') {
return {
success: false,
error: 'Your account has been rejected by an administrator',
};
}
}
// Create or update user
const result = await this.createOrUpdateUser(
userinfo.sub,
username,
email,
avatarUrl,
isAdminFromClaim
);
// Generate JWT tokens
const tokens = await this.generateTokens(result.userInfo);
return {
success: true,
user: result.userInfo,
tokens,
isFirstLogin: result.isFirstLogin,
};
} catch (error) {
console.error('[OIDCAuthProvider] Callback failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Authentication failed',
};
}
}
/**
* Check if user has access to the application
*/
private async checkAccessControl(userinfo: any): Promise<boolean> {
const method = await this.configService.get('oidc.access_control_method');
switch (method) {
case 'open':
return true;
case 'group_claim': {
const claimName = (await this.configService.get('oidc.access_group_claim')) || 'groups';
const requiredGroup = await this.configService.get('oidc.access_group_value');
if (!requiredGroup) {
console.error('[OIDCAuthProvider] Group claim access control enabled but no required group configured');
return false;
}
const userGroups = userinfo[claimName] || [];
if (Array.isArray(userGroups)) {
return userGroups.includes(requiredGroup);
}
return userGroups === requiredGroup;
}
case 'allowed_list': {
const allowedEmailsStr = await this.configService.get('oidc.allowed_emails');
const allowedUsernamesStr = await this.configService.get('oidc.allowed_usernames');
const allowedEmails = allowedEmailsStr ? JSON.parse(allowedEmailsStr) : [];
const allowedUsernames = allowedUsernamesStr ? JSON.parse(allowedUsernamesStr) : [];
return (
allowedEmails.includes(userinfo.email) ||
allowedUsernames.includes(userinfo.preferred_username)
);
}
case 'admin_approval':
// Admin approval check happens in handleCallback
return true;
default:
// If no method specified, default to open access
return true;
}
}
/**
* Check if user should be granted admin role from OIDC claims
*/
private async checkAdminClaim(userinfo: any): Promise<boolean> {
const enabled = await this.configService.get('oidc.admin_claim_enabled');
if (enabled !== 'true') {
return false;
}
const claimName = (await this.configService.get('oidc.admin_claim_name')) || 'groups';
const claimValue = await this.configService.get('oidc.admin_claim_value');
if (!claimValue) {
return false;
}
const userClaims = userinfo[claimName] || [];
if (Array.isArray(userClaims)) {
return userClaims.includes(claimValue);
}
return userClaims === claimValue;
}
/**
* Find user by OIDC subject
*/
private async findUserByOIDCSubject(oidcSubject: string) {
return await prisma.user.findFirst({
where: {
oidcSubject,
authProvider: 'oidc',
},
});
}
/**
* Create pending user (for admin approval flow)
*/
private async createPendingUser(
oidcSubject: string,
username: string,
email: string | undefined,
avatarUrl: string | undefined
) {
const providerName = await this.configService.get('oidc.provider_name');
await prisma.user.create({
data: {
plexId: oidcSubject, // Use oidcSubject as unique identifier
plexUsername: username,
plexEmail: email || null,
role: 'user',
isSetupAdmin: false,
avatarUrl: avatarUrl || null,
authProvider: 'oidc',
oidcSubject,
oidcProvider: providerName || 'unknown',
registrationStatus: 'pending_approval',
lastLoginAt: new Date(),
},
});
}
/**
* Create or update user in database
*/
private async createOrUpdateUser(
oidcSubject: string,
username: string,
email: string | undefined,
avatarUrl: string | undefined,
isAdminFromClaim: boolean
): Promise<{ userInfo: UserInfo; isFirstLogin: boolean }> {
const providerName = await this.configService.get('oidc.provider_name');
// Check if this is the first user (should be promoted to admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
const role = isFirstUser || isAdminFromClaim ? 'admin' : 'user';
// Create or update user
const user = await prisma.user.upsert({
where: { plexId: oidcSubject },
create: {
plexId: oidcSubject, // Use oidcSubject as plexId for unique constraint
plexUsername: username,
plexEmail: email || null,
role,
isSetupAdmin: isFirstUser,
avatarUrl: avatarUrl || null,
authProvider: 'oidc',
oidcSubject,
oidcProvider: providerName || 'unknown',
registrationStatus: 'approved',
lastLoginAt: new Date(),
},
update: {
plexUsername: username,
plexEmail: email || null,
avatarUrl: avatarUrl || null,
oidcProvider: providerName || 'unknown',
registrationStatus: 'approved',
lastLoginAt: new Date(),
// Update role if admin claim is present
...(isAdminFromClaim && { role: 'admin' }),
},
});
// Track if we need to trigger initial jobs
let shouldTriggerJobs = false;
// If this is the first user, trigger initial jobs (Audible refresh + Library scan)
// This happens after OIDC-only setup where no admin was created during wizard
if (isFirstUser) {
console.log('[OIDCAuthProvider] First OIDC user created - triggering initial jobs');
// Check if initial jobs have already been run (avoid duplicate runs)
const initialJobsRun = await this.configService.get('system.initial_jobs_run');
if (initialJobsRun !== 'true') {
shouldTriggerJobs = true;
// Trigger jobs in background (don't block authentication)
this.triggerInitialJobs().catch(err => {
console.error('[OIDCAuthProvider] Failed to trigger initial jobs:', err);
});
}
}
return {
userInfo: {
id: user.id,
username: user.plexUsername,
email: user.plexEmail || undefined,
avatarUrl: user.avatarUrl || undefined,
isAdmin: user.role === 'admin',
},
isFirstLogin: isFirstUser && shouldTriggerJobs,
};
}
/**
* Trigger initial jobs (Audible refresh + Library scan) after first OIDC login
* This is called automatically when the first user logs in via OIDC after setup
*/
private async triggerInitialJobs(): Promise<void> {
try {
const schedulerService = getSchedulerService();
// Get scheduled jobs by type
const audibleJob = await prisma.scheduledJob.findFirst({
where: { type: 'audible_refresh' },
});
const libraryJob = await prisma.scheduledJob.findFirst({
where: { type: 'plex_library_scan' },
});
console.log('[OIDCAuthProvider] Triggering initial jobs...');
// Trigger Audible refresh
if (audibleJob) {
await schedulerService.triggerJobNow(audibleJob.id);
console.log('[OIDCAuthProvider] Triggered Audible refresh job');
} else {
console.warn('[OIDCAuthProvider] Audible refresh job not found');
}
// Trigger Library scan
if (libraryJob) {
await schedulerService.triggerJobNow(libraryJob.id);
console.log('[OIDCAuthProvider] Triggered Library scan job');
} else {
console.warn('[OIDCAuthProvider] Library scan job not found');
}
// Mark initial jobs as run
await prisma.configuration.upsert({
where: { key: 'system.initial_jobs_run' },
update: { value: 'true' },
create: { key: 'system.initial_jobs_run', value: 'true' },
});
console.log('[OIDCAuthProvider] Initial jobs triggered successfully');
} catch (error) {
console.error('[OIDCAuthProvider] Error triggering initial jobs:', error);
throw error;
}
}
/**
* Generate JWT access and refresh tokens
*/
private async generateTokens(userInfo: UserInfo): Promise<AuthTokens> {
const accessToken = generateAccessToken({
sub: userInfo.id,
plexId: userInfo.id, // For backwards compatibility
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
});
const refreshToken = generateRefreshToken(userInfo.id);
return {
accessToken,
refreshToken,
};
}
/**
* Refresh JWT tokens
*/
async refreshToken(refreshToken: string): Promise<AuthTokens | null> {
// JWT refresh is handled by existing JWT utilities
// This method is a placeholder for future implementation
return null;
}
/**
* Validate user has access
*/
async validateAccess(userInfo: UserInfo): Promise<boolean> {
try {
// Check if user exists and is approved
const user = await prisma.user.findUnique({
where: { id: userInfo.id },
});
if (!user || user.authProvider !== 'oidc') {
return false;
}
if (user.registrationStatus === 'pending_approval' || user.registrationStatus === 'rejected') {
return false;
}
return true;
} catch (error) {
console.error('[OIDCAuthProvider] Access validation failed:', error);
return false;
}
}
/**
* Clean up expired flow states
*/
private cleanupExpiredStates(): void {
const now = Date.now();
for (const [state, flowState] of flowStateCache.entries()) {
if (now - flowState.timestamp > FLOW_STATE_TTL) {
flowStateCache.delete(state);
}
}
}
}
+261
View File
@@ -0,0 +1,261 @@
/**
* Plex Auth Provider Implementation
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import {
IAuthProvider,
UserInfo,
AuthTokens,
LoginInitiation,
CallbackParams,
AuthResult,
} from './IAuthProvider';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getConfigService } from '@/lib/services/config.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getBaseUrl } from '@/lib/utils/url';
import { prisma } from '@/lib/db';
export class PlexAuthProvider implements IAuthProvider {
type: 'plex' = 'plex';
private plexService = getPlexService();
private configService = getConfigService();
private encryptionService = getEncryptionService();
/**
* Initiate Plex OAuth login flow
*/
async initiateLogin(): Promise<LoginInitiation> {
try {
// Request a PIN from Plex
const pin = await this.plexService.requestPin();
// Generate OAuth URL
const baseCallbackUrl = process.env.PLEX_OAUTH_CALLBACK_URL ||
`${getBaseUrl()}/api/auth/plex/callback`;
const oauthUrl = this.plexService.getOAuthUrl(pin.code, pin.id, baseCallbackUrl);
return {
redirectUrl: oauthUrl,
pinId: pin.id.toString(),
};
} catch (error) {
console.error('[PlexAuthProvider] Failed to initiate login:', error);
throw new Error('Failed to initiate Plex authentication');
}
}
/**
* Handle OAuth callback - check PIN status and complete authentication
*/
async handleCallback(params: CallbackParams): Promise<AuthResult> {
try {
const { pinId } = params;
if (!pinId) {
return {
success: false,
error: 'Missing PIN ID',
};
}
// Check PIN status
const authToken = await this.plexService.checkPin(parseInt(pinId, 10));
if (!authToken) {
// Still waiting for user authorization
return {
success: false,
error: 'Waiting for user authorization',
};
}
// Get user info from Plex
const plexUser = await this.plexService.getUserInfo(authToken);
if (!plexUser || !plexUser.id || !plexUser.username) {
return {
success: false,
error: 'Failed to get user information from Plex',
};
}
// Verify user has access to configured server
const plexConfig = await this.configService.getPlexConfig();
if (!plexConfig.serverUrl || !plexConfig.machineIdentifier) {
return {
success: false,
error: 'Plex server is not configured',
};
}
const hasAccess = await this.plexService.verifyServerAccess(
plexConfig.serverUrl,
plexConfig.machineIdentifier,
authToken
);
if (!hasAccess) {
return {
success: false,
error: 'You do not have access to this Plex server',
};
}
// Check for Plex Home profiles
const homeUsers = await this.plexService.getHomeUsers(authToken);
if (homeUsers.length > 1) {
// Multiple profiles - need profile selection
return {
success: true,
requiresProfileSelection: true,
profiles: homeUsers,
};
}
// No additional profiles - create/update user with main account
const userInfo = await this.createOrUpdateUser(
plexUser.id.toString(),
plexUser.username,
plexUser.email,
plexUser.thumb,
authToken,
null // No home profile
);
// Generate JWT tokens
const tokens = await this.generateTokens(userInfo);
return {
success: true,
user: userInfo,
tokens,
};
} catch (error) {
console.error('[PlexAuthProvider] Callback failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Authentication failed',
};
}
}
/**
* Refresh JWT tokens
*/
async refreshToken(refreshToken: string): Promise<AuthTokens | null> {
// JWT refresh is handled by existing JWT utilities
// This method is a placeholder for future implementation
return null;
}
/**
* Validate user has access to the server
*/
async validateAccess(userInfo: UserInfo): Promise<boolean> {
try {
const plexConfig = await this.configService.getPlexConfig();
if (!plexConfig.serverUrl || !plexConfig.machineIdentifier) {
return false;
}
// Get user's Plex token from database
const user = await prisma.user.findUnique({
where: { id: userInfo.id },
});
if (!user || !user.authToken) {
return false;
}
// Decrypt token
const decryptedToken = this.encryptionService.decrypt(user.authToken);
// Verify server access
return await this.plexService.verifyServerAccess(
plexConfig.serverUrl,
plexConfig.machineIdentifier,
decryptedToken
);
} catch (error) {
console.error('[PlexAuthProvider] Access validation failed:', error);
return false;
}
}
/**
* Create or update user in database
*/
private async createOrUpdateUser(
plexId: string,
username: string,
email: string | undefined,
avatarUrl: string | undefined,
authToken: string,
homeUserId: string | null
): Promise<UserInfo> {
// Check if this is the first user (should be promoted to admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
const role = isFirstUser ? 'admin' : 'user';
// Create or update user in database
const user = await prisma.user.upsert({
where: { plexId },
create: {
plexId,
plexUsername: username,
plexEmail: email || null,
role,
isSetupAdmin: isFirstUser,
avatarUrl: avatarUrl || null,
authToken: this.encryptionService.encrypt(authToken),
authProvider: 'plex',
plexHomeUserId: homeUserId,
lastLoginAt: new Date(),
},
update: {
plexUsername: username,
plexEmail: email || null,
avatarUrl: avatarUrl || null,
authToken: this.encryptionService.encrypt(authToken),
authProvider: 'plex',
plexHomeUserId: homeUserId,
lastLoginAt: new Date(),
},
});
return {
id: user.id,
username: user.plexUsername,
email: user.plexEmail || undefined,
avatarUrl: user.avatarUrl || undefined,
isAdmin: user.role === 'admin',
};
}
/**
* Generate JWT access and refresh tokens
*/
private async generateTokens(userInfo: UserInfo): Promise<AuthTokens> {
const accessToken = generateAccessToken({
sub: userInfo.id,
plexId: userInfo.id, // For backwards compatibility
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
});
const refreshToken = generateRefreshToken(userInfo.id);
return {
accessToken,
refreshToken,
};
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Auth Provider Factory
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { IAuthProvider } from './IAuthProvider';
import { PlexAuthProvider } from './PlexAuthProvider';
import { OIDCAuthProvider } from './OIDCAuthProvider'; // Phase 3
import { LocalAuthProvider } from './LocalAuthProvider'; // Phase 4
import { getConfigService } from '@/lib/services/config.service';
export type AuthMethod = 'plex' | 'oidc' | 'local';
/**
* Get the appropriate auth provider based on backend mode and auth method
* @param method - Optional override for auth method (useful for multi-auth scenarios)
*/
export async function getAuthProvider(method?: AuthMethod): Promise<IAuthProvider> {
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
// Plex mode always uses Plex OAuth
if (backendMode === 'plex') {
return new PlexAuthProvider();
}
// Audiobookshelf mode - determine auth method
if (method) {
// Explicit method provided
if (method === 'oidc') {
return new OIDCAuthProvider();
} else if (method === 'local') {
return new LocalAuthProvider();
}
}
// Auto-detect from configuration
const oidcEnabled = (await configService.get('oidc.enabled')) === 'true';
const registrationEnabled = (await configService.get('auth.registration_enabled')) === 'true';
if (oidcEnabled) {
return new OIDCAuthProvider();
} else if (registrationEnabled) {
return new LocalAuthProvider();
}
// Fallback to Plex (shouldn't happen in normal flow)
return new PlexAuthProvider();
}
// Re-export types
export * from './IAuthProvider';
export { PlexAuthProvider } from './PlexAuthProvider';
export { OIDCAuthProvider } from './OIDCAuthProvider';
export { LocalAuthProvider } from './LocalAuthProvider';
+250
View File
@@ -0,0 +1,250 @@
/**
* Component: Configuration Service
* Documentation: documentation/backend/services/config.md
*/
import { prisma } from '@/lib/db';
import { getEncryptionService } from './encryption.service';
/**
* Configuration update payload
*/
export interface ConfigUpdate {
key: string;
value: string;
encrypted?: boolean;
category?: string;
description?: string;
}
/**
* Plex configuration structure
*/
export interface PlexConfig {
serverUrl: string | null;
authToken: string | null;
libraryId: string | null;
machineIdentifier: string | null;
}
/**
* Configuration service for reading settings from database
*/
export class ConfigurationService {
private cache: Map<string, string> = new Map();
private cacheExpiry: Map<string, number> = new Map();
private readonly CACHE_TTL = 60000; // 1 minute
/**
* Get a configuration value by key (decrypted if encrypted)
*/
async get(key: string): Promise<string | null> {
// Check cache first
const cached = this.cache.get(key);
const expiry = this.cacheExpiry.get(key);
if (cached && expiry && Date.now() < expiry) {
return cached;
}
// Fetch from database
try {
const config = await prisma.configuration.findUnique({
where: { key },
});
if (config && config.value) {
let value = config.value;
// Decrypt if encrypted
if (config.encrypted) {
const encryptionService = getEncryptionService();
value = encryptionService.decrypt(config.value);
}
// Cache the decrypted value
this.cache.set(key, value);
this.cacheExpiry.set(key, Date.now() + this.CACHE_TTL);
return value;
}
return null;
} catch (error) {
console.error(`[Config] Failed to get config key "${key}":`, error);
return null;
}
}
/**
* Get multiple configuration values
*/
async getMany(keys: string[]): Promise<Record<string, string | null>> {
const result: Record<string, string | null> = {};
await Promise.all(
keys.map(async (key) => {
result[key] = await this.get(key);
})
);
return result;
}
/**
* Get all configuration items for a specific category
*/
async getCategory(category: string): Promise<Record<string, any>> {
try {
const configs = await prisma.configuration.findMany({
where: { category },
});
const result: Record<string, any> = {};
for (const config of configs) {
let value = config.value;
// Decrypt if encrypted
if (config.encrypted && value) {
const encryptionService = getEncryptionService();
value = encryptionService.decrypt(value);
}
result[config.key] = {
value,
encrypted: config.encrypted,
description: config.description,
};
}
return result;
} catch (error) {
console.error(`[Config] Failed to get category "${category}":`, error);
return {};
}
}
/**
* Get all configuration items (with masked sensitive values)
*/
async getAll(): Promise<Record<string, any>> {
try {
const configs = await prisma.configuration.findMany();
const result: Record<string, any> = {};
for (const config of configs) {
result[config.key] = {
value: config.encrypted ? '***ENCRYPTED***' : config.value,
encrypted: config.encrypted,
category: config.category,
description: config.description,
};
}
return result;
} catch (error) {
console.error('[Config] Failed to get all configuration:', error);
return {};
}
}
/**
* Set multiple configuration values (encrypts if needed)
*/
async setMany(updates: ConfigUpdate[]): Promise<void> {
try {
const encryptionService = getEncryptionService();
for (const update of updates) {
let value = update.value;
// Encrypt if needed
if (update.encrypted) {
value = encryptionService.encrypt(value);
}
// Upsert configuration
await prisma.configuration.upsert({
where: { key: update.key },
create: {
key: update.key,
value,
encrypted: update.encrypted || false,
category: update.category,
description: update.description,
},
update: {
value,
encrypted: update.encrypted || false,
category: update.category,
description: update.description,
},
});
// Clear cache for this key
this.clearCache(update.key);
}
} catch (error) {
console.error('[Config] Failed to set configuration:', error);
throw error;
}
}
/**
* Get Plex-specific configuration
*/
async getPlexConfig(): Promise<PlexConfig> {
const config = await this.getMany([
'plex_url',
'plex_token',
'plex_audiobook_library_id',
'plex_machine_identifier',
]);
return {
serverUrl: config.plex_url,
authToken: config.plex_token,
libraryId: config.plex_audiobook_library_id,
machineIdentifier: config.plex_machine_identifier || null,
};
}
/**
* Get backend mode (Plex or Audiobookshelf)
*/
async getBackendMode(): Promise<'plex' | 'audiobookshelf'> {
const mode = await this.get('system.backend_mode');
return (mode as 'plex' | 'audiobookshelf') || 'plex';
}
/**
* Check if Audiobookshelf mode is enabled
*/
async isAudiobookshelfMode(): Promise<boolean> {
return (await this.getBackendMode()) === 'audiobookshelf';
}
/**
* Clear the cache for a specific key or all keys
*/
clearCache(key?: string): void {
if (key) {
this.cache.delete(key);
this.cacheExpiry.delete(key);
} else {
this.cache.clear();
this.cacheExpiry.clear();
}
}
}
// Singleton instance
let configService: ConfigurationService | null = null;
export function getConfigService(): ConfigurationService {
if (!configService) {
configService = new ConfigurationService();
}
return configService;
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Component: Encryption Service
* Documentation: documentation/backend/services/config.md
*/
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const KEY_LENGTH = 32;
export class EncryptionService {
private key: Buffer;
constructor() {
const encryptionKey = process.env.CONFIG_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error('CONFIG_ENCRYPTION_KEY environment variable is not set');
}
// Ensure key is exactly 32 bytes
if (encryptionKey.length < KEY_LENGTH) {
// Pad with zeros if too short
this.key = Buffer.alloc(KEY_LENGTH);
Buffer.from(encryptionKey).copy(this.key);
} else if (encryptionKey.length > KEY_LENGTH) {
// Truncate if too long
this.key = Buffer.from(encryptionKey).subarray(0, KEY_LENGTH);
} else {
this.key = Buffer.from(encryptionKey);
}
}
/**
* Encrypt a plaintext string
* @param plaintext - The string to encrypt
* @returns Base64-encoded string in format: iv:authTag:encryptedData
*/
encrypt(plaintext: string): string {
try {
// Generate random IV for this encryption
const iv = crypto.randomBytes(IV_LENGTH);
// Create cipher
const cipher = crypto.createCipheriv(ALGORITHM, this.key, iv);
// Encrypt data
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
encrypted += cipher.final('base64');
// Get auth tag
const authTag = cipher.getAuthTag();
// Combine IV, auth tag, and encrypted data
const result = `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
return result;
} catch (error) {
throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Decrypt an encrypted string
* @param encryptedData - Base64-encoded string in format: iv:authTag:encryptedData
* @returns Decrypted plaintext string
*/
decrypt(encryptedData: string): string {
try {
// Split the encrypted data
const parts = encryptedData.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted data format');
}
const [ivBase64, authTagBase64, encrypted] = parts;
// Decode components
const iv = Buffer.from(ivBase64, 'base64');
const authTag = Buffer.from(authTagBase64, 'base64');
// Create decipher
const decipher = crypto.createDecipheriv(ALGORITHM, this.key, iv);
decipher.setAuthTag(authTag);
// Decrypt data
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Generate a random encryption key (32 bytes)
* @returns Base64-encoded random key
*/
static generateKey(): string {
return crypto.randomBytes(KEY_LENGTH).toString('base64');
}
}
// Singleton instance
let encryptionService: EncryptionService | null = null;
export function getEncryptionService(): EncryptionService {
if (!encryptionService) {
encryptionService = new EncryptionService();
}
return encryptionService;
}
+845
View File
@@ -0,0 +1,845 @@
/**
* Component: Job Queue Service
* Documentation: documentation/backend/services/jobs.md
*/
import Queue, { Job as BullJob, JobOptions } from 'bull';
import Redis from 'ioredis';
import { prisma } from '../db';
import { TorrentResult } from '../utils/ranking-algorithm';
export type JobType =
| 'search_indexers'
| 'download_torrent'
| 'monitor_download'
| 'organize_files'
| 'scan_plex'
| 'match_plex'
| 'plex_library_scan'
| 'plex_recently_added_check'
| 'audible_refresh'
| 'retry_missing_torrents'
| 'retry_failed_imports'
| 'cleanup_seeded_torrents'
| 'monitor_rss_feeds';
export interface JobPayload {
jobId?: string; // Database job ID (added automatically by addJob)
[key: string]: any;
}
export interface SearchIndexersPayload extends JobPayload {
requestId: string;
audiobook: {
id: string;
title: string;
author: string;
};
}
export interface DownloadTorrentPayload extends JobPayload {
requestId: string;
audiobook: {
id: string;
title: string;
author: string;
};
torrent: TorrentResult;
}
export interface MonitorDownloadPayload extends JobPayload {
requestId: string;
downloadHistoryId: string;
downloadClientId: string;
downloadClient: 'qbittorrent' | 'transmission';
}
export interface OrganizeFilesPayload extends JobPayload {
requestId: string;
audiobookId: string;
downloadPath: string;
targetPath: string;
}
export interface ScanPlexPayload extends JobPayload {
libraryId?: string;
partial?: boolean;
path?: string;
}
export interface MatchPlexPayload extends JobPayload {
requestId: string;
audiobookId: string;
title: string;
author: string;
}
export interface PlexRecentlyAddedPayload extends JobPayload {
scheduledJobId?: string;
}
export interface MonitorRssFeedsPayload extends JobPayload {
scheduledJobId?: string;
}
export interface AudibleRefreshPayload extends JobPayload {
scheduledJobId?: string;
}
export interface RetryMissingTorrentsPayload extends JobPayload {
scheduledJobId?: string;
}
export interface RetryFailedImportsPayload extends JobPayload {
scheduledJobId?: string;
}
export interface CleanupSeededTorrentsPayload extends JobPayload {
scheduledJobId?: string;
}
export interface QueueStats {
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
}
export class JobQueueService {
private queue: Queue.Queue;
private redis: Redis;
constructor() {
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
// Create Redis client
this.redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
});
// Increase max listeners to accommodate all job processors (12 total)
this.redis.setMaxListeners(20);
// Create Bull queue
this.queue = new Queue('audiobook-jobs', redisUrl, {
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: 100,
removeOnFail: 200,
},
});
// Increase max listeners to accommodate all job processors (12 total)
this.queue.setMaxListeners(20);
this.setupEventHandlers();
this.startProcessors();
}
/**
* Setup event handlers for job lifecycle
*/
private setupEventHandlers(): void {
this.queue.on('completed', async (job: BullJob, result: any) => {
console.log(`Job ${job.id} completed:`, result);
await this.updateJobInDatabase(job.id as string, 'completed', result);
});
this.queue.on('failed', async (job: BullJob, error: Error) => {
console.error(`Job ${job.id} failed:`, error.message);
await this.updateJobInDatabase(
job.id as string,
'failed',
null,
error.message,
error.stack
);
// Handle permanent failures for specific job types after all retries exhausted
if (job.name === 'monitor_download' && job.data) {
const payload = job.data as MonitorDownloadPayload;
console.error(`[MonitorDownload] Job permanently failed for request ${payload.requestId} after ${job.attemptsMade} attempts`);
// Update request status to failed (only happens after all retries exhausted)
try {
await prisma.request.update({
where: { id: payload.requestId },
data: {
status: 'failed',
errorMessage: error.message || 'Failed to monitor download after multiple retries',
updatedAt: new Date(),
},
});
// Update download history
if (payload.downloadHistoryId) {
await prisma.downloadHistory.update({
where: { id: payload.downloadHistoryId },
data: {
downloadStatus: 'failed',
downloadError: error.message || 'Failed to monitor download',
},
});
}
} catch (updateError) {
console.error('[MonitorDownload] Failed to update request/download status:', updateError);
}
}
});
this.queue.on('stalled', async (job: BullJob) => {
console.warn(`Job ${job.id} stalled`);
await this.updateJobInDatabase(job.id as string, 'stuck');
});
this.queue.on('active', async (job: BullJob) => {
await this.updateJobInDatabase(job.id as string, 'active');
});
this.queue.on('error', (error: Error) => {
console.error('Queue error:', error);
});
}
/**
* Start job processors for each job type
*/
private startProcessors(): void {
// Search indexers processor
this.queue.process('search_indexers', 3, async (job: BullJob<SearchIndexersPayload>) => {
const { processSearchIndexers } = await import('../processors/search-indexers.processor');
return await processSearchIndexers(job.data);
});
// Download torrent processor
this.queue.process('download_torrent', 3, async (job: BullJob<DownloadTorrentPayload>) => {
const { processDownloadTorrent } = await import('../processors/download-torrent.processor');
return await processDownloadTorrent(job.data);
});
// Monitor download processor
this.queue.process('monitor_download', 5, async (job: BullJob<MonitorDownloadPayload>) => {
const { processMonitorDownload } = await import('../processors/monitor-download.processor');
return await processMonitorDownload(job.data);
});
// Organize files processor
this.queue.process('organize_files', 2, async (job: BullJob<OrganizeFilesPayload>) => {
const { processOrganizeFiles } = await import('../processors/organize-files.processor');
return await processOrganizeFiles(job.data);
});
// Scan Plex processor
this.queue.process('scan_plex', 1, async (job: BullJob<ScanPlexPayload>) => {
const { processScanPlex } = await import('../processors/scan-plex.processor');
return await processScanPlex(job.data);
});
// Match Plex processor
this.queue.process('match_plex', 3, async (job: BullJob<MatchPlexPayload>) => {
const { processMatchPlex } = await import('../processors/match-plex.processor');
return await processMatchPlex(job.data);
});
// Scheduled job processors
this.queue.process('plex_library_scan', 1, async (job: BullJob) => {
// plex_library_scan is just an alias for scan_plex
const { processScanPlex } = await import('../processors/scan-plex.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'plex_library_scan');
return await processScanPlex(payloadWithJobId);
});
this.queue.process('plex_recently_added_check', 1, async (job: BullJob<PlexRecentlyAddedPayload>) => {
const { processPlexRecentlyAddedCheck } = await import('../processors/plex-recently-added.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'plex_recently_added_check');
return await processPlexRecentlyAddedCheck(payloadWithJobId);
});
this.queue.process('monitor_rss_feeds', 1, async (job: BullJob<MonitorRssFeedsPayload>) => {
const { processMonitorRssFeeds } = await import('../processors/monitor-rss-feeds.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'monitor_rss_feeds');
return await processMonitorRssFeeds(payloadWithJobId);
});
this.queue.process('audible_refresh', 1, async (job: BullJob<AudibleRefreshPayload>) => {
const { processAudibleRefresh } = await import('../processors/audible-refresh.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'audible_refresh');
return await processAudibleRefresh(payloadWithJobId);
});
this.queue.process('retry_missing_torrents', 1, async (job: BullJob<RetryMissingTorrentsPayload>) => {
const { processRetryMissingTorrents } = await import('../processors/retry-missing-torrents.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'retry_missing_torrents');
return await processRetryMissingTorrents(payloadWithJobId);
});
this.queue.process('retry_failed_imports', 1, async (job: BullJob<RetryFailedImportsPayload>) => {
const { processRetryFailedImports } = await import('../processors/retry-failed-imports.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'retry_failed_imports');
return await processRetryFailedImports(payloadWithJobId);
});
this.queue.process('cleanup_seeded_torrents', 1, async (job: BullJob<CleanupSeededTorrentsPayload>) => {
const { processCleanupSeededTorrents } = await import('../processors/cleanup-seeded-torrents.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'cleanup_seeded_torrents');
return await processCleanupSeededTorrents(payloadWithJobId);
});
}
/**
* Ensure a database Job record exists for scheduled jobs
* If jobId is already in payload (manual trigger), return as-is
* Otherwise, create a Job record for timer-triggered scheduled jobs
* Also updates the lastRun timestamp for timer-triggered scheduled jobs
*/
private async ensureJobRecord(job: BullJob, jobType: JobType): Promise<any> {
const payload = job.data;
// If jobId already exists (manual trigger via addJob), return payload as-is
if (payload.jobId) {
return payload;
}
// Check if a Job record already exists for this Bull job
const existingJob = await prisma.job.findFirst({
where: { bullJobId: job.id as string },
});
if (existingJob) {
// Update lastRun for the scheduled job if this is a timer-triggered job
if (payload.scheduledJobId) {
await prisma.scheduledJob.update({
where: { id: payload.scheduledJobId },
data: { lastRun: new Date() },
}).catch(err => {
console.error(`[JobQueue] Failed to update lastRun for scheduled job ${payload.scheduledJobId}:`, err);
});
}
return { ...payload, jobId: existingJob.id };
}
// Create a new Job record for this scheduled job
const dbJob = await prisma.job.create({
data: {
bullJobId: job.id as string,
requestId: payload.requestId || null,
type: jobType,
status: 'pending',
priority: 0,
payload,
maxAttempts: 3,
},
});
// Update lastRun for the scheduled job if this is a timer-triggered job
if (payload.scheduledJobId) {
await prisma.scheduledJob.update({
where: { id: payload.scheduledJobId },
data: { lastRun: new Date() },
}).catch(err => {
console.error(`[JobQueue] Failed to update lastRun for scheduled job ${payload.scheduledJobId}:`, err);
});
}
return { ...payload, jobId: dbJob.id };
}
/**
* Update job status in database
*/
private async updateJobInDatabase(
bullJobId: string,
status: string,
result?: any,
errorMessage?: string,
stackTrace?: string
): Promise<void> {
try {
const updateData: any = {
status,
updatedAt: new Date(),
};
if (status === 'active') {
updateData.startedAt = new Date();
}
if (status === 'completed' || status === 'failed') {
updateData.completedAt = new Date();
}
if (result) {
updateData.result = result;
}
if (errorMessage) {
updateData.errorMessage = errorMessage;
}
if (stackTrace) {
updateData.stackTrace = stackTrace;
}
await prisma.job.updateMany({
where: { bullJobId },
data: updateData,
});
} catch (error) {
console.error('Failed to update job in database:', error);
}
}
/**
* Add a job to the queue
*/
private async addJob(
type: JobType,
payload: JobPayload,
options?: JobOptions
): Promise<string> {
// First create the database job record
const dbJob = await prisma.job.create({
data: {
bullJobId: null, // Will be updated after Bull job is created
requestId: payload.requestId || null,
type,
status: 'pending',
priority: options?.priority || 0,
payload,
maxAttempts: options?.attempts || 3,
},
});
// Add jobId to payload so processors can access it
const payloadWithJobId = { ...payload, jobId: dbJob.id };
// Create Bull job
const bullJob = await this.queue.add(type, payloadWithJobId, options);
// Update database job with Bull job ID
await prisma.job.update({
where: { id: dbJob.id },
data: { bullJobId: bullJob.id as string },
});
return dbJob.id;
}
/**
* Add search indexers job
*/
async addSearchJob(requestId: string, audiobook: { id: string; title: string; author: string }): Promise<string> {
return await this.addJob(
'search_indexers',
{
requestId,
audiobook,
} as SearchIndexersPayload,
{
priority: 10, // High priority for user-initiated requests
}
);
}
/**
* Add download torrent job
*/
async addDownloadJob(
requestId: string,
audiobook: { id: string; title: string; author: string },
torrent: TorrentResult
): Promise<string> {
return await this.addJob(
'download_torrent',
{
requestId,
audiobook,
torrent,
} as DownloadTorrentPayload,
{
priority: 9, // High priority - download selected torrent
}
);
}
/**
* Add monitor download job
*/
async addMonitorJob(
requestId: string,
downloadHistoryId: string,
downloadClientId: string,
downloadClient: 'qbittorrent' | 'transmission',
delaySeconds: number = 0
): Promise<string> {
return await this.addJob(
'monitor_download',
{
requestId,
downloadHistoryId,
downloadClientId,
downloadClient,
} as MonitorDownloadPayload,
{
priority: 5, // Medium priority
delay: delaySeconds * 1000, // Convert seconds to milliseconds
}
);
}
/**
* Add organize files job
*/
async addOrganizeJob(
requestId: string,
audiobookId: string,
downloadPath: string,
targetPath: string
): Promise<string> {
return await this.addJob(
'organize_files',
{
requestId,
audiobookId,
downloadPath,
targetPath,
} as OrganizeFilesPayload,
{
priority: 8,
}
);
}
/**
* Add Plex scan job
*/
async addPlexScanJob(libraryId: string, partial?: boolean, path?: string): Promise<string> {
return await this.addJob(
'scan_plex',
{
libraryId,
partial,
path,
} as ScanPlexPayload,
{
priority: 7,
}
);
}
/**
* Add Plex match job
*/
async addPlexMatchJob(
requestId: string,
audiobookId: string,
title: string,
author: string
): Promise<string> {
return await this.addJob(
'match_plex',
{
requestId,
audiobookId,
title,
author,
} as MatchPlexPayload,
{
priority: 6,
}
);
}
/**
* Add Plex recently added check job
*/
async addPlexRecentlyAddedJob(scheduledJobId?: string): Promise<string> {
return await this.addJob(
'plex_recently_added_check',
{
scheduledJobId,
} as PlexRecentlyAddedPayload,
{
priority: 8,
}
);
}
/**
* Add RSS feed monitoring job
*/
async addMonitorRssFeedsJob(scheduledJobId?: string): Promise<string> {
return await this.addJob(
'monitor_rss_feeds',
{
scheduledJobId,
} as MonitorRssFeedsPayload,
{
priority: 8,
}
);
}
/**
* Add Audible refresh job
*/
async addAudibleRefreshJob(scheduledJobId?: string): Promise<string> {
return await this.addJob(
'audible_refresh',
{
scheduledJobId,
} as AudibleRefreshPayload,
{
priority: 9,
}
);
}
/**
* Add retry missing torrents job
*/
async addRetryMissingTorrentsJob(scheduledJobId?: string): Promise<string> {
return await this.addJob(
'retry_missing_torrents',
{
scheduledJobId,
} as RetryMissingTorrentsPayload,
{
priority: 7,
}
);
}
/**
* Add retry failed imports job
*/
async addRetryFailedImportsJob(scheduledJobId?: string): Promise<string> {
return await this.addJob(
'retry_failed_imports',
{
scheduledJobId,
} as RetryFailedImportsPayload,
{
priority: 7,
}
);
}
/**
* Add cleanup seeded torrents job
*/
async addCleanupSeededTorrentsJob(scheduledJobId?: string): Promise<string> {
return await this.addJob(
'cleanup_seeded_torrents',
{
scheduledJobId,
} as CleanupSeededTorrentsPayload,
{
priority: 10,
}
);
}
/**
* Get job by ID
*/
async getJob(jobId: string): Promise<any | null> {
return await prisma.job.findUnique({
where: { id: jobId },
});
}
/**
* Get all jobs for a request
*/
async getJobsByRequest(requestId: string): Promise<any[]> {
return await prisma.job.findMany({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
}
/**
* Get queue statistics
*/
async getQueueStats(): Promise<QueueStats> {
const counts = await this.queue.getJobCounts();
return {
waiting: counts.waiting || 0,
active: counts.active || 0,
completed: counts.completed || 0,
failed: counts.failed || 0,
delayed: counts.delayed || 0,
};
}
/**
* Get active jobs
*/
async getActiveJobs(): Promise<any[]> {
const bullJobs = await this.queue.getActive();
const jobIds = bullJobs.map((j) => j.id as string);
return await prisma.job.findMany({
where: {
bullJobId: { in: jobIds },
},
});
}
/**
* Get failed jobs
*/
async getFailedJobs(limit: number = 50): Promise<any[]> {
return await prisma.job.findMany({
where: { status: 'failed' },
orderBy: { updatedAt: 'desc' },
take: limit,
});
}
/**
* Retry a failed job
*/
async retryJob(jobId: string): Promise<void> {
const job = await prisma.job.findUnique({
where: { id: jobId },
});
if (!job) {
throw new Error('Job not found');
}
if (job.bullJobId) {
const bullJob = await this.queue.getJob(job.bullJobId);
if (bullJob) {
await bullJob.retry();
}
}
await prisma.job.update({
where: { id: jobId },
data: {
status: 'pending',
attempts: 0,
errorMessage: null,
stackTrace: null,
},
});
}
/**
* Cancel a job
*/
async cancelJob(jobId: string): Promise<void> {
const job = await prisma.job.findUnique({
where: { id: jobId },
});
if (!job) {
throw new Error('Job not found');
}
if (job.bullJobId) {
const bullJob = await this.queue.getJob(job.bullJobId);
if (bullJob) {
await bullJob.remove();
}
}
await prisma.job.update({
where: { id: jobId },
data: { status: 'cancelled' },
});
}
/**
* Pause the queue
*/
async pauseQueue(): Promise<void> {
await this.queue.pause();
}
/**
* Resume the queue
*/
async resumeQueue(): Promise<void> {
await this.queue.resume();
}
/**
* Close queue connection (for graceful shutdown)
*/
async close(): Promise<void> {
await this.queue.close();
this.redis.disconnect();
}
/**
* Add a repeatable job with cron schedule
*/
async addRepeatableJob(
jobType: string,
payload: JobPayload,
cronExpression: string,
jobId: string
): Promise<void> {
await this.queue.add(jobType, payload, {
repeat: {
cron: cronExpression,
},
jobId,
});
console.log(`[JobQueue] Added repeatable job: ${jobType} with cron ${cronExpression}`);
}
/**
* Remove a repeatable job
*/
async removeRepeatableJob(
jobType: string,
cronExpression: string,
jobId: string
): Promise<void> {
await this.queue.removeRepeatable(jobType, {
cron: cronExpression,
jobId,
});
console.log(`[JobQueue] Removed repeatable job: ${jobType}`);
}
/**
* Get all repeatable jobs
*/
async getRepeatableJobs(): Promise<any[]> {
return await this.queue.getRepeatableJobs();
}
}
// Singleton instance
let jobQueueService: JobQueueService | null = null;
export function getJobQueueService(): JobQueueService {
if (!jobQueueService) {
jobQueueService = new JobQueueService();
}
return jobQueueService;
}
// Graceful shutdown
process.on('SIGTERM', async () => {
if (jobQueueService) {
console.log('Closing job queue...');
await jobQueueService.close();
}
});
@@ -0,0 +1,108 @@
/**
* Component: Audiobookshelf Library Service
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import {
ILibraryService,
LibraryConnectionResult,
ServerInfo,
Library,
LibraryItem,
} from './ILibraryService';
import {
getABSServerInfo,
getABSLibraries,
getABSLibraryItems,
getABSRecentItems,
getABSItem,
searchABSItems,
triggerABSScan,
} from '../audiobookshelf/api';
import { ABSLibraryItem } from '../audiobookshelf/types';
export class AudiobookshelfLibraryService implements ILibraryService {
async testConnection(): Promise<LibraryConnectionResult> {
try {
const serverInfo = await this.getServerInfo();
return {
success: true,
serverInfo,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async getServerInfo(): Promise<ServerInfo> {
const info = await getABSServerInfo();
return {
name: info.name || 'Audiobookshelf',
version: info.version,
identifier: info.name, // ABS doesn't have unique identifier like Plex
};
}
async getLibraries(): Promise<Library[]> {
const libraries = await getABSLibraries();
return libraries
.filter((lib: any) => lib.mediaType === 'book') // Only audiobook libraries
.map((lib: any) => ({
id: lib.id,
name: lib.name,
type: lib.mediaType,
itemCount: lib.stats?.totalItems,
}));
}
async getLibraryItems(libraryId: string): Promise<LibraryItem[]> {
const items = await getABSLibraryItems(libraryId);
return items.map(this.mapABSItemToLibraryItem);
}
async getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]> {
const items = await getABSRecentItems(libraryId, limit);
return items.map(this.mapABSItemToLibraryItem);
}
async getItem(itemId: string): Promise<LibraryItem | null> {
try {
const item = await getABSItem(itemId);
return this.mapABSItemToLibraryItem(item);
} catch {
return null;
}
}
async searchItems(libraryId: string, query: string): Promise<LibraryItem[]> {
const items = await searchABSItems(libraryId, query);
return items.map((result: any) => this.mapABSItemToLibraryItem(result.libraryItem));
}
async triggerLibraryScan(libraryId: string): Promise<void> {
await triggerABSScan(libraryId);
}
private mapABSItemToLibraryItem(item: ABSLibraryItem): LibraryItem {
const metadata = item.media.metadata;
return {
id: item.id,
externalId: item.id, // ABS item ID is the external ID
title: metadata.title,
author: metadata.authorName,
narrator: metadata.narratorName,
description: metadata.description,
coverUrl: item.media.coverPath ? `/api/items/${item.id}/cover` : undefined,
duration: item.media.duration,
asin: metadata.asin,
isbn: metadata.isbn,
year: metadata.publishedYear ? parseInt(metadata.publishedYear) : undefined,
addedAt: new Date(item.addedAt),
updatedAt: new Date(item.updatedAt),
};
}
}
@@ -0,0 +1,58 @@
/**
* Library Service Interface
* Documentation: documentation/features/audiobookshelf-integration.md
*/
export interface ServerInfo {
name: string;
version: string;
platform?: string;
identifier: string; // machineIdentifier (Plex) or serverId (ABS)
}
export interface Library {
id: string;
name: string;
type: string;
itemCount?: number;
}
export interface LibraryItem {
id: string; // ratingKey (Plex) or item id (ABS)
externalId: string; // plexGuid or abs_item_id
title: string;
author: string;
narrator?: string;
description?: string;
coverUrl?: string;
duration?: number; // seconds
asin?: string;
isbn?: string;
year?: number;
addedAt: Date;
updatedAt: Date;
}
export interface LibraryConnectionResult {
success: boolean;
serverInfo?: ServerInfo;
error?: string;
}
export interface ILibraryService {
// Connection
testConnection(): Promise<LibraryConnectionResult>;
getServerInfo(): Promise<ServerInfo>;
// Libraries
getLibraries(): Promise<Library[]>;
getLibraryItems(libraryId: string): Promise<LibraryItem[]>;
getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]>;
// Items
getItem(itemId: string): Promise<LibraryItem | null>;
searchItems(libraryId: string, query: string): Promise<LibraryItem[]>;
// Scanning
triggerLibraryScan(libraryId: string): Promise<void>;
}
@@ -0,0 +1,261 @@
/**
* Plex Library Service Implementation
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import {
ILibraryService,
ServerInfo,
Library,
LibraryItem,
LibraryConnectionResult,
} from './ILibraryService';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getConfigService } from '@/lib/services/config.service';
export class PlexLibraryService implements ILibraryService {
private plexService = getPlexService();
private configService = getConfigService();
/**
* Test connection to Plex server
*/
async testConnection(): Promise<LibraryConnectionResult> {
try {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
return {
success: false,
error: 'Plex server configuration is incomplete',
};
}
const result = await this.plexService.testConnection(
config.serverUrl,
config.authToken
);
if (!result.success) {
return {
success: false,
error: result.message,
};
}
return {
success: true,
serverInfo: result.info ? {
name: result.info.platform || 'Plex Media Server',
version: result.info.version,
platform: result.info.platform,
identifier: result.info.machineIdentifier,
} : undefined,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Connection failed',
};
}
}
/**
* Get Plex server information
*/
async getServerInfo(): Promise<ServerInfo> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
const result = await this.plexService.testConnection(
config.serverUrl,
config.authToken
);
if (!result.success || !result.info) {
throw new Error('Failed to get server information');
}
return {
name: result.info.platform || 'Plex Media Server',
version: result.info.version,
platform: result.info.platform,
identifier: result.info.machineIdentifier,
};
}
/**
* Get all libraries from Plex server
*/
async getLibraries(): Promise<Library[]> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
const libraries = await this.plexService.getLibraries(
config.serverUrl,
config.authToken
);
return libraries.map(lib => ({
id: lib.id,
name: lib.title,
type: lib.type,
itemCount: lib.itemCount,
}));
}
/**
* Get all items from a library
*/
async getLibraryItems(libraryId: string): Promise<LibraryItem[]> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
const items = await this.plexService.getLibraryContent(
config.serverUrl,
config.authToken,
libraryId
);
return items.map(item => this.mapPlexItemToLibraryItem(item));
}
/**
* Get recently added items from a library
*/
async getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
const items = await this.plexService.getRecentlyAdded(
config.serverUrl,
config.authToken,
libraryId,
limit
);
return items.map(item => this.mapPlexItemToLibraryItem(item));
}
/**
* Get a single item by its rating key
*/
async getItem(itemId: string): Promise<LibraryItem | null> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
try {
const metadata = await this.plexService.getItemMetadata(
config.serverUrl,
config.authToken,
itemId
);
if (!metadata) {
return null;
}
// Note: getItemMetadata only returns partial data (userRating)
// For full item data, we would need to fetch from library content
// This is a simplified implementation
return null;
} catch (error) {
console.error('[PlexLibraryService] Failed to get item:', error);
return null;
}
}
/**
* Search library for items matching query
*/
async searchItems(libraryId: string, query: string): Promise<LibraryItem[]> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
const items = await this.plexService.searchLibrary(
config.serverUrl,
config.authToken,
libraryId,
query
);
return items.map(item => this.mapPlexItemToLibraryItem(item));
}
/**
* Trigger library scan
*/
async triggerLibraryScan(libraryId: string): Promise<void> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
await this.plexService.scanLibrary(
config.serverUrl,
config.authToken,
libraryId
);
}
/**
* Map Plex audiobook to generic LibraryItem interface
*/
private mapPlexItemToLibraryItem(plexItem: any): LibraryItem {
// Extract ASIN from plexGuid if present
const asin = this.extractAsinFromGuid(plexItem.guid);
return {
id: plexItem.ratingKey,
externalId: plexItem.guid,
title: plexItem.title,
author: plexItem.author || '',
narrator: plexItem.narrator,
description: plexItem.summary,
coverUrl: plexItem.thumb,
duration: plexItem.duration ? Math.floor(plexItem.duration / 1000) : undefined, // Convert ms to seconds
asin,
isbn: undefined, // Plex doesn't typically store ISBN
year: plexItem.year,
addedAt: new Date(plexItem.addedAt * 1000), // Convert Unix timestamp to Date
updatedAt: new Date(plexItem.updatedAt * 1000),
};
}
/**
* Extract ASIN from Plex GUID
* Plex GUIDs can contain ASIN in formats like:
* - com.plexapp.agents.audible://B00ABC123?lang=en
* - plex://album/5d07bcfe403c64002036d1af
*/
private extractAsinFromGuid(guid: string): string | undefined {
if (!guid) return undefined;
// Match ASIN pattern in Audible agent GUIDs
const asinMatch = guid.match(/audible:\/\/([A-Z0-9]{10})/i);
if (asinMatch && asinMatch[1]) {
return asinMatch[1];
}
return undefined;
}
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Library Service Factory
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { ILibraryService } from './ILibraryService';
import { PlexLibraryService } from './PlexLibraryService';
import { AudiobookshelfLibraryService } from './AudiobookshelfLibraryService';
import { getConfigService } from '@/lib/services/config.service';
let cachedService: ILibraryService | null = null;
let cachedMode: 'plex' | 'audiobookshelf' | null = null;
/**
* Get the appropriate library service based on backend mode
* Returns cached instance if mode hasn't changed
*/
export async function getLibraryService(): Promise<ILibraryService> {
const configService = getConfigService();
const mode = await configService.getBackendMode();
// Return cached instance if mode hasn't changed
if (cachedService && cachedMode === mode) {
return cachedService;
}
// Create new instance based on mode
if (mode === 'audiobookshelf') {
cachedService = new AudiobookshelfLibraryService();
} else {
cachedService = new PlexLibraryService();
}
cachedMode = mode;
return cachedService;
}
/**
* Clear cached service instance (useful for testing or mode changes)
*/
export function clearLibraryServiceCache(): void {
cachedService = null;
cachedMode = null;
}
// Re-export types
export * from './ILibraryService';
export { PlexLibraryService } from './PlexLibraryService';
export { AudiobookshelfLibraryService } from './AudiobookshelfLibraryService';
+588
View File
@@ -0,0 +1,588 @@
/**
* Component: Recurring Jobs Scheduler Service
* Documentation: documentation/backend/services/scheduler.md
*/
import { getJobQueueService, ScanPlexPayload } from './job-queue.service';
import { prisma } from '../db';
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds';
export interface ScheduledJob {
id: string;
name: string;
type: string; // Changed from ScheduledJobType to string for Prisma compatibility
schedule: string; // Cron expression
enabled: boolean;
payload: any;
createdAt: Date;
updatedAt: Date;
lastRun: Date | null;
lastRunJobId: string | null; // Bull queue job ID of most recent execution
nextRun: Date | null;
}
export interface CreateScheduledJobDto {
name: string;
type: ScheduledJobType;
schedule: string;
enabled?: boolean;
payload?: any;
}
export interface UpdateScheduledJobDto {
name?: string;
schedule?: string;
enabled?: boolean;
payload?: any;
}
export class SchedulerService {
private jobQueue = getJobQueueService();
/**
* Initialize scheduler and set up default jobs if they don't exist
*/
async start(): Promise<void> {
console.log('[Scheduler] Initializing scheduler service...');
// Create default jobs if they don't exist
await this.ensureDefaultJobs();
// Load and schedule all enabled jobs
await this.scheduleAllJobs();
// Check and trigger overdue jobs
await this.triggerOverdueJobs();
console.log('[Scheduler] Scheduler service started');
}
/**
* Ensure default jobs exist in database
*/
private async ensureDefaultJobs(): Promise<void> {
const defaults = [
{
name: 'Library Scan',
type: 'plex_library_scan' as ScheduledJobType,
schedule: '0 */6 * * *', // Every 6 hours
enabled: false, // Start disabled until first setup is complete
payload: {},
},
{
name: 'Recently Added Check',
type: 'plex_recently_added_check' as ScheduledJobType,
schedule: '*/5 * * * *', // Every 5 minutes
enabled: true, // Enable by default for quick detection
payload: {},
},
{
name: 'Audible Data Refresh',
type: 'audible_refresh' as ScheduledJobType,
schedule: '0 0 * * *', // Daily at midnight
enabled: false, // Start disabled until first setup is complete
payload: {},
},
{
name: 'Retry Missing Torrents Search',
type: 'retry_missing_torrents' as ScheduledJobType,
schedule: '0 0 * * *', // Daily at midnight
enabled: true, // Enable by default
payload: {},
},
{
name: 'Retry Failed Imports',
type: 'retry_failed_imports' as ScheduledJobType,
schedule: '0 */6 * * *', // Every 6 hours
enabled: true, // Enable by default
payload: {},
},
{
name: 'Cleanup Seeded Torrents',
type: 'cleanup_seeded_torrents' as ScheduledJobType,
schedule: '*/30 * * * *', // Every 30 minutes
enabled: true, // Enable by default
payload: {},
},
{
name: 'Monitor RSS Feeds',
type: 'monitor_rss_feeds' as ScheduledJobType,
schedule: '*/15 * * * *', // Every 15 minutes
enabled: true, // Enable by default
payload: {},
},
];
for (const defaultJob of defaults) {
const existing = await prisma.scheduledJob.findFirst({
where: { type: defaultJob.type },
});
if (!existing) {
await prisma.scheduledJob.create({
data: defaultJob,
});
console.log(`[Scheduler] Created default job: ${defaultJob.name} (disabled by default)`);
}
}
}
/**
* Schedule all enabled jobs
*/
private async scheduleAllJobs(): Promise<void> {
const jobs = await prisma.scheduledJob.findMany({
where: { enabled: true },
});
for (const job of jobs) {
await this.scheduleJob(job);
}
console.log(`[Scheduler] Scheduled ${jobs.length} jobs`);
}
/**
* Schedule a single job using Bull's repeatable jobs
*/
private async scheduleJob(job: any): Promise<void> {
try {
await this.jobQueue.addRepeatableJob(
job.type,
{ scheduledJobId: job.id },
job.schedule,
`scheduled-${job.id}`
);
console.log(`[Scheduler] Job scheduled: ${job.name} (${job.schedule})`);
} catch (error) {
console.error(`[Scheduler] Failed to schedule job ${job.name}:`, error);
throw error;
}
}
/**
* Unschedule a job by removing it from Bull's repeatable jobs
*/
private async unscheduleJob(job: any): Promise<void> {
try {
await this.jobQueue.removeRepeatableJob(
job.type,
job.schedule,
`scheduled-${job.id}`
);
console.log(`[Scheduler] Job unscheduled: ${job.name}`);
} catch (error) {
console.error(`[Scheduler] Failed to unschedule job ${job.name}:`, error);
// Don't throw - job might not exist in Bull yet
}
}
/**
* Get all scheduled jobs
*/
async getScheduledJobs(): Promise<ScheduledJob[]> {
return await prisma.scheduledJob.findMany({
orderBy: { name: 'asc' },
});
}
/**
* Get single scheduled job by ID
*/
async getScheduledJob(id: string): Promise<ScheduledJob | null> {
return await prisma.scheduledJob.findUnique({
where: { id },
});
}
/**
* Create new scheduled job
*/
async createScheduledJob(dto: CreateScheduledJobDto): Promise<ScheduledJob> {
// Validate cron expression
this.validateCronExpression(dto.schedule);
const job = await prisma.scheduledJob.create({
data: {
name: dto.name,
type: dto.type,
schedule: dto.schedule,
enabled: dto.enabled ?? true,
payload: dto.payload || {},
},
});
if (job.enabled) {
await this.scheduleJob(job);
}
return job;
}
/**
* Update scheduled job
*/
async updateScheduledJob(
id: string,
dto: UpdateScheduledJobDto
): Promise<ScheduledJob> {
if (dto.schedule) {
this.validateCronExpression(dto.schedule);
}
// Get the old job to unschedule it
const oldJob = await prisma.scheduledJob.findUnique({
where: { id },
});
if (oldJob && oldJob.enabled) {
await this.unscheduleJob(oldJob);
}
const job = await prisma.scheduledJob.update({
where: { id },
data: {
...(dto.name && { name: dto.name }),
...(dto.schedule && { schedule: dto.schedule }),
...(dto.enabled !== undefined && { enabled: dto.enabled }),
...(dto.payload && { payload: dto.payload }),
updatedAt: new Date(),
},
});
// Reschedule if enabled
if (job.enabled) {
await this.scheduleJob(job);
}
return job;
}
/**
* Delete scheduled job
*/
async deleteScheduledJob(id: string): Promise<void> {
const job = await prisma.scheduledJob.findUnique({
where: { id },
});
if (job && job.enabled) {
await this.unscheduleJob(job);
}
await prisma.scheduledJob.delete({
where: { id },
});
}
/**
* Manually trigger a job to run immediately
*/
async triggerJobNow(id: string): Promise<string> {
const job = await this.getScheduledJob(id);
if (!job) {
throw new Error('Scheduled job not found');
}
// Trigger the appropriate job type
let bullJobId: string;
switch (job.type) {
case 'plex_library_scan':
bullJobId = await this.triggerPlexScan(job);
break;
case 'plex_recently_added_check':
bullJobId = await this.triggerPlexRecentlyAddedCheck(job);
break;
case 'audible_refresh':
bullJobId = await this.triggerAudibleRefresh(job);
break;
case 'retry_missing_torrents':
bullJobId = await this.triggerRetryMissingTorrents(job);
break;
case 'retry_failed_imports':
bullJobId = await this.triggerRetryFailedImports(job);
break;
case 'cleanup_seeded_torrents':
bullJobId = await this.triggerCleanupSeededTorrents(job);
break;
case 'monitor_rss_feeds':
bullJobId = await this.triggerMonitorRssFeeds(job);
break;
default:
throw new Error(`Unknown job type: ${job.type}`);
}
// Update last run time and store Bull job ID
await prisma.scheduledJob.update({
where: { id },
data: {
lastRun: new Date(),
lastRunJobId: bullJobId,
},
});
console.log(`[Scheduler] Job "${job.name}" triggered with Bull job ID: ${bullJobId}`);
return bullJobId;
}
/**
* Trigger library scan (Plex or Audiobookshelf based on backend mode)
*/
private async triggerPlexScan(job: any): Promise<string> {
const { getConfigService } = await import('./config.service');
const configService = getConfigService();
// Check backend mode
const backendMode = await configService.getBackendMode();
// Validate configuration based on backend mode
let libraryId: string | null = null;
const missingFields: string[] = [];
if (backendMode === 'audiobookshelf') {
const absConfig = await configService.getMany([
'audiobookshelf.server_url',
'audiobookshelf.api_token',
'audiobookshelf.library_id',
]);
if (!absConfig['audiobookshelf.server_url']) {
missingFields.push('Audiobookshelf server URL');
}
if (!absConfig['audiobookshelf.api_token']) {
missingFields.push('Audiobookshelf API token');
}
if (!absConfig['audiobookshelf.library_id']) {
missingFields.push('Audiobookshelf library ID');
}
if (missingFields.length > 0) {
const errorMsg = `Audiobookshelf is not configured. Missing: ${missingFields.join(', ')}. Please configure Audiobookshelf in the admin settings before running library scans.`;
console.error('[ScanLibrary] Error:', errorMsg);
throw new Error(errorMsg);
}
libraryId = job.payload?.libraryId || absConfig['audiobookshelf.library_id'];
} else {
const plexConfig = await configService.getMany([
'plex_url',
'plex_token',
'plex_audiobook_library_id',
]);
if (!plexConfig.plex_url) {
missingFields.push('Plex server URL');
}
if (!plexConfig.plex_token) {
missingFields.push('Plex auth token');
}
if (!plexConfig.plex_audiobook_library_id) {
missingFields.push('Plex audiobook library ID');
}
if (missingFields.length > 0) {
const errorMsg = `Plex is not configured. Missing: ${missingFields.join(', ')}. Please configure Plex in the admin settings before running library scans.`;
console.error('[ScanLibrary] Error:', errorMsg);
throw new Error(errorMsg);
}
libraryId = job.payload?.libraryId || plexConfig.plex_audiobook_library_id;
}
console.log(`[ScanLibrary] Triggering ${backendMode} library scan for library: ${libraryId}`);
return await this.jobQueue.addPlexScanJob(
libraryId || '',
job.payload?.partial,
job.payload?.path
);
}
/**
* Trigger Plex recently added check (lightweight polling)
*/
private async triggerPlexRecentlyAddedCheck(job: any): Promise<string> {
return await this.jobQueue.addPlexRecentlyAddedJob(job.id);
}
/**
* Trigger Audible data refresh
* Populates audible_cache table with popular/new-release audiobooks
* Caches cover thumbnails locally
* NO matching logic - that happens at query time
*/
private async triggerAudibleRefresh(job: any): Promise<string> {
return await this.jobQueue.addAudibleRefreshJob(job.id);
}
/**
* Enable a scheduled job
*/
async enableJob(id: string): Promise<void> {
await this.updateScheduledJob(id, { enabled: true });
}
/**
* Disable a scheduled job
*/
async disableJob(id: string): Promise<void> {
await this.updateScheduledJob(id, { enabled: false });
}
/**
* Check for overdue jobs and trigger them
*/
private async triggerOverdueJobs(): Promise<void> {
console.log('[Scheduler] Checking for overdue jobs...');
const jobs = await prisma.scheduledJob.findMany({
where: { enabled: true },
});
for (const job of jobs) {
try {
if (this.isJobOverdue(job)) {
console.log(`[Scheduler] Job "${job.name}" is overdue, triggering now...`);
await this.triggerJobNow(job.id);
}
} catch (error) {
console.error(`[Scheduler] Failed to trigger overdue job "${job.name}":`, error);
}
}
}
/**
* Check if a job is overdue based on its schedule and last run time
*/
private isJobOverdue(job: any): boolean {
// If never run, consider it overdue
if (!job.lastRun) {
return true;
}
// Parse cron expression to get interval in milliseconds
const intervalMs = this.getIntervalFromCron(job.schedule);
if (!intervalMs) {
console.warn(`[Scheduler] Could not parse interval for job "${job.name}", skipping`);
return false;
}
// Calculate time since last run
const timeSinceLastRun = Date.now() - new Date(job.lastRun).getTime();
// Job is overdue if time since last run exceeds the interval
return timeSinceLastRun >= intervalMs;
}
/**
* Get interval in milliseconds from cron expression
* Supports common patterns like "0 * * * *" (hourly), "0 *\/6 * * *" (every 6 hours), etc.
*/
private getIntervalFromCron(cronExpression: string): number | null {
const parts = cronExpression.split(' ');
if (parts.length < 5) {
return null;
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Every N hours: "0 */N * * *"
const hourMatch = hour.match(/^\*\/(\d+)$/);
if (minute === '0' && hourMatch && dayOfMonth === '*' && month === '*') {
const hours = parseInt(hourMatch[1], 10);
return hours * 60 * 60 * 1000;
}
// Hourly: "0 * * * *"
if (minute === '0' && hour === '*' && dayOfMonth === '*' && month === '*') {
return 60 * 60 * 1000; // 1 hour
}
// Every N minutes: "*/N * * * *"
const minuteMatch = minute.match(/^\*\/(\d+)$/);
if (minuteMatch && hour === '*' && dayOfMonth === '*' && month === '*') {
const minutes = parseInt(minuteMatch[1], 10);
return minutes * 60 * 1000;
}
// Weekly: "M H * * D" where D is day of week (0-7)
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
const dayNum = parseInt(dayOfWeek, 10);
if (!isNaN(hourNum) && !isNaN(minuteNum) && !isNaN(dayNum)) {
return 7 * 24 * 60 * 60 * 1000; // 7 days
}
}
// Daily at specific time: "M H * * *" where H is 0-23, M is 0-59
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
const hourNum = parseInt(hour, 10);
const minuteNum = parseInt(minute, 10);
if (!isNaN(hourNum) && !isNaN(minuteNum) && hourNum >= 0 && hourNum <= 23 && minuteNum >= 0 && minuteNum <= 59) {
return 24 * 60 * 60 * 1000; // 24 hours
}
}
// For other patterns, return a conservative default (24 hours)
console.warn(`[Scheduler] Unknown cron pattern "${cronExpression}", defaulting to 24 hours`);
return 24 * 60 * 60 * 1000;
}
/**
* Validate cron expression format
*/
private validateCronExpression(expression: string): void {
// Basic validation - check format
const parts = expression.split(' ');
if (parts.length < 5 || parts.length > 6) {
throw new Error('Invalid cron expression format');
}
// Additional validation could be added here
// For production, use a library like 'cron-parser'
}
/**
* Trigger retry for requests awaiting torrent search
*/
private async triggerRetryMissingTorrents(job: any): Promise<string> {
return await this.jobQueue.addRetryMissingTorrentsJob(job.id);
}
/**
* Trigger retry for requests awaiting import
*/
private async triggerRetryFailedImports(job: any): Promise<string> {
return await this.jobQueue.addRetryFailedImportsJob(job.id);
}
/**
* Trigger RSS feed monitoring
*/
private async triggerMonitorRssFeeds(job: any): Promise<string> {
return await this.jobQueue.addMonitorRssFeedsJob(job.id);
}
/**
* Trigger cleanup of torrents that have met seeding requirements
*/
private async triggerCleanupSeededTorrents(job: any): Promise<string> {
return await this.jobQueue.addCleanupSeededTorrentsJob(job.id);
}
}
// Singleton instance
let schedulerService: SchedulerService | null = null;
export function getSchedulerService(): SchedulerService {
if (!schedulerService) {
schedulerService = new SchedulerService();
}
return schedulerService;
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Component: Thumbnail Cache Service
* Documentation: documentation/integrations/audible.md
*/
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import axios from 'axios';
const CACHE_DIR = '/app/cache/thumbnails';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB max per image
const TIMEOUT_MS = 10000; // 10 second timeout for downloads
export class ThumbnailCacheService {
/**
* Ensure cache directory exists
*/
private async ensureCacheDir(): Promise<void> {
try {
await fs.mkdir(CACHE_DIR, { recursive: true });
} catch (error) {
console.error('[ThumbnailCache] Failed to create cache directory:', error);
throw error;
}
}
/**
* Generate a unique filename for a cached thumbnail
* @param asin - Audible ASIN
* @param url - Original URL (used for extension)
* @returns Filename for cached thumbnail
*/
private generateFilename(asin: string, url: string): string {
// Extract file extension from URL (default to .jpg if not found)
const urlPath = new URL(url).pathname;
const ext = path.extname(urlPath) || '.jpg';
// Use ASIN as filename for easy lookup and cleanup
return `${asin}${ext}`;
}
/**
* Download and cache a thumbnail from a URL
* @param asin - Audible ASIN
* @param url - URL of the thumbnail to download
* @returns Local file path of cached thumbnail, or null if failed
*/
async cacheThumbnail(asin: string, url: string): Promise<string | null> {
if (!url || !asin) {
return null;
}
try {
await this.ensureCacheDir();
const filename = this.generateFilename(asin, url);
const filePath = path.join(CACHE_DIR, filename);
// Check if file already exists
try {
await fs.access(filePath);
// File exists, return path
return filePath;
} catch {
// File doesn't exist, proceed with download
}
// Download image
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: TIMEOUT_MS,
maxContentLength: MAX_FILE_SIZE,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
});
// Verify content type is an image
const contentType = response.headers['content-type'];
if (!contentType || !contentType.startsWith('image/')) {
console.warn(`[ThumbnailCache] Invalid content type for ${asin}: ${contentType}`);
return null;
}
// Write to file
await fs.writeFile(filePath, Buffer.from(response.data));
console.log(`[ThumbnailCache] Cached thumbnail for ${asin}: ${filePath}`);
return filePath;
} catch (error) {
// Log error but don't throw - we'll fall back to the original URL
console.error(`[ThumbnailCache] Failed to cache thumbnail for ${asin}:`, error);
return null;
}
}
/**
* Delete a cached thumbnail
* @param asin - Audible ASIN
*/
async deleteThumbnail(asin: string): Promise<void> {
try {
// Find all files matching this ASIN (with any extension)
const files = await fs.readdir(CACHE_DIR);
const asinFiles = files.filter(f => f.startsWith(asin + '.'));
for (const file of asinFiles) {
const filePath = path.join(CACHE_DIR, file);
await fs.unlink(filePath);
console.log(`[ThumbnailCache] Deleted thumbnail: ${filePath}`);
}
} catch (error) {
console.error(`[ThumbnailCache] Failed to delete thumbnail for ${asin}:`, error);
}
}
/**
* Clean up thumbnails that are no longer referenced in the database
* @param activeAsins - Set of ASINs that should be kept
*/
async cleanupUnusedThumbnails(activeAsins: Set<string>): Promise<number> {
try {
await this.ensureCacheDir();
const files = await fs.readdir(CACHE_DIR);
let deletedCount = 0;
for (const file of files) {
// Extract ASIN from filename (remove extension)
const asin = path.parse(file).name;
// If ASIN is not in active set, delete the file
if (!activeAsins.has(asin)) {
const filePath = path.join(CACHE_DIR, file);
await fs.unlink(filePath);
deletedCount++;
console.log(`[ThumbnailCache] Deleted unused thumbnail: ${file}`);
}
}
console.log(`[ThumbnailCache] Cleanup complete: ${deletedCount} thumbnails deleted`);
return deletedCount;
} catch (error) {
console.error('[ThumbnailCache] Failed to cleanup thumbnails:', error);
return 0;
}
}
/**
* Get the cached path for a thumbnail
* @param cachedPath - Path from database
* @returns Path relative to app root for serving
*/
getCachedPath(cachedPath: string | null): string | null {
if (!cachedPath) {
return null;
}
// Return path relative to /app for serving
return cachedPath.replace('/app/', '/');
}
/**
* Get cache directory (for mounting in Docker)
*/
getCacheDirectory(): string {
return CACHE_DIR;
}
}
// Singleton instance
let thumbnailCacheService: ThumbnailCacheService | null = null;
export function getThumbnailCacheService(): ThumbnailCacheService {
if (!thumbnailCacheService) {
thumbnailCacheService = new ThumbnailCacheService();
}
return thumbnailCacheService;
}