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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user