mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add authors pages and requestType notifications
Introduce full authors browsing/detail feature and enhance notifications to support type-specific titles. - Add server APIs: authors search, author detail, and author books routes (audnexus integration) that require auth and enrich results with library matches. - Add frontend pages/components: /authors listing and /authors/[asin] detail pages; AuthorCard, AuthorGrid, AuthorDetailCard, SimilarAuthorsRow, and related skeletons. - Add hook and integration stubs: new useAuthors hook and audnexus-authors integration; update audible service to expose audibleBaseUrl. - Update AudiobookDetailsModal to use audibleBaseUrl and link author names to author detail pages. - Add header navigation link to Authors. - Notifications: extend docs and code to include requestType (audiobook|ebook), add getEventTitle/getEventMeta helpers, update queue signature and providers/processors/tests to pass/handle requestType so titles can be resolved per request type. - Misc: job queue, processors, provider tests and notification tests updated to reflect new behavior. This change enables browsing authors and provides type-aware notification titles without per-provider changes.
This commit is contained in:
@@ -13,11 +13,12 @@ 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)
|
||||
* - `label`: Human-readable name shown in the UI
|
||||
* - `title`: Default title used in notification messages
|
||||
* - `titleByRequestType`: Optional map of request-type-specific titles (e.g. audiobook → "Audiobook Available")
|
||||
* - `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: {
|
||||
@@ -35,8 +36,12 @@ export const NOTIFICATION_EVENTS = {
|
||||
priority: 'normal' as const,
|
||||
},
|
||||
request_available: {
|
||||
label: 'Audiobook Available',
|
||||
title: 'Audiobook Available',
|
||||
label: 'Request Available',
|
||||
title: 'Request Available',
|
||||
titleByRequestType: {
|
||||
audiobook: 'Audiobook Available',
|
||||
ebook: 'Ebook Available',
|
||||
} as Record<string, string>,
|
||||
emoji: '\u{1F389}',
|
||||
severity: 'success' as const,
|
||||
priority: 'high' as const,
|
||||
@@ -71,6 +76,20 @@ export function getEventMeta(event: NotificationEvent) {
|
||||
return NOTIFICATION_EVENTS[event];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: get the resolved notification title for an event.
|
||||
* If the event has a `titleByRequestType` map and a matching requestType is provided,
|
||||
* returns the type-specific title. Otherwise falls back to the default `title`.
|
||||
*/
|
||||
export function getEventTitle(event: NotificationEvent, requestType?: string): string {
|
||||
const meta = NOTIFICATION_EVENTS[event];
|
||||
if (requestType && 'titleByRequestType' in meta) {
|
||||
const typeTitle = (meta as typeof meta & { titleByRequestType: Record<string, string> }).titleByRequestType[requestType];
|
||||
if (typeTitle) return typeTitle;
|
||||
}
|
||||
return meta.title;
|
||||
}
|
||||
|
||||
/** Helper: get the human-readable label for an event */
|
||||
export function getEventLabel(event: NotificationEvent): string {
|
||||
return NOTIFICATION_EVENTS[event].label;
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Audiobook {
|
||||
asin: string;
|
||||
title: string;
|
||||
author: string;
|
||||
authorAsin?: string;
|
||||
narrator?: string;
|
||||
description?: string;
|
||||
coverArtUrl?: string;
|
||||
@@ -81,6 +82,7 @@ export function useAudiobookDetails(asin: string | null) {
|
||||
|
||||
return {
|
||||
audiobook: data?.audiobook || null,
|
||||
audibleBaseUrl: data?.audibleBaseUrl || 'https://www.audible.com',
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Component: Authors Fetching Hooks
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
import { Audiobook } from './useAudiobooks';
|
||||
|
||||
export interface Author {
|
||||
asin: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
genres: string[];
|
||||
similarCount: number;
|
||||
}
|
||||
|
||||
export interface SimilarAuthor {
|
||||
asin: string;
|
||||
name: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export interface AuthorDetail {
|
||||
asin: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
genres: string[];
|
||||
similar: SimilarAuthor[];
|
||||
audibleUrl?: string;
|
||||
}
|
||||
|
||||
export function useAuthorSearch(name: string) {
|
||||
const shouldFetch = name && name.length > 0;
|
||||
const endpoint = shouldFetch
|
||||
? `/api/authors/search?name=${encodeURIComponent(name)}`
|
||||
: null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30000,
|
||||
});
|
||||
|
||||
return {
|
||||
authors: (data?.authors || []) as Author[],
|
||||
query: data?.query || '',
|
||||
isLoading: shouldFetch && isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuthorDetail(asin: string | null) {
|
||||
const endpoint = asin ? `/api/authors/${asin}` : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 300000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
return {
|
||||
author: (data?.author || null) as AuthorDetail | null,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuthorBooks(asin: string | null, authorName: string | null) {
|
||||
const shouldFetch = asin && authorName;
|
||||
const endpoint = shouldFetch
|
||||
? `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}`
|
||||
: null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 60000, // Cache for 1 minute
|
||||
});
|
||||
|
||||
return {
|
||||
books: (data?.books || []) as Audiobook[],
|
||||
totalBooks: data?.totalBooks || 0,
|
||||
isLoading: !!shouldFetch && isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export interface AudibleAudiobook {
|
||||
asin: string;
|
||||
title: string;
|
||||
author: string;
|
||||
authorAsin?: string;
|
||||
narrator?: string;
|
||||
description?: string;
|
||||
coverArtUrl?: string;
|
||||
@@ -61,6 +62,13 @@ export class AudibleService {
|
||||
// Client will be created lazily on first use
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Audible base URL for the configured region
|
||||
*/
|
||||
public getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-initialization (used when region config changes)
|
||||
*/
|
||||
@@ -269,6 +277,10 @@ export class AudibleService {
|
||||
const authorText = $el.find('.authorLabel').text().trim() ||
|
||||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
||||
|
||||
// Extract author ASIN from author link if available
|
||||
const authorHref = $el.find('a[href*="/author/"]').first().attr('href') || '';
|
||||
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
||||
|
||||
const narratorText = $el.find('.narratorLabel').text().trim() ||
|
||||
$el.find('.bc-size-small .bc-text-bold').eq(1).text().trim();
|
||||
|
||||
@@ -281,6 +293,7 @@ export class AudibleService {
|
||||
asin,
|
||||
title,
|
||||
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
|
||||
authorAsin: authorAsinMatch?.[1] || undefined,
|
||||
narrator: narratorText.replace('Narrated by:', '').trim(),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
rating,
|
||||
@@ -367,6 +380,10 @@ export class AudibleService {
|
||||
const authorText = $el.find('.authorLabel').text().trim() ||
|
||||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
||||
|
||||
// Extract author ASIN from author link if available
|
||||
const authorHref = $el.find('a[href*="/author/"]').first().attr('href') || '';
|
||||
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
||||
|
||||
const narratorText = $el.find('.narratorLabel').text().trim();
|
||||
|
||||
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||
@@ -378,6 +395,7 @@ export class AudibleService {
|
||||
asin,
|
||||
title,
|
||||
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
|
||||
authorAsin: authorAsinMatch?.[1] || undefined,
|
||||
narrator: narratorText.replace('Narrated by:', '').trim(),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
rating,
|
||||
@@ -454,10 +472,15 @@ export class AudibleService {
|
||||
$el.find('.bc-heading a').text().trim();
|
||||
|
||||
// Extract author from author link
|
||||
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
|
||||
const authorLink = $el.find('a[href*="/author/"]').first();
|
||||
const authorText = authorLink.text().trim() ||
|
||||
$el.find('.authorLabel').text().trim() ||
|
||||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
||||
|
||||
// Extract author ASIN from author link href
|
||||
const authorHref = authorLink.attr('href') || '';
|
||||
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
||||
|
||||
// Extract narrator from narrator search link
|
||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||
$el.find('.narratorLabel').text().trim();
|
||||
@@ -478,6 +501,7 @@ export class AudibleService {
|
||||
asin,
|
||||
title,
|
||||
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
|
||||
authorAsin: authorAsinMatch?.[1] || undefined,
|
||||
narrator: narratorText.replace('Narrated by:', '').trim(),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
durationMinutes,
|
||||
@@ -510,6 +534,129 @@ export class AudibleService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for all books by a specific author, validated by ASIN.
|
||||
* Uses Audible's searchAuthor parameter and paginates through all results.
|
||||
* Filters: (1) author link must contain the target ASIN, (2) language must be English.
|
||||
*/
|
||||
async searchByAuthorAsin(authorName: string, authorAsin: string): Promise<AudibleAudiobook[]> {
|
||||
await this.initialize();
|
||||
|
||||
const MAX_PAGES = 10;
|
||||
const allBooks: AudibleAudiobook[] = [];
|
||||
const seenAsins = new Set<string>();
|
||||
|
||||
try {
|
||||
logger.info(`Searching books by author "${authorName}" (ASIN: ${authorAsin})...`);
|
||||
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const { data: response, meta } = await this.fetchWithRetry('/search', {
|
||||
params: {
|
||||
ipRedirectOverride: 'true',
|
||||
searchAuthor: authorName,
|
||||
pageSize: AUDIBLE_PAGE_SIZE,
|
||||
page,
|
||||
},
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
let pageResults = 0;
|
||||
|
||||
$('.s-result-item, .productListItem').each((_index, element) => {
|
||||
const $el = $(element);
|
||||
|
||||
// --- Language filter: require explicit "English" ---
|
||||
const langText = $el.find('span:contains("Language:")').text().trim() ||
|
||||
$el.find('.languageLabel').text().trim();
|
||||
// Extract language value (e.g. "Language: English" → "English")
|
||||
const langMatch = langText.match(/Language:\s*(.+)/i);
|
||||
const language = langMatch?.[1]?.trim();
|
||||
if (!language || language.toLowerCase() !== 'english') return;
|
||||
|
||||
// --- Author ASIN filter: verify target ASIN in author links ---
|
||||
const authorLinks = $el.find('a[href*="/author/"]');
|
||||
let hasMatchingAuthor = false;
|
||||
authorLinks.each((_i, link) => {
|
||||
const href = $(link).attr('href') || '';
|
||||
const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
||||
if (asinMatch && asinMatch[1] === authorAsin) {
|
||||
hasMatchingAuthor = true;
|
||||
return false; // break .each()
|
||||
}
|
||||
});
|
||||
if (!hasMatchingAuthor) return;
|
||||
|
||||
// --- Extract book ASIN ---
|
||||
const bookAsin = $el.find('li').attr('data-asin') ||
|
||||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||
if (!bookAsin || seenAsins.has(bookAsin)) return;
|
||||
seenAsins.add(bookAsin);
|
||||
|
||||
// --- Parse book details ---
|
||||
const title = $el.find('h2').first().text().trim() ||
|
||||
$el.find('h3 a').text().trim() ||
|
||||
$el.find('.bc-heading a').text().trim();
|
||||
|
||||
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
|
||||
$el.find('.authorLabel').text().trim() ||
|
||||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
||||
|
||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||
$el.find('.narratorLabel').text().trim();
|
||||
|
||||
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||
|
||||
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||
$el.find('span:contains("Length:")').text().trim();
|
||||
const durationMinutes = this.parseRuntime(runtimeText);
|
||||
|
||||
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
||||
$el.find('.a-icon-star span').first().text().trim();
|
||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||
|
||||
allBooks.push({
|
||||
asin: bookAsin,
|
||||
title,
|
||||
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
|
||||
authorAsin,
|
||||
narrator: narratorText.replace('Narrated by:', '').trim(),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
durationMinutes,
|
||||
rating,
|
||||
});
|
||||
|
||||
pageResults++;
|
||||
});
|
||||
|
||||
// Check if there are more pages
|
||||
const resultsText = $('.resultsInfo').text().trim();
|
||||
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
|
||||
const hasMore = totalResults > page * AUDIBLE_PAGE_SIZE;
|
||||
|
||||
logger.info(`Author books page ${page}: ${pageResults} valid results (${allBooks.length} total, ${totalResults} Audible total)`);
|
||||
|
||||
if (!hasMore || pageResults === 0) break;
|
||||
|
||||
// Pace between pages
|
||||
if (page < MAX_PAGES) {
|
||||
await this.delay(this.pacer.reportPageResult(meta));
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Author books search complete: "${authorName}" → ${allBooks.length} books`);
|
||||
return allBooks;
|
||||
} catch (error) {
|
||||
logger.error(`Author books search failed for "${authorName}"`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
collectedSoFar: allBooks.length,
|
||||
});
|
||||
// Return what we collected before the error
|
||||
return allBooks;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed audiobook information
|
||||
* Primary: Audnexus API (reliable, structured data)
|
||||
@@ -563,6 +710,7 @@ export class AudibleService {
|
||||
asin,
|
||||
title: data.title || '',
|
||||
author: data.authors?.map((a: any) => a.name).join(', ') || '',
|
||||
authorAsin: data.authors?.[0]?.asin || undefined,
|
||||
narrator: data.narrators?.map((n: any) => n.name).join(', ') || '',
|
||||
description: data.description || data.summary || '',
|
||||
coverArtUrl: data.image || '',
|
||||
@@ -723,6 +871,15 @@ export class AudibleService {
|
||||
logger.info(` Author from HTML: "${result.author}"`);
|
||||
}
|
||||
|
||||
// Author ASIN - extract from the first author link
|
||||
if (!result.authorAsin) {
|
||||
const firstAuthorHref = $('a[href*="/author/"]').first().attr('href') || '';
|
||||
const authorAsinMatch = firstAuthorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
||||
if (authorAsinMatch) {
|
||||
result.authorAsin = authorAsinMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Narrator - try multiple approaches (only in product details area)
|
||||
if (!result.narrator) {
|
||||
// Look specifically in the product details section
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Component: Audnexus Author API Integration
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Shared utilities for fetching author data from the Audnexus API.
|
||||
* Used by author search, author detail, and similar authors routes.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('Audnexus.Authors');
|
||||
|
||||
const AUDNEXUS_BASE = 'https://api.audnex.us';
|
||||
const AUDNEXUS_TIMEOUT = 10000;
|
||||
const AUDNEXUS_HEADERS = { 'User-Agent': 'ReadMeABook/1.0' };
|
||||
|
||||
export interface AudnexusAuthorSearch {
|
||||
asin: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AudnexusAuthorGenre {
|
||||
asin: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface AudnexusAuthorSimilar {
|
||||
asin: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AudnexusAuthorDetail {
|
||||
asin: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
region: string;
|
||||
genres?: AudnexusAuthorGenre[];
|
||||
similar?: AudnexusAuthorSimilar[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with retry and exponential backoff for Audnexus API
|
||||
*/
|
||||
export async function audnexusFetchWithRetry(url: string, params: Record<string, string>, maxRetries = 3): Promise<any> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await axios.get(url, {
|
||||
params,
|
||||
timeout: AUDNEXUS_TIMEOUT,
|
||||
headers: AUDNEXUS_HEADERS,
|
||||
});
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
const status = error.response?.status;
|
||||
const isRetryable = !status || status === 503 || status === 429 || status >= 500;
|
||||
|
||||
if (!isRetryable) throw error;
|
||||
if (attempt === maxRetries) break;
|
||||
|
||||
const backoffMs = Math.pow(2, attempt) * 1000;
|
||||
logger.info(`Audnexus request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, backoffMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Audnexus request failed after retries');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search authors via Audnexus and return deduplicated results
|
||||
*/
|
||||
export async function searchAuthors(name: string, region: string): Promise<AudnexusAuthorSearch[]> {
|
||||
const response = await audnexusFetchWithRetry(`${AUDNEXUS_BASE}/authors`, { region, name });
|
||||
const results: AudnexusAuthorSearch[] = response.data;
|
||||
|
||||
const seen = new Set<string>();
|
||||
return results.filter(author => {
|
||||
if (seen.has(author.asin)) return false;
|
||||
seen.add(author.asin);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch full author details from Audnexus
|
||||
*/
|
||||
export async function fetchAuthorDetail(asin: string, region: string): Promise<AudnexusAuthorDetail | null> {
|
||||
try {
|
||||
const response = await audnexusFetchWithRetry(`${AUDNEXUS_BASE}/authors/${asin}`, { region });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
logger.debug(`Author not found on Audnexus: ${asin}`);
|
||||
} else {
|
||||
logger.warn(`Failed to fetch author detail: ${asin}`, { error: error.message });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -622,7 +622,9 @@ async function processEbookOrganization(
|
||||
requestId,
|
||||
book.title,
|
||||
book.author,
|
||||
request.user.plexUsername || 'Unknown User'
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
undefined,
|
||||
'ebook'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
@@ -325,7 +325,9 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
request.id,
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User'
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
undefined,
|
||||
'audiobook'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
@@ -514,7 +514,9 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
request.id,
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User'
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
undefined,
|
||||
'audiobook'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ export type { SendNotificationPayload } from '../services/job-queue.service';
|
||||
* Calls NotificationService to send notifications to all enabled backends
|
||||
*/
|
||||
export async function processSendNotification(payload: SendNotificationPayload): Promise<void> {
|
||||
const { event, requestId, issueId, title, author, userName, message, jobId } = payload;
|
||||
const { event, requestId, issueId, title, author, userName, message, requestType, jobId } = payload;
|
||||
|
||||
const logger = RMABLogger.forJob(jobId, 'SendNotification');
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function processSendNotification(payload: SendNotificationPayload):
|
||||
author,
|
||||
userName,
|
||||
message,
|
||||
requestType,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ export interface SendNotificationPayload extends JobPayload {
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string;
|
||||
requestType?: string; // 'audiobook' | 'ebook' — drives type-specific notification titles
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
@@ -948,7 +949,8 @@ export class JobQueueService {
|
||||
title: string,
|
||||
author: string,
|
||||
userName: string,
|
||||
message?: string
|
||||
message?: string,
|
||||
requestType?: string
|
||||
): Promise<string> {
|
||||
logger.info(`Queueing notification: ${event}`, { requestId, title, userName });
|
||||
return await this.addJob(
|
||||
@@ -963,6 +965,7 @@ export class JobQueueService {
|
||||
author,
|
||||
userName,
|
||||
message,
|
||||
requestType,
|
||||
// Pass the original ID for notification display (e.g., Discord footer)
|
||||
...(event === 'issue_reported' && { issueId: requestId }),
|
||||
timestamp: new Date(),
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface NotificationPayload {
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string; // For error/issue events
|
||||
requestType?: string; // 'audiobook' | 'ebook' — drives type-specific titles via getEventTitle()
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events';
|
||||
import { getEventMeta, getEventTitle, type NotificationSeverity } from '@/lib/constants/notification-events';
|
||||
|
||||
export interface AppriseConfig {
|
||||
serverUrl: string;
|
||||
@@ -108,8 +108,7 @@ 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 { event, title, author, userName, message, requestType } = payload;
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const messageLines = [
|
||||
@@ -123,7 +122,7 @@ export class AppriseProvider implements INotificationProvider {
|
||||
}
|
||||
|
||||
return {
|
||||
title: meta.title,
|
||||
title: getEventTitle(event, requestType),
|
||||
body: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events';
|
||||
import { getEventMeta, getEventTitle, type NotificationSeverity } from '@/lib/constants/notification-events';
|
||||
|
||||
export interface DiscordConfig {
|
||||
webhookUrl: string;
|
||||
@@ -59,8 +59,9 @@ export class DiscordProvider implements INotificationProvider {
|
||||
}
|
||||
|
||||
private formatEmbed(payload: NotificationPayload): any {
|
||||
const { event, title, author, userName, message, requestId, timestamp } = payload;
|
||||
const { event, title, author, userName, message, requestId, requestType, timestamp } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
const resolvedTitle = getEventTitle(event, requestType);
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const fields = [
|
||||
@@ -74,7 +75,7 @@ export class DiscordProvider implements INotificationProvider {
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${meta.emoji} ${meta.title}`,
|
||||
title: `${meta.emoji} ${resolvedTitle}`,
|
||||
color: SEVERITY_COLORS[meta.severity],
|
||||
fields,
|
||||
footer: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
import { getEventMeta, type NotificationSeverity, type NotificationPriority } from '@/lib/constants/notification-events';
|
||||
import { getEventMeta, getEventTitle, type NotificationSeverity, type NotificationPriority } from '@/lib/constants/notification-events';
|
||||
|
||||
export interface NtfyConfig {
|
||||
serverUrl?: string;
|
||||
@@ -83,8 +83,7 @@ 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 { event, title, author, userName, message, requestType } = payload;
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const messageLines = [
|
||||
@@ -98,7 +97,7 @@ export class NtfyProvider implements INotificationProvider {
|
||||
}
|
||||
|
||||
return {
|
||||
title: meta.title,
|
||||
title: getEventTitle(event, requestType),
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
import { getEventMeta, type NotificationPriority } from '@/lib/constants/notification-events';
|
||||
import { getEventMeta, getEventTitle, type NotificationPriority } from '@/lib/constants/notification-events';
|
||||
|
||||
export interface PushoverConfig {
|
||||
userKey: string;
|
||||
@@ -77,12 +77,13 @@ export class PushoverProvider implements INotificationProvider {
|
||||
}
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
const { event, title, author, userName, message, requestType } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
const resolvedTitle = getEventTitle(event, requestType);
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const messageLines = [
|
||||
`${meta.emoji} ${meta.title}`,
|
||||
`${meta.emoji} ${resolvedTitle}`,
|
||||
'',
|
||||
`\u{1F4DA} ${title}`,
|
||||
`\u270D\uFE0F ${author}`,
|
||||
@@ -94,7 +95,7 @@ export class PushoverProvider implements INotificationProvider {
|
||||
}
|
||||
|
||||
return {
|
||||
title: meta.title,
|
||||
title: resolvedTitle,
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user