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:
kikootwo
2026-02-12 15:21:42 -05:00
parent e40e77c8fe
commit 89422fc77a
33 changed files with 1629 additions and 40 deletions
@@ -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)
@@ -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(),
};
+1
View File
@@ -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) });
+74
View File
@@ -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 }
);
}
}
+94
View File
@@ -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 }
);
}
}
+91
View File
@@ -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 }
);
}
}
+121
View File
@@ -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 (
<ProtectedRoute>
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8">
{/* Back navigation */}
<button
onClick={handleBack}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{fromAuthorName ? `Back to ${fromAuthorName}` : 'Back to Authors'}
</button>
{/* Author Detail Card */}
{authorLoading ? (
<AuthorDetailSkeleton />
) : author ? (
<AuthorDetailCard author={author} />
) : (
<div className="text-center py-16 space-y-4">
<svg className="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p className="text-xl text-gray-600 dark:text-gray-400">Author not found</p>
</div>
)}
{/* Similar Authors */}
{authorLoading ? (
<SimilarAuthorsSkeleton />
) : author && author.similar.length > 0 ? (
<SimilarAuthorsRow authors={author.similar} currentAuthorName={author.name} />
) : null}
{/* Books Section */}
{author && (
<div className="space-y-6">
{/* Sticky Books Header */}
<div className="sticky top-14 sm:top-16 z-30">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Books
</h2>
{!booksLoading && totalBooks > 0 && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({totalBooks} title{totalBooks !== 1 ? 's' : ''})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
</div>
</div>
</div>
{/* Books Grid */}
<AudiobookGrid
audiobooks={books}
isLoading={booksLoading}
emptyMessage={`No books found for ${author.name}`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}
+176
View File
@@ -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 (
<ProtectedRoute>
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
{/* Page Header */}
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
Browse Authors
</h1>
<p className="text-gray-600 dark:text-gray-400">
Search for your favorite audiobook authors
</p>
</div>
{/* Search Form */}
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg
className="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by author name..."
className="w-full pl-12 pr-12 py-4 text-lg border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
autoFocus
/>
{query && (
<button
type="button"
onClick={() => setQuery('')}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
</form>
{/* Results */}
{debouncedQuery ? (
<div className="space-y-6">
{/* Sticky Results Header */}
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-indigo-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Authors
</h2>
{!isLoading && authors.length > 0 && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({authors.length} result{authors.length !== 1 ? 's' : ''})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
</div>
</div>
</div>
{/* Author Grid */}
<AuthorGrid
authors={authors}
isLoading={!!isLoading}
emptyMessage={`No authors found for "${debouncedQuery}"`}
cardSize={cardSize}
/>
</div>
) : (
/* Empty State */
<div className="text-center py-16 space-y-4">
<svg
className="mx-auto h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
<p className="text-xl text-gray-600 dark:text-gray-400">
Start typing to search for authors
</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
Search by author name to discover their works
</p>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}
export default function AuthorsPage() {
return (
<Suspense>
<AuthorsPageContent />
</Suspense>
);
}
@@ -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}
</h2>
<p className="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-300">
{audiobook.author}
{audiobook.authorAsin ? (
<Link
href={`/authors/${audiobook.authorAsin}`}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
{audiobook.author}
</Link>
) : (
audiobook.author
)}
</p>
{audiobook.narrator && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
@@ -418,7 +432,7 @@ export function AudiobookDetailsModal({
<div>
<p className="text-gray-500 dark:text-gray-400">Source</p>
<a
href={`https://www.audible.com/pd/${asin}`}
href={`${audibleBaseUrl}/pd/${asin}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-orange-600 dark:text-orange-400 hover:underline"
+87
View File
@@ -0,0 +1,87 @@
/**
* Component: Author Card
* Documentation: documentation/frontend/components.md
*
* Premium circular portrait design - distinguishes authors from audiobook covers.
* Hover effects and typography match the AudiobookCard aesthetic.
* Clicking navigates to the author's detail page.
*/
'use client';
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Author } from '@/lib/hooks/useAuthors';
interface AuthorCardProps {
author: Author;
}
export function AuthorCard({ author }: AuthorCardProps) {
return (
<Link
href={`/authors/${author.asin}`}
className="group outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 rounded-2xl"
aria-label={`View details for ${author.name}`}
>
{/* Circular Portrait Container */}
<div className="flex justify-center">
<div
className="
relative overflow-hidden rounded-full
w-full aspect-square
shadow-lg shadow-black/20 dark:shadow-black/40
group-hover:shadow-xl group-hover:shadow-black/25 dark:group-hover:shadow-black/50
transform group-hover:scale-[1.04] group-hover:-translate-y-1
transition-all duration-300 ease-out
"
>
{author.image ? (
<Image
src={author.image}
alt=""
fill
className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
<svg className="w-1/3 h-1/3 text-blue-400 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
)}
{/* Subtle hover overlay */}
<div className="
absolute inset-0 rounded-full
bg-black/0 group-hover:bg-black/10
transition-colors duration-300
" />
</div>
</div>
{/* Author Info */}
<div className="mt-3 px-1 text-center">
<h3 className="font-semibold text-[15px] leading-snug text-gray-900 dark:text-gray-100 line-clamp-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200">
{author.name}
</h3>
{/* Genre Pills */}
{author.genres.length > 0 && (
<div className="mt-1.5 flex flex-wrap justify-center gap-1">
{author.genres.map(genre => (
<span
key={genre}
className="inline-block px-2 py-0.5 text-[11px] font-medium rounded-full bg-gray-100 dark:bg-gray-700/60 text-gray-500 dark:text-gray-400"
>
{genre}
</span>
))}
</div>
)}
</div>
</Link>
);
}
+135
View File
@@ -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 (
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
{/* Circular Portrait */}
<div className="flex-shrink-0">
<div className="relative w-36 h-36 sm:w-44 sm:h-44 lg:w-52 lg:h-52 rounded-full overflow-hidden shadow-xl shadow-black/20 dark:shadow-black/40">
{author.image ? (
<Image
src={author.image}
alt={author.name}
fill
className="object-cover"
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
priority
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
<svg className="w-1/3 h-1/3 text-blue-400 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
)}
</div>
</div>
{/* Author Info */}
<div className="flex-1 min-w-0 text-center sm:text-left">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100">
{author.name}
</h1>
{/* Genre Pills */}
{author.genres.length > 0 && (
<div className="mt-3 flex flex-wrap justify-center sm:justify-start gap-2">
{author.genres.map(genre => (
<span
key={genre}
className="inline-block px-3 py-1 text-xs font-medium rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-300"
>
{genre}
</span>
))}
</div>
)}
{/* Audible Link */}
{author.audibleUrl && (
<a
href={author.audibleUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
View on Audible
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{/* Description */}
{author.description && (
<div className="mt-4">
<p
className={`text-sm sm:text-base text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-line ${
!expanded && hasLongDescription ? 'line-clamp-4' : ''
}`}
>
{author.description}
</p>
{hasLongDescription && (
<button
onClick={() => setExpanded(!expanded)}
className="mt-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors"
>
{expanded ? 'Show less' : 'Read more'}
</button>
)}
</div>
)}
</div>
</div>
);
}
export function AuthorDetailSkeleton() {
return (
<div className="animate-pulse flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
{/* Portrait skeleton */}
<div className="flex-shrink-0">
<div className="w-36 h-36 sm:w-44 sm:h-44 lg:w-52 lg:h-52 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800">
<div className="w-full h-full rounded-full relative overflow-hidden">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
</div>
</div>
{/* Info skeleton */}
<div className="flex-1 min-w-0 text-center sm:text-left space-y-4">
<div className="h-9 bg-gray-200 dark:bg-gray-700 rounded-lg w-64 mx-auto sm:mx-0" />
<div className="flex gap-2 justify-center sm:justify-start">
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-6 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded-full" />
</div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-4/6" />
</div>
</div>
</div>
);
}
+100
View File
@@ -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<number, string> = {
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 (
<div className={`grid ${gridClasses} gap-5 sm:gap-6 lg:gap-8`}>
{Array.from({ length: 10 }).map((_, i) => (
<AuthorSkeletonCard key={i} index={i} />
))}
</div>
);
}
if (authors.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-6">
<svg className="w-10 h-10 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
<p className="text-gray-500 dark:text-gray-400 text-lg">{emptyMessage}</p>
</div>
);
}
return (
<div className={`grid ${gridClasses} gap-5 sm:gap-6 lg:gap-8`}>
{authors.map(author => (
<AuthorCard key={author.asin} author={author} />
))}
</div>
);
}
function AuthorSkeletonCard({ index = 0 }: { index?: number }) {
return (
<div
className="animate-pulse"
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Circular portrait skeleton */}
<div className="flex justify-center">
<div className="relative overflow-hidden rounded-full w-full aspect-square bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
</div>
{/* Text skeleton */}
<div className="mt-3 px-1 flex flex-col items-center space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-4/5" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/5" />
</div>
</div>
);
}
@@ -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<HTMLDivElement>(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 (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-indigo-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
Similar Authors
</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">
({authors.length})
</span>
</div>
<div className="relative group">
{/* Left arrow */}
{canScrollLeft && (
<button
onClick={() => scroll('left')}
className="hidden md:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
aria-label="Scroll left"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
{/* Scrollable row */}
<div
ref={scrollRef}
className="flex gap-4 sm:gap-5 overflow-x-auto scrollbar-hide pb-2 scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{authors.map(author => (
<Link
key={author.asin}
href={`/authors/${author.asin}${currentAuthorName ? `?from=${encodeURIComponent(currentAuthorName)}` : ''}`}
className="flex-shrink-0 w-24 sm:w-28 group/card outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-xl"
>
{/* Circular portrait */}
<div className="relative w-24 h-24 sm:w-28 sm:h-28 rounded-full overflow-hidden shadow-md shadow-black/15 dark:shadow-black/30 group-hover/card:shadow-lg group-hover/card:scale-[1.04] group-hover/card:-translate-y-0.5 transition-all duration-300">
{author.image ? (
<Image
src={author.image}
alt=""
fill
className="object-cover"
sizes="112px"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
<span className="text-xl font-bold text-blue-400 dark:text-blue-300">
{author.name.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Name */}
<p className="mt-2 text-xs sm:text-sm font-medium text-center text-gray-700 dark:text-gray-300 line-clamp-2 group-hover/card:text-indigo-600 dark:group-hover/card:text-indigo-400 transition-colors">
{author.name}
</p>
</Link>
))}
</div>
{/* Right arrow */}
{canScrollRight && (
<button
onClick={() => scroll('right')}
className="hidden md:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
aria-label="Scroll right"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{/* Fade edges */}
{canScrollLeft && (
<div className="hidden md:block absolute left-0 top-0 bottom-2 w-8 bg-gradient-to-r from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
)}
{canScrollRight && (
<div className="hidden md:block absolute right-0 top-0 bottom-2 w-8 bg-gradient-to-l from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
)}
</div>
</div>
);
}
export function SimilarAuthorsSkeleton() {
return (
<div className="space-y-3 animate-pulse">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gray-300 dark:bg-gray-600 rounded-full" />
<div className="h-7 w-40 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
<div className="flex gap-4 sm:gap-5 overflow-hidden">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex-shrink-0 w-24 sm:w-28" style={{ animationDelay: `${i * 50}ms` }}>
<div className="w-24 h-24 sm:w-28 sm:h-28 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 relative overflow-hidden">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
<div className="mt-2 h-3 bg-gray-200 dark:bg-gray-700 rounded w-4/5 mx-auto" />
</div>
))}
</div>
</div>
);
}
+13
View File
@@ -160,6 +160,12 @@ export function Header() {
>
Search
</Link>
<Link
href="/authors"
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
Authors
</Link>
{showBookDate && (
<Link
href="/bookdate"
@@ -264,6 +270,13 @@ export function Header() {
>
Search
</Link>
<Link
href="/authors"
onClick={() => 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
</Link>
{showBookDate && (
<Link
href="/bookdate"
+26 -7
View File
@@ -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;
+2
View File
@@ -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,
};
+88
View File
@@ -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,
};
}
+158 -1
View File
@@ -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
+104
View File
@@ -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) });
});
+3 -1
View File
@@ -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(),
});
+4 -1
View File
@@ -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'),
};
}
@@ -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),
})
);
@@ -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'));
+1
View File
@@ -172,6 +172,7 @@ describe('AppriseProvider', () => {
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
requestType: 'audiobook',
timestamp: new Date(),
}
);
@@ -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,