diff --git a/documentation/backend/services/notifications.md b/documentation/backend/services/notifications.md index 41172a5..aba1a9f 100644 --- a/documentation/backend/services/notifications.md +++ b/documentation/backend/services/notifications.md @@ -33,10 +33,16 @@ model NotificationBackend { |-------|---------|------------------------| | request_pending_approval | User creates request | Request needs admin approval | | request_approved | Admin approves OR auto-approval | Request approved (manual or auto) | -| request_available | Plex/ABS scan completes | Audiobook available in library | +| request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) | | request_error | Download/import fails | Request failed at any stage | | issue_reported | User reports issue | User reports problem with available audiobook | +**Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles. +- `request_available` + `requestType: 'audiobook'` → "Audiobook Available" +- `request_available` + `requestType: 'ebook'` → "Ebook Available" +- `request_available` + no requestType → "Request Available" (fallback) +- Use `getEventTitle(event, requestType?)` to resolve titles in providers + ## Notification Triggers **Request Creation (POST /api/requests)** @@ -60,10 +66,14 @@ model NotificationBackend { - Approve (with or without pre-selected torrent): After job triggered → request_approved - Deny: No notification -**Request Available (processors: scan-plex, plex-recently-added)** -- After `status: 'available'` update → request_available +**Audiobook Available (processors: scan-plex, plex-recently-added)** +- After `status: 'available'` update → request_available (requestType: 'audiobook') - Includes user info in query (plexUsername) +**Ebook Available (processor: organize-files)** +- After ebook `status: 'downloaded'` (terminal) → request_available (requestType: 'ebook') +- Ebooks don't transition to 'available' via Plex matching + **Request Error (processors: monitor-download, organize-files)** - After `status: 'failed'` or `status: 'warn'` update → request_error - Includes error message in payload @@ -166,6 +176,7 @@ model NotificationBackend { author: string, userName: string, message?: string, + requestType?: string, // 'audiobook' | 'ebook' — drives type-specific titles timestamp: Date } ``` @@ -174,7 +185,7 @@ model NotificationBackend { - Calls NotificationService.sendNotification() - Non-blocking error handling (logs but doesn't throw) -**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?)` +**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?, requestType?)` ## Architecture @@ -203,10 +214,15 @@ src/lib/services/notification/ **ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }` **ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }` -**Helper functions:** +**Helper functions (notification.service.ts):** - `getRegisteredProviderTypes(): string[]` — all registered type keys - `getAllProviderMetadata(): ProviderMetadata[]` — metadata for all providers +**Helper functions (notification-events.ts):** +- `getEventMeta(event)` — raw event metadata (label, title, emoji, severity, priority) +- `getEventTitle(event, requestType?)` — resolved title (checks `titleByRequestType` first, falls back to `title`) +- `getEventLabel(event)` — human-readable label for UI + **API Endpoint:** `GET /api/admin/notifications/providers` — returns all provider metadata (admin-only) ## Extensibility @@ -221,10 +237,10 @@ src/lib/services/notification/ No UI changes, no API route changes, no Zod schema changes needed — the UI renders dynamically from provider metadata. **Adding New Event (e.g., download_complete):** -1. Add 'download_complete' to NotificationEvent enum -2. Add to event labels in UI -3. Add trigger point in processor -4. Add message formatting in Discord/Pushover formatters +1. Add entry to `NOTIFICATION_EVENTS` in `notification-events.ts` (label, title, emoji, severity, priority) +2. Optionally add `titleByRequestType` for type-specific titles +3. Add trigger point in processor, passing `requestType` if relevant +4. Providers auto-resolve titles via `getEventTitle()` — no per-provider changes needed ## Tech Stack - Bull (job queue) diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts index f3524c0..847e192 100644 --- a/src/app/api/admin/notifications/test/route.ts +++ b/src/app/api/admin/notifications/test/route.ts @@ -68,6 +68,7 @@ export async function POST(request: NextRequest) { title: "The Hitchhiker's Guide to the Galaxy", author: 'Douglas Adams', userName: 'Test User', + requestType: 'audiobook', timestamp: new Date(), }; diff --git a/src/app/api/audiobooks/[asin]/route.ts b/src/app/api/audiobooks/[asin]/route.ts index 78a3dad..b229182 100644 --- a/src/app/api/audiobooks/[asin]/route.ts +++ b/src/app/api/audiobooks/[asin]/route.ts @@ -46,6 +46,7 @@ export async function GET( return NextResponse.json({ success: true, audiobook, + audibleBaseUrl: audibleService.getBaseUrl(), }); } catch (error) { logger.error('Failed to get audiobook details', { error: error instanceof Error ? error.message : String(error) }); diff --git a/src/app/api/authors/[asin]/books/route.ts b/src/app/api/authors/[asin]/books/route.ts new file mode 100644 index 0000000..83368b5 --- /dev/null +++ b/src/app/api/authors/[asin]/books/route.ts @@ -0,0 +1,74 @@ +/** + * Component: Author Books API Route + * Documentation: documentation/integrations/audible.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getAudibleService } from '@/lib/integrations/audible.service'; +import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; +import { getCurrentUser } from '@/lib/middleware/auth'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Authors.Books'); + +/** + * GET /api/authors/{asin}/books?name=Author+Name + * Scrape Audible for all books by this author, filtered by ASIN and English language. + * Enriched with library availability and request status. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ asin: string }> } +) { + try { + const currentUser = getCurrentUser(request); + if (!currentUser) { + return NextResponse.json( + { error: 'Unauthorized', message: 'Authentication required' }, + { status: 401 } + ); + } + + const { asin } = await params; + const authorName = request.nextUrl.searchParams.get('name'); + + if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) { + return NextResponse.json( + { error: 'ValidationError', message: 'Valid author ASIN is required' }, + { status: 400 } + ); + } + + if (!authorName || authorName.trim().length === 0) { + return NextResponse.json( + { error: 'ValidationError', message: 'Author name is required' }, + { status: 400 } + ); + } + + logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin})`); + + const audibleService = getAudibleService(); + const books = await audibleService.searchByAuthorAsin(authorName.trim(), asin); + + // Enrich with library availability and request status + const userId = currentUser.sub || undefined; + const enrichedBooks = await enrichAudiobooksWithMatches(books, userId); + + logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books`); + + return NextResponse.json({ + success: true, + books: enrichedBooks, + authorName: authorName.trim(), + authorAsin: asin, + totalBooks: enrichedBooks.length, + }); + } catch (error) { + logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json( + { error: 'FetchError', message: 'Failed to fetch author books' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/authors/[asin]/route.ts b/src/app/api/authors/[asin]/route.ts new file mode 100644 index 0000000..e494ef9 --- /dev/null +++ b/src/app/api/authors/[asin]/route.ts @@ -0,0 +1,94 @@ +/** + * Component: Author Detail API Route + * Documentation: documentation/integrations/audible.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/middleware/auth'; +import { getConfigService } from '@/lib/services/config.service'; +import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION, AudibleRegion } from '@/lib/types/audible'; +import { RMABLogger } from '@/lib/utils/logger'; +import { + AudnexusAuthorDetail, + fetchAuthorDetail, +} from '@/lib/integrations/audnexus-authors'; + +const logger = RMABLogger.create('API.Authors.Detail'); + +const SIMILAR_AUTHORS_LIMIT = 15; + +/** + * GET /api/authors/{asin} + * Fetch author detail from Audnexus with enriched similar author images + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ asin: string }> } +) { + try { + const currentUser = getCurrentUser(request); + if (!currentUser) { + return NextResponse.json( + { error: 'Unauthorized', message: 'Authentication required' }, + { status: 401 } + ); + } + + const { asin } = await params; + + if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) { + return NextResponse.json( + { error: 'ValidationError', message: 'Valid author ASIN is required' }, + { status: 400 } + ); + } + + const configService = getConfigService(); + const audibleRegion: AudibleRegion = await configService.getAudibleRegion(); + const regionConfig = AUDIBLE_REGIONS[audibleRegion] || AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION]; + const region = regionConfig.audnexusParam; + + logger.info(`Fetching author detail: ${asin} (region: ${region})`); + + // Fetch the primary author detail + const detail = await fetchAuthorDetail(asin, region); + if (!detail) { + return NextResponse.json( + { error: 'NotFound', message: 'Author not found' }, + { status: 404 } + ); + } + + // Fetch images for similar authors in parallel (capped) + const similarSlice = (detail.similar || []).slice(0, SIMILAR_AUTHORS_LIMIT); + const similarDetails = await Promise.all( + similarSlice.map(s => fetchAuthorDetail(s.asin, region)) + ); + + const similarAuthors = similarSlice.map((s, i) => ({ + asin: s.asin, + name: s.name, + image: similarDetails[i]?.image || undefined, + })); + + const author = { + asin: detail.asin, + name: detail.name, + description: detail.description || undefined, + image: detail.image || undefined, + genres: detail.genres?.map(g => g.name) || [], + similar: similarAuthors, + audibleUrl: `${regionConfig.baseUrl}/author/${asin}`, + }; + + logger.info(`Author detail complete: "${detail.name}" (${similarAuthors.length} similar authors)`); + + return NextResponse.json({ success: true, author }); + } catch (error) { + logger.error('Failed to fetch author detail', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json( + { error: 'FetchError', message: 'Failed to fetch author details' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/authors/search/route.ts b/src/app/api/authors/search/route.ts new file mode 100644 index 0000000..e45ebad --- /dev/null +++ b/src/app/api/authors/search/route.ts @@ -0,0 +1,91 @@ +/** + * Component: Author Search API Route + * Documentation: documentation/integrations/audible.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/middleware/auth'; +import { getConfigService } from '@/lib/services/config.service'; +import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION, AudibleRegion } from '@/lib/types/audible'; +import { RMABLogger } from '@/lib/utils/logger'; +import { + AudnexusAuthorDetail, + searchAuthors, + fetchAuthorDetail, +} from '@/lib/integrations/audnexus-authors'; + +const logger = RMABLogger.create('API.Authors.Search'); + +/** + * GET /api/authors/search?name=Brandon Sanderson + * Search for authors on Audnexus, deduplicate, and return enriched details + */ +export async function GET(request: NextRequest) { + try { + // Require authentication + const currentUser = getCurrentUser(request); + if (!currentUser) { + return NextResponse.json( + { error: 'Unauthorized', message: 'Authentication required' }, + { status: 401 } + ); + } + + const name = request.nextUrl.searchParams.get('name'); + + if (!name || name.trim().length === 0) { + return NextResponse.json( + { error: 'ValidationError', message: 'Author name is required' }, + { status: 400 } + ); + } + + // Get configured Audible region + const configService = getConfigService(); + const audibleRegion: AudibleRegion = await configService.getAudibleRegion(); + const region = AUDIBLE_REGIONS[audibleRegion]?.audnexusParam || AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION].audnexusParam; + + logger.info(`Searching authors: "${name}" (region: ${region})`); + + // Step 1: Search for authors (returns list with potential duplicates) + const searchResults = await searchAuthors(name.trim(), region); + + if (searchResults.length === 0) { + return NextResponse.json({ + success: true, + authors: [], + query: name.trim(), + }); + } + + // Step 2: Fetch details for all unique authors in parallel + const detailPromises = searchResults.map(author => fetchAuthorDetail(author.asin, region)); + const detailResults = await Promise.all(detailPromises); + + // Step 3: Build enriched results, filtering out any failed fetches + const authors = detailResults + .filter((detail): detail is AudnexusAuthorDetail => detail !== null) + .map(detail => ({ + asin: detail.asin, + name: detail.name, + description: detail.description || undefined, + image: detail.image || undefined, + genres: detail.genres?.map(g => g.name).slice(0, 3) || [], + similarCount: detail.similar?.length || 0, + })); + + logger.info(`Author search complete: "${name}" → ${authors.length} results`); + + return NextResponse.json({ + success: true, + authors, + query: name.trim(), + }); + } catch (error) { + logger.error('Failed to search authors', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json( + { error: 'SearchError', message: 'Failed to search authors' }, + { status: 500 } + ); + } +} diff --git a/src/app/authors/[asin]/page.tsx b/src/app/authors/[asin]/page.tsx new file mode 100644 index 0000000..accee7e --- /dev/null +++ b/src/app/authors/[asin]/page.tsx @@ -0,0 +1,121 @@ +/** + * Component: Author Detail Page + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import { use, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Header } from '@/components/layout/Header'; +import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; +import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard'; +import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow'; +import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors'; +import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; +import { CardSizeControls } from '@/components/ui/CardSizeControls'; +import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle'; +import { usePreferences } from '@/contexts/PreferencesContext'; + +export default function AuthorDetailPage({ + params, +}: { + params: Promise<{ asin: string }>; +}) { + const { asin } = use(params); + const router = useRouter(); + const searchParams = useSearchParams(); + const fromAuthorName = searchParams.get('from'); + const { author, isLoading: authorLoading } = useAuthorDetail(asin); + const { books, totalBooks, isLoading: booksLoading } = useAuthorBooks( + asin, + author?.name || null + ); + const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences(); + + const handleBack = useCallback(() => { + // Use browser back if we came from within the app, otherwise fallback to /authors + if (window.history.length > 1) { + router.back(); + } else { + router.push('/authors'); + } + }, [router]); + + return ( + +
+
+ +
+ {/* Back navigation */} + + + {/* Author Detail Card */} + {authorLoading ? ( + + ) : author ? ( + + ) : ( +
+ + + +

Author not found

+
+ )} + + {/* Similar Authors */} + {authorLoading ? ( + + ) : author && author.similar.length > 0 ? ( + + ) : null} + + {/* Books Section */} + {author && ( +
+ {/* Sticky Books Header */} +
+
+
+
+

+ Books +

+ {!booksLoading && totalBooks > 0 && ( + + ({totalBooks} title{totalBooks !== 1 ? 's' : ''}) + + )} +
+ + +
+
+
+
+ + {/* Books Grid */} + +
+ )} +
+
+
+ ); +} diff --git a/src/app/authors/page.tsx b/src/app/authors/page.tsx new file mode 100644 index 0000000..0b61ecb --- /dev/null +++ b/src/app/authors/page.tsx @@ -0,0 +1,176 @@ +/** + * Component: Authors Page + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import { Suspense, useState, useEffect, useCallback } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { Header } from '@/components/layout/Header'; +import { AuthorGrid } from '@/components/authors/AuthorGrid'; +import { useAuthorSearch } from '@/lib/hooks/useAuthors'; +import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; +import { CardSizeControls } from '@/components/ui/CardSizeControls'; +import { usePreferences } from '@/contexts/PreferencesContext'; + +function AuthorsPageContent() { + const searchParams = useSearchParams(); + const router = useRouter(); + const initialQuery = searchParams.get('q') || ''; + + const [query, setQuery] = useState(initialQuery); + const [debouncedQuery, setDebouncedQuery] = useState(initialQuery); + const { cardSize, setCardSize } = usePreferences(); + + // Debounce search query and sync to URL + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(query); + // Update URL without adding history entries + const trimmed = query.trim(); + if (trimmed) { + router.replace(`/authors?q=${encodeURIComponent(trimmed)}`, { scroll: false }); + } else { + router.replace('/authors', { scroll: false }); + } + }, 500); + + return () => clearTimeout(timer); + }, [query, router]); + + const { authors, isLoading } = useAuthorSearch(debouncedQuery); + + const handleSearch = useCallback((e: React.FormEvent) => { + e.preventDefault(); + }, []); + + return ( + +
+
+ +
+ {/* Page Header */} +
+

+ Browse Authors +

+

+ Search for your favorite audiobook authors +

+
+ + {/* Search Form */} +
+
+
+ + + +
+ 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 && ( + + )} +
+
+ + {/* Results */} + {debouncedQuery ? ( +
+ {/* Sticky Results Header */} +
+
+
+
+

+ Authors +

+ {!isLoading && authors.length > 0 && ( + + ({authors.length} result{authors.length !== 1 ? 's' : ''}) + + )} +
+ +
+
+
+
+ + {/* Author Grid */} + +
+ ) : ( + /* Empty State */ +
+ + + +

+ Start typing to search for authors +

+

+ Search by author name to discover their works +

+
+ )} +
+
+
+ ); +} + +export default function AuthorsPage() { + return ( + + + + ); +} diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx index 3a759c8..2d19a1f 100644 --- a/src/components/audiobooks/AudiobookDetailsModal.tsx +++ b/src/components/audiobooks/AudiobookDetailsModal.tsx @@ -10,6 +10,7 @@ import React, { useEffect, useState } from 'react'; import Image from 'next/image'; +import Link from 'next/link'; import { createPortal } from 'react-dom'; import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks'; import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests'; @@ -71,7 +72,7 @@ export function AudiobookDetailsModal({ }: AudiobookDetailsModalProps) { const { user } = useAuth(); const { squareCovers } = usePreferences(); - const { audiobook, isLoading, error } = useAudiobookDetails(isOpen ? asin : null); + const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null); const { createRequest, isLoading: isRequesting } = useCreateRequest(); const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null); const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin(); @@ -286,7 +287,20 @@ export function AudiobookDetailsModal({ {audiobook.title}

- {audiobook.author} + {audiobook.authorAsin ? ( + { + e.stopPropagation(); + onClose(); + }} + className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" + > + {audiobook.author} + + ) : ( + audiobook.author + )}

{audiobook.narrator && (

@@ -418,7 +432,7 @@ export function AudiobookDetailsModal({

Source

+ {/* Circular Portrait Container */} +
+
+ {author.image ? ( + + ) : ( +
+ + + +
+ )} + + {/* Subtle hover overlay */} +
+
+
+ + {/* Author Info */} +
+

+ {author.name} +

+ + {/* Genre Pills */} + {author.genres.length > 0 && ( +
+ {author.genres.map(genre => ( + + {genre} + + ))} +
+ )} +
+ + ); +} diff --git a/src/components/authors/AuthorDetailCard.tsx b/src/components/authors/AuthorDetailCard.tsx new file mode 100644 index 0000000..9acccce --- /dev/null +++ b/src/components/authors/AuthorDetailCard.tsx @@ -0,0 +1,135 @@ +/** + * Component: Author Detail Card + * Documentation: documentation/frontend/components.md + * + * Hero section for the author detail page with circular portrait, + * name, collapsible biography, and genre pills. + */ + +'use client'; + +import React, { useState } from 'react'; +import Image from 'next/image'; +import { AuthorDetail } from '@/lib/hooks/useAuthors'; + +interface AuthorDetailCardProps { + author: AuthorDetail; +} + +export function AuthorDetailCard({ author }: AuthorDetailCardProps) { + const [expanded, setExpanded] = useState(false); + const hasLongDescription = (author.description?.length || 0) > 300; + + return ( +
+ {/* Circular Portrait */} +
+
+ {author.image ? ( + {author.name} + ) : ( +
+ + + +
+ )} +
+
+ + {/* Author Info */} +
+

+ {author.name} +

+ + {/* Genre Pills */} + {author.genres.length > 0 && ( +
+ {author.genres.map(genre => ( + + {genre} + + ))} +
+ )} + + {/* Audible Link */} + {author.audibleUrl && ( +
+ View on Audible + + + + + )} + + {/* Description */} + {author.description && ( +
+

+ {author.description} +

+ {hasLongDescription && ( + + )} +
+ )} +
+
+ ); +} + +export function AuthorDetailSkeleton() { + return ( +
+ {/* Portrait skeleton */} +
+
+
+
+
+
+
+ + {/* Info skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/components/authors/AuthorGrid.tsx b/src/components/authors/AuthorGrid.tsx new file mode 100644 index 0000000..b30ad3d --- /dev/null +++ b/src/components/authors/AuthorGrid.tsx @@ -0,0 +1,100 @@ +/** + * Component: Author Grid + * Documentation: documentation/frontend/components.md + * + * Premium grid layout for author cards with loading skeletons and empty state. + * Mirrors AudiobookGrid patterns with author-appropriate column counts. + */ + +'use client'; + +import React from 'react'; +import { AuthorCard } from './AuthorCard'; +import { Author } from '@/lib/hooks/useAuthors'; + +interface AuthorGridProps { + authors: Author[]; + isLoading?: boolean; + emptyMessage?: string; + cardSize?: number; +} + +// Authors use wider spacing since circular portraits need room to breathe. +// Slightly fewer columns than AudiobookGrid at each breakpoint since circles +// are visually wider than 2:3 portrait covers. +function getGridClasses(size: number): string { + const sizeMap: Record = { + 1: 'grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9', + 2: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8', + 3: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7', + 4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6', + 5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5', + 6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4', + 7: 'grid-cols-2 md:grid-cols-3', + 8: 'grid-cols-2', + 9: 'grid-cols-1', + }; + return sizeMap[size] || sizeMap[5]; +} + +export function AuthorGrid({ + authors, + isLoading = false, + emptyMessage = 'No authors found', + cardSize = 5, +}: AuthorGridProps) { + const gridClasses = getGridClasses(cardSize); + + if (isLoading) { + return ( +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+ ); + } + + if (authors.length === 0) { + return ( +
+
+ + + +
+

{emptyMessage}

+
+ ); + } + + return ( +
+ {authors.map(author => ( + + ))} +
+ ); +} + +function AuthorSkeletonCard({ index = 0 }: { index?: number }) { + return ( +
+ {/* Circular portrait skeleton */} +
+
+
+
+
+ + {/* Text skeleton */} +
+
+
+
+
+ ); +} diff --git a/src/components/authors/SimilarAuthorsRow.tsx b/src/components/authors/SimilarAuthorsRow.tsx new file mode 100644 index 0000000..94b588b --- /dev/null +++ b/src/components/authors/SimilarAuthorsRow.tsx @@ -0,0 +1,168 @@ +/** + * Component: Similar Authors Row + * Documentation: documentation/frontend/components.md + * + * Horizontal scrollable carousel of similar author cards. + * Desktop: left/right nav arrows. Mobile: drag-to-scroll. + * Each card navigates to the author's detail page. + */ + +'use client'; + +import React, { useRef, useState, useEffect, useCallback } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { SimilarAuthor } from '@/lib/hooks/useAuthors'; + +interface SimilarAuthorsRowProps { + authors: SimilarAuthor[]; + currentAuthorName?: string; +} + +export function SimilarAuthorsRow({ authors, currentAuthorName }: SimilarAuthorsRowProps) { + const scrollRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const checkScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + setCanScrollLeft(el.scrollLeft > 4); + setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4); + }, []); + + useEffect(() => { + checkScroll(); + const el = scrollRef.current; + if (!el) return; + el.addEventListener('scroll', checkScroll, { passive: true }); + const observer = new ResizeObserver(checkScroll); + observer.observe(el); + return () => { + el.removeEventListener('scroll', checkScroll); + observer.disconnect(); + }; + }, [checkScroll, authors]); + + const scroll = (direction: 'left' | 'right') => { + const el = scrollRef.current; + if (!el) return; + const scrollAmount = el.clientWidth * 0.7; + el.scrollBy({ + left: direction === 'left' ? -scrollAmount : scrollAmount, + behavior: 'smooth', + }); + }; + + if (authors.length === 0) return null; + + return ( +
+
+
+

+ Similar Authors +

+ + ({authors.length}) + +
+ +
+ {/* Left arrow */} + {canScrollLeft && ( + + )} + + {/* Scrollable row */} +
+ {authors.map(author => ( + + {/* Circular portrait */} +
+ {author.image ? ( + + ) : ( +
+ + {author.name.charAt(0).toUpperCase()} + +
+ )} +
+ + {/* Name */} +

+ {author.name} +

+ + ))} +
+ + {/* Right arrow */} + {canScrollRight && ( + + )} + + {/* Fade edges */} + {canScrollLeft && ( +
+ )} + {canScrollRight && ( +
+ )} +
+
+ ); +} + +export function SimilarAuthorsSkeleton() { + return ( +
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 6fafe8b..869e85e 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -160,6 +160,12 @@ export function Header() { > Search + + Authors + {showBookDate && ( Search + setShowMobileMenu(false)} + className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors" + > + Authors + {showBookDate && ( , emoji: '\u{1F389}', severity: 'success' as const, priority: 'high' as const, @@ -71,6 +76,20 @@ export function getEventMeta(event: NotificationEvent) { return NOTIFICATION_EVENTS[event]; } +/** + * Helper: get the resolved notification title for an event. + * If the event has a `titleByRequestType` map and a matching requestType is provided, + * returns the type-specific title. Otherwise falls back to the default `title`. + */ +export function getEventTitle(event: NotificationEvent, requestType?: string): string { + const meta = NOTIFICATION_EVENTS[event]; + if (requestType && 'titleByRequestType' in meta) { + const typeTitle = (meta as typeof meta & { titleByRequestType: Record }).titleByRequestType[requestType]; + if (typeTitle) return typeTitle; + } + return meta.title; +} + /** Helper: get the human-readable label for an event */ export function getEventLabel(event: NotificationEvent): string { return NOTIFICATION_EVENTS[event].label; diff --git a/src/lib/hooks/useAudiobooks.ts b/src/lib/hooks/useAudiobooks.ts index 22486b6..65e416c 100644 --- a/src/lib/hooks/useAudiobooks.ts +++ b/src/lib/hooks/useAudiobooks.ts @@ -12,6 +12,7 @@ export interface Audiobook { asin: string; title: string; author: string; + authorAsin?: string; narrator?: string; description?: string; coverArtUrl?: string; @@ -81,6 +82,7 @@ export function useAudiobookDetails(asin: string | null) { return { audiobook: data?.audiobook || null, + audibleBaseUrl: data?.audibleBaseUrl || 'https://www.audible.com', isLoading, error, }; diff --git a/src/lib/hooks/useAuthors.ts b/src/lib/hooks/useAuthors.ts new file mode 100644 index 0000000..d7eff18 --- /dev/null +++ b/src/lib/hooks/useAuthors.ts @@ -0,0 +1,88 @@ +/** + * Component: Authors Fetching Hooks + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import useSWR from 'swr'; +import { authenticatedFetcher } from '@/lib/utils/api'; +import { Audiobook } from './useAudiobooks'; + +export interface Author { + asin: string; + name: string; + description?: string; + image?: string; + genres: string[]; + similarCount: number; +} + +export interface SimilarAuthor { + asin: string; + name: string; + image?: string; +} + +export interface AuthorDetail { + asin: string; + name: string; + description?: string; + image?: string; + genres: string[]; + similar: SimilarAuthor[]; + audibleUrl?: string; +} + +export function useAuthorSearch(name: string) { + const shouldFetch = name && name.length > 0; + const endpoint = shouldFetch + ? `/api/authors/search?name=${encodeURIComponent(name)}` + : null; + + const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, { + revalidateOnFocus: false, + dedupingInterval: 30000, + }); + + return { + authors: (data?.authors || []) as Author[], + query: data?.query || '', + isLoading: shouldFetch && isLoading, + error, + }; +} + +export function useAuthorDetail(asin: string | null) { + const endpoint = asin ? `/api/authors/${asin}` : null; + + const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, { + revalidateOnFocus: false, + dedupingInterval: 300000, // Cache for 5 minutes + }); + + return { + author: (data?.author || null) as AuthorDetail | null, + isLoading, + error, + }; +} + +export function useAuthorBooks(asin: string | null, authorName: string | null) { + const shouldFetch = asin && authorName; + const endpoint = shouldFetch + ? `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}` + : null; + + const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, { + revalidateOnFocus: false, + dedupingInterval: 60000, // Cache for 1 minute + }); + + return { + books: (data?.books || []) as Audiobook[], + totalBooks: data?.totalBooks || 0, + isLoading: !!shouldFetch && isLoading, + error, + }; +} diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts index 6d601f7..21cfab2 100644 --- a/src/lib/integrations/audible.service.ts +++ b/src/lib/integrations/audible.service.ts @@ -30,6 +30,7 @@ export interface AudibleAudiobook { asin: string; title: string; author: string; + authorAsin?: string; narrator?: string; description?: string; coverArtUrl?: string; @@ -61,6 +62,13 @@ export class AudibleService { // Client will be created lazily on first use } + /** + * Get the current Audible base URL for the configured region + */ + public getBaseUrl(): string { + return this.baseUrl; + } + /** * Force re-initialization (used when region config changes) */ @@ -269,6 +277,10 @@ export class AudibleService { const authorText = $el.find('.authorLabel').text().trim() || $el.find('.bc-size-small .bc-text-bold').first().text().trim(); + // Extract author ASIN from author link if available + const authorHref = $el.find('a[href*="/author/"]').first().attr('href') || ''; + const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); + const narratorText = $el.find('.narratorLabel').text().trim() || $el.find('.bc-size-small .bc-text-bold').eq(1).text().trim(); @@ -281,6 +293,7 @@ export class AudibleService { asin, title, author: authorText.replace('By:', '').replace('Written by:', '').trim(), + authorAsin: authorAsinMatch?.[1] || undefined, narrator: narratorText.replace('Narrated by:', '').trim(), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), rating, @@ -367,6 +380,10 @@ export class AudibleService { const authorText = $el.find('.authorLabel').text().trim() || $el.find('.bc-size-small .bc-text-bold').first().text().trim(); + // Extract author ASIN from author link if available + const authorHref = $el.find('a[href*="/author/"]').first().attr('href') || ''; + const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); + const narratorText = $el.find('.narratorLabel').text().trim(); const coverArtUrl = $el.find('img').attr('src') || ''; @@ -378,6 +395,7 @@ export class AudibleService { asin, title, author: authorText.replace('By:', '').replace('Written by:', '').trim(), + authorAsin: authorAsinMatch?.[1] || undefined, narrator: narratorText.replace('Narrated by:', '').trim(), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), rating, @@ -454,10 +472,15 @@ export class AudibleService { $el.find('.bc-heading a').text().trim(); // Extract author from author link - const authorText = $el.find('a[href*="/author/"]').first().text().trim() || + const authorLink = $el.find('a[href*="/author/"]').first(); + const authorText = authorLink.text().trim() || $el.find('.authorLabel').text().trim() || $el.find('.bc-size-small .bc-text-bold').first().text().trim(); + // Extract author ASIN from author link href + const authorHref = authorLink.attr('href') || ''; + const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); + // Extract narrator from narrator search link const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() || $el.find('.narratorLabel').text().trim(); @@ -478,6 +501,7 @@ export class AudibleService { asin, title, author: authorText.replace('By:', '').replace('Written by:', '').trim(), + authorAsin: authorAsinMatch?.[1] || undefined, narrator: narratorText.replace('Narrated by:', '').trim(), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), durationMinutes, @@ -510,6 +534,129 @@ export class AudibleService { } } + /** + * Search for all books by a specific author, validated by ASIN. + * Uses Audible's searchAuthor parameter and paginates through all results. + * Filters: (1) author link must contain the target ASIN, (2) language must be English. + */ + async searchByAuthorAsin(authorName: string, authorAsin: string): Promise { + await this.initialize(); + + const MAX_PAGES = 10; + const allBooks: AudibleAudiobook[] = []; + const seenAsins = new Set(); + + try { + logger.info(`Searching books by author "${authorName}" (ASIN: ${authorAsin})...`); + + for (let page = 1; page <= MAX_PAGES; page++) { + const { data: response, meta } = await this.fetchWithRetry('/search', { + params: { + ipRedirectOverride: 'true', + searchAuthor: authorName, + pageSize: AUDIBLE_PAGE_SIZE, + page, + }, + }); + + const $ = cheerio.load(response.data); + let pageResults = 0; + + $('.s-result-item, .productListItem').each((_index, element) => { + const $el = $(element); + + // --- Language filter: require explicit "English" --- + const langText = $el.find('span:contains("Language:")').text().trim() || + $el.find('.languageLabel').text().trim(); + // Extract language value (e.g. "Language: English" → "English") + const langMatch = langText.match(/Language:\s*(.+)/i); + const language = langMatch?.[1]?.trim(); + if (!language || language.toLowerCase() !== 'english') return; + + // --- Author ASIN filter: verify target ASIN in author links --- + const authorLinks = $el.find('a[href*="/author/"]'); + let hasMatchingAuthor = false; + authorLinks.each((_i, link) => { + const href = $(link).attr('href') || ''; + const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); + if (asinMatch && asinMatch[1] === authorAsin) { + hasMatchingAuthor = true; + return false; // break .each() + } + }); + if (!hasMatchingAuthor) return; + + // --- Extract book ASIN --- + const bookAsin = $el.find('li').attr('data-asin') || + $el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || + $el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || + $el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || ''; + if (!bookAsin || seenAsins.has(bookAsin)) return; + seenAsins.add(bookAsin); + + // --- Parse book details --- + const title = $el.find('h2').first().text().trim() || + $el.find('h3 a').text().trim() || + $el.find('.bc-heading a').text().trim(); + + const authorText = $el.find('a[href*="/author/"]').first().text().trim() || + $el.find('.authorLabel').text().trim() || + $el.find('.bc-size-small .bc-text-bold').first().text().trim(); + + const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() || + $el.find('.narratorLabel').text().trim(); + + const coverArtUrl = $el.find('img').attr('src') || ''; + + const runtimeText = $el.find('.runtimeLabel').text().trim() || + $el.find('span:contains("Length:")').text().trim(); + const durationMinutes = this.parseRuntime(runtimeText); + + const ratingText = $el.find('.ratingsLabel').text().trim() || + $el.find('.a-icon-star span').first().text().trim(); + const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; + + allBooks.push({ + asin: bookAsin, + title, + author: authorText.replace('By:', '').replace('Written by:', '').trim(), + authorAsin, + narrator: narratorText.replace('Narrated by:', '').trim(), + coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), + durationMinutes, + rating, + }); + + pageResults++; + }); + + // Check if there are more pages + const resultsText = $('.resultsInfo').text().trim(); + const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0'); + const hasMore = totalResults > page * AUDIBLE_PAGE_SIZE; + + logger.info(`Author books page ${page}: ${pageResults} valid results (${allBooks.length} total, ${totalResults} Audible total)`); + + if (!hasMore || pageResults === 0) break; + + // Pace between pages + if (page < MAX_PAGES) { + await this.delay(this.pacer.reportPageResult(meta)); + } + } + + logger.info(`Author books search complete: "${authorName}" → ${allBooks.length} books`); + return allBooks; + } catch (error) { + logger.error(`Author books search failed for "${authorName}"`, { + error: error instanceof Error ? error.message : String(error), + collectedSoFar: allBooks.length, + }); + // Return what we collected before the error + return allBooks; + } + } + /** * Get detailed audiobook information * Primary: Audnexus API (reliable, structured data) @@ -563,6 +710,7 @@ export class AudibleService { asin, title: data.title || '', author: data.authors?.map((a: any) => a.name).join(', ') || '', + authorAsin: data.authors?.[0]?.asin || undefined, narrator: data.narrators?.map((n: any) => n.name).join(', ') || '', description: data.description || data.summary || '', coverArtUrl: data.image || '', @@ -723,6 +871,15 @@ export class AudibleService { logger.info(` Author from HTML: "${result.author}"`); } + // Author ASIN - extract from the first author link + if (!result.authorAsin) { + const firstAuthorHref = $('a[href*="/author/"]').first().attr('href') || ''; + const authorAsinMatch = firstAuthorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); + if (authorAsinMatch) { + result.authorAsin = authorAsinMatch[1]; + } + } + // Narrator - try multiple approaches (only in product details area) if (!result.narrator) { // Look specifically in the product details section diff --git a/src/lib/integrations/audnexus-authors.ts b/src/lib/integrations/audnexus-authors.ts new file mode 100644 index 0000000..2e33ede --- /dev/null +++ b/src/lib/integrations/audnexus-authors.ts @@ -0,0 +1,104 @@ +/** + * Component: Audnexus Author API Integration + * Documentation: documentation/integrations/audible.md + * + * Shared utilities for fetching author data from the Audnexus API. + * Used by author search, author detail, and similar authors routes. + */ + +import axios from 'axios'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('Audnexus.Authors'); + +const AUDNEXUS_BASE = 'https://api.audnex.us'; +const AUDNEXUS_TIMEOUT = 10000; +const AUDNEXUS_HEADERS = { 'User-Agent': 'ReadMeABook/1.0' }; + +export interface AudnexusAuthorSearch { + asin: string; + name: string; +} + +export interface AudnexusAuthorGenre { + asin: string; + name: string; + type: string; +} + +export interface AudnexusAuthorSimilar { + asin: string; + name: string; +} + +export interface AudnexusAuthorDetail { + asin: string; + name: string; + description?: string; + image?: string; + region: string; + genres?: AudnexusAuthorGenre[]; + similar?: AudnexusAuthorSimilar[]; +} + +/** + * Fetch with retry and exponential backoff for Audnexus API + */ +export async function audnexusFetchWithRetry(url: string, params: Record, maxRetries = 3): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await axios.get(url, { + params, + timeout: AUDNEXUS_TIMEOUT, + headers: AUDNEXUS_HEADERS, + }); + } catch (error: any) { + lastError = error; + const status = error.response?.status; + const isRetryable = !status || status === 503 || status === 429 || status >= 500; + + if (!isRetryable) throw error; + if (attempt === maxRetries) break; + + const backoffMs = Math.pow(2, attempt) * 1000; + logger.info(`Audnexus request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, backoffMs)); + } + } + + throw lastError || new Error('Audnexus request failed after retries'); +} + +/** + * Search authors via Audnexus and return deduplicated results + */ +export async function searchAuthors(name: string, region: string): Promise { + const response = await audnexusFetchWithRetry(`${AUDNEXUS_BASE}/authors`, { region, name }); + const results: AudnexusAuthorSearch[] = response.data; + + const seen = new Set(); + return results.filter(author => { + if (seen.has(author.asin)) return false; + seen.add(author.asin); + return true; + }); +} + +/** + * Fetch full author details from Audnexus + */ +export async function fetchAuthorDetail(asin: string, region: string): Promise { + try { + const response = await audnexusFetchWithRetry(`${AUDNEXUS_BASE}/authors/${asin}`, { region }); + return response.data; + } catch (error: any) { + if (error.response?.status === 404) { + logger.debug(`Author not found on Audnexus: ${asin}`); + } else { + logger.warn(`Failed to fetch author detail: ${asin}`, { error: error.message }); + } + return null; + } +} diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index ed9091f..a27ce1b 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -622,7 +622,9 @@ async function processEbookOrganization( requestId, book.title, book.author, - request.user.plexUsername || 'Unknown User' + request.user.plexUsername || 'Unknown User', + undefined, + 'ebook' ).catch((error) => { logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); }); diff --git a/src/lib/processors/plex-recently-added.processor.ts b/src/lib/processors/plex-recently-added.processor.ts index 1ce4321..fcb6331 100644 --- a/src/lib/processors/plex-recently-added.processor.ts +++ b/src/lib/processors/plex-recently-added.processor.ts @@ -325,7 +325,9 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa request.id, audiobook.title, audiobook.author, - request.user.plexUsername || 'Unknown User' + request.user.plexUsername || 'Unknown User', + undefined, + 'audiobook' ).catch((error) => { logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); }); diff --git a/src/lib/processors/scan-plex.processor.ts b/src/lib/processors/scan-plex.processor.ts index cfbec59..ca062d4 100644 --- a/src/lib/processors/scan-plex.processor.ts +++ b/src/lib/processors/scan-plex.processor.ts @@ -514,7 +514,9 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise { request.id, audiobook.title, audiobook.author, - request.user.plexUsername || 'Unknown User' + request.user.plexUsername || 'Unknown User', + undefined, + 'audiobook' ).catch((error) => { logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); }); diff --git a/src/lib/processors/send-notification.processor.ts b/src/lib/processors/send-notification.processor.ts index b30939a..da2f894 100644 --- a/src/lib/processors/send-notification.processor.ts +++ b/src/lib/processors/send-notification.processor.ts @@ -18,7 +18,7 @@ export type { SendNotificationPayload } from '../services/job-queue.service'; * Calls NotificationService to send notifications to all enabled backends */ export async function processSendNotification(payload: SendNotificationPayload): Promise { - const { event, requestId, issueId, title, author, userName, message, jobId } = payload; + const { event, requestId, issueId, title, author, userName, message, requestType, jobId } = payload; const logger = RMABLogger.forJob(jobId, 'SendNotification'); @@ -34,6 +34,7 @@ export async function processSendNotification(payload: SendNotificationPayload): author, userName, message, + requestType, timestamp: new Date(), }); diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts index 94653fd..1075bae 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -155,6 +155,7 @@ export interface SendNotificationPayload extends JobPayload { author: string; userName: string; message?: string; + requestType?: string; // 'audiobook' | 'ebook' — drives type-specific notification titles timestamp: Date; } @@ -948,7 +949,8 @@ export class JobQueueService { title: string, author: string, userName: string, - message?: string + message?: string, + requestType?: string ): Promise { logger.info(`Queueing notification: ${event}`, { requestId, title, userName }); return await this.addJob( @@ -963,6 +965,7 @@ export class JobQueueService { author, userName, message, + requestType, // Pass the original ID for notification display (e.g., Discord footer) ...(event === 'issue_reported' && { issueId: requestId }), timestamp: new Date(), diff --git a/src/lib/services/notification/INotificationProvider.ts b/src/lib/services/notification/INotificationProvider.ts index 1210337..5bcce28 100644 --- a/src/lib/services/notification/INotificationProvider.ts +++ b/src/lib/services/notification/INotificationProvider.ts @@ -18,6 +18,7 @@ export interface NotificationPayload { author: string; userName: string; message?: string; // For error/issue events + requestType?: string; // 'audiobook' | 'ebook' — drives type-specific titles via getEventTitle() timestamp: Date; } diff --git a/src/lib/services/notification/providers/apprise.provider.ts b/src/lib/services/notification/providers/apprise.provider.ts index 8b7d845..9c290c7 100644 --- a/src/lib/services/notification/providers/apprise.provider.ts +++ b/src/lib/services/notification/providers/apprise.provider.ts @@ -4,7 +4,7 @@ */ import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; -import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events'; +import { getEventMeta, getEventTitle, type NotificationSeverity } from '@/lib/constants/notification-events'; export interface AppriseConfig { serverUrl: string; @@ -108,8 +108,7 @@ export class AppriseProvider implements INotificationProvider { } private formatMessage(payload: NotificationPayload): { title: string; body: string } { - const { event, title, author, userName, message } = payload; - const meta = getEventMeta(event); + const { event, title, author, userName, message, requestType } = payload; const isIssue = event === 'issue_reported'; const messageLines = [ @@ -123,7 +122,7 @@ export class AppriseProvider implements INotificationProvider { } return { - title: meta.title, + title: getEventTitle(event, requestType), body: messageLines.join('\n'), }; } diff --git a/src/lib/services/notification/providers/discord.provider.ts b/src/lib/services/notification/providers/discord.provider.ts index a9aaee3..f1dadcc 100644 --- a/src/lib/services/notification/providers/discord.provider.ts +++ b/src/lib/services/notification/providers/discord.provider.ts @@ -4,7 +4,7 @@ */ import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; -import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events'; +import { getEventMeta, getEventTitle, type NotificationSeverity } from '@/lib/constants/notification-events'; export interface DiscordConfig { webhookUrl: string; @@ -59,8 +59,9 @@ export class DiscordProvider implements INotificationProvider { } private formatEmbed(payload: NotificationPayload): any { - const { event, title, author, userName, message, requestId, timestamp } = payload; + const { event, title, author, userName, message, requestId, requestType, timestamp } = payload; const meta = getEventMeta(event); + const resolvedTitle = getEventTitle(event, requestType); const isIssue = event === 'issue_reported'; const fields = [ @@ -74,7 +75,7 @@ export class DiscordProvider implements INotificationProvider { } return { - title: `${meta.emoji} ${meta.title}`, + title: `${meta.emoji} ${resolvedTitle}`, color: SEVERITY_COLORS[meta.severity], fields, footer: { diff --git a/src/lib/services/notification/providers/ntfy.provider.ts b/src/lib/services/notification/providers/ntfy.provider.ts index 539f4fb..e293df5 100644 --- a/src/lib/services/notification/providers/ntfy.provider.ts +++ b/src/lib/services/notification/providers/ntfy.provider.ts @@ -4,7 +4,7 @@ */ import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; -import { getEventMeta, type NotificationSeverity, type NotificationPriority } from '@/lib/constants/notification-events'; +import { getEventMeta, getEventTitle, type NotificationSeverity, type NotificationPriority } from '@/lib/constants/notification-events'; export interface NtfyConfig { serverUrl?: string; @@ -83,8 +83,7 @@ export class NtfyProvider implements INotificationProvider { } private formatMessage(payload: NotificationPayload): { title: string; message: string } { - const { event, title, author, userName, message } = payload; - const meta = getEventMeta(event); + const { event, title, author, userName, message, requestType } = payload; const isIssue = event === 'issue_reported'; const messageLines = [ @@ -98,7 +97,7 @@ export class NtfyProvider implements INotificationProvider { } return { - title: meta.title, + title: getEventTitle(event, requestType), message: messageLines.join('\n'), }; } diff --git a/src/lib/services/notification/providers/pushover.provider.ts b/src/lib/services/notification/providers/pushover.provider.ts index 29ee69b..19ab355 100644 --- a/src/lib/services/notification/providers/pushover.provider.ts +++ b/src/lib/services/notification/providers/pushover.provider.ts @@ -4,7 +4,7 @@ */ import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; -import { getEventMeta, type NotificationPriority } from '@/lib/constants/notification-events'; +import { getEventMeta, getEventTitle, type NotificationPriority } from '@/lib/constants/notification-events'; export interface PushoverConfig { userKey: string; @@ -77,12 +77,13 @@ export class PushoverProvider implements INotificationProvider { } private formatMessage(payload: NotificationPayload): { title: string; message: string } { - const { event, title, author, userName, message } = payload; + const { event, title, author, userName, message, requestType } = payload; const meta = getEventMeta(event); + const resolvedTitle = getEventTitle(event, requestType); const isIssue = event === 'issue_reported'; const messageLines = [ - `${meta.emoji} ${meta.title}`, + `${meta.emoji} ${resolvedTitle}`, '', `\u{1F4DA} ${title}`, `\u270D\uFE0F ${author}`, @@ -94,7 +95,7 @@ export class PushoverProvider implements INotificationProvider { } return { - title: meta.title, + title: resolvedTitle, message: messageLines.join('\n'), }; } diff --git a/tests/api/admin-notifications-test.routes.test.ts b/tests/api/admin-notifications-test.routes.test.ts index 284cd9f..d1503e2 100644 --- a/tests/api/admin-notifications-test.routes.test.ts +++ b/tests/api/admin-notifications-test.routes.test.ts @@ -116,6 +116,7 @@ describe('Admin notifications test route', () => { title: expect.any(String), author: expect.any(String), userName: 'Test User', + requestType: 'audiobook', timestamp: expect.any(Date), }) ); diff --git a/tests/processors/send-notification.processor.test.ts b/tests/processors/send-notification.processor.test.ts index c33c59a..b659227 100644 --- a/tests/processors/send-notification.processor.test.ts +++ b/tests/processors/send-notification.processor.test.ts @@ -71,6 +71,33 @@ describe('processSendNotification', () => { }); }); + it('forwards requestType to notification service', async () => { + const { processSendNotification } = await import('@/lib/processors/send-notification.processor'); + + const payload = { + event: 'request_available' as const, + requestId: 'req-1', + title: 'Test Book', + author: 'Test Author', + userName: 'Test User', + requestType: 'ebook', + timestamp: new Date('2024-01-01T00:00:00Z'), + jobId: 'job-1', + }; + + await processSendNotification(payload); + + expect(notificationServiceMock.sendNotification).toHaveBeenCalledWith({ + event: 'request_available', + requestId: 'req-1', + title: 'Test Book', + author: 'Test Author', + userName: 'Test User', + requestType: 'ebook', + timestamp: expect.any(Date), + }); + }); + it('does not throw if notification service fails', async () => { notificationServiceMock.sendNotification.mockRejectedValue(new Error('Service error')); diff --git a/tests/services/apprise.provider.test.ts b/tests/services/apprise.provider.test.ts index 1ea0b86..3f3f268 100644 --- a/tests/services/apprise.provider.test.ts +++ b/tests/services/apprise.provider.test.ts @@ -172,6 +172,7 @@ describe('AppriseProvider', () => { title: 'Test Book', author: 'Test Author', userName: 'Test User', + requestType: 'audiobook', timestamp: new Date(), } ); diff --git a/tests/services/notification.service.test.ts b/tests/services/notification.service.test.ts index f74b2c5..9024b80 100644 --- a/tests/services/notification.service.test.ts +++ b/tests/services/notification.service.test.ts @@ -31,6 +31,32 @@ vi.mock('@/lib/services/encryption.service', () => ({ getEncryptionService: () => encryptionMock, })); +describe('getEventTitle', () => { + it('returns type-specific title when requestType matches titleByRequestType', async () => { + const { getEventTitle } = await import('@/lib/constants/notification-events'); + expect(getEventTitle('request_available', 'audiobook')).toBe('Audiobook Available'); + expect(getEventTitle('request_available', 'ebook')).toBe('Ebook Available'); + }); + + it('returns default title when requestType is not provided', async () => { + const { getEventTitle } = await import('@/lib/constants/notification-events'); + expect(getEventTitle('request_available')).toBe('Request Available'); + expect(getEventTitle('request_available', undefined)).toBe('Request Available'); + }); + + it('returns default title when requestType does not match any entry', async () => { + const { getEventTitle } = await import('@/lib/constants/notification-events'); + expect(getEventTitle('request_available', 'podcast')).toBe('Request Available'); + }); + + it('returns default title for events without titleByRequestType', async () => { + const { getEventTitle } = await import('@/lib/constants/notification-events'); + expect(getEventTitle('request_approved', 'audiobook')).toBe('Request Approved'); + expect(getEventTitle('request_error')).toBe('Request Error'); + expect(getEventTitle('request_pending_approval')).toBe('New Request Pending Approval'); + }); +}); + describe('NotificationService', () => { beforeEach(() => { vi.clearAllMocks(); @@ -275,6 +301,68 @@ describe('NotificationService', () => { expect(body.embeds[0].color).toBe(2278750); // Green for approved (0x22C55E) }); + it('uses type-specific title for request_available with requestType', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + const { DiscordProvider } = await import('@/lib/services/notification'); + const provider = new DiscordProvider(); + + // Test audiobook + await provider.send( + { webhookUrl: 'https://discord.com/webhook' }, + { + event: 'request_available', + requestId: 'req-1', + title: 'Test Book', + author: 'Test Author', + userName: 'Test User', + requestType: 'audiobook', + timestamp: new Date('2024-01-01T00:00:00Z'), + } + ); + + let body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.embeds[0].title).toBe('\u{1F389} Audiobook Available'); + + // Test ebook + fetchMock.mockClear(); + await provider.send( + { webhookUrl: 'https://discord.com/webhook' }, + { + event: 'request_available', + requestId: 'req-2', + title: 'Test Book 2', + author: 'Test Author 2', + userName: 'Test User', + requestType: 'ebook', + timestamp: new Date('2024-01-01T00:00:00Z'), + } + ); + + body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.embeds[0].title).toBe('\u{1F389} Ebook Available'); + + // Test fallback (no requestType) + fetchMock.mockClear(); + await provider.send( + { webhookUrl: 'https://discord.com/webhook' }, + { + event: 'request_available', + requestId: 'req-3', + title: 'Test Book 3', + author: 'Test Author 3', + userName: 'Test User', + timestamp: new Date('2024-01-01T00:00:00Z'), + } + ); + + body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.embeds[0].title).toBe('\u{1F389} Request Available'); + }); + it('uses default username if not provided', async () => { fetchMock.mockResolvedValue({ ok: true,