diff --git a/documentation/backend/services/notifications.md b/documentation/backend/services/notifications.md
index 41172a5..aba1a9f 100644
--- a/documentation/backend/services/notifications.md
+++ b/documentation/backend/services/notifications.md
@@ -33,10 +33,16 @@ model NotificationBackend {
|-------|---------|------------------------|
| request_pending_approval | User creates request | Request needs admin approval |
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
-| request_available | Plex/ABS scan completes | Audiobook available in library |
+| request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) |
| request_error | Download/import fails | Request failed at any stage |
| issue_reported | User reports issue | User reports problem with available audiobook |
+**Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles.
+- `request_available` + `requestType: 'audiobook'` → "Audiobook Available"
+- `request_available` + `requestType: 'ebook'` → "Ebook Available"
+- `request_available` + no requestType → "Request Available" (fallback)
+- Use `getEventTitle(event, requestType?)` to resolve titles in providers
+
## Notification Triggers
**Request Creation (POST /api/requests)**
@@ -60,10 +66,14 @@ model NotificationBackend {
- Approve (with or without pre-selected torrent): After job triggered → request_approved
- Deny: No notification
-**Request Available (processors: scan-plex, plex-recently-added)**
-- After `status: 'available'` update → request_available
+**Audiobook Available (processors: scan-plex, plex-recently-added)**
+- After `status: 'available'` update → request_available (requestType: 'audiobook')
- Includes user info in query (plexUsername)
+**Ebook Available (processor: organize-files)**
+- After ebook `status: 'downloaded'` (terminal) → request_available (requestType: 'ebook')
+- Ebooks don't transition to 'available' via Plex matching
+
**Request Error (processors: monitor-download, organize-files)**
- After `status: 'failed'` or `status: 'warn'` update → request_error
- Includes error message in payload
@@ -166,6 +176,7 @@ model NotificationBackend {
author: string,
userName: string,
message?: string,
+ requestType?: string, // 'audiobook' | 'ebook' — drives type-specific titles
timestamp: Date
}
```
@@ -174,7 +185,7 @@ model NotificationBackend {
- Calls NotificationService.sendNotification()
- Non-blocking error handling (logs but doesn't throw)
-**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?)`
+**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?, requestType?)`
## Architecture
@@ -203,10 +214,15 @@ src/lib/services/notification/
**ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }`
**ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }`
-**Helper functions:**
+**Helper functions (notification.service.ts):**
- `getRegisteredProviderTypes(): string[]` — all registered type keys
- `getAllProviderMetadata(): ProviderMetadata[]` — metadata for all providers
+**Helper functions (notification-events.ts):**
+- `getEventMeta(event)` — raw event metadata (label, title, emoji, severity, priority)
+- `getEventTitle(event, requestType?)` — resolved title (checks `titleByRequestType` first, falls back to `title`)
+- `getEventLabel(event)` — human-readable label for UI
+
**API Endpoint:** `GET /api/admin/notifications/providers` — returns all provider metadata (admin-only)
## Extensibility
@@ -221,10 +237,10 @@ src/lib/services/notification/
No UI changes, no API route changes, no Zod schema changes needed — the UI renders dynamically from provider metadata.
**Adding New Event (e.g., download_complete):**
-1. Add 'download_complete' to NotificationEvent enum
-2. Add to event labels in UI
-3. Add trigger point in processor
-4. Add message formatting in Discord/Pushover formatters
+1. Add entry to `NOTIFICATION_EVENTS` in `notification-events.ts` (label, title, emoji, severity, priority)
+2. Optionally add `titleByRequestType` for type-specific titles
+3. Add trigger point in processor, passing `requestType` if relevant
+4. Providers auto-resolve titles via `getEventTitle()` — no per-provider changes needed
## Tech Stack
- Bull (job queue)
diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts
index f3524c0..847e192 100644
--- a/src/app/api/admin/notifications/test/route.ts
+++ b/src/app/api/admin/notifications/test/route.ts
@@ -68,6 +68,7 @@ export async function POST(request: NextRequest) {
title: "The Hitchhiker's Guide to the Galaxy",
author: 'Douglas Adams',
userName: 'Test User',
+ requestType: 'audiobook',
timestamp: new Date(),
};
diff --git a/src/app/api/audiobooks/[asin]/route.ts b/src/app/api/audiobooks/[asin]/route.ts
index 78a3dad..b229182 100644
--- a/src/app/api/audiobooks/[asin]/route.ts
+++ b/src/app/api/audiobooks/[asin]/route.ts
@@ -46,6 +46,7 @@ export async function GET(
return NextResponse.json({
success: true,
audiobook,
+ audibleBaseUrl: audibleService.getBaseUrl(),
});
} catch (error) {
logger.error('Failed to get audiobook details', { error: error instanceof Error ? error.message : String(error) });
diff --git a/src/app/api/authors/[asin]/books/route.ts b/src/app/api/authors/[asin]/books/route.ts
new file mode 100644
index 0000000..83368b5
--- /dev/null
+++ b/src/app/api/authors/[asin]/books/route.ts
@@ -0,0 +1,74 @@
+/**
+ * Component: Author Books API Route
+ * Documentation: documentation/integrations/audible.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { getAudibleService } from '@/lib/integrations/audible.service';
+import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
+import { getCurrentUser } from '@/lib/middleware/auth';
+import { RMABLogger } from '@/lib/utils/logger';
+
+const logger = RMABLogger.create('API.Authors.Books');
+
+/**
+ * GET /api/authors/{asin}/books?name=Author+Name
+ * Scrape Audible for all books by this author, filtered by ASIN and English language.
+ * Enriched with library availability and request status.
+ */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ asin: string }> }
+) {
+ try {
+ const currentUser = getCurrentUser(request);
+ if (!currentUser) {
+ return NextResponse.json(
+ { error: 'Unauthorized', message: 'Authentication required' },
+ { status: 401 }
+ );
+ }
+
+ const { asin } = await params;
+ const authorName = request.nextUrl.searchParams.get('name');
+
+ if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
+ return NextResponse.json(
+ { error: 'ValidationError', message: 'Valid author ASIN is required' },
+ { status: 400 }
+ );
+ }
+
+ if (!authorName || authorName.trim().length === 0) {
+ return NextResponse.json(
+ { error: 'ValidationError', message: 'Author name is required' },
+ { status: 400 }
+ );
+ }
+
+ logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin})`);
+
+ const audibleService = getAudibleService();
+ const books = await audibleService.searchByAuthorAsin(authorName.trim(), asin);
+
+ // Enrich with library availability and request status
+ const userId = currentUser.sub || undefined;
+ const enrichedBooks = await enrichAudiobooksWithMatches(books, userId);
+
+ logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books`);
+
+ return NextResponse.json({
+ success: true,
+ books: enrichedBooks,
+ authorName: authorName.trim(),
+ authorAsin: asin,
+ totalBooks: enrichedBooks.length,
+ });
+ } catch (error) {
+ logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) });
+ return NextResponse.json(
+ { error: 'FetchError', message: 'Failed to fetch author books' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/authors/[asin]/route.ts b/src/app/api/authors/[asin]/route.ts
new file mode 100644
index 0000000..e494ef9
--- /dev/null
+++ b/src/app/api/authors/[asin]/route.ts
@@ -0,0 +1,94 @@
+/**
+ * Component: Author Detail API Route
+ * Documentation: documentation/integrations/audible.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { getCurrentUser } from '@/lib/middleware/auth';
+import { getConfigService } from '@/lib/services/config.service';
+import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION, AudibleRegion } from '@/lib/types/audible';
+import { RMABLogger } from '@/lib/utils/logger';
+import {
+ AudnexusAuthorDetail,
+ fetchAuthorDetail,
+} from '@/lib/integrations/audnexus-authors';
+
+const logger = RMABLogger.create('API.Authors.Detail');
+
+const SIMILAR_AUTHORS_LIMIT = 15;
+
+/**
+ * GET /api/authors/{asin}
+ * Fetch author detail from Audnexus with enriched similar author images
+ */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ asin: string }> }
+) {
+ try {
+ const currentUser = getCurrentUser(request);
+ if (!currentUser) {
+ return NextResponse.json(
+ { error: 'Unauthorized', message: 'Authentication required' },
+ { status: 401 }
+ );
+ }
+
+ const { asin } = await params;
+
+ if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
+ return NextResponse.json(
+ { error: 'ValidationError', message: 'Valid author ASIN is required' },
+ { status: 400 }
+ );
+ }
+
+ const configService = getConfigService();
+ const audibleRegion: AudibleRegion = await configService.getAudibleRegion();
+ const regionConfig = AUDIBLE_REGIONS[audibleRegion] || AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION];
+ const region = regionConfig.audnexusParam;
+
+ logger.info(`Fetching author detail: ${asin} (region: ${region})`);
+
+ // Fetch the primary author detail
+ const detail = await fetchAuthorDetail(asin, region);
+ if (!detail) {
+ return NextResponse.json(
+ { error: 'NotFound', message: 'Author not found' },
+ { status: 404 }
+ );
+ }
+
+ // Fetch images for similar authors in parallel (capped)
+ const similarSlice = (detail.similar || []).slice(0, SIMILAR_AUTHORS_LIMIT);
+ const similarDetails = await Promise.all(
+ similarSlice.map(s => fetchAuthorDetail(s.asin, region))
+ );
+
+ const similarAuthors = similarSlice.map((s, i) => ({
+ asin: s.asin,
+ name: s.name,
+ image: similarDetails[i]?.image || undefined,
+ }));
+
+ const author = {
+ asin: detail.asin,
+ name: detail.name,
+ description: detail.description || undefined,
+ image: detail.image || undefined,
+ genres: detail.genres?.map(g => g.name) || [],
+ similar: similarAuthors,
+ audibleUrl: `${regionConfig.baseUrl}/author/${asin}`,
+ };
+
+ logger.info(`Author detail complete: "${detail.name}" (${similarAuthors.length} similar authors)`);
+
+ return NextResponse.json({ success: true, author });
+ } catch (error) {
+ logger.error('Failed to fetch author detail', { error: error instanceof Error ? error.message : String(error) });
+ return NextResponse.json(
+ { error: 'FetchError', message: 'Failed to fetch author details' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/authors/search/route.ts b/src/app/api/authors/search/route.ts
new file mode 100644
index 0000000..e45ebad
--- /dev/null
+++ b/src/app/api/authors/search/route.ts
@@ -0,0 +1,91 @@
+/**
+ * Component: Author Search API Route
+ * Documentation: documentation/integrations/audible.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { getCurrentUser } from '@/lib/middleware/auth';
+import { getConfigService } from '@/lib/services/config.service';
+import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION, AudibleRegion } from '@/lib/types/audible';
+import { RMABLogger } from '@/lib/utils/logger';
+import {
+ AudnexusAuthorDetail,
+ searchAuthors,
+ fetchAuthorDetail,
+} from '@/lib/integrations/audnexus-authors';
+
+const logger = RMABLogger.create('API.Authors.Search');
+
+/**
+ * GET /api/authors/search?name=Brandon Sanderson
+ * Search for authors on Audnexus, deduplicate, and return enriched details
+ */
+export async function GET(request: NextRequest) {
+ try {
+ // Require authentication
+ const currentUser = getCurrentUser(request);
+ if (!currentUser) {
+ return NextResponse.json(
+ { error: 'Unauthorized', message: 'Authentication required' },
+ { status: 401 }
+ );
+ }
+
+ const name = request.nextUrl.searchParams.get('name');
+
+ if (!name || name.trim().length === 0) {
+ return NextResponse.json(
+ { error: 'ValidationError', message: 'Author name is required' },
+ { status: 400 }
+ );
+ }
+
+ // Get configured Audible region
+ const configService = getConfigService();
+ const audibleRegion: AudibleRegion = await configService.getAudibleRegion();
+ const region = AUDIBLE_REGIONS[audibleRegion]?.audnexusParam || AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION].audnexusParam;
+
+ logger.info(`Searching authors: "${name}" (region: ${region})`);
+
+ // Step 1: Search for authors (returns list with potential duplicates)
+ const searchResults = await searchAuthors(name.trim(), region);
+
+ if (searchResults.length === 0) {
+ return NextResponse.json({
+ success: true,
+ authors: [],
+ query: name.trim(),
+ });
+ }
+
+ // Step 2: Fetch details for all unique authors in parallel
+ const detailPromises = searchResults.map(author => fetchAuthorDetail(author.asin, region));
+ const detailResults = await Promise.all(detailPromises);
+
+ // Step 3: Build enriched results, filtering out any failed fetches
+ const authors = detailResults
+ .filter((detail): detail is AudnexusAuthorDetail => detail !== null)
+ .map(detail => ({
+ asin: detail.asin,
+ name: detail.name,
+ description: detail.description || undefined,
+ image: detail.image || undefined,
+ genres: detail.genres?.map(g => g.name).slice(0, 3) || [],
+ similarCount: detail.similar?.length || 0,
+ }));
+
+ logger.info(`Author search complete: "${name}" → ${authors.length} results`);
+
+ return NextResponse.json({
+ success: true,
+ authors,
+ query: name.trim(),
+ });
+ } catch (error) {
+ logger.error('Failed to search authors', { error: error instanceof Error ? error.message : String(error) });
+ return NextResponse.json(
+ { error: 'SearchError', message: 'Failed to search authors' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/authors/[asin]/page.tsx b/src/app/authors/[asin]/page.tsx
new file mode 100644
index 0000000..accee7e
--- /dev/null
+++ b/src/app/authors/[asin]/page.tsx
@@ -0,0 +1,121 @@
+/**
+ * Component: Author Detail Page
+ * Documentation: documentation/frontend/components.md
+ */
+
+'use client';
+
+import { use, useCallback } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { Header } from '@/components/layout/Header';
+import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
+import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard';
+import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow';
+import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors';
+import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
+import { CardSizeControls } from '@/components/ui/CardSizeControls';
+import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
+import { usePreferences } from '@/contexts/PreferencesContext';
+
+export default function AuthorDetailPage({
+ params,
+}: {
+ params: Promise<{ asin: string }>;
+}) {
+ const { asin } = use(params);
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const fromAuthorName = searchParams.get('from');
+ const { author, isLoading: authorLoading } = useAuthorDetail(asin);
+ const { books, totalBooks, isLoading: booksLoading } = useAuthorBooks(
+ asin,
+ author?.name || null
+ );
+ const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
+
+ const handleBack = useCallback(() => {
+ // Use browser back if we came from within the app, otherwise fallback to /authors
+ if (window.history.length > 1) {
+ router.back();
+ } else {
+ router.push('/authors');
+ }
+ }, [router]);
+
+ return (
+
+
+
+
+
+ {/* Back navigation */}
+
+
+ {/* Author Detail Card */}
+ {authorLoading ? (
+
+ ) : author ? (
+
+ ) : (
+
+
+
Author not found
+
+ )}
+
+ {/* Similar Authors */}
+ {authorLoading ? (
+
+ ) : author && author.similar.length > 0 ? (
+
+ ) : null}
+
+ {/* Books Section */}
+ {author && (
+
+ {/* Sticky Books Header */}
+
+
+
+
+
+ Books
+
+ {!booksLoading && totalBooks > 0 && (
+
+ ({totalBooks} title{totalBooks !== 1 ? 's' : ''})
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Books Grid */}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/authors/page.tsx b/src/app/authors/page.tsx
new file mode 100644
index 0000000..0b61ecb
--- /dev/null
+++ b/src/app/authors/page.tsx
@@ -0,0 +1,176 @@
+/**
+ * Component: Authors Page
+ * Documentation: documentation/frontend/components.md
+ */
+
+'use client';
+
+import { Suspense, useState, useEffect, useCallback } from 'react';
+import { useSearchParams, useRouter } from 'next/navigation';
+import { Header } from '@/components/layout/Header';
+import { AuthorGrid } from '@/components/authors/AuthorGrid';
+import { useAuthorSearch } from '@/lib/hooks/useAuthors';
+import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
+import { CardSizeControls } from '@/components/ui/CardSizeControls';
+import { usePreferences } from '@/contexts/PreferencesContext';
+
+function AuthorsPageContent() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const initialQuery = searchParams.get('q') || '';
+
+ const [query, setQuery] = useState(initialQuery);
+ const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
+ const { cardSize, setCardSize } = usePreferences();
+
+ // Debounce search query and sync to URL
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedQuery(query);
+ // Update URL without adding history entries
+ const trimmed = query.trim();
+ if (trimmed) {
+ router.replace(`/authors?q=${encodeURIComponent(trimmed)}`, { scroll: false });
+ } else {
+ router.replace('/authors', { scroll: false });
+ }
+ }, 500);
+
+ return () => clearTimeout(timer);
+ }, [query, router]);
+
+ const { authors, isLoading } = useAuthorSearch(debouncedQuery);
+
+ const handleSearch = useCallback((e: React.FormEvent) => {
+ e.preventDefault();
+ }, []);
+
+ return (
+
+
+
+
+
+ {/* Page Header */}
+
+
+ Browse Authors
+
+
+ Search for your favorite audiobook authors
+
+
+
+ {/* Search Form */}
+
+
+ {/* Results */}
+ {debouncedQuery ? (
+
+ {/* Sticky Results Header */}
+
+
+
+
+
+ Authors
+
+ {!isLoading && authors.length > 0 && (
+
+ ({authors.length} result{authors.length !== 1 ? 's' : ''})
+
+ )}
+
+
+
+
+
+
+
+ {/* Author Grid */}
+
+
+ ) : (
+ /* Empty State */
+
+
+
+ Start typing to search for authors
+
+
+ Search by author name to discover their works
+
+
+ )}
+
+
+
+ );
+}
+
+export default function AuthorsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx
index 3a759c8..2d19a1f 100644
--- a/src/components/audiobooks/AudiobookDetailsModal.tsx
+++ b/src/components/audiobooks/AudiobookDetailsModal.tsx
@@ -10,6 +10,7 @@
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
+import Link from 'next/link';
import { createPortal } from 'react-dom';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
@@ -71,7 +72,7 @@ export function AudiobookDetailsModal({
}: AudiobookDetailsModalProps) {
const { user } = useAuth();
const { squareCovers } = usePreferences();
- const { audiobook, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
+ const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
const { createRequest, isLoading: isRequesting } = useCreateRequest();
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
@@ -286,7 +287,20 @@ export function AudiobookDetailsModal({
{audiobook.title}
- {audiobook.author}
+ {audiobook.authorAsin ? (
+ {
+ e.stopPropagation();
+ onClose();
+ }}
+ className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
+ >
+ {audiobook.author}
+
+ ) : (
+ audiobook.author
+ )}
{audiobook.narrator && (
@@ -418,7 +432,7 @@ export function AudiobookDetailsModal({
Source
+ {/* Circular Portrait Container */}
+
+
+ {author.image ? (
+
+ ) : (
+
+ )}
+
+ {/* Subtle hover overlay */}
+
+
+
+
+ {/* Author Info */}
+
+
+ {author.name}
+
+
+ {/* Genre Pills */}
+ {author.genres.length > 0 && (
+
+ {author.genres.map(genre => (
+
+ {genre}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/authors/AuthorDetailCard.tsx b/src/components/authors/AuthorDetailCard.tsx
new file mode 100644
index 0000000..9acccce
--- /dev/null
+++ b/src/components/authors/AuthorDetailCard.tsx
@@ -0,0 +1,135 @@
+/**
+ * Component: Author Detail Card
+ * Documentation: documentation/frontend/components.md
+ *
+ * Hero section for the author detail page with circular portrait,
+ * name, collapsible biography, and genre pills.
+ */
+
+'use client';
+
+import React, { useState } from 'react';
+import Image from 'next/image';
+import { AuthorDetail } from '@/lib/hooks/useAuthors';
+
+interface AuthorDetailCardProps {
+ author: AuthorDetail;
+}
+
+export function AuthorDetailCard({ author }: AuthorDetailCardProps) {
+ const [expanded, setExpanded] = useState(false);
+ const hasLongDescription = (author.description?.length || 0) > 300;
+
+ return (
+
+ {/* Circular Portrait */}
+
+
+ {author.image ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Author Info */}
+
+
+ {author.name}
+
+
+ {/* Genre Pills */}
+ {author.genres.length > 0 && (
+
+ {author.genres.map(genre => (
+
+ {genre}
+
+ ))}
+
+ )}
+
+ {/* Audible Link */}
+ {author.audibleUrl && (
+
+ View on Audible
+
+
+ )}
+
+ {/* Description */}
+ {author.description && (
+
+
+ {author.description}
+
+ {hasLongDescription && (
+
+ )}
+
+ )}
+
+
+ );
+}
+
+export function AuthorDetailSkeleton() {
+ return (
+
+ {/* Portrait skeleton */}
+
+
+ {/* Info skeleton */}
+
+
+ );
+}
diff --git a/src/components/authors/AuthorGrid.tsx b/src/components/authors/AuthorGrid.tsx
new file mode 100644
index 0000000..b30ad3d
--- /dev/null
+++ b/src/components/authors/AuthorGrid.tsx
@@ -0,0 +1,100 @@
+/**
+ * Component: Author Grid
+ * Documentation: documentation/frontend/components.md
+ *
+ * Premium grid layout for author cards with loading skeletons and empty state.
+ * Mirrors AudiobookGrid patterns with author-appropriate column counts.
+ */
+
+'use client';
+
+import React from 'react';
+import { AuthorCard } from './AuthorCard';
+import { Author } from '@/lib/hooks/useAuthors';
+
+interface AuthorGridProps {
+ authors: Author[];
+ isLoading?: boolean;
+ emptyMessage?: string;
+ cardSize?: number;
+}
+
+// Authors use wider spacing since circular portraits need room to breathe.
+// Slightly fewer columns than AudiobookGrid at each breakpoint since circles
+// are visually wider than 2:3 portrait covers.
+function getGridClasses(size: number): string {
+ const sizeMap: Record = {
+ 1: 'grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
+ 2: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8',
+ 3: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7',
+ 4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6',
+ 5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
+ 6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
+ 7: 'grid-cols-2 md:grid-cols-3',
+ 8: 'grid-cols-2',
+ 9: 'grid-cols-1',
+ };
+ return sizeMap[size] || sizeMap[5];
+}
+
+export function AuthorGrid({
+ authors,
+ isLoading = false,
+ emptyMessage = 'No authors found',
+ cardSize = 5,
+}: AuthorGridProps) {
+ const gridClasses = getGridClasses(cardSize);
+
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 10 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (authors.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {authors.map(author => (
+
+ ))}
+
+ );
+}
+
+function AuthorSkeletonCard({ index = 0 }: { index?: number }) {
+ return (
+
+ {/* Circular portrait skeleton */}
+
+
+ {/* Text skeleton */}
+
+
+ );
+}
diff --git a/src/components/authors/SimilarAuthorsRow.tsx b/src/components/authors/SimilarAuthorsRow.tsx
new file mode 100644
index 0000000..94b588b
--- /dev/null
+++ b/src/components/authors/SimilarAuthorsRow.tsx
@@ -0,0 +1,168 @@
+/**
+ * Component: Similar Authors Row
+ * Documentation: documentation/frontend/components.md
+ *
+ * Horizontal scrollable carousel of similar author cards.
+ * Desktop: left/right nav arrows. Mobile: drag-to-scroll.
+ * Each card navigates to the author's detail page.
+ */
+
+'use client';
+
+import React, { useRef, useState, useEffect, useCallback } from 'react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { SimilarAuthor } from '@/lib/hooks/useAuthors';
+
+interface SimilarAuthorsRowProps {
+ authors: SimilarAuthor[];
+ currentAuthorName?: string;
+}
+
+export function SimilarAuthorsRow({ authors, currentAuthorName }: SimilarAuthorsRowProps) {
+ const scrollRef = useRef(null);
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
+ const [canScrollRight, setCanScrollRight] = useState(false);
+
+ const checkScroll = useCallback(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ setCanScrollLeft(el.scrollLeft > 4);
+ setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4);
+ }, []);
+
+ useEffect(() => {
+ checkScroll();
+ const el = scrollRef.current;
+ if (!el) return;
+ el.addEventListener('scroll', checkScroll, { passive: true });
+ const observer = new ResizeObserver(checkScroll);
+ observer.observe(el);
+ return () => {
+ el.removeEventListener('scroll', checkScroll);
+ observer.disconnect();
+ };
+ }, [checkScroll, authors]);
+
+ const scroll = (direction: 'left' | 'right') => {
+ const el = scrollRef.current;
+ if (!el) return;
+ const scrollAmount = el.clientWidth * 0.7;
+ el.scrollBy({
+ left: direction === 'left' ? -scrollAmount : scrollAmount,
+ behavior: 'smooth',
+ });
+ };
+
+ if (authors.length === 0) return null;
+
+ return (
+
+
+
+
+ Similar Authors
+
+
+ ({authors.length})
+
+
+
+
+ {/* Left arrow */}
+ {canScrollLeft && (
+
+ )}
+
+ {/* Scrollable row */}
+
+ {authors.map(author => (
+
+ {/* Circular portrait */}
+
+ {author.image ? (
+
+ ) : (
+
+
+ {author.name.charAt(0).toUpperCase()}
+
+
+ )}
+
+
+ {/* Name */}
+
+ {author.name}
+
+
+ ))}
+
+
+ {/* Right arrow */}
+ {canScrollRight && (
+
+ )}
+
+ {/* Fade edges */}
+ {canScrollLeft && (
+
+ )}
+ {canScrollRight && (
+
+ )}
+
+
+ );
+}
+
+export function SimilarAuthorsSkeleton() {
+ return (
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index 6fafe8b..869e85e 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -160,6 +160,12 @@ export function Header() {
>
Search
+
+ Authors
+
{showBookDate && (
Search
+ setShowMobileMenu(false)}
+ className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
+ >
+ Authors
+
{showBookDate && (
,
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 }).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;
diff --git a/src/lib/hooks/useAudiobooks.ts b/src/lib/hooks/useAudiobooks.ts
index 22486b6..65e416c 100644
--- a/src/lib/hooks/useAudiobooks.ts
+++ b/src/lib/hooks/useAudiobooks.ts
@@ -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,
};
diff --git a/src/lib/hooks/useAuthors.ts b/src/lib/hooks/useAuthors.ts
new file mode 100644
index 0000000..d7eff18
--- /dev/null
+++ b/src/lib/hooks/useAuthors.ts
@@ -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,
+ };
+}
diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts
index 6d601f7..21cfab2 100644
--- a/src/lib/integrations/audible.service.ts
+++ b/src/lib/integrations/audible.service.ts
@@ -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 {
+ await this.initialize();
+
+ const MAX_PAGES = 10;
+ const allBooks: AudibleAudiobook[] = [];
+ const seenAsins = new Set();
+
+ 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
diff --git a/src/lib/integrations/audnexus-authors.ts b/src/lib/integrations/audnexus-authors.ts
new file mode 100644
index 0000000..2e33ede
--- /dev/null
+++ b/src/lib/integrations/audnexus-authors.ts
@@ -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, maxRetries = 3): Promise {
+ 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 {
+ const response = await audnexusFetchWithRetry(`${AUDNEXUS_BASE}/authors`, { region, name });
+ const results: AudnexusAuthorSearch[] = response.data;
+
+ const seen = new Set();
+ 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 {
+ 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;
+ }
+}
diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts
index ed9091f..a27ce1b 100644
--- a/src/lib/processors/organize-files.processor.ts
+++ b/src/lib/processors/organize-files.processor.ts
@@ -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) });
});
diff --git a/src/lib/processors/plex-recently-added.processor.ts b/src/lib/processors/plex-recently-added.processor.ts
index 1ce4321..fcb6331 100644
--- a/src/lib/processors/plex-recently-added.processor.ts
+++ b/src/lib/processors/plex-recently-added.processor.ts
@@ -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) });
});
diff --git a/src/lib/processors/scan-plex.processor.ts b/src/lib/processors/scan-plex.processor.ts
index cfbec59..ca062d4 100644
--- a/src/lib/processors/scan-plex.processor.ts
+++ b/src/lib/processors/scan-plex.processor.ts
@@ -514,7 +514,9 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise {
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) });
});
diff --git a/src/lib/processors/send-notification.processor.ts b/src/lib/processors/send-notification.processor.ts
index b30939a..da2f894 100644
--- a/src/lib/processors/send-notification.processor.ts
+++ b/src/lib/processors/send-notification.processor.ts
@@ -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 {
- 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(),
});
diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts
index 94653fd..1075bae 100644
--- a/src/lib/services/job-queue.service.ts
+++ b/src/lib/services/job-queue.service.ts
@@ -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 {
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(),
diff --git a/src/lib/services/notification/INotificationProvider.ts b/src/lib/services/notification/INotificationProvider.ts
index 1210337..5bcce28 100644
--- a/src/lib/services/notification/INotificationProvider.ts
+++ b/src/lib/services/notification/INotificationProvider.ts
@@ -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;
}
diff --git a/src/lib/services/notification/providers/apprise.provider.ts b/src/lib/services/notification/providers/apprise.provider.ts
index 8b7d845..9c290c7 100644
--- a/src/lib/services/notification/providers/apprise.provider.ts
+++ b/src/lib/services/notification/providers/apprise.provider.ts
@@ -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'),
};
}
diff --git a/src/lib/services/notification/providers/discord.provider.ts b/src/lib/services/notification/providers/discord.provider.ts
index a9aaee3..f1dadcc 100644
--- a/src/lib/services/notification/providers/discord.provider.ts
+++ b/src/lib/services/notification/providers/discord.provider.ts
@@ -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: {
diff --git a/src/lib/services/notification/providers/ntfy.provider.ts b/src/lib/services/notification/providers/ntfy.provider.ts
index 539f4fb..e293df5 100644
--- a/src/lib/services/notification/providers/ntfy.provider.ts
+++ b/src/lib/services/notification/providers/ntfy.provider.ts
@@ -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'),
};
}
diff --git a/src/lib/services/notification/providers/pushover.provider.ts b/src/lib/services/notification/providers/pushover.provider.ts
index 29ee69b..19ab355 100644
--- a/src/lib/services/notification/providers/pushover.provider.ts
+++ b/src/lib/services/notification/providers/pushover.provider.ts
@@ -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'),
};
}
diff --git a/tests/api/admin-notifications-test.routes.test.ts b/tests/api/admin-notifications-test.routes.test.ts
index 284cd9f..d1503e2 100644
--- a/tests/api/admin-notifications-test.routes.test.ts
+++ b/tests/api/admin-notifications-test.routes.test.ts
@@ -116,6 +116,7 @@ describe('Admin notifications test route', () => {
title: expect.any(String),
author: expect.any(String),
userName: 'Test User',
+ requestType: 'audiobook',
timestamp: expect.any(Date),
})
);
diff --git a/tests/processors/send-notification.processor.test.ts b/tests/processors/send-notification.processor.test.ts
index c33c59a..b659227 100644
--- a/tests/processors/send-notification.processor.test.ts
+++ b/tests/processors/send-notification.processor.test.ts
@@ -71,6 +71,33 @@ describe('processSendNotification', () => {
});
});
+ it('forwards requestType to notification service', async () => {
+ const { processSendNotification } = await import('@/lib/processors/send-notification.processor');
+
+ const payload = {
+ event: 'request_available' as const,
+ requestId: 'req-1',
+ title: 'Test Book',
+ author: 'Test Author',
+ userName: 'Test User',
+ requestType: 'ebook',
+ timestamp: new Date('2024-01-01T00:00:00Z'),
+ jobId: 'job-1',
+ };
+
+ await processSendNotification(payload);
+
+ expect(notificationServiceMock.sendNotification).toHaveBeenCalledWith({
+ event: 'request_available',
+ requestId: 'req-1',
+ title: 'Test Book',
+ author: 'Test Author',
+ userName: 'Test User',
+ requestType: 'ebook',
+ timestamp: expect.any(Date),
+ });
+ });
+
it('does not throw if notification service fails', async () => {
notificationServiceMock.sendNotification.mockRejectedValue(new Error('Service error'));
diff --git a/tests/services/apprise.provider.test.ts b/tests/services/apprise.provider.test.ts
index 1ea0b86..3f3f268 100644
--- a/tests/services/apprise.provider.test.ts
+++ b/tests/services/apprise.provider.test.ts
@@ -172,6 +172,7 @@ describe('AppriseProvider', () => {
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
+ requestType: 'audiobook',
timestamp: new Date(),
}
);
diff --git a/tests/services/notification.service.test.ts b/tests/services/notification.service.test.ts
index f74b2c5..9024b80 100644
--- a/tests/services/notification.service.test.ts
+++ b/tests/services/notification.service.test.ts
@@ -31,6 +31,32 @@ vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock,
}));
+describe('getEventTitle', () => {
+ it('returns type-specific title when requestType matches titleByRequestType', async () => {
+ const { getEventTitle } = await import('@/lib/constants/notification-events');
+ expect(getEventTitle('request_available', 'audiobook')).toBe('Audiobook Available');
+ expect(getEventTitle('request_available', 'ebook')).toBe('Ebook Available');
+ });
+
+ it('returns default title when requestType is not provided', async () => {
+ const { getEventTitle } = await import('@/lib/constants/notification-events');
+ expect(getEventTitle('request_available')).toBe('Request Available');
+ expect(getEventTitle('request_available', undefined)).toBe('Request Available');
+ });
+
+ it('returns default title when requestType does not match any entry', async () => {
+ const { getEventTitle } = await import('@/lib/constants/notification-events');
+ expect(getEventTitle('request_available', 'podcast')).toBe('Request Available');
+ });
+
+ it('returns default title for events without titleByRequestType', async () => {
+ const { getEventTitle } = await import('@/lib/constants/notification-events');
+ expect(getEventTitle('request_approved', 'audiobook')).toBe('Request Approved');
+ expect(getEventTitle('request_error')).toBe('Request Error');
+ expect(getEventTitle('request_pending_approval')).toBe('New Request Pending Approval');
+ });
+});
+
describe('NotificationService', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -275,6 +301,68 @@ describe('NotificationService', () => {
expect(body.embeds[0].color).toBe(2278750); // Green for approved (0x22C55E)
});
+ it('uses type-specific title for request_available with requestType', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ const { DiscordProvider } = await import('@/lib/services/notification');
+ const provider = new DiscordProvider();
+
+ // Test audiobook
+ await provider.send(
+ { webhookUrl: 'https://discord.com/webhook' },
+ {
+ event: 'request_available',
+ requestId: 'req-1',
+ title: 'Test Book',
+ author: 'Test Author',
+ userName: 'Test User',
+ requestType: 'audiobook',
+ timestamp: new Date('2024-01-01T00:00:00Z'),
+ }
+ );
+
+ let body = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(body.embeds[0].title).toBe('\u{1F389} Audiobook Available');
+
+ // Test ebook
+ fetchMock.mockClear();
+ await provider.send(
+ { webhookUrl: 'https://discord.com/webhook' },
+ {
+ event: 'request_available',
+ requestId: 'req-2',
+ title: 'Test Book 2',
+ author: 'Test Author 2',
+ userName: 'Test User',
+ requestType: 'ebook',
+ timestamp: new Date('2024-01-01T00:00:00Z'),
+ }
+ );
+
+ body = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(body.embeds[0].title).toBe('\u{1F389} Ebook Available');
+
+ // Test fallback (no requestType)
+ fetchMock.mockClear();
+ await provider.send(
+ { webhookUrl: 'https://discord.com/webhook' },
+ {
+ event: 'request_available',
+ requestId: 'req-3',
+ title: 'Test Book 3',
+ author: 'Test Author 3',
+ userName: 'Test User',
+ timestamp: new Date('2024-01-01T00:00:00Z'),
+ }
+ );
+
+ body = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(body.embeds[0].title).toBe('\u{1F389} Request Available');
+ });
+
it('uses default username if not provided', async () => {
fetchMock.mockResolvedValue({
ok: true,