mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add reported-issues, Goodreads sync & notifs
Introduce user-reported-issues and Goodreads shelf sync features and wire them into notifications. Adds Prisma migrations and schema changes (ReportedIssue, GoodreadsShelf, GoodreadsBookMapping), API endpoints for reporting (POST /audiobooks/[asin]/report-issue) and admin management (list, resolve/dismiss, replace), and an admin UI section to view/dismiss/replace reported issues. Adds a new notification event (issue_reported) with updates to notification schemas, docs and provider handling, plus a notification-events constants file. Refactors request creation to use createRequestForUser service, adds a Goodreads sync processor/service/hooks/UI modals, a scrape-resilience util, and related tests and minor integration updates.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Component: Notification Event Constants
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*
|
||||
* Single source of truth for all notification event types and metadata.
|
||||
* Add new events here — all providers, API schemas, and UI labels derive from this.
|
||||
*/
|
||||
|
||||
export type NotificationSeverity = 'info' | 'success' | 'error' | 'warning';
|
||||
export type NotificationPriority = 'normal' | 'high';
|
||||
|
||||
/**
|
||||
* Central registry of notification events.
|
||||
*
|
||||
* Each entry defines:
|
||||
* - `label`: Human-readable name shown in the UI
|
||||
* - `title`: Title used in notification messages
|
||||
* - `emoji`: Emoji prefix for notification titles
|
||||
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
|
||||
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
|
||||
*/
|
||||
export const NOTIFICATION_EVENTS = {
|
||||
request_pending_approval: {
|
||||
label: 'Request Pending Approval',
|
||||
title: 'New Request Pending Approval',
|
||||
emoji: '\u{1F4EC}',
|
||||
severity: 'info' as const,
|
||||
priority: 'normal' as const,
|
||||
},
|
||||
request_approved: {
|
||||
label: 'Request Approved',
|
||||
title: 'Request Approved',
|
||||
emoji: '\u2705',
|
||||
severity: 'success' as const,
|
||||
priority: 'normal' as const,
|
||||
},
|
||||
request_available: {
|
||||
label: 'Audiobook Available',
|
||||
title: 'Audiobook Available',
|
||||
emoji: '\u{1F389}',
|
||||
severity: 'success' as const,
|
||||
priority: 'high' as const,
|
||||
},
|
||||
request_error: {
|
||||
label: 'Request Error',
|
||||
title: 'Request Error',
|
||||
emoji: '\u274C',
|
||||
severity: 'error' as const,
|
||||
priority: 'high' as const,
|
||||
},
|
||||
issue_reported: {
|
||||
label: 'Issue Reported',
|
||||
title: 'Issue Reported',
|
||||
emoji: '\u{1F6A9}',
|
||||
severity: 'warning' as const,
|
||||
priority: 'high' as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Union type of all valid notification event keys */
|
||||
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
|
||||
|
||||
/** Ordered array of all notification event keys (for Zod schemas, iteration) */
|
||||
export const NOTIFICATION_EVENT_KEYS = Object.keys(NOTIFICATION_EVENTS) as [NotificationEvent, ...NotificationEvent[]];
|
||||
|
||||
/** Metadata shape for a single notification event */
|
||||
export type NotificationEventMeta = (typeof NOTIFICATION_EVENTS)[NotificationEvent];
|
||||
|
||||
/** Helper: get event metadata by key */
|
||||
export function getEventMeta(event: NotificationEvent) {
|
||||
return NOTIFICATION_EVENTS[event];
|
||||
}
|
||||
|
||||
/** Helper: get the human-readable label for an event */
|
||||
export function getEventLabel(event: NotificationEvent): string {
|
||||
return NOTIFICATION_EVENTS[event].label;
|
||||
}
|
||||
|
||||
/** Record mapping all event keys to their labels (for UI dropdowns, etc.) */
|
||||
export const EVENT_LABELS: Record<NotificationEvent, string> = Object.fromEntries(
|
||||
Object.entries(NOTIFICATION_EVENTS).map(([key, meta]) => [key, meta.label])
|
||||
) as Record<NotificationEvent, string>;
|
||||
@@ -26,6 +26,7 @@ export interface Audiobook {
|
||||
requestStatus?: string | null; // Status of request (if any)
|
||||
requestId?: string | null; // ID of request (if any)
|
||||
requestedByUsername?: string | null; // Username who requested (only if not current user)
|
||||
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
||||
}
|
||||
|
||||
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Component: Goodreads Shelves Hook
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
export interface ShelfBook {
|
||||
coverUrl: string;
|
||||
asin: string | null;
|
||||
title: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
export interface GoodreadsShelf {
|
||||
id: string;
|
||||
name: string;
|
||||
rssUrl: string;
|
||||
lastSyncAt: string | null;
|
||||
createdAt: string;
|
||||
bookCount: number | null;
|
||||
books: ShelfBook[];
|
||||
}
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
fetchWithAuth(url).then((res) => res.json());
|
||||
|
||||
export function useGoodreadsShelves() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken ? '/api/user/goodreads-shelves' : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(
|
||||
endpoint,
|
||||
fetcher,
|
||||
{ refreshInterval: 30000 }
|
||||
);
|
||||
|
||||
return {
|
||||
shelves: (data?.shelves || []) as GoodreadsShelf[],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAddGoodreadsShelf() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const addShelf = async (rssUrl: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/user/goodreads-shelves', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rssUrl }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to add shelf');
|
||||
}
|
||||
|
||||
// Revalidate shelves list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
|
||||
|
||||
return data.shelf as GoodreadsShelf;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { addShelf, isLoading, error };
|
||||
}
|
||||
|
||||
export function useDeleteGoodreadsShelf() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const deleteShelf = async (shelfId: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/user/goodreads-shelves/${shelfId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to remove shelf');
|
||||
}
|
||||
|
||||
// Revalidate shelves list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { deleteShelf, isLoading, error };
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Component: Reported Issues Hooks
|
||||
* Documentation: documentation/backend/services/reported-issues.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
fetchWithAuth(url).then((res) => res.json());
|
||||
|
||||
/**
|
||||
* Hook for reporting an issue with an audiobook (user action)
|
||||
*/
|
||||
export function useReportIssue() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reportIssue = async (
|
||||
asin: string,
|
||||
reason: string,
|
||||
metadata?: { title?: string; author?: string; coverArtUrl?: string }
|
||||
) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/audiobooks/${asin}/report-issue`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason, ...metadata }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to report issue');
|
||||
}
|
||||
|
||||
// Revalidate audiobook lists to show issue indicator
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
|
||||
|
||||
return data.issue;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { reportIssue, isLoading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching open reported issues (admin dashboard)
|
||||
*/
|
||||
export function useAdminReportedIssues() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken ? '/api/admin/reported-issues' : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
|
||||
refreshInterval: 10000,
|
||||
});
|
||||
|
||||
return {
|
||||
issues: data?.issues || [],
|
||||
count: data?.count || 0,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for dismissing a reported issue (admin action)
|
||||
*/
|
||||
export function useDismissIssue() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const dismissIssue = async (issueId: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/admin/reported-issues/${issueId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'dismiss' }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to dismiss issue');
|
||||
}
|
||||
|
||||
// Revalidate issues list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
|
||||
|
||||
return data.issue;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { dismissIssue, isLoading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for replacing audiobook content via reported issue (admin action)
|
||||
*/
|
||||
export function useReplaceWithTorrent() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const replaceWithTorrent = async (issueId: string, torrent: any) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/admin/reported-issues/${issueId}/replace`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ torrent }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to replace audiobook');
|
||||
}
|
||||
|
||||
// Revalidate issues list and audiobook lists
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
|
||||
|
||||
return data.request;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { replaceWithTorrent, isLoading, error };
|
||||
}
|
||||
@@ -8,10 +8,24 @@ import * as cheerio from 'cheerio';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible';
|
||||
import {
|
||||
pickUserAgent,
|
||||
getBrowserHeaders,
|
||||
jitteredBackoff,
|
||||
AdaptivePacer,
|
||||
FetchResultMeta,
|
||||
} from '../utils/scrape-resilience';
|
||||
|
||||
// Module-level logger
|
||||
const logger = RMABLogger.create('Audible');
|
||||
|
||||
/**
|
||||
* Audible supports a pageSize query parameter (default ~20).
|
||||
* Using 50 significantly reduces the number of HTTP requests needed
|
||||
* for bulk operations like popular/new-release refreshes and search.
|
||||
*/
|
||||
const AUDIBLE_PAGE_SIZE = 50;
|
||||
|
||||
export interface AudibleAudiobook {
|
||||
asin: string;
|
||||
title: string;
|
||||
@@ -40,6 +54,8 @@ export class AudibleService {
|
||||
private baseUrl: string = 'https://www.audible.com';
|
||||
private region: AudibleRegion = 'us';
|
||||
private initialized: boolean = false;
|
||||
private sessionUserAgent: string = '';
|
||||
private pacer: AdaptivePacer = new AdaptivePacer();
|
||||
|
||||
constructor() {
|
||||
// Client will be created lazily on first use
|
||||
@@ -77,18 +93,16 @@ export class AudibleService {
|
||||
const configService = getConfigService();
|
||||
this.region = await configService.getAudibleRegion();
|
||||
this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl;
|
||||
this.sessionUserAgent = pickUserAgent();
|
||||
this.pacer.reset();
|
||||
|
||||
logger.info(`Initializing Audible service with region: ${this.region} (${this.baseUrl})`);
|
||||
|
||||
// Create axios client with region-specific base URL
|
||||
// Create axios client with region-specific base URL and realistic browser headers
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
},
|
||||
headers: getBrowserHeaders(this.sessionUserAgent),
|
||||
params: {
|
||||
ipRedirectOverride: 'true', // Prevent IP-based region redirects
|
||||
language: 'english', // Force English locale (prevents IP-based language serving for non-English IPs)
|
||||
@@ -101,14 +115,12 @@ export class AudibleService {
|
||||
// Fallback to default region
|
||||
this.region = DEFAULT_AUDIBLE_REGION;
|
||||
this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl;
|
||||
this.sessionUserAgent = pickUserAgent();
|
||||
this.pacer.reset();
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
},
|
||||
headers: getBrowserHeaders(this.sessionUserAgent),
|
||||
params: {
|
||||
ipRedirectOverride: 'true',
|
||||
language: 'english',
|
||||
@@ -119,24 +131,29 @@ export class AudibleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with retry logic and exponential backoff
|
||||
* Retries on network errors and rate limiting (503, 429)
|
||||
* Fetch with retry logic and jittered exponential backoff.
|
||||
* Returns the axios response plus metadata about retries encountered.
|
||||
*/
|
||||
private async fetchWithRetry(
|
||||
url: string,
|
||||
config: any = {},
|
||||
maxRetries: number = 5
|
||||
): Promise<any> {
|
||||
): Promise<{ data: any; meta: FetchResultMeta }> {
|
||||
let lastError: Error | null = null;
|
||||
let retriesUsed = 0;
|
||||
let encountered503 = false;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await this.client.get(url, config);
|
||||
const response = await this.client.get(url, config);
|
||||
return { data: response, meta: { retriesUsed, encountered503 } };
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
const status = error.response?.status;
|
||||
const isRetryable = !status || status === 503 || status === 429 || status >= 500;
|
||||
|
||||
if (status === 503) encountered503 = true;
|
||||
|
||||
// Don't retry on 404, 403, etc.
|
||||
if (!isRetryable) {
|
||||
throw error;
|
||||
@@ -147,8 +164,10 @@ export class AudibleService {
|
||||
break;
|
||||
}
|
||||
|
||||
// Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s, 8s...)
|
||||
const backoffMs = Math.pow(2, attempt) * 1000;
|
||||
retriesUsed++;
|
||||
|
||||
// Jittered exponential backoff instead of predictable doubling
|
||||
const backoffMs = jitteredBackoff(attempt);
|
||||
logger.info(` Request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`);
|
||||
|
||||
await this.delay(backoffMs);
|
||||
@@ -210,15 +229,18 @@ export class AudibleService {
|
||||
|
||||
const audiobooks: AudibleAudiobook[] = [];
|
||||
let page = 1;
|
||||
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page
|
||||
const maxPages = Math.ceil(limit / AUDIBLE_PAGE_SIZE);
|
||||
|
||||
this.pacer.reset();
|
||||
|
||||
while (audiobooks.length < limit && page <= maxPages) {
|
||||
try {
|
||||
logger.info(` Fetching page ${page}/${maxPages}...`);
|
||||
|
||||
const response = await this.fetchWithRetry('/adblbestsellers', {
|
||||
const { data: response, meta } = await this.fetchWithRetry('/adblbestsellers', {
|
||||
params: {
|
||||
ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects
|
||||
pageSize: AUDIBLE_PAGE_SIZE,
|
||||
...(page > 1 ? { page } : {}),
|
||||
},
|
||||
});
|
||||
@@ -269,17 +291,17 @@ export class AudibleService {
|
||||
|
||||
logger.info(` Found ${foundOnPage} audiobooks on page ${page}`);
|
||||
|
||||
// If we got fewer than expected, probably no more pages
|
||||
if (foundOnPage < 10) {
|
||||
// If we got significantly fewer than requested, probably no more pages
|
||||
if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) {
|
||||
logger.info(` Reached end of available pages`);
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
|
||||
// Add delay between pages to respect rate limiting
|
||||
// Adaptive delay between pages based on retry pressure
|
||||
if (page <= maxPages && audiobooks.length < limit) {
|
||||
await this.delay(1500);
|
||||
await this.delay(this.pacer.reportPageResult(meta));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch page ${page} of popular audiobooks`, {
|
||||
@@ -305,15 +327,18 @@ export class AudibleService {
|
||||
|
||||
const audiobooks: AudibleAudiobook[] = [];
|
||||
let page = 1;
|
||||
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page
|
||||
const maxPages = Math.ceil(limit / AUDIBLE_PAGE_SIZE);
|
||||
|
||||
this.pacer.reset();
|
||||
|
||||
while (audiobooks.length < limit && page <= maxPages) {
|
||||
try {
|
||||
logger.info(` Fetching page ${page}/${maxPages}...`);
|
||||
|
||||
const response = await this.fetchWithRetry('/newreleases', {
|
||||
const { data: response, meta } = await this.fetchWithRetry('/newreleases', {
|
||||
params: {
|
||||
ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects
|
||||
pageSize: AUDIBLE_PAGE_SIZE,
|
||||
...(page > 1 ? { page } : {}),
|
||||
},
|
||||
});
|
||||
@@ -363,17 +388,17 @@ export class AudibleService {
|
||||
|
||||
logger.info(` Found ${foundOnPage} audiobooks on page ${page}`);
|
||||
|
||||
// If we got fewer than expected, probably no more pages
|
||||
if (foundOnPage < 10) {
|
||||
// If we got significantly fewer than requested, probably no more pages
|
||||
if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) {
|
||||
logger.info(` Reached end of available pages`);
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
|
||||
// Add delay between pages to respect rate limiting
|
||||
// Adaptive delay between pages based on retry pressure
|
||||
if (page <= maxPages && audiobooks.length < limit) {
|
||||
await this.delay(1500);
|
||||
await this.delay(this.pacer.reportPageResult(meta));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch page ${page} of new releases`, {
|
||||
@@ -398,10 +423,11 @@ export class AudibleService {
|
||||
try {
|
||||
logger.info(` Searching for "${query}"...`);
|
||||
|
||||
const response = await this.fetchWithRetry('/search', {
|
||||
const { data: response } = await this.fetchWithRetry('/search', {
|
||||
params: {
|
||||
ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects
|
||||
keywords: query,
|
||||
pageSize: AUDIBLE_PAGE_SIZE,
|
||||
page,
|
||||
},
|
||||
});
|
||||
@@ -470,7 +496,7 @@ export class AudibleService {
|
||||
results: audiobooks,
|
||||
totalResults,
|
||||
page,
|
||||
hasMore: audiobooks.length > 0 && totalResults > page * 20,
|
||||
hasMore: audiobooks.length > 0 && totalResults > page * AUDIBLE_PAGE_SIZE,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Search failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
@@ -581,7 +607,7 @@ export class AudibleService {
|
||||
*/
|
||||
private async scrapeAudibleDetails(asin: string): Promise<AudibleAudiobook | null> {
|
||||
try {
|
||||
const response = await this.fetchWithRetry(`/pd/${asin}`, {
|
||||
const { data: response } = await this.fetchWithRetry(`/pd/${asin}`, {
|
||||
params: {
|
||||
ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects
|
||||
},
|
||||
|
||||
@@ -87,7 +87,7 @@ export class ProwlarrService {
|
||||
headers: {
|
||||
'X-Api-Key': this.apiKey,
|
||||
},
|
||||
timeout: 30000, // 30 seconds
|
||||
timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download
|
||||
paramsSerializer: {
|
||||
serialize: (params) => {
|
||||
// Custom serializer to handle arrays correctly for Prowlarr API
|
||||
@@ -314,7 +314,7 @@ export class ProwlarrService {
|
||||
limit: 100,
|
||||
extended: 1,
|
||||
},
|
||||
timeout: 30000,
|
||||
timeout: 60000,
|
||||
responseType: 'text', // Get XML as text
|
||||
});
|
||||
|
||||
|
||||
@@ -1109,7 +1109,8 @@ export class QBittorrentService implements IDownloadClient {
|
||||
stalledDL: 'downloading',
|
||||
stalledUP: 'seeding',
|
||||
pausedDL: 'paused',
|
||||
pausedUP: 'paused',
|
||||
// pausedUP = download finished, paused on upload side (e.g. RDT-Client, ratio met)
|
||||
pausedUP: 'seeding',
|
||||
queuedDL: 'queued',
|
||||
queuedUP: 'seeding',
|
||||
checkingDL: 'checking',
|
||||
@@ -1125,7 +1126,8 @@ export class QBittorrentService implements IDownloadClient {
|
||||
forcedMetaDL: 'downloading',
|
||||
// qBittorrent v5.0+ renamed paused → stopped
|
||||
stoppedDL: 'paused',
|
||||
stoppedUP: 'paused',
|
||||
// stoppedUP = download finished, stopped on upload side (qBittorrent v5.0+)
|
||||
stoppedUP: 'seeding',
|
||||
// Other states
|
||||
checkingResumeData: 'checking',
|
||||
moving: 'downloading',
|
||||
@@ -1162,11 +1164,12 @@ export class QBittorrentService implements IDownloadClient {
|
||||
stalledDL: 'downloading',
|
||||
stalledUP: 'completed',
|
||||
pausedDL: 'paused',
|
||||
pausedUP: 'paused',
|
||||
// pausedUP = download finished, paused on upload side (e.g. RDT-Client, ratio met)
|
||||
pausedUP: 'completed',
|
||||
queuedDL: 'queued',
|
||||
queuedUP: 'completed',
|
||||
checkingDL: 'checking',
|
||||
checkingUP: 'checking',
|
||||
checkingUP: 'completed',
|
||||
error: 'failed',
|
||||
missingFiles: 'failed',
|
||||
allocating: 'downloading',
|
||||
@@ -1178,7 +1181,8 @@ export class QBittorrentService implements IDownloadClient {
|
||||
forcedMetaDL: 'downloading',
|
||||
// qBittorrent v5.0+ renamed paused → stopped
|
||||
stoppedDL: 'paused',
|
||||
stoppedUP: 'paused',
|
||||
// stoppedUP = download finished, stopped on upload side (qBittorrent v5.0+)
|
||||
stoppedUP: 'completed',
|
||||
// Other states
|
||||
checkingResumeData: 'checking',
|
||||
moving: 'downloading',
|
||||
|
||||
@@ -44,6 +44,12 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
|
||||
|
||||
// Fetch popular and new releases - 200 items each
|
||||
const popular = await audibleService.getPopularAudiobooks(200);
|
||||
|
||||
// Batch cooldown between popular and new releases to reduce detection
|
||||
const batchCooldownMs = 15000 + Math.floor(Math.random() * 15000);
|
||||
logger.info(`Batch cooldown: waiting ${Math.round(batchCooldownMs / 1000)}s before fetching new releases...`);
|
||||
await new Promise(resolve => setTimeout(resolve, batchCooldownMs));
|
||||
|
||||
const newReleases = await audibleService.getNewReleases(200);
|
||||
|
||||
logger.info(`Fetched ${popular.length} popular, ${newReleases.length} new releases from Audible`);
|
||||
|
||||
@@ -8,34 +8,28 @@
|
||||
|
||||
import { getNotificationService } from '../services/notification';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import type { SendNotificationPayload } from '../services/job-queue.service';
|
||||
|
||||
export interface SendNotificationPayload {
|
||||
jobId?: string;
|
||||
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error';
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
// Re-export for consumers that import from this module
|
||||
export type { SendNotificationPayload } from '../services/job-queue.service';
|
||||
|
||||
/**
|
||||
* Process send notification job
|
||||
* Calls NotificationService to send notifications to all enabled backends
|
||||
*/
|
||||
export async function processSendNotification(payload: SendNotificationPayload): Promise<void> {
|
||||
const { event, requestId, title, author, userName, message, jobId } = payload;
|
||||
const { event, requestId, issueId, title, author, userName, message, jobId } = payload;
|
||||
|
||||
const logger = RMABLogger.forJob(jobId, 'SendNotification');
|
||||
|
||||
logger.info(`Processing notification: ${event}`, { requestId });
|
||||
logger.info(`Processing notification: ${event}`, { requestId: requestId || issueId });
|
||||
|
||||
try {
|
||||
const notificationService = getNotificationService();
|
||||
await notificationService.sendNotification({
|
||||
event,
|
||||
requestId,
|
||||
issueId,
|
||||
title,
|
||||
author,
|
||||
userName,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Component: Sync Goodreads Shelves Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Dedicated processor for syncing Goodreads shelf RSS feeds.
|
||||
* Resolves books to Audible ASINs and creates requests.
|
||||
*/
|
||||
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface SyncGoodreadsShelvesPayload {
|
||||
jobId?: string;
|
||||
scheduledJobId?: string;
|
||||
/** If set, only process this specific shelf (used for immediate sync on add) */
|
||||
shelfId?: string;
|
||||
/** Max Audible lookups per shelf. 0 = unlimited. */
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
export async function processSyncGoodreadsShelves(payload: SyncGoodreadsShelvesPayload): Promise<any> {
|
||||
const { jobId, shelfId, maxLookupsPerShelf } = payload;
|
||||
const logger = RMABLogger.forJob(jobId, 'SyncGoodreadsShelves');
|
||||
|
||||
logger.info(shelfId
|
||||
? `Starting immediate Goodreads sync for shelf ${shelfId}...`
|
||||
: 'Starting scheduled Goodreads shelves sync...'
|
||||
);
|
||||
|
||||
const { processGoodreadsShelves } = await import('../services/goodreads-sync.service');
|
||||
const stats = await processGoodreadsShelves(logger, {
|
||||
shelfId,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
logger.info('Goodreads sync complete', { stats });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: shelfId ? 'Goodreads shelf synced' : 'Goodreads shelves synced',
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
@@ -186,7 +186,7 @@ export async function deleteABSItem(itemId: string): Promise<void> {
|
||||
throw new Error('Audiobookshelf not configured');
|
||||
}
|
||||
|
||||
const url = `${serverUrl.replace(/\/$/, '')}/api/items/${itemId}`;
|
||||
const url = `${serverUrl.replace(/\/$/, '')}/api/items/${itemId}?hard=1`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -95,6 +95,39 @@ export class EncryptionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value matches the format produced by encrypt().
|
||||
* Validates: 3 colon-separated base64 parts where IV=16 bytes, authTag=16 bytes.
|
||||
*/
|
||||
isEncryptedFormat(value: string): boolean {
|
||||
if (typeof value !== 'string') return false;
|
||||
|
||||
const parts = value.split(':');
|
||||
if (parts.length !== 3) return false;
|
||||
|
||||
const [ivBase64, authTagBase64, encryptedBase64] = parts;
|
||||
|
||||
// All parts must be non-empty valid base64
|
||||
const base64Regex = /^[A-Za-z0-9+/]+=*$/;
|
||||
if (!ivBase64 || !authTagBase64 || !encryptedBase64) return false;
|
||||
if (!base64Regex.test(ivBase64) || !base64Regex.test(authTagBase64) || !base64Regex.test(encryptedBase64)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
const authTag = Buffer.from(authTagBase64, 'base64');
|
||||
|
||||
// IV and authTag must decode to exactly the expected byte lengths
|
||||
if (iv.length !== IV_LENGTH) return false;
|
||||
if (authTag.length !== AUTH_TAG_LENGTH) return false;
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random encryption key (32 bytes)
|
||||
* @returns Base64-encoded random key
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Component: Goodreads Shelf Sync Service
|
||||
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||
*
|
||||
* Fetches Goodreads shelf RSS feeds, resolves books to Audible ASINs,
|
||||
* and creates requests via the shared request-creator service.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('GoodreadsSync');
|
||||
|
||||
/** Default max Audible lookups per shelf per scheduled sync cycle */
|
||||
const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10;
|
||||
|
||||
/** Days before retrying a noMatch book */
|
||||
const NO_MATCH_RETRY_DAYS = 7;
|
||||
|
||||
interface GoodreadsRssBook {
|
||||
bookId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Goodreads RSS feed XML into structured book data.
|
||||
*/
|
||||
function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRssBook[] } {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
allowBooleanAttributes: true,
|
||||
});
|
||||
|
||||
const parsed = parser.parse(xml);
|
||||
const channel = parsed?.rss?.channel;
|
||||
if (!channel) {
|
||||
throw new Error('Invalid Goodreads RSS: no channel element');
|
||||
}
|
||||
|
||||
const shelfName = typeof channel.title === 'string' ? channel.title : 'Goodreads Shelf';
|
||||
|
||||
// Normalize items to array
|
||||
let items = channel.item;
|
||||
if (!items) return { shelfName, books: [] };
|
||||
if (!Array.isArray(items)) items = [items];
|
||||
|
||||
const books: GoodreadsRssBook[] = [];
|
||||
for (const item of items) {
|
||||
const bookId = item.book_id?.toString();
|
||||
if (!bookId) continue;
|
||||
|
||||
const title = (item.title || '').toString().trim();
|
||||
const authorName = (item.author_name || '').toString().trim();
|
||||
// Goodreads RSS has book_image_url or book_medium_image_url
|
||||
const coverUrl = (item.book_large_image_url || item.book_medium_image_url || item.book_image_url || '').toString().trim() || undefined;
|
||||
|
||||
if (title && authorName) {
|
||||
books.push({ bookId, title, author: authorName, coverUrl });
|
||||
}
|
||||
}
|
||||
|
||||
return { shelfName, books };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and validate a Goodreads RSS URL.
|
||||
* Returns the parsed shelf name and books if valid.
|
||||
*/
|
||||
export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: GoodreadsRssBook[] }> {
|
||||
const response = await axios.get(rssUrl, { timeout: 15000 });
|
||||
return parseGoodreadsRss(response.data);
|
||||
}
|
||||
|
||||
export interface GoodreadsSyncStats {
|
||||
shelvesProcessed: number;
|
||||
booksFound: number;
|
||||
lookupsPerformed: number;
|
||||
requestsCreated: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface GoodreadsSyncOptions {
|
||||
/** Process only this shelf ID (for immediate single-shelf sync) */
|
||||
shelfId?: string;
|
||||
/** Max Audible lookups per shelf. 0 = unlimited. Default: 10 for scheduled, unlimited for immediate. */
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Goodreads shelves: fetch RSS, resolve ASINs, create requests.
|
||||
* Called from the dedicated sync_goodreads_shelves processor.
|
||||
*/
|
||||
export async function processGoodreadsShelves(
|
||||
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
|
||||
options: GoodreadsSyncOptions = {}
|
||||
): Promise<GoodreadsSyncStats> {
|
||||
const log = jobLogger || logger;
|
||||
const stats: GoodreadsSyncStats = { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 };
|
||||
|
||||
const maxLookups = options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
|
||||
|
||||
const whereClause = options.shelfId ? { id: options.shelfId } : {};
|
||||
const shelves = await prisma.goodreadsShelf.findMany({
|
||||
where: whereClause,
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
});
|
||||
|
||||
if (shelves.length === 0) {
|
||||
log.info(options.shelfId ? 'Shelf not found' : 'No Goodreads shelves configured, skipping');
|
||||
return stats;
|
||||
}
|
||||
|
||||
log.info(`Processing ${shelves.length} Goodreads shelf(s)${maxLookups > 0 ? ` (max ${maxLookups} lookups/shelf)` : ' (unlimited lookups)'}`);
|
||||
|
||||
for (const shelf of shelves) {
|
||||
try {
|
||||
await processShelf(shelf, stats, log, maxLookups);
|
||||
stats.shelvesProcessed++;
|
||||
} catch (error) {
|
||||
stats.errors++;
|
||||
log.error(`Failed to process shelf "${shelf.name}" for user ${shelf.user.plexUsername}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Goodreads sync complete: ${stats.shelvesProcessed} shelves, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`);
|
||||
return stats;
|
||||
}
|
||||
|
||||
async function processShelf(
|
||||
shelf: { id: string; rssUrl: string; name: string; user: { id: string; plexUsername: string } },
|
||||
stats: GoodreadsSyncStats,
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
maxLookups: number
|
||||
) {
|
||||
log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`);
|
||||
|
||||
let rssData: { shelfName: string; books: GoodreadsRssBook[] };
|
||||
try {
|
||||
rssData = await fetchAndValidateRss(shelf.rssUrl);
|
||||
} catch (error) {
|
||||
log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const books = rssData.books;
|
||||
stats.booksFound += books.length;
|
||||
log.info(`Found ${books.length} books in shelf "${shelf.name}"`);
|
||||
|
||||
let lookupsThisCycle = 0;
|
||||
const unlimitedLookups = maxLookups === 0;
|
||||
|
||||
for (const book of books) {
|
||||
// Look up existing mapping
|
||||
let mapping = await prisma.goodreadsBookMapping.findUnique({
|
||||
where: { goodreadsBookId: book.bookId },
|
||||
});
|
||||
|
||||
if (!mapping) {
|
||||
// No mapping exists — perform Audible lookup if under cap
|
||||
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) {
|
||||
continue; // Will be resolved in a future cycle
|
||||
}
|
||||
|
||||
mapping = await performAudibleLookup(book, log);
|
||||
lookupsThisCycle++;
|
||||
stats.lookupsPerformed++;
|
||||
|
||||
// If lookup found an ASIN, fall through to create request immediately
|
||||
if (!mapping?.audibleAsin) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Mapping exists with noMatch — check if we should retry
|
||||
if (mapping.noMatch) {
|
||||
if (mapping.lastSearchAt) {
|
||||
const daysSinceSearch = (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceSearch >= NO_MATCH_RETRY_DAYS && (unlimitedLookups || lookupsThisCycle < maxLookups)) {
|
||||
log.info(`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`);
|
||||
mapping = await performAudibleLookup(book, log, mapping.id);
|
||||
lookupsThisCycle++;
|
||||
stats.lookupsPerformed++;
|
||||
|
||||
// If retry found an ASIN, fall through to create request
|
||||
if (!mapping?.audibleAsin) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue; // Still no match, skip
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Mapping has ASIN — try to create request
|
||||
if (mapping.audibleAsin) {
|
||||
try {
|
||||
const result = await createRequestForUser(shelf.user.id, {
|
||||
asin: mapping.audibleAsin,
|
||||
title: mapping.title,
|
||||
author: mapping.author,
|
||||
coverArtUrl: mapping.coverUrl || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
stats.requestsCreated++;
|
||||
log.info(`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`);
|
||||
}
|
||||
// If not success, it's already available/requested/duplicate — silently skip
|
||||
} catch (error) {
|
||||
log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect enriched book data (coverUrl + ASIN) for display
|
||||
const bookIds = books.map(b => b.bookId);
|
||||
const mappings = bookIds.length > 0
|
||||
? await prisma.goodreadsBookMapping.findMany({
|
||||
where: { goodreadsBookId: { in: bookIds } },
|
||||
select: { goodreadsBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true },
|
||||
})
|
||||
: [];
|
||||
const mappingsByBookId = new Map(mappings.map(m => [m.goodreadsBookId, m]));
|
||||
|
||||
// Look up AudibleCache records for high-quality cached cover URLs
|
||||
const matchedAsins = mappings
|
||||
.map(m => m.audibleAsin)
|
||||
.filter((asin): asin is string => !!asin);
|
||||
const cachedCovers = matchedAsins.length > 0
|
||||
? await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: matchedAsins } },
|
||||
select: { asin: true, coverArtUrl: true, cachedCoverPath: true },
|
||||
})
|
||||
: [];
|
||||
const coverByAsin = new Map(
|
||||
cachedCovers
|
||||
.filter(c => c.cachedCoverPath || c.coverArtUrl)
|
||||
.map(c => {
|
||||
let coverUrl = c.coverArtUrl || '';
|
||||
if (c.cachedCoverPath) {
|
||||
const filename = c.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
return [c.asin, coverUrl] as const;
|
||||
})
|
||||
);
|
||||
|
||||
const bookData = books
|
||||
.map(b => {
|
||||
const mapping = mappingsByBookId.get(b.bookId);
|
||||
// Prefer cached cover (local proxy) > mapping cover > Goodreads RSS cover
|
||||
const coverUrl = coverByAsin.get(mapping?.audibleAsin || '') || mapping?.coverUrl || b.coverUrl;
|
||||
if (!coverUrl) return null;
|
||||
return {
|
||||
coverUrl,
|
||||
asin: mapping?.audibleAsin || null,
|
||||
title: mapping?.title || b.title,
|
||||
author: mapping?.author || b.author,
|
||||
};
|
||||
})
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.slice(0, 8);
|
||||
|
||||
// Update shelf metadata
|
||||
await prisma.goodreadsShelf.update({
|
||||
where: { id: shelf.id },
|
||||
data: {
|
||||
lastSyncAt: new Date(),
|
||||
bookCount: books.length,
|
||||
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function performAudibleLookup(
|
||||
book: GoodreadsRssBook,
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
existingMappingId?: string
|
||||
): Promise<any> {
|
||||
const audibleService = getAudibleService();
|
||||
|
||||
try {
|
||||
const searchQuery = `${book.title} ${book.author}`;
|
||||
log.info(`Searching Audible for: "${searchQuery}"`);
|
||||
|
||||
const searchResult = await audibleService.search(searchQuery);
|
||||
const firstResult = searchResult.results[0];
|
||||
|
||||
if (firstResult?.asin) {
|
||||
log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`);
|
||||
|
||||
// Use clean Audible/Audnexus metadata instead of Goodreads data
|
||||
// (Goodreads titles contain series info like "(The Empyrean, #1)" that pollute indexer searches)
|
||||
const data = {
|
||||
title: firstResult.title,
|
||||
author: firstResult.author,
|
||||
audibleAsin: firstResult.asin,
|
||||
coverUrl: firstResult.coverArtUrl || book.coverUrl || null,
|
||||
noMatch: false,
|
||||
lastSearchAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data });
|
||||
}
|
||||
return prisma.goodreadsBookMapping.create({
|
||||
data: { goodreadsBookId: book.bookId, ...data },
|
||||
});
|
||||
}
|
||||
|
||||
// No match found
|
||||
log.info(`No Audible match for "${book.title}" by ${book.author}`);
|
||||
|
||||
const noMatchData = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl: book.coverUrl || null,
|
||||
noMatch: true,
|
||||
lastSearchAt: new Date(),
|
||||
audibleAsin: null,
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: noMatchData });
|
||||
}
|
||||
return prisma.goodreadsBookMapping.create({
|
||||
data: { goodreadsBookId: book.bookId, ...noMatchData },
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
// Still create/update mapping so we don't retry every cycle
|
||||
const errorData = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl: book.coverUrl || null,
|
||||
noMatch: true,
|
||||
lastSearchAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: errorData });
|
||||
}
|
||||
return prisma.goodreadsBookMapping.create({
|
||||
data: { goodreadsBookId: book.bookId, ...errorData },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { prisma } from '../db';
|
||||
import { TorrentResult } from '../utils/ranking-algorithm';
|
||||
import { DownloadClientType } from '../interfaces/download-client.interface';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import type { NotificationEvent } from '@/lib/constants/notification-events';
|
||||
|
||||
const logger = RMABLogger.create('JobQueue');
|
||||
|
||||
@@ -25,6 +26,7 @@ export type JobType =
|
||||
| 'retry_failed_imports'
|
||||
| 'cleanup_seeded_torrents'
|
||||
| 'monitor_rss_feeds'
|
||||
| 'sync_goodreads_shelves'
|
||||
| 'send_notification'
|
||||
// Ebook-specific job types
|
||||
| 'search_ebook'
|
||||
@@ -100,6 +102,12 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
}
|
||||
|
||||
export interface SyncGoodreadsShelvesPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
shelfId?: string;
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
// Ebook-specific payload interfaces
|
||||
export interface SearchEbookPayload extends JobPayload {
|
||||
requestId: string;
|
||||
@@ -140,8 +148,9 @@ export interface MonitorDirectDownloadPayload extends JobPayload {
|
||||
}
|
||||
|
||||
export interface SendNotificationPayload extends JobPayload {
|
||||
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error';
|
||||
requestId: string;
|
||||
event: NotificationEvent;
|
||||
requestId?: string;
|
||||
issueId?: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
@@ -340,6 +349,12 @@ export class JobQueueService {
|
||||
return await processCleanupSeededTorrents(payloadWithJobId);
|
||||
});
|
||||
|
||||
this.queue.process('sync_goodreads_shelves', 1, async (job: BullJob<SyncGoodreadsShelvesPayload>) => {
|
||||
const { processSyncGoodreadsShelves } = await import('../processors/sync-goodreads-shelves.processor');
|
||||
const payloadWithJobId = await this.ensureJobRecord(job, 'sync_goodreads_shelves');
|
||||
return await processSyncGoodreadsShelves(payloadWithJobId);
|
||||
});
|
||||
|
||||
// Send notification processor
|
||||
this.queue.process('send_notification', 5, async (job: BullJob<SendNotificationPayload>) => {
|
||||
const { processSendNotification } = await import('../processors/send-notification.processor');
|
||||
@@ -695,6 +710,23 @@ export class JobQueueService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add sync Goodreads shelves job
|
||||
*/
|
||||
async addSyncGoodreadsShelvesJob(scheduledJobId?: string, shelfId?: string, maxLookupsPerShelf?: number): Promise<string> {
|
||||
return await this.addJob(
|
||||
'sync_goodreads_shelves',
|
||||
{
|
||||
scheduledJobId,
|
||||
shelfId,
|
||||
maxLookupsPerShelf,
|
||||
} as SyncGoodreadsShelvesPayload,
|
||||
{
|
||||
priority: 7,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// EBOOK-SPECIFIC JOB METHODS
|
||||
// =========================================================================
|
||||
@@ -911,7 +943,7 @@ export class JobQueueService {
|
||||
* Add notification job
|
||||
*/
|
||||
async addNotificationJob(
|
||||
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error',
|
||||
event: NotificationEvent,
|
||||
requestId: string,
|
||||
title: string,
|
||||
author: string,
|
||||
@@ -923,11 +955,16 @@ export class JobQueueService {
|
||||
'send_notification',
|
||||
{
|
||||
event,
|
||||
requestId,
|
||||
// issue_reported passes an issue ID, not a request ID — omit from payload
|
||||
// so addJob doesn't try to create a FK to the requests table.
|
||||
// The ID is still available in the notification payload for display.
|
||||
requestId: event === 'issue_reported' ? undefined : requestId,
|
||||
title,
|
||||
author,
|
||||
userName,
|
||||
message,
|
||||
// Pass the original ID for notification display (e.g., Discord footer)
|
||||
...(event === 'issue_reported' && { issueId: requestId }),
|
||||
timestamp: new Date(),
|
||||
} as SendNotificationPayload,
|
||||
{
|
||||
|
||||
@@ -3,24 +3,21 @@
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
// Event types
|
||||
export type NotificationEvent =
|
||||
| 'request_pending_approval'
|
||||
| 'request_approved'
|
||||
| 'request_available'
|
||||
| 'request_error';
|
||||
// Re-export event types from central source of truth
|
||||
export type { NotificationEvent } from '@/lib/constants/notification-events';
|
||||
|
||||
// Backend type — string-based, registry is the runtime source of truth
|
||||
export type NotificationBackendType = string;
|
||||
|
||||
// Notification payload
|
||||
export interface NotificationPayload {
|
||||
event: NotificationEvent;
|
||||
requestId: string;
|
||||
event: import('@/lib/constants/notification-events').NotificationEvent;
|
||||
requestId?: string;
|
||||
issueId?: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string; // For error events
|
||||
message?: string; // For error/issue events
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,16 @@ export type {
|
||||
ProviderMetadata,
|
||||
} from './INotificationProvider';
|
||||
|
||||
// Centralized event constants (re-exported for convenience)
|
||||
export {
|
||||
NOTIFICATION_EVENTS,
|
||||
NOTIFICATION_EVENT_KEYS,
|
||||
EVENT_LABELS,
|
||||
getEventMeta,
|
||||
getEventLabel,
|
||||
} from '@/lib/constants/notification-events';
|
||||
export type { NotificationSeverity, NotificationPriority, NotificationEventMeta } from '@/lib/constants/notification-events';
|
||||
|
||||
// Core service
|
||||
export {
|
||||
NotificationService,
|
||||
|
||||
@@ -130,7 +130,7 @@ export class NotificationService {
|
||||
|
||||
const encrypted = { ...config };
|
||||
for (const field of provider.sensitiveFields) {
|
||||
if (encrypted[field] && !this.isEncrypted(encrypted[field])) {
|
||||
if (encrypted[field] && !this.encryptionService.isEncryptedFormat(encrypted[field])) {
|
||||
encrypted[field] = this.encryptionService.encrypt(encrypted[field]);
|
||||
}
|
||||
}
|
||||
@@ -155,25 +155,66 @@ export class NotificationService {
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encrypt any sensitive fields that were stored as plaintext due to
|
||||
* the isEncrypted() false-positive bug (URLs with exactly 2 colons).
|
||||
* Safe to call multiple times — skips already-encrypted values.
|
||||
*/
|
||||
async reEncryptUnprotectedBackends(): Promise<number> {
|
||||
let fixed = 0;
|
||||
|
||||
try {
|
||||
const backends = await prisma.notificationBackend.findMany();
|
||||
|
||||
for (const backend of backends) {
|
||||
const provider = getProvider(backend.type);
|
||||
if (!provider) continue;
|
||||
|
||||
const config = backend.config as any;
|
||||
let needsUpdate = false;
|
||||
const updatedConfig = { ...config };
|
||||
|
||||
for (const field of provider.sensitiveFields) {
|
||||
if (updatedConfig[field] && !this.encryptionService.isEncryptedFormat(updatedConfig[field])) {
|
||||
updatedConfig[field] = this.encryptionService.encrypt(updatedConfig[field]);
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await prisma.notificationBackend.update({
|
||||
where: { id: backend.id },
|
||||
data: { config: updatedConfig },
|
||||
});
|
||||
fixed++;
|
||||
logger.info(`Re-encrypted plaintext sensitive fields for backend: ${backend.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fixed > 0) {
|
||||
logger.warn(`Re-encrypted ${fixed} backend(s) with unprotected sensitive fields`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to re-encrypt backends', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return fixed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive config values
|
||||
*/
|
||||
private decryptConfig(sensitiveFields: string[], config: any): any {
|
||||
const decrypted = { ...config };
|
||||
for (const field of sensitiveFields) {
|
||||
if (decrypted[field] && this.isEncrypted(decrypted[field])) {
|
||||
if (decrypted[field] && this.encryptionService.isEncryptedFormat(decrypted[field])) {
|
||||
decrypted[field] = this.encryptionService.decrypt(decrypted[field]);
|
||||
}
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is encrypted (has iv:authTag:data format)
|
||||
*/
|
||||
private isEncrypted(value: string): boolean {
|
||||
return value.includes(':') && value.split(':').length === 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events';
|
||||
|
||||
export interface AppriseConfig {
|
||||
serverUrl: string;
|
||||
@@ -13,12 +14,12 @@ export interface AppriseConfig {
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
// Apprise notification types by event
|
||||
const APPRISE_TYPES: Record<string, string> = {
|
||||
request_pending_approval: 'info',
|
||||
request_approved: 'success',
|
||||
request_available: 'success',
|
||||
request_error: 'failure',
|
||||
// Apprise notification types by severity
|
||||
const SEVERITY_TYPES: Record<NotificationSeverity, string> = {
|
||||
info: 'info',
|
||||
success: 'success',
|
||||
error: 'failure',
|
||||
warning: 'warning',
|
||||
};
|
||||
|
||||
export class AppriseProvider implements INotificationProvider {
|
||||
@@ -41,10 +42,11 @@ export class AppriseProvider implements INotificationProvider {
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const appriseConfig = config as unknown as AppriseConfig;
|
||||
const meta = getEventMeta(payload.event);
|
||||
const { title, body } = this.formatMessage(payload);
|
||||
|
||||
const serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
|
||||
const notificationType = APPRISE_TYPES[payload.event] || 'info';
|
||||
const notificationType = SEVERITY_TYPES[meta.severity];
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -107,26 +109,21 @@ export class AppriseProvider implements INotificationProvider {
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; body: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
|
||||
const eventTitles: Record<string, string> = {
|
||||
request_pending_approval: 'New Request Pending Approval',
|
||||
request_approved: 'Request Approved',
|
||||
request_available: 'Audiobook Available',
|
||||
request_error: 'Request Error',
|
||||
};
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const messageLines = [
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
`\u{1F4DA} ${title}`,
|
||||
`\u270D\uFE0F ${author}`,
|
||||
`\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push(`⚠️ Error: ${message}`);
|
||||
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitles[event],
|
||||
title: meta.title,
|
||||
body: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events';
|
||||
|
||||
export interface DiscordConfig {
|
||||
webhookUrl: string;
|
||||
@@ -11,20 +12,12 @@ export interface DiscordConfig {
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
// Discord embed colors by event type
|
||||
const DISCORD_COLORS = {
|
||||
request_pending_approval: 0xfbbf24, // yellow-400
|
||||
request_approved: 0x22c55e, // green-500
|
||||
request_available: 0x3b82f6, // blue-500
|
||||
request_error: 0xef4444, // red-500
|
||||
};
|
||||
|
||||
// Discord embed titles
|
||||
const DISCORD_TITLES = {
|
||||
request_pending_approval: '📬 New Request Pending Approval',
|
||||
request_approved: '✅ Request Approved',
|
||||
request_available: '🎉 Audiobook Available',
|
||||
request_error: '❌ Request Error',
|
||||
// Discord embed colors by severity
|
||||
const SEVERITY_COLORS: Record<NotificationSeverity, number> = {
|
||||
info: 0xfbbf24, // yellow-400
|
||||
success: 0x22c55e, // green-500
|
||||
error: 0xef4444, // red-500
|
||||
warning: 0xf97316, // orange-500
|
||||
};
|
||||
|
||||
export class DiscordProvider implements INotificationProvider {
|
||||
@@ -67,23 +60,25 @@ export class DiscordProvider implements INotificationProvider {
|
||||
|
||||
private formatEmbed(payload: NotificationPayload): any {
|
||||
const { event, title, author, userName, message, requestId, timestamp } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const fields = [
|
||||
{ name: 'Title', value: title, inline: false },
|
||||
{ name: 'Author', value: author, inline: true },
|
||||
{ name: 'Requested By', value: userName, inline: true },
|
||||
{ name: isIssue ? 'Reported By' : 'Requested By', value: userName, inline: true },
|
||||
];
|
||||
|
||||
if (message) {
|
||||
fields.push({ name: 'Error', value: message, inline: false });
|
||||
fields.push({ name: isIssue ? 'Reason' : 'Error', value: message, inline: false });
|
||||
}
|
||||
|
||||
return {
|
||||
title: DISCORD_TITLES[event],
|
||||
color: DISCORD_COLORS[event],
|
||||
title: `${meta.emoji} ${meta.title}`,
|
||||
color: SEVERITY_COLORS[meta.severity],
|
||||
fields,
|
||||
footer: {
|
||||
text: `Request ID: ${requestId}`,
|
||||
text: isIssue ? `Issue ID: ${payload.issueId}` : `Request ID: ${requestId}`,
|
||||
},
|
||||
timestamp: timestamp.toISOString(),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
import { getEventMeta, type NotificationSeverity, type NotificationPriority } from '@/lib/constants/notification-events';
|
||||
|
||||
export interface NtfyConfig {
|
||||
serverUrl?: string;
|
||||
@@ -14,20 +15,18 @@ export interface NtfyConfig {
|
||||
|
||||
const DEFAULT_SERVER_URL = 'https://ntfy.sh';
|
||||
|
||||
// ntfy priorities by event type (1=min, 2=low, 3=default, 4=high, 5=urgent)
|
||||
const NTFY_PRIORITIES = {
|
||||
request_pending_approval: 3, // Default
|
||||
request_approved: 3, // Default
|
||||
request_available: 4, // High
|
||||
request_error: 4, // High
|
||||
// ntfy priorities by notification priority (1=min, 2=low, 3=default, 4=high, 5=urgent)
|
||||
const PRIORITY_MAP: Record<NotificationPriority, number> = {
|
||||
normal: 3,
|
||||
high: 4,
|
||||
};
|
||||
|
||||
// ntfy tags (emojis) by event type
|
||||
const NTFY_TAGS = {
|
||||
request_pending_approval: ['mailbox_with_mail'],
|
||||
request_approved: ['white_check_mark'],
|
||||
request_available: ['tada'],
|
||||
request_error: ['x'],
|
||||
// ntfy tags (emojis) by severity
|
||||
const SEVERITY_TAGS: Record<NotificationSeverity, string[]> = {
|
||||
info: ['mailbox_with_mail'],
|
||||
success: ['white_check_mark'],
|
||||
error: ['x'],
|
||||
warning: ['triangular_flag_on_post'],
|
||||
};
|
||||
|
||||
export class NtfyProvider implements INotificationProvider {
|
||||
@@ -48,10 +47,12 @@ export class NtfyProvider implements INotificationProvider {
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const ntfyConfig = config as unknown as NtfyConfig;
|
||||
const meta = getEventMeta(payload.event);
|
||||
const { title, message } = this.formatMessage(payload);
|
||||
|
||||
const serverUrl = (ntfyConfig.serverUrl || DEFAULT_SERVER_URL).replace(/\/+$/, '');
|
||||
const url = `${serverUrl}/${ntfyConfig.topic}`;
|
||||
// ntfy JSON publishing requires POSTing to the base server URL (not the topic URL).
|
||||
// The topic is included in the JSON body. See: https://docs.ntfy.sh/publish/#publish-as-json
|
||||
const url = (ntfyConfig.serverUrl || DEFAULT_SERVER_URL).replace(/\/+$/, '');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -65,8 +66,8 @@ export class NtfyProvider implements INotificationProvider {
|
||||
topic: ntfyConfig.topic,
|
||||
title,
|
||||
message,
|
||||
priority: ntfyConfig.priority ?? NTFY_PRIORITIES[payload.event],
|
||||
tags: NTFY_TAGS[payload.event],
|
||||
priority: ntfyConfig.priority ?? PRIORITY_MAP[meta.priority],
|
||||
tags: SEVERITY_TAGS[meta.severity],
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
@@ -83,26 +84,21 @@ export class NtfyProvider implements INotificationProvider {
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
|
||||
const eventTitles = {
|
||||
request_pending_approval: 'New Request Pending Approval',
|
||||
request_approved: 'Request Approved',
|
||||
request_available: 'Audiobook Available',
|
||||
request_error: 'Request Error',
|
||||
};
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const messageLines = [
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
`\u{1F4DA} ${title}`,
|
||||
`\u270D\uFE0F ${author}`,
|
||||
`\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push(`⚠️ Error: ${message}`);
|
||||
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitles[event],
|
||||
title: meta.title,
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
import { getEventMeta, type NotificationPriority } from '@/lib/constants/notification-events';
|
||||
|
||||
export interface PushoverConfig {
|
||||
userKey: string;
|
||||
@@ -12,12 +13,10 @@ export interface PushoverConfig {
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
// Pushover priorities by event type
|
||||
const PUSHOVER_PRIORITIES = {
|
||||
request_pending_approval: 0, // Normal
|
||||
request_approved: 0, // Normal
|
||||
request_available: 1, // High
|
||||
request_error: 1, // High
|
||||
// Pushover priorities by notification priority (Normal=0, High=1)
|
||||
const PRIORITY_MAP: Record<NotificationPriority, number> = {
|
||||
normal: 0,
|
||||
high: 1,
|
||||
};
|
||||
|
||||
export class PushoverProvider implements INotificationProvider {
|
||||
@@ -48,6 +47,7 @@ export class PushoverProvider implements INotificationProvider {
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const pushoverConfig = config as unknown as PushoverConfig;
|
||||
const meta = getEventMeta(payload.event);
|
||||
const { title, message } = this.formatMessage(payload);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
@@ -55,7 +55,7 @@ export class PushoverProvider implements INotificationProvider {
|
||||
user: pushoverConfig.userKey,
|
||||
title,
|
||||
message,
|
||||
priority: String(pushoverConfig.priority ?? PUSHOVER_PRIORITIES[payload.event]),
|
||||
priority: String(pushoverConfig.priority ?? PRIORITY_MAP[meta.priority]),
|
||||
...(pushoverConfig.device && { device: pushoverConfig.device }),
|
||||
});
|
||||
|
||||
@@ -78,43 +78,23 @@ export class PushoverProvider implements INotificationProvider {
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
|
||||
let eventTitle = '';
|
||||
let eventEmoji = '';
|
||||
|
||||
switch (event) {
|
||||
case 'request_pending_approval':
|
||||
eventTitle = 'New Request Pending Approval';
|
||||
eventEmoji = '📬';
|
||||
break;
|
||||
case 'request_approved':
|
||||
eventTitle = 'Request Approved';
|
||||
eventEmoji = '✅';
|
||||
break;
|
||||
case 'request_available':
|
||||
eventTitle = 'Audiobook Available';
|
||||
eventEmoji = '🎉';
|
||||
break;
|
||||
case 'request_error':
|
||||
eventTitle = 'Request Error';
|
||||
eventEmoji = '❌';
|
||||
break;
|
||||
}
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const messageLines = [
|
||||
`${eventEmoji} ${eventTitle}`,
|
||||
`${meta.emoji} ${meta.title}`,
|
||||
'',
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
`\u{1F4DA} ${title}`,
|
||||
`\u270D\uFE0F ${author}`,
|
||||
`\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push('', `⚠️ Error: ${message}`);
|
||||
messageLines.push('', isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitle,
|
||||
title: meta.title,
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Component: Reported Issue Service
|
||||
* Documentation: documentation/backend/services/reported-issues.md
|
||||
*
|
||||
* Handles user-reported problems with available audiobooks.
|
||||
* Supports dismiss (admin closes) and replace (admin picks new torrent) workflows.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('ReportedIssue');
|
||||
|
||||
/**
|
||||
* Report an issue with an available audiobook
|
||||
*/
|
||||
export async function reportIssue(
|
||||
asin: string,
|
||||
reporterId: string,
|
||||
reason: string,
|
||||
metadata?: { title?: string; author?: string; coverArtUrl?: string }
|
||||
) {
|
||||
// Validate the book is in the library
|
||||
const plexMatch = await findPlexMatch({
|
||||
asin,
|
||||
title: metadata?.title || '',
|
||||
author: metadata?.author || '',
|
||||
});
|
||||
|
||||
if (!plexMatch) {
|
||||
throw new ReportedIssueError('This audiobook is not currently in your library', 404);
|
||||
}
|
||||
|
||||
// Find or create audiobook record for this ASIN
|
||||
let audiobook = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: asin },
|
||||
});
|
||||
|
||||
if (!audiobook) {
|
||||
audiobook = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: asin,
|
||||
title: metadata?.title || 'Unknown Title',
|
||||
author: metadata?.author || 'Unknown Author',
|
||||
coverArtUrl: metadata?.coverArtUrl,
|
||||
status: 'requested',
|
||||
},
|
||||
});
|
||||
logger.info(`Created audiobook record for ASIN ${asin} to link reported issue`);
|
||||
}
|
||||
|
||||
// Check for existing open issue
|
||||
const existingIssue = await prisma.reportedIssue.findFirst({
|
||||
where: {
|
||||
audiobookId: audiobook.id,
|
||||
status: 'open',
|
||||
},
|
||||
});
|
||||
|
||||
if (existingIssue) {
|
||||
throw new ReportedIssueError('An issue has already been reported for this audiobook', 409);
|
||||
}
|
||||
|
||||
const issue = await prisma.reportedIssue.create({
|
||||
data: {
|
||||
audiobookId: audiobook.id,
|
||||
reporterId,
|
||||
reason,
|
||||
},
|
||||
include: {
|
||||
audiobook: { select: { title: true, author: true, audibleAsin: true } },
|
||||
reporter: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Issue reported for "${audiobook.title}" by user ${reporterId}`);
|
||||
|
||||
// Queue notification (non-blocking)
|
||||
try {
|
||||
const { getJobQueueService } = await import('./job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'issue_reported',
|
||||
issue.id,
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
issue.reporter.plexUsername,
|
||||
reason
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to queue issue_reported notification', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return issue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a reported issue (admin action)
|
||||
*/
|
||||
export async function dismissIssue(issueId: string, adminUserId: string) {
|
||||
const issue = await prisma.reportedIssue.findUnique({
|
||||
where: { id: issueId },
|
||||
});
|
||||
|
||||
if (!issue) {
|
||||
throw new ReportedIssueError('Issue not found', 404);
|
||||
}
|
||||
|
||||
if (issue.status !== 'open') {
|
||||
throw new ReportedIssueError('Issue is already resolved', 409);
|
||||
}
|
||||
|
||||
const updated = await prisma.reportedIssue.update({
|
||||
where: { id: issueId },
|
||||
data: {
|
||||
status: 'dismissed',
|
||||
resolvedAt: new Date(),
|
||||
resolvedById: adminUserId,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Issue ${issueId} dismissed by admin ${adminUserId}`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace audiobook content for a reported issue (atomic admin action):
|
||||
* 1. Validate issue is open
|
||||
* 2. Delete old content (via request delete or direct library deletion)
|
||||
* 3. Create new request + start download with selected torrent
|
||||
* 4. Resolve issue as "replaced"
|
||||
*/
|
||||
export async function replaceAudiobook(
|
||||
issueId: string,
|
||||
adminUserId: string,
|
||||
torrent: any
|
||||
) {
|
||||
const issue = await prisma.reportedIssue.findUnique({
|
||||
where: { id: issueId },
|
||||
include: {
|
||||
audiobook: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
author: true,
|
||||
audibleAsin: true,
|
||||
coverArtUrl: true,
|
||||
narrator: true,
|
||||
plexGuid: true,
|
||||
absItemId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!issue) {
|
||||
throw new ReportedIssueError('Issue not found', 404);
|
||||
}
|
||||
|
||||
if (issue.status !== 'open') {
|
||||
throw new ReportedIssueError('Issue is already resolved', 409);
|
||||
}
|
||||
|
||||
const audiobook = issue.audiobook;
|
||||
|
||||
// Step 1: Find existing active request for this audiobook
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId: audiobook.id,
|
||||
type: 'audiobook',
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Step 2: Delete old content
|
||||
if (existingRequest) {
|
||||
// Has an RMAB request — use deleteRequest which handles torrent cleanup, files, library backend
|
||||
const { deleteRequest } = await import('./request-delete.service');
|
||||
const deleteResult = await deleteRequest(existingRequest.id, adminUserId);
|
||||
if (!deleteResult.success) {
|
||||
logger.warn(`deleteRequest partial failure for ${existingRequest.id}: ${deleteResult.error}`);
|
||||
// Continue anyway - we want replacement to proceed
|
||||
}
|
||||
logger.info(`Deleted existing request ${existingRequest.id} for replacement`);
|
||||
} else {
|
||||
// No RMAB request — book was added to library outside RMAB
|
||||
await deleteFromLibrary(audiobook);
|
||||
logger.info(`Deleted library content directly for "${audiobook.title}" (no RMAB request)`);
|
||||
}
|
||||
|
||||
// Step 3: Reset audiobook record for new request
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobook.id },
|
||||
data: {
|
||||
status: 'requested',
|
||||
plexGuid: null,
|
||||
absItemId: null,
|
||||
filePath: null,
|
||||
fileFormat: null,
|
||||
fileSizeBytes: null,
|
||||
filesHash: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Step 4: Create new request + start download (admin-initiated, no approval needed)
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: adminUserId,
|
||||
audiobookId: audiobook.id,
|
||||
status: 'downloading',
|
||||
type: 'audiobook',
|
||||
progress: 0,
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { id: true, plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Queue download job with selected torrent
|
||||
const { getJobQueueService } = await import('./job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addDownloadJob(
|
||||
newRequest.id,
|
||||
{
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
},
|
||||
torrent
|
||||
);
|
||||
|
||||
// Step 5: Resolve issue
|
||||
await prisma.reportedIssue.update({
|
||||
where: { id: issueId },
|
||||
data: {
|
||||
status: 'replaced',
|
||||
resolvedAt: new Date(),
|
||||
resolvedById: adminUserId,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Issue ${issueId} resolved via replacement. New request: ${newRequest.id}`);
|
||||
return { issue, request: newRequest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all open issues with audiobook metadata and reporter info (admin list)
|
||||
*/
|
||||
export async function getOpenIssues() {
|
||||
return prisma.reportedIssue.findMany({
|
||||
where: { status: 'open' },
|
||||
include: {
|
||||
audiobook: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
author: true,
|
||||
coverArtUrl: true,
|
||||
audibleAsin: true,
|
||||
},
|
||||
},
|
||||
reporter: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch query for open issues by ASINs (used for enrichment in audiobook-matcher)
|
||||
*/
|
||||
export async function getOpenIssuesByAsins(asins: string[]): Promise<Set<string>> {
|
||||
if (asins.length === 0) return new Set();
|
||||
|
||||
const issues = await prisma.reportedIssue.findMany({
|
||||
where: {
|
||||
status: 'open',
|
||||
audiobook: {
|
||||
audibleAsin: { in: asins },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
audiobook: {
|
||||
select: { audibleAsin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return new Set(
|
||||
issues
|
||||
.map((i) => i.audiobook.audibleAsin)
|
||||
.filter((asin): asin is string => asin !== null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete audiobook content from library backend directly (no RMAB request).
|
||||
* Used when a book was added to Plex/ABS outside of RMAB.
|
||||
* Mirrors the library deletion logic from request-delete.service.ts lines 280-440.
|
||||
*/
|
||||
async function deleteFromLibrary(audiobook: {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
audibleAsin: string | null;
|
||||
plexGuid: string | null;
|
||||
absItemId: string | null;
|
||||
}) {
|
||||
const { getConfigService } = await import('./config.service');
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
|
||||
// Delete from library backend API
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
// absItemId may be null if the book was added outside RMAB.
|
||||
// Fall back to looking up the ABS item ID from plex_library by ASIN
|
||||
// (plexGuid stores the ABS item ID when using ABS backend).
|
||||
let itemId = audiobook.absItemId;
|
||||
if (!itemId && audiobook.audibleAsin) {
|
||||
const libraryRecord = await prisma.plexLibrary.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ asin: audiobook.audibleAsin },
|
||||
{ plexGuid: { contains: audiobook.audibleAsin } },
|
||||
],
|
||||
},
|
||||
select: { plexGuid: true },
|
||||
});
|
||||
itemId = libraryRecord?.plexGuid ?? null;
|
||||
}
|
||||
|
||||
if (itemId) {
|
||||
try {
|
||||
const { deleteABSItem } = await import('./audiobookshelf/api');
|
||||
await deleteABSItem(itemId);
|
||||
logger.info(`Deleted ABS item ${itemId} for "${audiobook.title}"`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete ABS item ${itemId}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.warn(`No ABS item ID found for "${audiobook.title}" (ASIN: ${audiobook.audibleAsin}) — skipping ABS deletion`);
|
||||
}
|
||||
} else if (backendMode === 'plex' && audiobook.plexGuid) {
|
||||
try {
|
||||
const plexLibraryRecord = await prisma.plexLibrary.findUnique({
|
||||
where: { plexGuid: audiobook.plexGuid },
|
||||
select: { plexRatingKey: true },
|
||||
});
|
||||
|
||||
if (plexLibraryRecord?.plexRatingKey) {
|
||||
const plexServerUrl = (await configService.get('plex_url')) || '';
|
||||
const plexToken = (await configService.get('plex_token')) || '';
|
||||
|
||||
if (plexServerUrl && plexToken) {
|
||||
const { getPlexService } = await import('../integrations/plex.service');
|
||||
const plexService = getPlexService();
|
||||
await plexService.deleteItem(plexServerUrl, plexToken, plexLibraryRecord.plexRatingKey);
|
||||
logger.info(`Deleted Plex item ${plexLibraryRecord.plexRatingKey} for "${audiobook.title}"`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete Plex item for "${audiobook.title}"`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete plex_library records by ASIN
|
||||
if (audiobook.audibleAsin) {
|
||||
try {
|
||||
const result = await prisma.plexLibrary.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ asin: audiobook.audibleAsin },
|
||||
{ plexGuid: { contains: audiobook.audibleAsin } },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (result.count > 0) {
|
||||
logger.info(`Deleted ${result.count} plex_library record(s) by ASIN "${audiobook.audibleAsin}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete plex_library records for ASIN "${audiobook.audibleAsin}"`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for reported issues
|
||||
*/
|
||||
export class ReportedIssueError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ReportedIssueError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Component: Request Creator Service
|
||||
* Documentation: documentation/backend/services/requests.md
|
||||
*
|
||||
* Shared request-creation logic used by both the API route and Goodreads sync.
|
||||
* Encapsulates: duplicate detection, library check, Audnexus enrichment,
|
||||
* audiobook record creation, approval flow, notification queuing, and search job triggering.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('RequestCreator');
|
||||
|
||||
export interface CreateRequestInput {
|
||||
asin: string;
|
||||
title: string;
|
||||
author: string;
|
||||
narrator?: string;
|
||||
description?: string;
|
||||
coverArtUrl?: string;
|
||||
}
|
||||
|
||||
export interface CreateRequestOptions {
|
||||
skipAutoSearch?: boolean;
|
||||
}
|
||||
|
||||
export type CreateRequestResult =
|
||||
| { success: true; request: any }
|
||||
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found'; message: string };
|
||||
|
||||
/**
|
||||
* Create a request for a user, with full duplicate detection, library checks,
|
||||
* Audnexus enrichment, approval flow, notifications, and search job triggering.
|
||||
*/
|
||||
export async function createRequestForUser(
|
||||
userId: string,
|
||||
audiobook: CreateRequestInput,
|
||||
options: CreateRequestOptions = {}
|
||||
): Promise<CreateRequestResult> {
|
||||
const { skipAutoSearch = false } = options;
|
||||
|
||||
// Check for existing active request (downloaded/available) for this ASIN
|
||||
const existingActiveRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobook: { audibleAsin: audiobook.asin },
|
||||
type: 'audiobook',
|
||||
status: { in: ['downloaded', 'available'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingActiveRequest) {
|
||||
const status = existingActiveRequest.status;
|
||||
return {
|
||||
success: false,
|
||||
reason: status === 'available' ? 'already_available' : 'being_processed',
|
||||
message: status === 'available'
|
||||
? 'This audiobook is already available in your library'
|
||||
: 'This audiobook is being processed and will be available soon',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if audiobook is already in Plex/ABS library
|
||||
const plexMatch = await findPlexMatch({
|
||||
asin: audiobook.asin,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
narrator: audiobook.narrator,
|
||||
});
|
||||
|
||||
if (plexMatch) {
|
||||
return {
|
||||
success: false,
|
||||
reason: 'already_available',
|
||||
message: 'This audiobook is already available in your library',
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch full details from Audnexus for year/series
|
||||
let year: number | undefined;
|
||||
let series: string | undefined;
|
||||
let seriesPart: string | undefined;
|
||||
try {
|
||||
const audibleService = getAudibleService();
|
||||
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
|
||||
|
||||
if (audnexusData?.releaseDate) {
|
||||
try {
|
||||
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
||||
if (!isNaN(releaseYear)) {
|
||||
year = releaseYear;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
if (audnexusData?.series) series = audnexusData.series;
|
||||
if (audnexusData?.seriesPart) seriesPart = audnexusData.seriesPart;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Find or create audiobook record
|
||||
let audiobookRecord = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: audiobook.asin },
|
||||
});
|
||||
|
||||
if (!audiobookRecord) {
|
||||
audiobookRecord = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: audiobook.asin,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
narrator: audiobook.narrator,
|
||||
description: audiobook.description,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
year,
|
||||
series,
|
||||
seriesPart,
|
||||
status: 'requested',
|
||||
},
|
||||
});
|
||||
logger.debug(`Created audiobook ${audiobookRecord.id} for ASIN ${audiobook.asin}`);
|
||||
} else {
|
||||
// Update existing record with clean metadata (e.g. Audnexus title replacing Goodreads title)
|
||||
const updates: Record<string, any> = {};
|
||||
if (audiobook.title && audiobook.title !== audiobookRecord.title) updates.title = audiobook.title;
|
||||
if (audiobook.author && audiobook.author !== audiobookRecord.author) updates.author = audiobook.author;
|
||||
if (audiobook.coverArtUrl && !audiobookRecord.coverArtUrl) updates.coverArtUrl = audiobook.coverArtUrl;
|
||||
if (year) updates.year = year;
|
||||
if (series) updates.series = series;
|
||||
if (seriesPart) updates.seriesPart = seriesPart;
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
audiobookRecord = await prisma.audiobook.update({
|
||||
where: { id: audiobookRecord.id },
|
||||
data: updates,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user already has an active request for this audiobook
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
audiobookId: audiobookRecord.id,
|
||||
type: 'audiobook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingRequest) {
|
||||
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
|
||||
if (!canReRequest) {
|
||||
return {
|
||||
success: false,
|
||||
reason: 'duplicate',
|
||||
message: 'You have already requested this audiobook',
|
||||
};
|
||||
}
|
||||
// Delete existing failed/warn/cancelled request
|
||||
logger.debug(`Deleting existing ${existingRequest.status} request ${existingRequest.id} to allow re-request`);
|
||||
await prisma.request.delete({ where: { id: existingRequest.id } });
|
||||
}
|
||||
|
||||
// Check ANY user's active request for same audiobook (avoid duplicate processing)
|
||||
const anyActiveRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId: audiobookRecord.id,
|
||||
type: 'audiobook',
|
||||
status: { notIn: ['failed', 'warn', 'cancelled', 'available', 'downloaded'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (anyActiveRequest && anyActiveRequest.userId !== userId) {
|
||||
return {
|
||||
success: false,
|
||||
reason: 'being_processed',
|
||||
message: 'This audiobook is already being requested by another user',
|
||||
};
|
||||
}
|
||||
|
||||
// Determine if approval is needed
|
||||
let needsApproval = false;
|
||||
let shouldTriggerSearch = !skipAutoSearch;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true, autoApproveRequests: true, plexUsername: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { success: false, reason: 'user_not_found', message: 'User not found' };
|
||||
}
|
||||
|
||||
if (user.role === 'admin') {
|
||||
needsApproval = false;
|
||||
} else {
|
||||
if (user.autoApproveRequests === true) {
|
||||
needsApproval = false;
|
||||
} else if (user.autoApproveRequests === false) {
|
||||
needsApproval = true;
|
||||
} else {
|
||||
const globalConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'auto_approve_requests' },
|
||||
});
|
||||
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
|
||||
needsApproval = !globalAutoApprove;
|
||||
}
|
||||
}
|
||||
|
||||
let initialStatus: string;
|
||||
if (needsApproval) {
|
||||
initialStatus = 'awaiting_approval';
|
||||
shouldTriggerSearch = false;
|
||||
} else if (skipAutoSearch) {
|
||||
initialStatus = 'awaiting_search';
|
||||
} else {
|
||||
initialStatus = 'pending';
|
||||
}
|
||||
|
||||
// Create request
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId,
|
||||
audiobookId: audiobookRecord.id,
|
||||
status: initialStatus,
|
||||
type: 'audiobook',
|
||||
progress: 0,
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { id: true, plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
// Send notification
|
||||
const notificationType = initialStatus === 'awaiting_approval' ? 'request_pending_approval' : 'request_approved';
|
||||
await jobQueue.addNotificationJob(
|
||||
notificationType,
|
||||
newRequest.id,
|
||||
audiobookRecord.title,
|
||||
audiobookRecord.author,
|
||||
user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
// Trigger search job
|
||||
if (shouldTriggerSearch) {
|
||||
await jobQueue.addSearchJob(newRequest.id, {
|
||||
id: audiobookRecord.id,
|
||||
title: audiobookRecord.title,
|
||||
author: audiobookRecord.author,
|
||||
asin: audiobookRecord.audibleAsin || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, request: newRequest };
|
||||
}
|
||||
@@ -4,12 +4,13 @@
|
||||
*/
|
||||
|
||||
import { getJobQueueService, ScanPlexPayload } from './job-queue.service';
|
||||
import { getNotificationService } from './notification';
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('Scheduler');
|
||||
|
||||
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 type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves';
|
||||
|
||||
export interface ScheduledJob {
|
||||
id: string;
|
||||
@@ -49,6 +50,9 @@ export class SchedulerService {
|
||||
async start(): Promise<void> {
|
||||
logger.info('Initializing scheduler service...');
|
||||
|
||||
// Re-encrypt any notification backends with plaintext sensitive fields
|
||||
await getNotificationService().reEncryptUnprotectedBackends();
|
||||
|
||||
// Create default jobs if they don't exist
|
||||
await this.ensureDefaultJobs();
|
||||
|
||||
@@ -115,6 +119,13 @@ export class SchedulerService {
|
||||
enabled: true, // Enable by default
|
||||
payload: {},
|
||||
},
|
||||
{
|
||||
name: 'Sync Goodreads Shelves',
|
||||
type: 'sync_goodreads_shelves' as ScheduledJobType,
|
||||
schedule: '0 */6 * * *', // Every 6 hours
|
||||
enabled: true, // Enable by default
|
||||
payload: {},
|
||||
},
|
||||
];
|
||||
|
||||
for (const defaultJob of defaults) {
|
||||
@@ -314,6 +325,9 @@ export class SchedulerService {
|
||||
case 'monitor_rss_feeds':
|
||||
bullJobId = await this.triggerMonitorRssFeeds(job);
|
||||
break;
|
||||
case 'sync_goodreads_shelves':
|
||||
bullJobId = await this.triggerSyncGoodreadsShelves(job);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown job type: ${job.type}`);
|
||||
}
|
||||
@@ -578,6 +592,13 @@ export class SchedulerService {
|
||||
private async triggerCleanupSeededTorrents(job: any): Promise<string> {
|
||||
return await this.jobQueue.addCleanupSeededTorrentsJob(job.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Goodreads shelves sync
|
||||
*/
|
||||
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
|
||||
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -241,11 +241,19 @@ export async function enrichAudiobooksWithMatches(
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich with reported issue status
|
||||
const { getOpenIssuesByAsins } = await import('@/lib/services/reported-issue.service');
|
||||
const asinsWithIssues = await getOpenIssuesByAsins(asins);
|
||||
for (const result of results) {
|
||||
(result as any).hasReportedIssue = asinsWithIssues.has(result.asin);
|
||||
}
|
||||
|
||||
logger.debug('Batch summary', {
|
||||
total: results.length,
|
||||
available: results.filter(r => r.isAvailable).length,
|
||||
notAvailable: results.filter(r => !r.isAvailable).length,
|
||||
requested: userId ? results.filter(r => (r as any).isRequested).length : 'N/A',
|
||||
reportedIssues: asinsWithIssues.size,
|
||||
});
|
||||
|
||||
return results;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Component: Scrape Resilience Utilities
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Anti-503 resilience for Audible scraping: UA rotation, jittered backoff,
|
||||
* browser-like headers, adaptive pacing, and circuit breaker.
|
||||
*/
|
||||
|
||||
/** Pool of modern browser User-Agent strings */
|
||||
const USER_AGENTS = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
] as const;
|
||||
|
||||
/** Randomly select a User-Agent (call once per session, not per request) */
|
||||
export function pickUserAgent(): string {
|
||||
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
|
||||
}
|
||||
|
||||
/** Build a full set of realistic browser headers for the given UA */
|
||||
export function getBrowserHeaders(userAgent: string): Record<string, string> {
|
||||
return {
|
||||
'User-Agent': userAgent,
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Jittered exponential backoff: 2^attempt * baseMs * random(0.5, 1.5)
|
||||
* Avoids predictable retry timing that is trivially fingerprinted.
|
||||
*/
|
||||
export function jitteredBackoff(attempt: number, baseMs: number = 1000): number {
|
||||
const jitter = 0.5 + Math.random(); // 0.5 – 1.5
|
||||
return Math.round(Math.pow(2, attempt) * baseMs * jitter);
|
||||
}
|
||||
|
||||
/** Random integer in [minMs, maxMs] */
|
||||
export function randomDelay(minMs: number, maxMs: number): number {
|
||||
return minMs + Math.floor(Math.random() * (maxMs - minMs + 1));
|
||||
}
|
||||
|
||||
/** Metadata returned alongside each fetch result */
|
||||
export interface FetchResultMeta {
|
||||
retriesUsed: number;
|
||||
encountered503: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adaptive pacer that increases inter-page delays when retries are needed,
|
||||
* and triggers a circuit-breaker cooldown after consecutive retry-pages.
|
||||
*/
|
||||
export class AdaptivePacer {
|
||||
private consecutiveRetryPages = 0;
|
||||
private static readonly CIRCUIT_BREAKER_THRESHOLD = 3;
|
||||
|
||||
/** Report the result of a page fetch and get the recommended delay before the next page. */
|
||||
reportPageResult(meta: FetchResultMeta): number {
|
||||
if (meta.retriesUsed > 0) {
|
||||
this.consecutiveRetryPages++;
|
||||
|
||||
// Circuit breaker: pause 45-60s after sustained retries
|
||||
if (this.consecutiveRetryPages >= AdaptivePacer.CIRCUIT_BREAKER_THRESHOLD) {
|
||||
this.consecutiveRetryPages = 0;
|
||||
return randomDelay(45_000, 60_000);
|
||||
}
|
||||
|
||||
// Adaptive increase: multiply delay range by 1 + 0.5 * consecutive
|
||||
const multiplier = 1 + 0.5 * this.consecutiveRetryPages;
|
||||
return randomDelay(
|
||||
Math.round(2000 * multiplier),
|
||||
Math.round(4000 * multiplier),
|
||||
);
|
||||
}
|
||||
|
||||
// Successful page – gradually recover
|
||||
if (this.consecutiveRetryPages > 0) {
|
||||
this.consecutiveRetryPages--;
|
||||
}
|
||||
|
||||
// Base delay range
|
||||
return randomDelay(2000, 4000);
|
||||
}
|
||||
|
||||
/** Reset state (call between batches or on re-initialization). */
|
||||
reset(): void {
|
||||
this.consecutiveRetryPages = 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user