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