Compare commits

...

5 Commits

Author SHA1 Message Date
kikootwo 6f0d71ee9b Detect external DB/Redis via flags; sanitize URLs
Improve entrypoint handling for external services and startup wrappers. entrypoint.sh now more robustly parses REDIS_URL (handles optional :password@host) and masks credentials when printing DATABASE_URL/REDIS_URL. It exports USE_EXTERNAL_POSTGRES and USE_EXTERNAL_REDIS so supervisor wrappers can decide behavior without re-parsing URLs. The temporary PostgreSQL shutdown was moved to after Prisma migrations and a warning was added when pushing schema to an external DB. postgres-start.sh and redis-start.sh were simplified to check the USE_EXTERNAL_* flags and sleep if an external service is configured. Also cleaned up formatting of the PostgreSQL ownership error message.
2026-02-12 15:59:09 -05:00
kikootwo a145dc9877 Merge pull request #75 from sudo-kraken/main
fix: Add support for external PostgreSQL and Redis instances
2026-02-12 15:24:37 -05:00
kikootwo 89422fc77a 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.
2026-02-12 15:21:42 -05:00
kikootwo e40e77c8fe Retry Audible search without series info
Try the full Goodreads title first; if no ASIN is found, strip trailing parenthetical series info (e.g. "(Series, #2)"), retry the Audible search with the cleaned title, and add informative logs. This fixes failed Audible lookups caused by Goodreads titles that include series metadata.
2026-02-12 11:13:06 -05:00
Joe Harrison 7addb1dc70 fix: Add support for external PostgreSQL and Redis instances
Implements smart detection that allows users to provide external DATABASE_URL
or REDIS_URL. When external services are detected, internal instances are
automatically disabled to save resources. Maintains full backward compatibility
with existing setup
2026-02-12 15:04:09 +00:00
40 changed files with 1871 additions and 145 deletions
+18
View File
@@ -53,6 +53,24 @@ services:
# CONFIG_ENCRYPTION_KEY: "your-custom-encryption-key-here" # CONFIG_ENCRYPTION_KEY: "your-custom-encryption-key-here"
# POSTGRES_PASSWORD: "your-custom-postgres-password-here" # POSTGRES_PASSWORD: "your-custom-postgres-password-here"
# ========================================================================
# OPTIONAL: External PostgreSQL and Redis
# ========================================================================
# To use external PostgreSQL or Redis instances instead of the internal ones,
# uncomment and configure the appropriate URL(s):
#
# External PostgreSQL example:
# DATABASE_URL: "postgresql://username:password@postgres.example.com:5432/readmeabook"
#
# External Redis example:
# REDIS_URL: "redis://redis.example.com:6379"
# REDIS_URL: "redis://:password@redis.example.com:6379" # With password
#
# Note: When using external services:
# - The internal PostgreSQL/Redis will NOT start (smart detection)
# - You do NOT need to mount ./pgdata or ./redis volumes
# - Ensure your external services are accessible from the container
# ======================================================================== # ========================================================================
# OPTIONAL: Rootless Podman Support # OPTIONAL: Rootless Podman Support
# ======================================================================== # ========================================================================
+111 -39
View File
@@ -157,16 +157,46 @@ export PLEX_PRODUCT_NAME="${PLEX_PRODUCT_NAME:-ReadMeABook}"
export LOG_LEVEL="${LOG_LEVEL:-info}" export LOG_LEVEL="${LOG_LEVEL:-info}"
# ============================================================================ # ============================================================================
# INITIALIZE POSTGRESQL # DETECT EXTERNAL SERVICES
# ============================================================================ # ============================================================================
PGDATA="/var/lib/postgresql/data" # Check if user provided external DATABASE_URL or REDIS_URL
PG_WAS_EMPTY=0 USE_EXTERNAL_POSTGRES=false
USE_EXTERNAL_REDIS=false
# Ensure correct ownership of data directories (critical for bind mounts) if [ -n "$DATABASE_URL" ]; then
echo "🔧 Setting up directory permissions..." DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:/]*\).*|\1|p')
if [ "$DB_HOST" != "127.0.0.1" ] && [ "$DB_HOST" != "localhost" ]; then
USE_EXTERNAL_POSTGRES=true
echo "️ External PostgreSQL detected at $DB_HOST"
fi
fi
# PostgreSQL directories - owned by postgres user, group accessible if [ -n "$REDIS_URL" ]; then
if ! chown -R postgres:postgres "$PGDATA" /var/run/postgresql 2>/dev/null; then # Extract host from REDIS_URL - handles both redis://host:port and redis://:password@host:port
if echo "$REDIS_URL" | grep -q '@'; then
REDIS_HOST=$(echo "$REDIS_URL" | sed -n 's|.*@\([^:/]*\).*|\1|p')
else
REDIS_HOST=$(echo "$REDIS_URL" | sed -n 's|redis://\([^:/]*\).*|\1|p')
fi
if [ -n "$REDIS_HOST" ] && [ "$REDIS_HOST" != "127.0.0.1" ] && [ "$REDIS_HOST" != "localhost" ]; then
USE_EXTERNAL_REDIS=true
echo "️ External Redis detected at $REDIS_HOST"
fi
fi
# ============================================================================
# INITIALIZE POSTGRESQL (only if using internal PostgreSQL)
# ============================================================================
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
echo "📦 Configuring internal PostgreSQL..."
PGDATA="/var/lib/postgresql/data"
PG_WAS_EMPTY=0
# Ensure correct ownership of data directories (critical for bind mounts)
echo "🔧 Setting up directory permissions..."
# PostgreSQL directories - owned by postgres user, group accessible
if ! chown -R postgres:postgres "$PGDATA" /var/run/postgresql 2>/dev/null; then
echo "" echo ""
echo "❌ ERROR: Failed to set ownership on PostgreSQL directories" echo "❌ ERROR: Failed to set ownership on PostgreSQL directories"
echo "" echo ""
@@ -196,28 +226,35 @@ if ! chown -R postgres:postgres "$PGDATA" /var/run/postgresql 2>/dev/null; then
echo " # Let Docker create them on first run" echo " # Let Docker create them on first run"
echo "" echo ""
exit 1 exit 1
fi fi
if [ -n "$PGID" ]; then if [ -n "$PGID" ]; then
# With PUID/PGID: Use 750 (owner rwx, group rx) for PostgreSQL data # With PUID/PGID: Use 750 (owner rwx, group rx) for PostgreSQL data
# This allows the PGID group to read PostgreSQL files if needed # This allows the PGID group to read PostgreSQL files if needed
chmod 750 "$PGDATA" chmod 750 "$PGDATA"
chmod 775 /var/run/postgresql chmod 775 /var/run/postgresql
else else
# Without PUID/PGID: Use strict 700 permissions (owner only) # Without PUID/PGID: Use strict 700 permissions (owner only)
chmod 700 "$PGDATA" chmod 700 "$PGDATA"
chmod 775 /var/run/postgresql chmod 775 /var/run/postgresql
fi
else
echo "⏭️ Skipping internal PostgreSQL setup (using external database)"
fi fi
# Redis directory - owned by redis user (remapped to PUID:PGID if set) # Redis directory - owned by redis user (remapped to PUID:PGID if set)
if ! chown -R redis:redis /var/lib/redis 2>/dev/null; then if [ "$USE_EXTERNAL_REDIS" = "false" ]; then
if ! chown -R redis:redis /var/lib/redis 2>/dev/null; then
echo "" echo ""
echo "❌ ERROR: Failed to set ownership on Redis directory" echo "❌ ERROR: Failed to set ownership on Redis directory"
echo " See solutions above for PostgreSQL directories" echo " See solutions above for PostgreSQL directories"
echo "" echo ""
exit 1 exit 1
fi
chmod 770 /var/lib/redis
else
echo "⏭️ Skipping internal Redis setup (using external Redis)"
fi fi
chmod 770 /var/lib/redis
# App directories - owned by node user (remapped to PUID:PGID if set) # App directories - owned by node user (remapped to PUID:PGID if set)
# These need group write permissions for shared access # These need group write permissions for shared access
@@ -232,7 +269,9 @@ chmod 775 /app/config /app/cache
echo "✅ Directory permissions configured" echo "✅ Directory permissions configured"
if [ ! -f "$PGDATA/PG_VERSION" ]; then if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
# Only initialize/setup PostgreSQL if using internal instance
if [ ! -f "$PGDATA/PG_VERSION" ]; then
PG_WAS_EMPTY=1 PG_WAS_EMPTY=1
echo "📦 Initializing PostgreSQL database..." echo "📦 Initializing PostgreSQL database..."
su - postgres -c "/usr/lib/postgresql/16/bin/initdb -D $PGDATA" su - postgres -c "/usr/lib/postgresql/16/bin/initdb -D $PGDATA"
@@ -255,30 +294,30 @@ logging_collector = off
EOF EOF
echo "✅ PostgreSQL initialized" echo "✅ PostgreSQL initialized"
else else
echo "✅ PostgreSQL data directory already exists" echo "✅ PostgreSQL data directory already exists"
fi fi
# ============================================================================ # ========================================================================
# START POSTGRESQL TEMPORARILY TO CREATE USER/DATABASE # START POSTGRESQL TEMPORARILY TO CREATE USER/DATABASE
# ============================================================================ # ========================================================================
echo "🔧 Starting PostgreSQL for setup..." echo "🔧 Starting PostgreSQL for setup..."
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA -w start -o '-c listen_addresses=127.0.0.1'" su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA -w start -o '-c listen_addresses=127.0.0.1'"
# Wait for PostgreSQL to be ready # Wait for PostgreSQL to be ready
for i in {1..30}; do for i in {1..30}; do
if su - postgres -c "/usr/lib/postgresql/16/bin/pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; then if su - postgres -c "/usr/lib/postgresql/16/bin/pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; then
echo "✅ PostgreSQL is ready" echo "✅ PostgreSQL is ready"
break break
fi fi
echo "⏳ Waiting for PostgreSQL to be ready... ($i/30)" echo "⏳ Waiting for PostgreSQL to be ready... ($i/30)"
sleep 1 sleep 1
done done
# Always ensure user and database exist (safe due to IF NOT EXISTS checks) # Always ensure user and database exist (safe due to IF NOT EXISTS checks)
# This handles cases where data directory exists but user/database don't # This handles cases where data directory exists but user/database don't
echo "👤 Ensuring database user and database exist..." echo "👤 Ensuring database user and database exist..."
su - postgres -c "psql -h 127.0.0.1 -U postgres" <<EOF su - postgres -c "psql -h 127.0.0.1 -U postgres" <<EOF
DO \$\$ DO \$\$
BEGIN BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '$POSTGRES_USER') THEN IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '$POSTGRES_USER') THEN
@@ -296,19 +335,36 @@ GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;
ALTER DATABASE $POSTGRES_DB OWNER TO $POSTGRES_USER; ALTER DATABASE $POSTGRES_DB OWNER TO $POSTGRES_USER;
EOF EOF
if [ "$PG_WAS_EMPTY" -eq 1 ]; then if [ "$PG_WAS_EMPTY" -eq 1 ]; then
echo "✅ Database initialized and setup complete" echo "✅ Database initialized and setup complete"
else else
echo "✅ Database user and permissions verified" echo "✅ Database user and permissions verified"
fi
fi fi
# ============================================================================ # ============================================================================
# SET ENVIRONMENT VARIABLES FOR APP # SET ENVIRONMENT VARIABLES FOR APP
# ============================================================================ # ============================================================================
# URL-encode the password to handle special characters # Set DATABASE_URL and REDIS_URL based on whether we're using internal or external services
ENCODED_PASSWORD=$(urlencode "$POSTGRES_PASSWORD") if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
export DATABASE_URL="postgresql://$POSTGRES_USER:$ENCODED_PASSWORD@127.0.0.1:5432/$POSTGRES_DB" # URL-encode the password to handle special characters
export REDIS_URL="redis://127.0.0.1:6379" ENCODED_PASSWORD=$(urlencode "$POSTGRES_PASSWORD")
export DATABASE_URL="postgresql://$POSTGRES_USER:$ENCODED_PASSWORD@127.0.0.1:5432/$POSTGRES_DB"
echo "✅ Using internal PostgreSQL (127.0.0.1:5432)"
else
# DATABASE_URL already set by user - do not modify
echo "✅ Using external DATABASE_URL: $(echo "$DATABASE_URL" | sed 's|//.*@|//***@|')"
fi
if [ "$USE_EXTERNAL_REDIS" = "false" ]; then
export REDIS_URL="redis://127.0.0.1:6379"
echo "✅ Using internal Redis (127.0.0.1:6379)"
else
# REDIS_URL already set by user - do not modify
echo "✅ Using external REDIS_URL: $(echo "$REDIS_URL" | sed 's|//.*@|//***@|')"
fi
export NODE_ENV="production" export NODE_ENV="production"
export PORT="3030" export PORT="3030"
export HOSTNAME="0.0.0.0" export HOSTNAME="0.0.0.0"
@@ -318,6 +374,8 @@ export HOSTNAME="0.0.0.0"
cat > /etc/environment <<EOF cat > /etc/environment <<EOF
DATABASE_URL=$DATABASE_URL DATABASE_URL=$DATABASE_URL
REDIS_URL=$REDIS_URL REDIS_URL=$REDIS_URL
USE_EXTERNAL_POSTGRES=$USE_EXTERNAL_POSTGRES
USE_EXTERNAL_REDIS=$USE_EXTERNAL_REDIS
JWT_SECRET=$JWT_SECRET JWT_SECRET=$JWT_SECRET
JWT_REFRESH_SECRET=$JWT_REFRESH_SECRET JWT_REFRESH_SECRET=$JWT_REFRESH_SECRET
CONFIG_ENCRYPTION_KEY=$CONFIG_ENCRYPTION_KEY CONFIG_ENCRYPTION_KEY=$CONFIG_ENCRYPTION_KEY
@@ -335,15 +393,21 @@ EOF
echo "✅ Environment configured" echo "✅ Environment configured"
# ============================================================================ # ============================================================================
# RUN PRISMA MIGRATIONS (while PostgreSQL is still running) # RUN PRISMA MIGRATIONS
# ============================================================================ # ============================================================================
if [ "$USE_EXTERNAL_POSTGRES" = "true" ]; then
echo "⚠️ Running schema sync against EXTERNAL database - prisma db push --accept-data-loss"
echo " This runs on every container start. Ensure your external database is backed up."
fi
echo "🔄 Running Prisma migrations..." echo "🔄 Running Prisma migrations..."
cd /app cd /app
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..." su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..."
# Stop PostgreSQL (supervisord will start it) # Stop internal PostgreSQL (supervisord will restart it via wrapper)
echo "🔧 Stopping temporary PostgreSQL instance..." if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA stop -m fast" echo "🔧 Stopping temporary PostgreSQL instance..."
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA stop -m fast"
fi
# ============================================================================ # ============================================================================
# DISPLAY STARTUP INFO # DISPLAY STARTUP INFO
@@ -361,8 +425,16 @@ if [ "$POSTGRES_PASSWORD" = "$(generate_secret)" ]; then
fi fi
echo "" echo ""
echo "📊 Services starting:" echo "📊 Services starting:"
echo " - PostgreSQL (internal, user=postgres)" if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
echo " - Redis (internal, UID:GID=${PUID:-102}:${PGID:-102})" echo " - PostgreSQL (internal, 127.0.0.1:5432)"
else
echo " - PostgreSQL (external - local instance disabled)"
fi
if [ "$USE_EXTERNAL_REDIS" = "false" ]; then
echo " - Redis (internal, 127.0.0.1:6379, UID:GID=${PUID:-102}:${PGID:-102})"
else
echo " - Redis (external - local instance disabled)"
fi
echo " - Next.js App (port 3030, UID:GID=${PUID:-1000}:${PGID:-1000})" echo " - Next.js App (port 3030, UID:GID=${PUID:-1000}:${PGID:-1000})"
if [ "${ROOTLESS_CONTAINER}" = "true" ]; then if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
echo "" echo ""
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# PostgreSQL startup wrapper for unified container
# Checks USE_EXTERNAL_POSTGRES flag (set by entrypoint) to decide whether
# to start the local instance or sleep to keep supervisord happy.
set -e
# Load environment from /etc/environment (set by entrypoint)
if [ -f /etc/environment ]; then
set -a
source /etc/environment
set +a
fi
if [ "$USE_EXTERNAL_POSTGRES" = "true" ]; then
echo "[PostgreSQL] External database configured - skipping local instance"
exec sleep infinity
fi
echo "[PostgreSQL] Starting local PostgreSQL server..."
exec /usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data
+10 -1
View File
@@ -1,5 +1,8 @@
#!/bin/bash #!/bin/bash
# Redis startup wrapper for unified container # Redis startup wrapper for unified container
# Checks USE_EXTERNAL_REDIS flag (set by entrypoint) to decide whether
# to start the local instance or sleep to keep supervisord happy.
#
# Uses gosu to ensure correct PUID:PGID for file operations # Uses gosu to ensure correct PUID:PGID for file operations
# #
# Supports: # Supports:
@@ -15,11 +18,17 @@ if [ -f /etc/environment ]; then
set +a set +a
fi fi
if [ "$USE_EXTERNAL_REDIS" = "true" ]; then
echo "[Redis] External Redis configured - skipping local instance"
exec sleep infinity
fi
echo "[Redis] Starting local Redis server..."
# Get PUID/PGID (default to redis user's current IDs if not set) # Get PUID/PGID (default to redis user's current IDs if not set)
PUID=${PUID:-$(id -u redis)} PUID=${PUID:-$(id -u redis)}
PGID=${PGID:-$(id -g redis)} PGID=${PGID:-$(id -g redis)}
echo "[Redis] Starting Redis server..."
echo "[Redis] Process will run as UID:GID = $PUID:$PGID" echo "[Redis] Process will run as UID:GID = $PUID:$PGID"
# ============================================================================= # =============================================================================
+1 -1
View File
@@ -7,7 +7,7 @@ loglevel=info
pidfile=/var/run/supervisord.pid pidfile=/var/run/supervisord.pid
[program:postgresql] [program:postgresql]
command=/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data command=/app/postgres-start.sh
user=postgres user=postgres
autostart=true autostart=true
autorestart=true autorestart=true
+5
View File
@@ -115,6 +115,11 @@ COPY --chown=root:root docker/unified/redis-start.sh /app/redis-start.sh
# Convert line endings and make executable # Convert line endings and make executable
RUN sed -i 's/\r$//' /app/redis-start.sh && chmod +x /app/redis-start.sh RUN sed -i 's/\r$//' /app/redis-start.sh && chmod +x /app/redis-start.sh
# Copy postgres startup wrapper
COPY --chown=root:root docker/unified/postgres-start.sh /app/postgres-start.sh
# Convert line endings and make executable
RUN sed -i 's/\r$//' /app/postgres-start.sh && chmod +x /app/postgres-start.sh
# Expose app port # Expose app port
EXPOSE 3030 EXPOSE 3030
@@ -33,10 +33,16 @@ model NotificationBackend {
|-------|---------|------------------------| |-------|---------|------------------------|
| request_pending_approval | User creates request | Request needs admin approval | | request_pending_approval | User creates request | Request needs admin approval |
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) | | 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 | | request_error | Download/import fails | Request failed at any stage |
| issue_reported | User reports issue | User reports problem with available audiobook | | 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 ## Notification Triggers
**Request Creation (POST /api/requests)** **Request Creation (POST /api/requests)**
@@ -60,10 +66,14 @@ model NotificationBackend {
- Approve (with or without pre-selected torrent): After job triggered → request_approved - Approve (with or without pre-selected torrent): After job triggered → request_approved
- Deny: No notification - Deny: No notification
**Request Available (processors: scan-plex, plex-recently-added)** **Audiobook Available (processors: scan-plex, plex-recently-added)**
- After `status: 'available'` update → request_available - After `status: 'available'` update → request_available (requestType: 'audiobook')
- Includes user info in query (plexUsername) - 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)** **Request Error (processors: monitor-download, organize-files)**
- After `status: 'failed'` or `status: 'warn'` update → request_error - After `status: 'failed'` or `status: 'warn'` update → request_error
- Includes error message in payload - Includes error message in payload
@@ -166,6 +176,7 @@ model NotificationBackend {
author: string, author: string,
userName: string, userName: string,
message?: string, message?: string,
requestType?: string, // 'audiobook' | 'ebook' — drives type-specific titles
timestamp: Date timestamp: Date
} }
``` ```
@@ -174,7 +185,7 @@ model NotificationBackend {
- Calls NotificationService.sendNotification() - Calls NotificationService.sendNotification()
- Non-blocking error handling (logs but doesn't throw) - 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 ## Architecture
@@ -203,10 +214,15 @@ src/lib/services/notification/
**ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }` **ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }`
**ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }` **ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }`
**Helper functions:** **Helper functions (notification.service.ts):**
- `getRegisteredProviderTypes(): string[]` — all registered type keys - `getRegisteredProviderTypes(): string[]` — all registered type keys
- `getAllProviderMetadata(): ProviderMetadata[]` — metadata for all providers - `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) **API Endpoint:** `GET /api/admin/notifications/providers` — returns all provider metadata (admin-only)
## Extensibility ## 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. 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):** **Adding New Event (e.g., download_complete):**
1. Add 'download_complete' to NotificationEvent enum 1. Add entry to `NOTIFICATION_EVENTS` in `notification-events.ts` (label, title, emoji, severity, priority)
2. Add to event labels in UI 2. Optionally add `titleByRequestType` for type-specific titles
3. Add trigger point in processor 3. Add trigger point in processor, passing `requestType` if relevant
4. Add message formatting in Discord/Pushover formatters 4. Providers auto-resolve titles via `getEventTitle()` — no per-provider changes needed
## Tech Stack ## Tech Stack
- Bull (job queue) - Bull (job queue)
@@ -68,6 +68,7 @@ export async function POST(request: NextRequest) {
title: "The Hitchhiker's Guide to the Galaxy", title: "The Hitchhiker's Guide to the Galaxy",
author: 'Douglas Adams', author: 'Douglas Adams',
userName: 'Test User', userName: 'Test User',
requestType: 'audiobook',
timestamp: new Date(), timestamp: new Date(),
}; };
+1
View File
@@ -46,6 +46,7 @@ export async function GET(
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
audiobook, audiobook,
audibleBaseUrl: audibleService.getBaseUrl(),
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get audiobook details', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to get audiobook details', { error: error instanceof Error ? error.message : String(error) });
+74
View File
@@ -0,0 +1,74 @@
/**
* Component: Author Books API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Authors.Books');
/**
* GET /api/authors/{asin}/books?name=Author+Name
* Scrape Audible for all books by this author, filtered by ASIN and English language.
* Enriched with library availability and request status.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
try {
const currentUser = getCurrentUser(request);
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'Authentication required' },
{ status: 401 }
);
}
const { asin } = await params;
const authorName = request.nextUrl.searchParams.get('name');
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Valid author ASIN is required' },
{ status: 400 }
);
}
if (!authorName || authorName.trim().length === 0) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Author name is required' },
{ status: 400 }
);
}
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin})`);
const audibleService = getAudibleService();
const books = await audibleService.searchByAuthorAsin(authorName.trim(), asin);
// Enrich with library availability and request status
const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(books, userId);
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books`);
return NextResponse.json({
success: true,
books: enrichedBooks,
authorName: authorName.trim(),
authorAsin: asin,
totalBooks: enrichedBooks.length,
});
} catch (error) {
logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'FetchError', message: 'Failed to fetch author books' },
{ status: 500 }
);
}
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Component: Author Detail API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION, AudibleRegion } from '@/lib/types/audible';
import { RMABLogger } from '@/lib/utils/logger';
import {
AudnexusAuthorDetail,
fetchAuthorDetail,
} from '@/lib/integrations/audnexus-authors';
const logger = RMABLogger.create('API.Authors.Detail');
const SIMILAR_AUTHORS_LIMIT = 15;
/**
* GET /api/authors/{asin}
* Fetch author detail from Audnexus with enriched similar author images
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
try {
const currentUser = getCurrentUser(request);
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'Authentication required' },
{ status: 401 }
);
}
const { asin } = await params;
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Valid author ASIN is required' },
{ status: 400 }
);
}
const configService = getConfigService();
const audibleRegion: AudibleRegion = await configService.getAudibleRegion();
const regionConfig = AUDIBLE_REGIONS[audibleRegion] || AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION];
const region = regionConfig.audnexusParam;
logger.info(`Fetching author detail: ${asin} (region: ${region})`);
// Fetch the primary author detail
const detail = await fetchAuthorDetail(asin, region);
if (!detail) {
return NextResponse.json(
{ error: 'NotFound', message: 'Author not found' },
{ status: 404 }
);
}
// Fetch images for similar authors in parallel (capped)
const similarSlice = (detail.similar || []).slice(0, SIMILAR_AUTHORS_LIMIT);
const similarDetails = await Promise.all(
similarSlice.map(s => fetchAuthorDetail(s.asin, region))
);
const similarAuthors = similarSlice.map((s, i) => ({
asin: s.asin,
name: s.name,
image: similarDetails[i]?.image || undefined,
}));
const author = {
asin: detail.asin,
name: detail.name,
description: detail.description || undefined,
image: detail.image || undefined,
genres: detail.genres?.map(g => g.name) || [],
similar: similarAuthors,
audibleUrl: `${regionConfig.baseUrl}/author/${asin}`,
};
logger.info(`Author detail complete: "${detail.name}" (${similarAuthors.length} similar authors)`);
return NextResponse.json({ success: true, author });
} catch (error) {
logger.error('Failed to fetch author detail', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'FetchError', message: 'Failed to fetch author details' },
{ status: 500 }
);
}
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Component: Author Search API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION, AudibleRegion } from '@/lib/types/audible';
import { RMABLogger } from '@/lib/utils/logger';
import {
AudnexusAuthorDetail,
searchAuthors,
fetchAuthorDetail,
} from '@/lib/integrations/audnexus-authors';
const logger = RMABLogger.create('API.Authors.Search');
/**
* GET /api/authors/search?name=Brandon Sanderson
* Search for authors on Audnexus, deduplicate, and return enriched details
*/
export async function GET(request: NextRequest) {
try {
// Require authentication
const currentUser = getCurrentUser(request);
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'Authentication required' },
{ status: 401 }
);
}
const name = request.nextUrl.searchParams.get('name');
if (!name || name.trim().length === 0) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Author name is required' },
{ status: 400 }
);
}
// Get configured Audible region
const configService = getConfigService();
const audibleRegion: AudibleRegion = await configService.getAudibleRegion();
const region = AUDIBLE_REGIONS[audibleRegion]?.audnexusParam || AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION].audnexusParam;
logger.info(`Searching authors: "${name}" (region: ${region})`);
// Step 1: Search for authors (returns list with potential duplicates)
const searchResults = await searchAuthors(name.trim(), region);
if (searchResults.length === 0) {
return NextResponse.json({
success: true,
authors: [],
query: name.trim(),
});
}
// Step 2: Fetch details for all unique authors in parallel
const detailPromises = searchResults.map(author => fetchAuthorDetail(author.asin, region));
const detailResults = await Promise.all(detailPromises);
// Step 3: Build enriched results, filtering out any failed fetches
const authors = detailResults
.filter((detail): detail is AudnexusAuthorDetail => detail !== null)
.map(detail => ({
asin: detail.asin,
name: detail.name,
description: detail.description || undefined,
image: detail.image || undefined,
genres: detail.genres?.map(g => g.name).slice(0, 3) || [],
similarCount: detail.similar?.length || 0,
}));
logger.info(`Author search complete: "${name}" → ${authors.length} results`);
return NextResponse.json({
success: true,
authors,
query: name.trim(),
});
} catch (error) {
logger.error('Failed to search authors', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'SearchError', message: 'Failed to search authors' },
{ status: 500 }
);
}
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Component: Author Detail Page
* Documentation: documentation/frontend/components.md
*/
'use client';
import { use, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard';
import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow';
import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { usePreferences } from '@/contexts/PreferencesContext';
export default function AuthorDetailPage({
params,
}: {
params: Promise<{ asin: string }>;
}) {
const { asin } = use(params);
const router = useRouter();
const searchParams = useSearchParams();
const fromAuthorName = searchParams.get('from');
const { author, isLoading: authorLoading } = useAuthorDetail(asin);
const { books, totalBooks, isLoading: booksLoading } = useAuthorBooks(
asin,
author?.name || null
);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
const handleBack = useCallback(() => {
// Use browser back if we came from within the app, otherwise fallback to /authors
if (window.history.length > 1) {
router.back();
} else {
router.push('/authors');
}
}, [router]);
return (
<ProtectedRoute>
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8">
{/* Back navigation */}
<button
onClick={handleBack}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{fromAuthorName ? `Back to ${fromAuthorName}` : 'Back to Authors'}
</button>
{/* Author Detail Card */}
{authorLoading ? (
<AuthorDetailSkeleton />
) : author ? (
<AuthorDetailCard author={author} />
) : (
<div className="text-center py-16 space-y-4">
<svg className="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p className="text-xl text-gray-600 dark:text-gray-400">Author not found</p>
</div>
)}
{/* Similar Authors */}
{authorLoading ? (
<SimilarAuthorsSkeleton />
) : author && author.similar.length > 0 ? (
<SimilarAuthorsRow authors={author.similar} currentAuthorName={author.name} />
) : null}
{/* Books Section */}
{author && (
<div className="space-y-6">
{/* Sticky Books Header */}
<div className="sticky top-14 sm:top-16 z-30">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Books
</h2>
{!booksLoading && totalBooks > 0 && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({totalBooks} title{totalBooks !== 1 ? 's' : ''})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
</div>
</div>
</div>
{/* Books Grid */}
<AudiobookGrid
audiobooks={books}
isLoading={booksLoading}
emptyMessage={`No books found for ${author.name}`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Component: Authors Page
* Documentation: documentation/frontend/components.md
*/
'use client';
import { Suspense, useState, useEffect, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Header } from '@/components/layout/Header';
import { AuthorGrid } from '@/components/authors/AuthorGrid';
import { useAuthorSearch } from '@/lib/hooks/useAuthors';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { usePreferences } from '@/contexts/PreferencesContext';
function AuthorsPageContent() {
const searchParams = useSearchParams();
const router = useRouter();
const initialQuery = searchParams.get('q') || '';
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const { cardSize, setCardSize } = usePreferences();
// Debounce search query and sync to URL
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
// Update URL without adding history entries
const trimmed = query.trim();
if (trimmed) {
router.replace(`/authors?q=${encodeURIComponent(trimmed)}`, { scroll: false });
} else {
router.replace('/authors', { scroll: false });
}
}, 500);
return () => clearTimeout(timer);
}, [query, router]);
const { authors, isLoading } = useAuthorSearch(debouncedQuery);
const handleSearch = useCallback((e: React.FormEvent) => {
e.preventDefault();
}, []);
return (
<ProtectedRoute>
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
{/* Page Header */}
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
Browse Authors
</h1>
<p className="text-gray-600 dark:text-gray-400">
Search for your favorite audiobook authors
</p>
</div>
{/* Search Form */}
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg
className="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by author name..."
className="w-full pl-12 pr-12 py-4 text-lg border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
autoFocus
/>
{query && (
<button
type="button"
onClick={() => setQuery('')}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
</form>
{/* Results */}
{debouncedQuery ? (
<div className="space-y-6">
{/* Sticky Results Header */}
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-indigo-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Authors
</h2>
{!isLoading && authors.length > 0 && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({authors.length} result{authors.length !== 1 ? 's' : ''})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
</div>
</div>
</div>
{/* Author Grid */}
<AuthorGrid
authors={authors}
isLoading={!!isLoading}
emptyMessage={`No authors found for "${debouncedQuery}"`}
cardSize={cardSize}
/>
</div>
) : (
/* Empty State */
<div className="text-center py-16 space-y-4">
<svg
className="mx-auto h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
<p className="text-xl text-gray-600 dark:text-gray-400">
Start typing to search for authors
</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
Search by author name to discover their works
</p>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}
export default function AuthorsPage() {
return (
<Suspense>
<AuthorsPageContent />
</Suspense>
);
}
@@ -10,6 +10,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks'; import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests'; import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
@@ -71,7 +72,7 @@ export function AudiobookDetailsModal({
}: AudiobookDetailsModalProps) { }: AudiobookDetailsModalProps) {
const { user } = useAuth(); const { user } = useAuth();
const { squareCovers } = usePreferences(); 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 { createRequest, isLoading: isRequesting } = useCreateRequest();
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null); const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin(); const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
@@ -286,7 +287,20 @@ export function AudiobookDetailsModal({
{audiobook.title} {audiobook.title}
</h2> </h2>
<p className="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-300"> <p className="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-300">
{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} {audiobook.author}
</Link>
) : (
audiobook.author
)}
</p> </p>
{audiobook.narrator && ( {audiobook.narrator && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
@@ -418,7 +432,7 @@ export function AudiobookDetailsModal({
<div> <div>
<p className="text-gray-500 dark:text-gray-400">Source</p> <p className="text-gray-500 dark:text-gray-400">Source</p>
<a <a
href={`https://www.audible.com/pd/${asin}`} href={`${audibleBaseUrl}/pd/${asin}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-orange-600 dark:text-orange-400 hover:underline" className="inline-flex items-center gap-1 text-orange-600 dark:text-orange-400 hover:underline"
+87
View File
@@ -0,0 +1,87 @@
/**
* Component: Author Card
* Documentation: documentation/frontend/components.md
*
* Premium circular portrait design - distinguishes authors from audiobook covers.
* Hover effects and typography match the AudiobookCard aesthetic.
* Clicking navigates to the author's detail page.
*/
'use client';
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Author } from '@/lib/hooks/useAuthors';
interface AuthorCardProps {
author: Author;
}
export function AuthorCard({ author }: AuthorCardProps) {
return (
<Link
href={`/authors/${author.asin}`}
className="group outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 rounded-2xl"
aria-label={`View details for ${author.name}`}
>
{/* Circular Portrait Container */}
<div className="flex justify-center">
<div
className="
relative overflow-hidden rounded-full
w-full aspect-square
shadow-lg shadow-black/20 dark:shadow-black/40
group-hover:shadow-xl group-hover:shadow-black/25 dark:group-hover:shadow-black/50
transform group-hover:scale-[1.04] group-hover:-translate-y-1
transition-all duration-300 ease-out
"
>
{author.image ? (
<Image
src={author.image}
alt=""
fill
className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
<svg className="w-1/3 h-1/3 text-blue-400 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
)}
{/* Subtle hover overlay */}
<div className="
absolute inset-0 rounded-full
bg-black/0 group-hover:bg-black/10
transition-colors duration-300
" />
</div>
</div>
{/* Author Info */}
<div className="mt-3 px-1 text-center">
<h3 className="font-semibold text-[15px] leading-snug text-gray-900 dark:text-gray-100 line-clamp-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200">
{author.name}
</h3>
{/* Genre Pills */}
{author.genres.length > 0 && (
<div className="mt-1.5 flex flex-wrap justify-center gap-1">
{author.genres.map(genre => (
<span
key={genre}
className="inline-block px-2 py-0.5 text-[11px] font-medium rounded-full bg-gray-100 dark:bg-gray-700/60 text-gray-500 dark:text-gray-400"
>
{genre}
</span>
))}
</div>
)}
</div>
</Link>
);
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Component: Author Detail Card
* Documentation: documentation/frontend/components.md
*
* Hero section for the author detail page with circular portrait,
* name, collapsible biography, and genre pills.
*/
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { AuthorDetail } from '@/lib/hooks/useAuthors';
interface AuthorDetailCardProps {
author: AuthorDetail;
}
export function AuthorDetailCard({ author }: AuthorDetailCardProps) {
const [expanded, setExpanded] = useState(false);
const hasLongDescription = (author.description?.length || 0) > 300;
return (
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
{/* Circular Portrait */}
<div className="flex-shrink-0">
<div className="relative w-36 h-36 sm:w-44 sm:h-44 lg:w-52 lg:h-52 rounded-full overflow-hidden shadow-xl shadow-black/20 dark:shadow-black/40">
{author.image ? (
<Image
src={author.image}
alt={author.name}
fill
className="object-cover"
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
priority
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
<svg className="w-1/3 h-1/3 text-blue-400 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
)}
</div>
</div>
{/* Author Info */}
<div className="flex-1 min-w-0 text-center sm:text-left">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100">
{author.name}
</h1>
{/* Genre Pills */}
{author.genres.length > 0 && (
<div className="mt-3 flex flex-wrap justify-center sm:justify-start gap-2">
{author.genres.map(genre => (
<span
key={genre}
className="inline-block px-3 py-1 text-xs font-medium rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-300"
>
{genre}
</span>
))}
</div>
)}
{/* Audible Link */}
{author.audibleUrl && (
<a
href={author.audibleUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
View on Audible
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{/* Description */}
{author.description && (
<div className="mt-4">
<p
className={`text-sm sm:text-base text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-line ${
!expanded && hasLongDescription ? 'line-clamp-4' : ''
}`}
>
{author.description}
</p>
{hasLongDescription && (
<button
onClick={() => setExpanded(!expanded)}
className="mt-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors"
>
{expanded ? 'Show less' : 'Read more'}
</button>
)}
</div>
)}
</div>
</div>
);
}
export function AuthorDetailSkeleton() {
return (
<div className="animate-pulse flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
{/* Portrait skeleton */}
<div className="flex-shrink-0">
<div className="w-36 h-36 sm:w-44 sm:h-44 lg:w-52 lg:h-52 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800">
<div className="w-full h-full rounded-full relative overflow-hidden">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
</div>
</div>
{/* Info skeleton */}
<div className="flex-1 min-w-0 text-center sm:text-left space-y-4">
<div className="h-9 bg-gray-200 dark:bg-gray-700 rounded-lg w-64 mx-auto sm:mx-0" />
<div className="flex gap-2 justify-center sm:justify-start">
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-6 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded-full" />
</div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-4/6" />
</div>
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Component: Author Grid
* Documentation: documentation/frontend/components.md
*
* Premium grid layout for author cards with loading skeletons and empty state.
* Mirrors AudiobookGrid patterns with author-appropriate column counts.
*/
'use client';
import React from 'react';
import { AuthorCard } from './AuthorCard';
import { Author } from '@/lib/hooks/useAuthors';
interface AuthorGridProps {
authors: Author[];
isLoading?: boolean;
emptyMessage?: string;
cardSize?: number;
}
// Authors use wider spacing since circular portraits need room to breathe.
// Slightly fewer columns than AudiobookGrid at each breakpoint since circles
// are visually wider than 2:3 portrait covers.
function getGridClasses(size: number): string {
const sizeMap: Record<number, string> = {
1: 'grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
2: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8',
3: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7',
4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6',
5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
7: 'grid-cols-2 md:grid-cols-3',
8: 'grid-cols-2',
9: 'grid-cols-1',
};
return sizeMap[size] || sizeMap[5];
}
export function AuthorGrid({
authors,
isLoading = false,
emptyMessage = 'No authors found',
cardSize = 5,
}: AuthorGridProps) {
const gridClasses = getGridClasses(cardSize);
if (isLoading) {
return (
<div className={`grid ${gridClasses} gap-5 sm:gap-6 lg:gap-8`}>
{Array.from({ length: 10 }).map((_, i) => (
<AuthorSkeletonCard key={i} index={i} />
))}
</div>
);
}
if (authors.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-6">
<svg className="w-10 h-10 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
<p className="text-gray-500 dark:text-gray-400 text-lg">{emptyMessage}</p>
</div>
);
}
return (
<div className={`grid ${gridClasses} gap-5 sm:gap-6 lg:gap-8`}>
{authors.map(author => (
<AuthorCard key={author.asin} author={author} />
))}
</div>
);
}
function AuthorSkeletonCard({ index = 0 }: { index?: number }) {
return (
<div
className="animate-pulse"
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Circular portrait skeleton */}
<div className="flex justify-center">
<div className="relative overflow-hidden rounded-full w-full aspect-square bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
</div>
{/* Text skeleton */}
<div className="mt-3 px-1 flex flex-col items-center space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-4/5" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/5" />
</div>
</div>
);
}
@@ -0,0 +1,168 @@
/**
* Component: Similar Authors Row
* Documentation: documentation/frontend/components.md
*
* Horizontal scrollable carousel of similar author cards.
* Desktop: left/right nav arrows. Mobile: drag-to-scroll.
* Each card navigates to the author's detail page.
*/
'use client';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { SimilarAuthor } from '@/lib/hooks/useAuthors';
interface SimilarAuthorsRowProps {
authors: SimilarAuthor[];
currentAuthorName?: string;
}
export function SimilarAuthorsRow({ authors, currentAuthorName }: SimilarAuthorsRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const checkScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 4);
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4);
}, []);
useEffect(() => {
checkScroll();
const el = scrollRef.current;
if (!el) return;
el.addEventListener('scroll', checkScroll, { passive: true });
const observer = new ResizeObserver(checkScroll);
observer.observe(el);
return () => {
el.removeEventListener('scroll', checkScroll);
observer.disconnect();
};
}, [checkScroll, authors]);
const scroll = (direction: 'left' | 'right') => {
const el = scrollRef.current;
if (!el) return;
const scrollAmount = el.clientWidth * 0.7;
el.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth',
});
};
if (authors.length === 0) return null;
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-indigo-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
Similar Authors
</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">
({authors.length})
</span>
</div>
<div className="relative group">
{/* Left arrow */}
{canScrollLeft && (
<button
onClick={() => scroll('left')}
className="hidden md:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
aria-label="Scroll left"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
{/* Scrollable row */}
<div
ref={scrollRef}
className="flex gap-4 sm:gap-5 overflow-x-auto scrollbar-hide pb-2 scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{authors.map(author => (
<Link
key={author.asin}
href={`/authors/${author.asin}${currentAuthorName ? `?from=${encodeURIComponent(currentAuthorName)}` : ''}`}
className="flex-shrink-0 w-24 sm:w-28 group/card outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-xl"
>
{/* Circular portrait */}
<div className="relative w-24 h-24 sm:w-28 sm:h-28 rounded-full overflow-hidden shadow-md shadow-black/15 dark:shadow-black/30 group-hover/card:shadow-lg group-hover/card:scale-[1.04] group-hover/card:-translate-y-0.5 transition-all duration-300">
{author.image ? (
<Image
src={author.image}
alt=""
fill
className="object-cover"
sizes="112px"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
<span className="text-xl font-bold text-blue-400 dark:text-blue-300">
{author.name.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Name */}
<p className="mt-2 text-xs sm:text-sm font-medium text-center text-gray-700 dark:text-gray-300 line-clamp-2 group-hover/card:text-indigo-600 dark:group-hover/card:text-indigo-400 transition-colors">
{author.name}
</p>
</Link>
))}
</div>
{/* Right arrow */}
{canScrollRight && (
<button
onClick={() => scroll('right')}
className="hidden md:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
aria-label="Scroll right"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{/* Fade edges */}
{canScrollLeft && (
<div className="hidden md:block absolute left-0 top-0 bottom-2 w-8 bg-gradient-to-r from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
)}
{canScrollRight && (
<div className="hidden md:block absolute right-0 top-0 bottom-2 w-8 bg-gradient-to-l from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
)}
</div>
</div>
);
}
export function SimilarAuthorsSkeleton() {
return (
<div className="space-y-3 animate-pulse">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gray-300 dark:bg-gray-600 rounded-full" />
<div className="h-7 w-40 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
<div className="flex gap-4 sm:gap-5 overflow-hidden">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex-shrink-0 w-24 sm:w-28" style={{ animationDelay: `${i * 50}ms` }}>
<div className="w-24 h-24 sm:w-28 sm:h-28 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 relative overflow-hidden">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
<div className="mt-2 h-3 bg-gray-200 dark:bg-gray-700 rounded w-4/5 mx-auto" />
</div>
))}
</div>
</div>
);
}
+13
View File
@@ -160,6 +160,12 @@ export function Header() {
> >
Search Search
</Link> </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 && ( {showBookDate && (
<Link <Link
href="/bookdate" href="/bookdate"
@@ -264,6 +270,13 @@ export function Header() {
> >
Search Search
</Link> </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 && ( {showBookDate && (
<Link <Link
href="/bookdate" href="/bookdate"
+22 -3
View File
@@ -14,7 +14,8 @@ export type NotificationPriority = 'normal' | 'high';
* *
* Each entry defines: * Each entry defines:
* - `label`: Human-readable name shown in the UI * - `label`: Human-readable name shown in the UI
* - `title`: Title used in notification messages * - `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 * - `emoji`: Emoji prefix for notification titles
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags) * - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels) * - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
@@ -35,8 +36,12 @@ export const NOTIFICATION_EVENTS = {
priority: 'normal' as const, priority: 'normal' as const,
}, },
request_available: { request_available: {
label: 'Audiobook Available', label: 'Request Available',
title: 'Audiobook Available', title: 'Request Available',
titleByRequestType: {
audiobook: 'Audiobook Available',
ebook: 'Ebook Available',
} as Record<string, string>,
emoji: '\u{1F389}', emoji: '\u{1F389}',
severity: 'success' as const, severity: 'success' as const,
priority: 'high' as const, priority: 'high' as const,
@@ -71,6 +76,20 @@ export function getEventMeta(event: NotificationEvent) {
return NOTIFICATION_EVENTS[event]; 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 */ /** Helper: get the human-readable label for an event */
export function getEventLabel(event: NotificationEvent): string { export function getEventLabel(event: NotificationEvent): string {
return NOTIFICATION_EVENTS[event].label; return NOTIFICATION_EVENTS[event].label;
+2
View File
@@ -12,6 +12,7 @@ export interface Audiobook {
asin: string; asin: string;
title: string; title: string;
author: string; author: string;
authorAsin?: string;
narrator?: string; narrator?: string;
description?: string; description?: string;
coverArtUrl?: string; coverArtUrl?: string;
@@ -81,6 +82,7 @@ export function useAudiobookDetails(asin: string | null) {
return { return {
audiobook: data?.audiobook || null, audiobook: data?.audiobook || null,
audibleBaseUrl: data?.audibleBaseUrl || 'https://www.audible.com',
isLoading, isLoading,
error, error,
}; };
+88
View File
@@ -0,0 +1,88 @@
/**
* Component: Authors Fetching Hooks
* Documentation: documentation/frontend/components.md
*/
'use client';
import useSWR from 'swr';
import { authenticatedFetcher } from '@/lib/utils/api';
import { Audiobook } from './useAudiobooks';
export interface Author {
asin: string;
name: string;
description?: string;
image?: string;
genres: string[];
similarCount: number;
}
export interface SimilarAuthor {
asin: string;
name: string;
image?: string;
}
export interface AuthorDetail {
asin: string;
name: string;
description?: string;
image?: string;
genres: string[];
similar: SimilarAuthor[];
audibleUrl?: string;
}
export function useAuthorSearch(name: string) {
const shouldFetch = name && name.length > 0;
const endpoint = shouldFetch
? `/api/authors/search?name=${encodeURIComponent(name)}`
: null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 30000,
});
return {
authors: (data?.authors || []) as Author[],
query: data?.query || '',
isLoading: shouldFetch && isLoading,
error,
};
}
export function useAuthorDetail(asin: string | null) {
const endpoint = asin ? `/api/authors/${asin}` : null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 300000, // Cache for 5 minutes
});
return {
author: (data?.author || null) as AuthorDetail | null,
isLoading,
error,
};
}
export function useAuthorBooks(asin: string | null, authorName: string | null) {
const shouldFetch = asin && authorName;
const endpoint = shouldFetch
? `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}`
: null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 60000, // Cache for 1 minute
});
return {
books: (data?.books || []) as Audiobook[],
totalBooks: data?.totalBooks || 0,
isLoading: !!shouldFetch && isLoading,
error,
};
}
+158 -1
View File
@@ -30,6 +30,7 @@ export interface AudibleAudiobook {
asin: string; asin: string;
title: string; title: string;
author: string; author: string;
authorAsin?: string;
narrator?: string; narrator?: string;
description?: string; description?: string;
coverArtUrl?: string; coverArtUrl?: string;
@@ -61,6 +62,13 @@ export class AudibleService {
// Client will be created lazily on first use // 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) * Force re-initialization (used when region config changes)
*/ */
@@ -269,6 +277,10 @@ export class AudibleService {
const authorText = $el.find('.authorLabel').text().trim() || const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().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 narratorText = $el.find('.narratorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').eq(1).text().trim(); $el.find('.bc-size-small .bc-text-bold').eq(1).text().trim();
@@ -281,6 +293,7 @@ export class AudibleService {
asin, asin,
title, title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(), author: authorText.replace('By:', '').replace('Written by:', '').trim(),
authorAsin: authorAsinMatch?.[1] || undefined,
narrator: narratorText.replace('Narrated by:', '').trim(), narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
rating, rating,
@@ -367,6 +380,10 @@ export class AudibleService {
const authorText = $el.find('.authorLabel').text().trim() || const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().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 narratorText = $el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || ''; const coverArtUrl = $el.find('img').attr('src') || '';
@@ -378,6 +395,7 @@ export class AudibleService {
asin, asin,
title, title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(), author: authorText.replace('By:', '').replace('Written by:', '').trim(),
authorAsin: authorAsinMatch?.[1] || undefined,
narrator: narratorText.replace('Narrated by:', '').trim(), narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
rating, rating,
@@ -454,10 +472,15 @@ export class AudibleService {
$el.find('.bc-heading a').text().trim(); $el.find('.bc-heading a').text().trim();
// Extract author from author link // 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('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().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 // Extract narrator from narrator search link
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() || const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
$el.find('.narratorLabel').text().trim(); $el.find('.narratorLabel').text().trim();
@@ -478,6 +501,7 @@ export class AudibleService {
asin, asin,
title, title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(), author: authorText.replace('By:', '').replace('Written by:', '').trim(),
authorAsin: authorAsinMatch?.[1] || undefined,
narrator: narratorText.replace('Narrated by:', '').trim(), narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes, 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 * Get detailed audiobook information
* Primary: Audnexus API (reliable, structured data) * Primary: Audnexus API (reliable, structured data)
@@ -563,6 +710,7 @@ export class AudibleService {
asin, asin,
title: data.title || '', title: data.title || '',
author: data.authors?.map((a: any) => a.name).join(', ') || '', author: data.authors?.map((a: any) => a.name).join(', ') || '',
authorAsin: data.authors?.[0]?.asin || undefined,
narrator: data.narrators?.map((n: any) => n.name).join(', ') || '', narrator: data.narrators?.map((n: any) => n.name).join(', ') || '',
description: data.description || data.summary || '', description: data.description || data.summary || '',
coverArtUrl: data.image || '', coverArtUrl: data.image || '',
@@ -723,6 +871,15 @@ export class AudibleService {
logger.info(` Author from HTML: "${result.author}"`); 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) // Narrator - try multiple approaches (only in product details area)
if (!result.narrator) { if (!result.narrator) {
// Look specifically in the product details section // Look specifically in the product details section
+104
View File
@@ -0,0 +1,104 @@
/**
* Component: Audnexus Author API Integration
* Documentation: documentation/integrations/audible.md
*
* Shared utilities for fetching author data from the Audnexus API.
* Used by author search, author detail, and similar authors routes.
*/
import axios from 'axios';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('Audnexus.Authors');
const AUDNEXUS_BASE = 'https://api.audnex.us';
const AUDNEXUS_TIMEOUT = 10000;
const AUDNEXUS_HEADERS = { 'User-Agent': 'ReadMeABook/1.0' };
export interface AudnexusAuthorSearch {
asin: string;
name: string;
}
export interface AudnexusAuthorGenre {
asin: string;
name: string;
type: string;
}
export interface AudnexusAuthorSimilar {
asin: string;
name: string;
}
export interface AudnexusAuthorDetail {
asin: string;
name: string;
description?: string;
image?: string;
region: string;
genres?: AudnexusAuthorGenre[];
similar?: AudnexusAuthorSimilar[];
}
/**
* Fetch with retry and exponential backoff for Audnexus API
*/
export async function audnexusFetchWithRetry(url: string, params: Record<string, string>, maxRetries = 3): Promise<any> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await axios.get(url, {
params,
timeout: AUDNEXUS_TIMEOUT,
headers: AUDNEXUS_HEADERS,
});
} catch (error: any) {
lastError = error;
const status = error.response?.status;
const isRetryable = !status || status === 503 || status === 429 || status >= 500;
if (!isRetryable) throw error;
if (attempt === maxRetries) break;
const backoffMs = Math.pow(2, attempt) * 1000;
logger.info(`Audnexus request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, backoffMs));
}
}
throw lastError || new Error('Audnexus request failed after retries');
}
/**
* Search authors via Audnexus and return deduplicated results
*/
export async function searchAuthors(name: string, region: string): Promise<AudnexusAuthorSearch[]> {
const response = await audnexusFetchWithRetry(`${AUDNEXUS_BASE}/authors`, { region, name });
const results: AudnexusAuthorSearch[] = response.data;
const seen = new Set<string>();
return results.filter(author => {
if (seen.has(author.asin)) return false;
seen.add(author.asin);
return true;
});
}
/**
* Fetch full author details from Audnexus
*/
export async function fetchAuthorDetail(asin: string, region: string): Promise<AudnexusAuthorDetail | null> {
try {
const response = await audnexusFetchWithRetry(`${AUDNEXUS_BASE}/authors/${asin}`, { region });
return response.data;
} catch (error: any) {
if (error.response?.status === 404) {
logger.debug(`Author not found on Audnexus: ${asin}`);
} else {
logger.warn(`Failed to fetch author detail: ${asin}`, { error: error.message });
}
return null;
}
}
@@ -622,7 +622,9 @@ async function processEbookOrganization(
requestId, requestId,
book.title, book.title,
book.author, book.author,
request.user.plexUsername || 'Unknown User' request.user.plexUsername || 'Unknown User',
undefined,
'ebook'
).catch((error) => { ).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(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, request.id,
audiobook.title, audiobook.title,
audiobook.author, audiobook.author,
request.user.plexUsername || 'Unknown User' request.user.plexUsername || 'Unknown User',
undefined,
'audiobook'
).catch((error) => { ).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
}); });
+3 -1
View File
@@ -514,7 +514,9 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
request.id, request.id,
audiobook.title, audiobook.title,
audiobook.author, audiobook.author,
request.user.plexUsername || 'Unknown User' request.user.plexUsername || 'Unknown User',
undefined,
'audiobook'
).catch((error) => { ).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(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 * Calls NotificationService to send notifications to all enabled backends
*/ */
export async function processSendNotification(payload: SendNotificationPayload): Promise<void> { 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'); const logger = RMABLogger.forJob(jobId, 'SendNotification');
@@ -34,6 +34,7 @@ export async function processSendNotification(payload: SendNotificationPayload):
author, author,
userName, userName,
message, message,
requestType,
timestamp: new Date(), timestamp: new Date(),
}); });
+16 -4
View File
@@ -289,11 +289,23 @@ async function performAudibleLookup(
const audibleService = getAudibleService(); const audibleService = getAudibleService();
try { try {
const searchQuery = `${book.title} ${book.author}`; // Try full Goodreads title first, then fall back to stripped title
log.info(`Searching Audible for: "${searchQuery}"`); // (Goodreads titles often include series info like "(Demonica, #2)" that return 0 Audible results)
const fullQuery = `${book.title} ${book.author}`;
log.info(`Searching Audible for: "${fullQuery}"`);
const searchResult = await audibleService.search(searchQuery); let searchResult = await audibleService.search(fullQuery);
const firstResult = searchResult.results[0]; let firstResult = searchResult.results[0];
if (!firstResult?.asin) {
const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim();
if (cleanTitle !== book.title) {
const cleanQuery = `${cleanTitle} ${book.author}`;
log.info(`No results with full title, retrying without series info: "${cleanQuery}"`);
searchResult = await audibleService.search(cleanQuery);
firstResult = searchResult.results[0];
}
}
if (firstResult?.asin) { if (firstResult?.asin) {
log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`); log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`);
+4 -1
View File
@@ -155,6 +155,7 @@ export interface SendNotificationPayload extends JobPayload {
author: string; author: string;
userName: string; userName: string;
message?: string; message?: string;
requestType?: string; // 'audiobook' | 'ebook' — drives type-specific notification titles
timestamp: Date; timestamp: Date;
} }
@@ -948,7 +949,8 @@ export class JobQueueService {
title: string, title: string,
author: string, author: string,
userName: string, userName: string,
message?: string message?: string,
requestType?: string
): Promise<string> { ): Promise<string> {
logger.info(`Queueing notification: ${event}`, { requestId, title, userName }); logger.info(`Queueing notification: ${event}`, { requestId, title, userName });
return await this.addJob( return await this.addJob(
@@ -963,6 +965,7 @@ export class JobQueueService {
author, author,
userName, userName,
message, message,
requestType,
// Pass the original ID for notification display (e.g., Discord footer) // Pass the original ID for notification display (e.g., Discord footer)
...(event === 'issue_reported' && { issueId: requestId }), ...(event === 'issue_reported' && { issueId: requestId }),
timestamp: new Date(), timestamp: new Date(),
@@ -18,6 +18,7 @@ export interface NotificationPayload {
author: string; author: string;
userName: string; userName: string;
message?: string; // For error/issue events message?: string; // For error/issue events
requestType?: string; // 'audiobook' | 'ebook' — drives type-specific titles via getEventTitle()
timestamp: Date; timestamp: Date;
} }
@@ -4,7 +4,7 @@
*/ */
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; 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 { export interface AppriseConfig {
serverUrl: string; serverUrl: string;
@@ -108,8 +108,7 @@ export class AppriseProvider implements INotificationProvider {
} }
private formatMessage(payload: NotificationPayload): { title: string; body: string } { private formatMessage(payload: NotificationPayload): { title: string; body: string } {
const { event, title, author, userName, message } = payload; const { event, title, author, userName, message, requestType } = payload;
const meta = getEventMeta(event);
const isIssue = event === 'issue_reported'; const isIssue = event === 'issue_reported';
const messageLines = [ const messageLines = [
@@ -123,7 +122,7 @@ export class AppriseProvider implements INotificationProvider {
} }
return { return {
title: meta.title, title: getEventTitle(event, requestType),
body: messageLines.join('\n'), body: messageLines.join('\n'),
}; };
} }
@@ -4,7 +4,7 @@
*/ */
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; 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 { export interface DiscordConfig {
webhookUrl: string; webhookUrl: string;
@@ -59,8 +59,9 @@ export class DiscordProvider implements INotificationProvider {
} }
private formatEmbed(payload: NotificationPayload): any { 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 meta = getEventMeta(event);
const resolvedTitle = getEventTitle(event, requestType);
const isIssue = event === 'issue_reported'; const isIssue = event === 'issue_reported';
const fields = [ const fields = [
@@ -74,7 +75,7 @@ export class DiscordProvider implements INotificationProvider {
} }
return { return {
title: `${meta.emoji} ${meta.title}`, title: `${meta.emoji} ${resolvedTitle}`,
color: SEVERITY_COLORS[meta.severity], color: SEVERITY_COLORS[meta.severity],
fields, fields,
footer: { footer: {
@@ -4,7 +4,7 @@
*/ */
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; 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 { export interface NtfyConfig {
serverUrl?: string; serverUrl?: string;
@@ -83,8 +83,7 @@ export class NtfyProvider implements INotificationProvider {
} }
private formatMessage(payload: NotificationPayload): { title: string; message: string } { 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 isIssue = event === 'issue_reported'; const isIssue = event === 'issue_reported';
const messageLines = [ const messageLines = [
@@ -98,7 +97,7 @@ export class NtfyProvider implements INotificationProvider {
} }
return { return {
title: meta.title, title: getEventTitle(event, requestType),
message: messageLines.join('\n'), message: messageLines.join('\n'),
}; };
} }
@@ -4,7 +4,7 @@
*/ */
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; 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 { export interface PushoverConfig {
userKey: string; userKey: string;
@@ -77,12 +77,13 @@ export class PushoverProvider implements INotificationProvider {
} }
private formatMessage(payload: NotificationPayload): { title: string; message: string } { 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 meta = getEventMeta(event);
const resolvedTitle = getEventTitle(event, requestType);
const isIssue = event === 'issue_reported'; const isIssue = event === 'issue_reported';
const messageLines = [ const messageLines = [
`${meta.emoji} ${meta.title}`, `${meta.emoji} ${resolvedTitle}`,
'', '',
`\u{1F4DA} ${title}`, `\u{1F4DA} ${title}`,
`\u270D\uFE0F ${author}`, `\u270D\uFE0F ${author}`,
@@ -94,7 +95,7 @@ export class PushoverProvider implements INotificationProvider {
} }
return { return {
title: meta.title, title: resolvedTitle,
message: messageLines.join('\n'), message: messageLines.join('\n'),
}; };
} }
@@ -116,6 +116,7 @@ describe('Admin notifications test route', () => {
title: expect.any(String), title: expect.any(String),
author: expect.any(String), author: expect.any(String),
userName: 'Test User', userName: 'Test User',
requestType: 'audiobook',
timestamp: expect.any(Date), 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 () => { it('does not throw if notification service fails', async () => {
notificationServiceMock.sendNotification.mockRejectedValue(new Error('Service error')); notificationServiceMock.sendNotification.mockRejectedValue(new Error('Service error'));
+1
View File
@@ -172,6 +172,7 @@ describe('AppriseProvider', () => {
title: 'Test Book', title: 'Test Book',
author: 'Test Author', author: 'Test Author',
userName: 'Test User', userName: 'Test User',
requestType: 'audiobook',
timestamp: new Date(), timestamp: new Date(),
} }
); );
@@ -31,6 +31,32 @@ vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock, 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', () => { describe('NotificationService', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -275,6 +301,68 @@ describe('NotificationService', () => {
expect(body.embeds[0].color).toBe(2278750); // Green for approved (0x22C55E) 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 () => { it('uses default username if not provided', async () => {
fetchMock.mockResolvedValue({ fetchMock.mockResolvedValue({
ok: true, ok: true,