Compare commits

...

21 Commits

Author SHA1 Message Date
kikootwo 7a1a8ffa50 Bump package version to 1.0.10
Update package.json version from 1.0.9 to 1.0.10 to prepare a new release.
2026-02-20 20:44:47 -05:00
kikootwo d70f6c9957 Add Deluge integration; revamp admin Jobs & Logs UI
Introduce Deluge download client service and tests, remove obsolete rdtclient service, and update qbittorrent integration/tests and download-client interfaces/manager. Large UI refactor for admin pages: Jobs and Logs were redesigned to be responsive (mobile card views + desktop tables), improved headers, dialogs, controls, and better status/detail rendering. Also updated DownloadClient components (card, management, modal), organize-files processor, audible-series integration, and related unit tests to align with integration changes. Minor UX and accessibility tweaks, cron handling/validation adjustments, and a few formatting/cleanup fixes throughout.
2026-02-20 20:44:26 -05:00
kikootwo 04dbb05a6e Bump package version to 1.0.9
Update package.json version from 1.0.8 to 1.0.9 to mark a new patch release. No other changes in this diff.
2026-02-20 10:19:50 -05:00
kikootwo cb9f1b81bc Add series browsing, search, and detail UI
Introduce full support for Audible series exploration: API routes, frontend pages, components, hooks, and integrations. Key changes:

- Prisma: add Audiobook.seriesAsin for linking audiobooks to series detail pages.
- Backend: add /api/series/search and /api/series/[asin] routes that require auth; scrape Audible series data and enrich books with library availability.
- Integrations/services: add audible-series integration and update request/HTTP services to support the workflow.
- Frontend: add /series and /series/[asin] pages, new components (SeriesCard, SeriesGrid, SeriesDetailCard, SimilarSeriesRow) and wire them to a new useSeries hook; update AudiobookDetailsModal to show/link series; add Series link to Header.
- Misc: extend audiobook types with series fields and add seriesLabels to language-config for scraping.

These changes enable users to search for series, view series metadata and books, and navigate between audiobook and series detail pages.
2026-02-20 10:19:30 -05:00
kikootwo 5d8ac2f73d Add language config and locale-aware parsing
Introduce centralized language configuration and wire locale-aware behavior across scraping and ranking. Adds src/lib/constants/language-config.ts with per-language scraping rules, stop words, and character replacements; replaces AudibleRegion.isEnglish with a language field in types and AUDIBLE_REGIONS. Update AudibleService, ebook scraper, processors, and API routes to use getLanguageForRegion so Anna's Archive searches, scraping selectors, runtime/rating parsing, and ranking use language-specific params and filters. Extend ranking algorithm to accept stopWords and characterReplacements and apply them during normalization and matching. Update UI selects to mark non-English regions and adjust tests accordingly.
2026-02-20 06:32:44 -05:00
kikootwo c146383735 Don't coerce customPath to undefined
Pass sanitizedCustomPath through directly instead of using `sanitizedCustomPath || undefined`. The previous fallback converted falsy values (e.g. empty string) to undefined; this change preserves explicit empty or falsy values so downstream logic can distinguish them from undefined.
2026-02-18 17:51:35 -05:00
kikootwo 3820b9b21d Add DB pooling, throttling and monitor backoff
Add connection pool params to DATABASE_URL and configure Prisma to use the pooled URL (connection_limit=20, pool_timeout=30) to reduce connection exhaustion. Introduce safeguards and throttling across processors: limit in-flight progress DB updates in direct-download, add short delays when processing RSS, retry-failed-imports, and retry-missing-torrents, and stagger scheduler triggers to avoid bursts. Implement adaptive monitor-download polling with stallCount/lastProgress and exponential backoff, and thread these fields through JobQueueService (including reduced worker concurrency for several queues). Batch audiobook enrichment queries to small parallel batches to limit DB load. Update tests to reflect new monitor payload parameters. Overall intent: reduce DB connection pool pressure and smooth load spikes during startup and heavy processing.
2026-02-18 02:43:00 -05:00
kikootwo 20798b3dc0 Add RDT-Client support and Prowlarr prompt
Introduce RDT-Client integration and related UI/behavior changes.

- Add RDTClientService extending QBittorrentService with RDT-specific behavior (stale-torrent deletion, postProcess cleanup, no-op categories).
- Register 'rdtclient' in supported client types, display names, and protocol mapping; create RDT client factory in DownloadClientManager.
- Add RDT-Client card to DownloadClientManagement UI and placeholder URL in DownloadClientModal.
- Update qbittorrent service: omit per-torrent savepath/sequential options (favor category/automatic management), make several methods protected, and clean up related comments.
- Make organize-files.processor treat rdtclient as a special-case for cleanup (remove local torrent entries after organize).
- Add prowlarr service singleton invalidation and call it when Prowlarr settings are updated so background jobs pick up new credentials.
- Add confirmation flow when changing Prowlarr URL/API key: new useIndexersSettings logic to detect credential changes, prompt ConfirmModal from IndexersTab, and optionally clear configured indexers on confirmed change.

These changes ensure Real-Debrid-backed qBittorrent-compatible clients are supported correctly and that switching Prowlarr credentials is handled safely.
2026-02-17 17:03:21 -05:00
kikootwo 3f8180a246 Add server readiness check & init retries
Wait for the Next.js server and DB to be healthy before initializing services in docker/unified/app-start.sh. Adds a health probe with configurable timeout and retries, backoff retries for the /api/init call, improved logging, and error handling when the server process exits.

In src/lib/services/scheduler.service.ts, make re-encryption of notification backends non-fatal by catching and logging errors, and make creation of default scheduled jobs robust by creating each job independently with per-job error handling and logging. Summary counts are logged for created/failed defaults so failures don't block the scheduler from starting.
2026-02-13 14:03:21 -05:00
kikootwo c97df7798a Merge pull request #78 from gtronset/feature/unraid-template-v2
Update Unriad Template to fix permissions and fix `WebUI` link
2026-02-12 23:11:02 -05:00
Gavin Tronset c0096cda1a Update Unriad Template to fix permisions and WebUI link 2026-02-12 16:20:59 -08:00
kikootwo 98a2cc2813 Mock getBaseUrl in Audible service tests
Add a getBaseUrl mock to audibleServiceMock in audiobooks-browse route tests that returns 'https://www.audible.com'. This ensures tests have a defined base URL for Audible service calls and prevents issues caused by an undefined method during test execution.
2026-02-12 16:09:55 -05:00
kikootwo 4df49633b4 Bump package version to 1.0.8
Update package.json version from 1.0.7 to 1.0.8 to prepare a patch release.
2026-02-12 16:00:45 -05:00
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
kikootwo eca24e46a8 Add test mocks and update delete API assertion
Add missing mocks used by updated code paths: mock PreferencesContext in profile page tests and add useReplaceWithTorrent/replaceWithTorrent mock for InteractiveTorrentSearchModal tests. Update Audiobookshelf API test to expect DELETE to include ?hard=1 and Authorization header. Extend the prisma test helper in audiobook-matcher tests with a reportedIssue.findMany mock and ensure it resolves to an empty array for the test.
2026-02-11 17:02:21 -05:00
kikootwo b1561a8311 Bump package version to 1.0.7
Update package.json version from 1.0.6 to 1.0.7 to reflect a new patch release.
2026-02-11 16:50:45 -05:00
kikootwo 20c8fb0898 Add reported-issues, Goodreads sync & notifs
Introduce user-reported-issues and Goodreads shelf sync features and wire them into notifications. Adds Prisma migrations and schema changes (ReportedIssue, GoodreadsShelf, GoodreadsBookMapping), API endpoints for reporting (POST /audiobooks/[asin]/report-issue) and admin management (list, resolve/dismiss, replace), and an admin UI section to view/dismiss/replace reported issues. Adds a new notification event (issue_reported) with updates to notification schemas, docs and provider handling, plus a notification-events constants file. Refactors request creation to use createRequestForUser service, adds a Goodreads sync processor/service/hooks/UI modals, a scrape-resilience util, and related tests and minor integration updates.
2026-02-11 16:49:55 -05:00
152 changed files with 10364 additions and 1621 deletions
+18
View File
@@ -53,6 +53,24 @@ services:
# CONFIG_ENCRYPTION_KEY: "your-custom-encryption-key-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
# ========================================================================
+67 -6
View File
@@ -53,14 +53,75 @@ start_server() {
start_server
SERVER_PID=$!
echo "[App] Waiting for server to be ready..."
sleep 5
# =============================================================================
# WAIT FOR SERVER READINESS
# =============================================================================
# The health endpoint (/api/health) checks both the Next.js server AND database
# connectivity. We must wait for both before initializing scheduled jobs.
# Initialize application services (creates default scheduled jobs)
echo "[App] Initializing application services..."
curl -sf http://localhost:3030/api/init || echo "[App] Warning: Failed to initialize services (may already be initialized)"
HEALTH_URL="http://localhost:3030/api/health"
INIT_URL="http://localhost:3030/api/init"
READY_TIMEOUT=${APP_READY_TIMEOUT:-60}
INIT_RETRIES=${APP_INIT_RETRIES:-5}
echo "[App] Server ready with PID $SERVER_PID"
echo "[App] Waiting for server to be ready (timeout: ${READY_TIMEOUT}s)..."
READY=false
for i in $(seq 1 "$READY_TIMEOUT"); do
# Check if the server process is still alive
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "[App] ERROR: Server process (PID $SERVER_PID) exited unexpectedly"
exit 1
fi
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
READY=true
echo "[App] Server is healthy (took ${i}s)"
break
fi
# Log progress every 10 seconds
if [ $((i % 10)) -eq 0 ]; then
echo "[App] Still waiting for server... (${i}/${READY_TIMEOUT}s)"
fi
sleep 1
done
if [ "$READY" = "false" ]; then
echo "[App] ERROR: Server did not become healthy within ${READY_TIMEOUT}s"
echo "[App] The scheduler will not be initialized - scheduled jobs may be missing"
echo "[App] Check server logs above for errors (database connection, port conflict, etc.)"
else
# =========================================================================
# INITIALIZE APPLICATION SERVICES
# =========================================================================
# Creates default scheduled jobs, runs credential migration, etc.
# Retry with backoff to handle transient failures during startup.
echo "[App] Initializing application services..."
INIT_SUCCESS=false
for attempt in $(seq 1 "$INIT_RETRIES"); do
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$INIT_URL" 2>/dev/null) || HTTP_CODE="000"
if [ "$HTTP_CODE" = "200" ]; then
INIT_SUCCESS=true
echo "[App] Services initialized successfully"
break
fi
echo "[App] Init attempt $attempt/$INIT_RETRIES failed (HTTP $HTTP_CODE), retrying in ${attempt}s..."
sleep "$attempt"
done
if [ "$INIT_SUCCESS" = "false" ]; then
echo "[App] ERROR: Failed to initialize services after $INIT_RETRIES attempts"
echo "[App] Scheduled jobs may be missing - check application logs for details"
fi
fi
echo "[App] Server running with PID $SERVER_PID"
# Verify the process is running with correct UID:GID (for debugging)
if [ -f "/proc/$SERVER_PID/status" ]; then
+171 -99
View File
@@ -157,67 +157,104 @@ export PLEX_PRODUCT_NAME="${PLEX_PRODUCT_NAME:-ReadMeABook}"
export LOG_LEVEL="${LOG_LEVEL:-info}"
# ============================================================================
# INITIALIZE POSTGRESQL
# DETECT EXTERNAL SERVICES
# ============================================================================
PGDATA="/var/lib/postgresql/data"
PG_WAS_EMPTY=0
# Check if user provided external DATABASE_URL or REDIS_URL
USE_EXTERNAL_POSTGRES=false
USE_EXTERNAL_REDIS=false
# 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 "❌ ERROR: Failed to set ownership on PostgreSQL directories"
echo ""
echo " This usually happens when using bind mounts on incompatible filesystems."
echo ""
echo " Common causes:"
echo " - WSL2: Project on Windows filesystem (/mnt/c/...)"
echo " - NFS/CIFS: Mount without proper permission support"
echo ""
echo " Solutions:"
echo ""
echo " 1. Use Docker named volumes (recommended for WSL2):"
echo " In docker-compose.yml, change:"
echo " - ./pgdata:/var/lib/postgresql/data"
echo " To:"
echo " - pgdata:/var/lib/postgresql/data"
echo " Then add at bottom:"
echo " volumes:"
echo " pgdata:"
echo ""
echo " 2. Move project to Linux filesystem (WSL2):"
echo " mkdir -p ~/readmeabook && cd ~/readmeabook"
echo " # Copy docker-compose.yml and restart"
echo ""
echo " 3. Pre-create directories with correct ownership:"
echo " mkdir -p pgdata redis config cache"
echo " # Let Docker create them on first run"
echo ""
exit 1
if [ -n "$DATABASE_URL" ]; then
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
if [ -n "$PGID" ]; then
# With PUID/PGID: Use 750 (owner rwx, group rx) for PostgreSQL data
# This allows the PGID group to read PostgreSQL files if needed
chmod 750 "$PGDATA"
chmod 775 /var/run/postgresql
if [ -n "$REDIS_URL" ]; 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 "❌ ERROR: Failed to set ownership on PostgreSQL directories"
echo ""
echo " This usually happens when using bind mounts on incompatible filesystems."
echo ""
echo " Common causes:"
echo " - WSL2: Project on Windows filesystem (/mnt/c/...)"
echo " - NFS/CIFS: Mount without proper permission support"
echo ""
echo " Solutions:"
echo ""
echo " 1. Use Docker named volumes (recommended for WSL2):"
echo " In docker-compose.yml, change:"
echo " - ./pgdata:/var/lib/postgresql/data"
echo " To:"
echo " - pgdata:/var/lib/postgresql/data"
echo " Then add at bottom:"
echo " volumes:"
echo " pgdata:"
echo ""
echo " 2. Move project to Linux filesystem (WSL2):"
echo " mkdir -p ~/readmeabook && cd ~/readmeabook"
echo " # Copy docker-compose.yml and restart"
echo ""
echo " 3. Pre-create directories with correct ownership:"
echo " mkdir -p pgdata redis config cache"
echo " # Let Docker create them on first run"
echo ""
exit 1
fi
if [ -n "$PGID" ]; then
# With PUID/PGID: Use 750 (owner rwx, group rx) for PostgreSQL data
# This allows the PGID group to read PostgreSQL files if needed
chmod 750 "$PGDATA"
chmod 775 /var/run/postgresql
else
# Without PUID/PGID: Use strict 700 permissions (owner only)
chmod 700 "$PGDATA"
chmod 775 /var/run/postgresql
fi
else
# Without PUID/PGID: Use strict 700 permissions (owner only)
chmod 700 "$PGDATA"
chmod 775 /var/run/postgresql
echo "⏭️ Skipping internal PostgreSQL setup (using external database)"
fi
# Redis directory - owned by redis user (remapped to PUID:PGID if set)
if ! chown -R redis:redis /var/lib/redis 2>/dev/null; then
echo ""
echo "❌ ERROR: Failed to set ownership on Redis directory"
echo " See solutions above for PostgreSQL directories"
echo ""
exit 1
if [ "$USE_EXTERNAL_REDIS" = "false" ]; then
if ! chown -R redis:redis /var/lib/redis 2>/dev/null; then
echo ""
echo "❌ ERROR: Failed to set ownership on Redis directory"
echo " See solutions above for PostgreSQL directories"
echo ""
exit 1
fi
chmod 770 /var/lib/redis
else
echo "⏭️ Skipping internal Redis setup (using external Redis)"
fi
chmod 770 /var/lib/redis
# App directories - owned by node user (remapped to PUID:PGID if set)
# These need group write permissions for shared access
@@ -232,18 +269,20 @@ chmod 775 /app/config /app/cache
echo "✅ Directory permissions configured"
if [ ! -f "$PGDATA/PG_VERSION" ]; then
PG_WAS_EMPTY=1
echo "📦 Initializing PostgreSQL database..."
su - postgres -c "/usr/lib/postgresql/16/bin/initdb -D $PGDATA"
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
# Only initialize/setup PostgreSQL if using internal instance
if [ ! -f "$PGDATA/PG_VERSION" ]; then
PG_WAS_EMPTY=1
echo "📦 Initializing PostgreSQL database..."
su - postgres -c "/usr/lib/postgresql/16/bin/initdb -D $PGDATA"
# Configure PostgreSQL for local access
echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
echo "host all all ::1/128 trust" >> "$PGDATA/pg_hba.conf"
echo "local all all trust" >> "$PGDATA/pg_hba.conf"
# Configure PostgreSQL for local access
echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
echo "host all all ::1/128 trust" >> "$PGDATA/pg_hba.conf"
echo "local all all trust" >> "$PGDATA/pg_hba.conf"
# Update postgresql.conf for performance
cat >> "$PGDATA/postgresql.conf" <<EOF
# Update postgresql.conf for performance
cat >> "$PGDATA/postgresql.conf" <<EOF
listen_addresses = '127.0.0.1'
max_connections = 100
shared_buffers = 128MB
@@ -254,31 +293,31 @@ log_destination = 'stderr'
logging_collector = off
EOF
echo "✅ PostgreSQL initialized"
else
echo "✅ PostgreSQL data directory already exists"
fi
# ============================================================================
# START POSTGRESQL TEMPORARILY TO CREATE USER/DATABASE
# ============================================================================
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'"
# Wait for PostgreSQL to be ready
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
echo "✅ PostgreSQL is ready"
break
echo "✅ PostgreSQL initialized"
else
echo "✅ PostgreSQL data directory already exists"
fi
echo "⏳ Waiting for PostgreSQL to be ready... ($i/30)"
sleep 1
done
# 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
echo "👤 Ensuring database user and database exist..."
su - postgres -c "psql -h 127.0.0.1 -U postgres" <<EOF
# ========================================================================
# START POSTGRESQL TEMPORARILY TO CREATE USER/DATABASE
# ========================================================================
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'"
# Wait for PostgreSQL to be ready
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
echo "✅ PostgreSQL is ready"
break
fi
echo "⏳ Waiting for PostgreSQL to be ready... ($i/30)"
sleep 1
done
# 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
echo "👤 Ensuring database user and database exist..."
su - postgres -c "psql -h 127.0.0.1 -U postgres" <<EOF
DO \$\$
BEGIN
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;
EOF
if [ "$PG_WAS_EMPTY" -eq 1 ]; then
echo "✅ Database initialized and setup complete"
else
echo "✅ Database user and permissions verified"
if [ "$PG_WAS_EMPTY" -eq 1 ]; then
echo "✅ Database initialized and setup complete"
else
echo "✅ Database user and permissions verified"
fi
fi
# ============================================================================
# SET ENVIRONMENT VARIABLES FOR APP
# ============================================================================
# URL-encode the password to handle special characters
ENCODED_PASSWORD=$(urlencode "$POSTGRES_PASSWORD")
export DATABASE_URL="postgresql://$POSTGRES_USER:$ENCODED_PASSWORD@127.0.0.1:5432/$POSTGRES_DB"
export REDIS_URL="redis://127.0.0.1:6379"
# Set DATABASE_URL and REDIS_URL based on whether we're using internal or external services
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
# URL-encode the password to handle special characters
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 PORT="3030"
export HOSTNAME="0.0.0.0"
@@ -318,6 +374,8 @@ export HOSTNAME="0.0.0.0"
cat > /etc/environment <<EOF
DATABASE_URL=$DATABASE_URL
REDIS_URL=$REDIS_URL
USE_EXTERNAL_POSTGRES=$USE_EXTERNAL_POSTGRES
USE_EXTERNAL_REDIS=$USE_EXTERNAL_REDIS
JWT_SECRET=$JWT_SECRET
JWT_REFRESH_SECRET=$JWT_REFRESH_SECRET
CONFIG_ENCRYPTION_KEY=$CONFIG_ENCRYPTION_KEY
@@ -335,15 +393,21 @@ EOF
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..."
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..."
# Stop PostgreSQL (supervisord will start it)
echo "🔧 Stopping temporary PostgreSQL instance..."
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA stop -m fast"
# Stop internal PostgreSQL (supervisord will restart it via wrapper)
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
echo "🔧 Stopping temporary PostgreSQL instance..."
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA stop -m fast"
fi
# ============================================================================
# DISPLAY STARTUP INFO
@@ -361,8 +425,16 @@ if [ "$POSTGRES_PASSWORD" = "$(generate_secret)" ]; then
fi
echo ""
echo "📊 Services starting:"
echo " - PostgreSQL (internal, user=postgres)"
echo " - Redis (internal, UID:GID=${PUID:-102}:${PGID:-102})"
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
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})"
if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
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
# 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
#
# Supports:
@@ -15,11 +18,17 @@ if [ -f /etc/environment ]; then
set +a
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)
PUID=${PUID:-$(id -u redis)}
PGID=${PGID:-$(id -g redis)}
echo "[Redis] Starting Redis server..."
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
[program:postgresql]
command=/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data
command=/app/postgres-start.sh
user=postgres
autostart=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
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 3030
+38 -16
View File
@@ -7,7 +7,7 @@ Sends notifications for audiobook request events (pending approval, approved, av
## Key Details
- **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API)
- **Events:** request_pending_approval, request_approved, request_available, request_error
- **Events:** request_pending_approval, request_approved, request_available, request_error, issue_reported
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
- **Delivery:** Async via Bull job queue (priority 5)
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
@@ -33,8 +33,15 @@ model NotificationBackend {
|-------|---------|------------------------|
| request_pending_approval | User creates request | Request needs admin approval |
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
| request_available | Plex/ABS scan completes | Audiobook available in library |
| request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) |
| request_error | Download/import fails | Request failed at any stage |
| issue_reported | User reports issue | User reports problem with available audiobook |
**Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles.
- `request_available` + `requestType: 'audiobook'` → "Audiobook Available"
- `request_available` + `requestType: 'ebook'` → "Ebook Available"
- `request_available` + no requestType → "Request Available" (fallback)
- Use `getEventTitle(event, requestType?)` to resolve titles in providers
## Notification Triggers
@@ -59,14 +66,22 @@ model NotificationBackend {
- Approve (with or without pre-selected torrent): After job triggered → request_approved
- Deny: No notification
**Request Available (processors: scan-plex, plex-recently-added)**
- After `status: 'available'` update → request_available
**Audiobook Available (processors: scan-plex, plex-recently-added)**
- After `status: 'available'` update → request_available (requestType: 'audiobook')
- Includes user info in query (plexUsername)
**Ebook Available (processor: organize-files)**
- After ebook `status: 'downloaded'` (terminal) → request_available (requestType: 'ebook')
- Ebooks don't transition to 'available' via Plex matching
**Request Error (processors: monitor-download, organize-files)**
- After `status: 'failed'` or `status: 'warn'` update → request_error
- Includes error message in payload
**Issue Reported (reported-issue.service.ts)**
- After user reports issue with available audiobook → issue_reported
- Payload: issue ID (as requestId), book title/author, reporter username, reason (as message)
## Configuration Encryption
**Encrypted Values:**
@@ -91,13 +106,14 @@ model NotificationBackend {
- Format: Event title + book details + user + error (if applicable)
**Discord (Rich Embeds):**
- Color-coded by event (yellow=pending, green=approved, blue=available, red=error)
- Fields: Title, Author, Requested By, Error (if applicable)
- Footer: Request ID
- Color-coded by event (yellow=pending, green=approved, blue=available, red=error, orange=issue)
- Fields: Title, Author, Requested/Reported By, Error/Reason (if applicable)
- Footer: Request/Issue ID
- Timestamp: Event time
**ntfy (JSON with Tags):**
- Tags: mailbox_with_mail, white_check_mark, tada, x (rendered as emojis by ntfy)
**ntfy (JSON Publishing to Base URL):**
- Endpoint: POST to base `serverUrl` (default: https://ntfy.sh), topic in JSON body
- Tags: mailbox_with_mail, white_check_mark, tada, x, triangular_flag_on_post (rendered as emojis by ntfy)
- Priority: Default (3) for pending/approved, High (4) for available/error
- Format: Event title + book details + user + error (if applicable)
- Auth: Optional Bearer token via `accessToken` config field
@@ -142,7 +158,7 @@ model NotificationBackend {
**Modal Features:**
- Type-first selection (user clicks "Add Discord" or "Add Pushover")
- Password inputs for sensitive values
- Event subscription checkboxes (4 events, default: available + error)
- Event subscription checkboxes (5 events, default: available + error)
- Test button (sends synchronous test notification)
- Save button (validates and creates/updates backend)
@@ -160,6 +176,7 @@ model NotificationBackend {
author: string,
userName: string,
message?: string,
requestType?: string, // 'audiobook' | 'ebook' — drives type-specific titles
timestamp: Date
}
```
@@ -168,7 +185,7 @@ model NotificationBackend {
- Calls NotificationService.sendNotification()
- Non-blocking error handling (logs but doesn't throw)
**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?)`
**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?, requestType?)`
## Architecture
@@ -197,10 +214,15 @@ src/lib/services/notification/
**ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }`
**ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }`
**Helper functions:**
**Helper functions (notification.service.ts):**
- `getRegisteredProviderTypes(): string[]` — all registered type keys
- `getAllProviderMetadata(): ProviderMetadata[]` — metadata for all providers
**Helper functions (notification-events.ts):**
- `getEventMeta(event)` — raw event metadata (label, title, emoji, severity, priority)
- `getEventTitle(event, requestType?)` — resolved title (checks `titleByRequestType` first, falls back to `title`)
- `getEventLabel(event)` — human-readable label for UI
**API Endpoint:** `GET /api/admin/notifications/providers` — returns all provider metadata (admin-only)
## Extensibility
@@ -215,10 +237,10 @@ src/lib/services/notification/
No UI changes, no API route changes, no Zod schema changes needed — the UI renders dynamically from provider metadata.
**Adding New Event (e.g., download_complete):**
1. Add 'download_complete' to NotificationEvent enum
2. Add to event labels in UI
3. Add trigger point in processor
4. Add message formatting in Discord/Pushover formatters
1. Add entry to `NOTIFICATION_EVENTS` in `notification-events.ts` (label, title, emoji, severity, priority)
2. Optionally add `titleByRequestType` for type-specific titles
3. Add trigger point in processor, passing `requestType` if relevant
4. Providers auto-resolve titles via `getEventTitle()` — no per-provider changes needed
## Tech Stack
- Bull (job queue)
+14 -8
View File
@@ -175,19 +175,19 @@ interface TorrentInfo {
}
type TorrentState =
// Core states
// Core states (*DL = download phase, *UP = upload/post-download phase)
| 'downloading' | 'uploading'
| 'stalledDL' | 'stalledUP'
| 'pausedDL' | 'pausedUP'
| 'queuedDL' | 'queuedUP'
| 'checkingDL' | 'checkingUP'
| 'stalledDL' | 'stalledUP' // stalledUP → completed (download done)
| 'pausedDL' | 'pausedUP' // pausedUP → completed (download done, paused seeding)
| 'queuedDL' | 'queuedUP' // queuedUP → completed (download done)
| 'checkingDL' | 'checkingUP' // checkingUP → completed (download done, rechecking)
| 'error' | 'missingFiles' | 'allocating'
// Forced states (user clicked "Force Resume")
| 'forcedDL' | 'forcedUP'
| 'forcedDL' | 'forcedUP' // forcedUP → completed (download done)
// Metadata fetching
| 'metaDL' | 'forcedMetaDL'
// qBittorrent v5.0+ (renamed paused → stopped)
| 'stoppedDL' | 'stoppedUP'
| 'stoppedDL' | 'stoppedUP' // stoppedUP → completed (download done)
// Other
| 'checkingResumeData' | 'moving';
```
@@ -241,7 +241,13 @@ type TorrentState =
- Adding all 8 missing states to `TorrentState` type union
- Adding mappings to both `mapState()` (legacy) and `mapStateToDownloadStatus()` (unified interface)
- `forcedUP``seeding`/`completed` enables monitor to trigger import
- `stoppedDL`/`stoppedUP``paused` ensures qBittorrent v5.x compatibility
- `stoppedDL``paused` ensures qBittorrent v5.x compatibility
**16. pausedUP/stoppedUP mapped as paused instead of completed** - qBittorrent (after ratio limits) transitions directly to `pausedUP`/`stoppedUP` without passing through `uploading`/`stalledUP`. The `*UP` suffix means the download phase is complete and the torrent is on the upload side. Both states were incorrectly mapped to `'paused'`, causing the monitor to re-schedule checks indefinitely instead of triggering file organization. Fixed by:
- `pausedUP``seeding` (unified) / `completed` (legacy) — triggers completion in monitor
- `stoppedUP``seeding` (unified) / `completed` (legacy) — same fix for qBittorrent v5.x
- `pausedDL`/`stoppedDL` remain `paused` — download phase genuinely paused
- Key insight: any `*UP` state is post-download; any `*DL` state is pre-completion
## Tech Stack
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "readmeabook",
"version": "1.0.6",
"version": "1.0.10",
"private": true,
"scripts": {
"dev": "next dev",
@@ -0,0 +1,46 @@
-- CreateTable
CREATE TABLE "goodreads_shelves" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"rss_url" TEXT NOT NULL,
"last_sync_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "goodreads_shelves_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "goodreads_book_mappings" (
"id" TEXT NOT NULL,
"goodreads_book_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"author" TEXT NOT NULL,
"audible_asin" TEXT,
"cover_url" TEXT,
"no_match" BOOLEAN NOT NULL DEFAULT false,
"last_search_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "goodreads_book_mappings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "goodreads_shelves_user_id_idx" ON "goodreads_shelves"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "goodreads_shelves_user_id_rss_url_key" ON "goodreads_shelves"("user_id", "rss_url");
-- CreateIndex
CREATE UNIQUE INDEX "goodreads_book_mappings_goodreads_book_id_key" ON "goodreads_book_mappings"("goodreads_book_id");
-- CreateIndex
CREATE INDEX "goodreads_book_mappings_goodreads_book_id_idx" ON "goodreads_book_mappings"("goodreads_book_id");
-- CreateIndex
CREATE INDEX "goodreads_book_mappings_audible_asin_idx" ON "goodreads_book_mappings"("audible_asin");
-- AddForeignKey
ALTER TABLE "goodreads_shelves" ADD CONSTRAINT "goodreads_shelves_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- Add cached book count and cover URLs to goodreads_shelves for rich UI display
ALTER TABLE "goodreads_shelves" ADD COLUMN "book_count" INTEGER;
ALTER TABLE "goodreads_shelves" ADD COLUMN "cover_urls" TEXT;
@@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE "reported_issues" (
"id" TEXT NOT NULL,
"audiobook_id" TEXT NOT NULL,
"reporter_id" TEXT NOT NULL,
"reason" VARCHAR(250) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'open',
"resolved_at" TIMESTAMP(3),
"resolved_by_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "reported_issues_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "reported_issues_audiobook_id_idx" ON "reported_issues"("audiobook_id");
-- CreateIndex
CREATE INDEX "reported_issues_reporter_id_idx" ON "reported_issues"("reporter_id");
-- CreateIndex
CREATE INDEX "reported_issues_status_idx" ON "reported_issues"("status");
-- AddForeignKey
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_audiobook_id_fkey" FOREIGN KEY ("audiobook_id") REFERENCES "audiobooks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_reporter_id_fkey" FOREIGN KEY ("reporter_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_resolved_by_id_fkey" FOREIGN KEY ("resolved_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+74 -1
View File
@@ -64,6 +64,9 @@ model User {
requests Request[]
bookDateRecommendations BookDateRecommendation[]
bookDateSwipes BookDateSwipe[]
goodreadsShelves GoodreadsShelf[]
reportedIssues ReportedIssue[] @relation("Reporter")
resolvedIssues ReportedIssue[] @relation("Resolver")
@@index([plexId])
@@index([role])
@@ -173,6 +176,7 @@ model Audiobook {
year Int? // Release year extracted from releaseDate
series String? // Book series name (e.g., "The Mistborn Saga")
seriesPart String? @map("series_part") // Series position (e.g., "1", "1.5", "Book 1")
seriesAsin String? @map("series_asin") // Audible series ASIN for linking to series detail page
// Request tracking
status String @default("requested") // requested, downloading, processing, completed, failed
@@ -197,7 +201,8 @@ model Audiobook {
completedAt DateTime? @map("completed_at")
// Relations
requests Request[]
requests Request[]
reportedIssues ReportedIssue[]
@@index([audibleAsin])
@@index([plexGuid])
@@ -456,3 +461,71 @@ model NotificationBackend {
@@index([enabled])
@@map("notification_backends")
}
// ============================================================================
// REPORTED ISSUES TABLE
// User-reported problems with available audiobooks (corrupted, wrong book, etc.)
// ============================================================================
model ReportedIssue {
id String @id @default(uuid())
audiobookId String @map("audiobook_id")
reporterId String @map("reporter_id")
reason String @db.VarChar(250)
status String @default("open") // open, dismissed, replaced
resolvedAt DateTime? @map("resolved_at")
resolvedById String? @map("resolved_by_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
audiobook Audiobook @relation(fields: [audiobookId], references: [id], onDelete: Cascade)
reporter User @relation("Reporter", fields: [reporterId], references: [id], onDelete: Cascade)
resolvedBy User? @relation("Resolver", fields: [resolvedById], references: [id], onDelete: SetNull)
@@index([audiobookId])
@@index([reporterId])
@@index([status])
@@map("reported_issues")
}
// ============================================================================
// GOODREADS SYNC TABLES
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
// ============================================================================
model GoodreadsShelf {
id String @id @default(uuid())
userId String @map("user_id")
name String // Extracted from RSS <title>
rssUrl String @map("rss_url") @db.Text
lastSyncAt DateTime? @map("last_sync_at")
bookCount Int? @map("book_count")
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, rssUrl])
@@index([userId])
@@map("goodreads_shelves")
}
model GoodreadsBookMapping {
id String @id @default(uuid())
goodreadsBookId String @unique @map("goodreads_book_id")
title String
author String
audibleAsin String? @map("audible_asin")
coverUrl String? @map("cover_url") @db.Text
noMatch Boolean @default(false) @map("no_match")
lastSearchAt DateTime? @map("last_search_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([goodreadsBookId])
@@index([audibleAsin])
@@map("goodreads_book_mappings")
}
@@ -0,0 +1,242 @@
/**
* Component: Admin Reported Issues Section
* Documentation: documentation/backend/services/reported-issues.md
*
* Displays open reported issues on the admin dashboard.
* Allows dismiss or search-for-replacement actions.
*/
'use client';
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useToast } from '@/components/ui/Toast';
import { formatDistanceToNow } from 'date-fns';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { fetchJSON } from '@/lib/utils/api';
import { mutate } from 'swr';
interface ReportedIssue {
id: string;
reason: string;
status: string;
createdAt: string;
audiobook: {
id: string;
title: string;
author: string;
coverArtUrl: string | null;
audibleAsin: string | null;
};
reporter: {
id: string;
plexUsername: string;
avatarUrl: string | null;
};
}
interface ReportedIssuesSectionProps {
issues: ReportedIssue[];
}
export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
const toast = useToast();
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
const [replaceIssue, setReplaceIssue] = useState<ReportedIssue | null>(null);
const handleDismiss = async (issueId: string) => {
setLoadingStates((prev) => ({ ...prev, [issueId]: true }));
try {
await fetchJSON(`/api/admin/reported-issues/${issueId}/resolve`, {
method: 'POST',
body: JSON.stringify({ action: 'dismiss' }),
});
toast.success('Issue dismissed');
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
} catch (error) {
toast.error(
`Failed to dismiss issue: ${error instanceof Error ? error.message : 'Unknown error'}`
);
} finally {
setLoadingStates((prev) => ({ ...prev, [issueId]: false }));
}
};
const handleReplaceSuccess = async () => {
toast.success('Replacement download started');
setReplaceIssue(null);
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/metrics'));
};
return (
<>
<div className="mb-8">
{/* Section Header */}
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center gap-2">
<svg
className="w-6 h-6 text-orange-600 dark:text-orange-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"
/>
</svg>
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
Reported Issues
</h2>
</div>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
{issues.length}
</span>
</div>
{/* Issues Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{issues.map((issue) => {
const isLoading = loadingStates[issue.id] || false;
return (
<div
key={issue.id}
className="bg-white dark:bg-gray-800 border-2 border-orange-200 dark:border-orange-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
>
{/* Card Content */}
<div className="p-4">
<div className="flex gap-3">
{/* Cover Image */}
<div className="flex-shrink-0">
{issue.audiobook.coverArtUrl ? (
<img
src={issue.audiobook.coverArtUrl}
alt={issue.audiobook.title}
className="w-16 h-16 rounded object-cover"
/>
) : (
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-400 dark:text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
{issue.audiobook.title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
{issue.audiobook.author}
</p>
{/* Reporter */}
<div className="flex items-center gap-2 mt-2">
{issue.reporter.avatarUrl ? (
<img
src={issue.reporter.avatarUrl}
alt={issue.reporter.plexUsername}
className="w-5 h-5 rounded-full"
/>
) : (
<div className="w-5 h-5 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
<svg
className="w-3 h-3 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clipRule="evenodd"
/>
</svg>
</div>
)}
<span className="text-xs text-gray-600 dark:text-gray-400">
{issue.reporter.plexUsername}
</span>
</div>
{/* Timestamp */}
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{formatDistanceToNow(new Date(issue.createdAt), { addSuffix: true })}
</p>
</div>
</div>
{/* Reason */}
<p className="mt-3 text-sm text-gray-700 dark:text-gray-300 line-clamp-2 break-words bg-orange-50 dark:bg-orange-900/20 rounded-lg px-3 py-2 border border-orange-100 dark:border-orange-800/50">
{issue.reason}
</p>
</div>
{/* Action Buttons */}
<div className="border-t border-orange-200 dark:border-orange-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
<button
onClick={() => handleDismiss(issue.id)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<span>Dismiss</span>
</button>
<button
onClick={() => setReplaceIssue(issue)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>Replace</span>
</button>
</div>
</div>
);
})}
</div>
</div>
{/* Interactive Search Modal for Replacement */}
{replaceIssue && createPortal(
<div className="fixed inset-0 z-[60]">
<InteractiveTorrentSearchModal
isOpen={!!replaceIssue}
onClose={() => setReplaceIssue(null)}
onSuccess={handleReplaceSuccess}
audiobook={{
title: replaceIssue.audiobook.title,
author: replaceIssue.audiobook.author,
}}
asin={replaceIssue.audiobook.audibleAsin || undefined}
replaceIssueId={replaceIssue.id}
/>
</div>,
document.body
)}
</>
);
}
+209 -123
View File
@@ -78,13 +78,11 @@ function AdminJobsPageContent() {
const showEditDialog = (job: ScheduledJob) => {
setEditForm({ schedule: job.schedule, enabled: job.enabled });
// Check if it's a preset
const preset = SCHEDULE_PRESETS.find(p => p.cron === job.schedule);
if (preset) {
setScheduleMode('preset');
setSelectedPreset(preset.cron);
} else {
// Try to parse as custom schedule
const parsed = cronToCustomSchedule(job.schedule);
if (parsed.type === 'custom') {
setScheduleMode('advanced');
@@ -111,7 +109,7 @@ function AdminJobsPageContent() {
method: 'POST',
});
toast.success(`Job "${jobName}" triggered successfully`);
fetchJobs(); // Refresh list
fetchJobs();
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to trigger job';
toast.error(errorMsg);
@@ -124,7 +122,6 @@ function AdminJobsPageContent() {
const saveJobSchedule = async () => {
if (!editDialog.job) return;
// Calculate final cron expression based on mode
let finalCron: string;
if (scheduleMode === 'preset') {
finalCron = selectedPreset;
@@ -134,7 +131,6 @@ function AdminJobsPageContent() {
finalCron = editForm.schedule;
}
// Validate cron expression
if (!isValidCron(finalCron)) {
toast.error('Invalid cron expression. Please check your schedule.');
return;
@@ -151,7 +147,7 @@ function AdminJobsPageContent() {
});
toast.success(`Job "${editDialog.job.name}" updated successfully`);
hideEditDialog();
fetchJobs(); // Refresh list
fetchJobs();
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to update job';
toast.error(errorMsg);
@@ -173,36 +169,131 @@ function AdminJobsPageContent() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
Scheduled Jobs
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Manage recurring tasks and automated jobs
</p>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{/* Header — stacks on mobile, row on sm+ */}
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
Scheduled Jobs
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Manage recurring tasks and automated jobs
</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-800 dark:text-red-200">{error}</p>
<p className="text-red-800 dark:text-red-200 text-sm">{error}</p>
</div>
)}
{/* Jobs Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{/* Jobs — Card layout on mobile, Table on sm+ */}
<div className="space-y-3 sm:hidden">
{jobs.map((job) => (
<div
key={job.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Card header */}
<div className="px-4 py-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
{job.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{job.type}
</div>
</div>
<span
className={`flex-shrink-0 mt-0.5 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full ${
job.enabled
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{job.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
{/* Card body */}
<div className="px-4 pb-3 space-y-2 border-t border-gray-100 dark:border-gray-700/60 pt-3">
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Schedule
</div>
<div className="text-sm text-gray-900 dark:text-gray-100">
{cronToHuman(job.schedule)}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5">
{job.schedule}
</div>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Last Run
</div>
<div className="text-sm text-gray-700 dark:text-gray-300">
{job.lastRun ? new Date(job.lastRun).toLocaleString() : 'Never'}
</div>
</div>
</div>
{/* Card actions */}
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/60 flex gap-2">
<button
onClick={() => showEditDialog(job)}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
<button
onClick={() => showConfirmDialog(job.id, job.name)}
disabled={triggering === job.id}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40 text-blue-700 dark:text-blue-400 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{triggering === job.id ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
Running...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Trigger
</>
)}
</button>
</div>
</div>
))}
{jobs.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No scheduled jobs found</p>
</div>
)}
</div>
{/* Jobs Table — hidden on mobile, visible on sm+ */}
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
@@ -312,31 +403,31 @@ function AdminJobsPageContent() {
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> <strong>Library Scan:</strong> Automatically scans your media library for new audiobooks</li>
<li> <strong>Audible Data Refresh:</strong> Caches popular and new release audiobooks from Audible</li>
<li> Trigger jobs manually using the "Trigger Now" button</li>
<li> Trigger jobs manually using the &quot;Trigger Now&quot; button</li>
<li> Schedule format follows cron syntax (minute hour day month weekday)</li>
</ul>
</div>
{/* Confirmation Dialog */}
{confirmDialog.isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl sm:rounded-lg shadow-xl w-full max-w-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Confirm Job Trigger
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
<p className="text-gray-600 dark:text-gray-400 text-sm mb-6">
Are you sure you want to trigger &quot;{confirmDialog.jobName}&quot; now?
</p>
<div className="flex justify-end gap-3">
<div className="flex gap-3">
<button
onClick={hideConfirmDialog}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
className="flex-1 px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium"
>
Cancel
</button>
<button
onClick={triggerJob}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
className="flex-1 px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium"
>
Trigger Job
</button>
@@ -347,12 +438,27 @@ function AdminJobsPageContent() {
{/* Edit Job Dialog */}
{editDialog.isOpen && editDialog.job && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Edit Job Schedule
</h3>
<div className="space-y-4 mb-6">
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] overflow-y-auto">
{/* Dialog header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 px-5 py-4 border-b border-gray-200 dark:border-gray-700 rounded-t-2xl">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Edit Job Schedule
</h3>
<button
onClick={hideEditDialog}
className="p-2 -mr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Close dialog"
>
<svg className="w-5 h-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>
</div>
<div className="px-5 py-5 space-y-5">
{/* Job Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@@ -362,46 +468,29 @@ function AdminJobsPageContent() {
type="text"
value={editDialog.job.name}
disabled
className="w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-600 rounded-lg cursor-not-allowed"
className="w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-600 rounded-lg cursor-not-allowed text-sm"
/>
</div>
{/* Schedule Mode Tabs */}
{/* Schedule Mode Tabs — grid on mobile to avoid overflow */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Schedule Type
</label>
<div className="flex gap-2 mb-3">
<button
onClick={() => setScheduleMode('preset')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
scheduleMode === 'preset'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Common Schedules
</button>
<button
onClick={() => setScheduleMode('custom')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
scheduleMode === 'custom'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Custom Schedule
</button>
<button
onClick={() => setScheduleMode('advanced')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
scheduleMode === 'advanced'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Advanced (Cron)
</button>
<div className="grid grid-cols-3 gap-1 p-1 bg-gray-100 dark:bg-gray-700/60 rounded-xl mb-4">
{(['preset', 'custom', 'advanced'] as const).map((mode) => (
<button
key={mode}
onClick={() => setScheduleMode(mode)}
className={`px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
scheduleMode === mode
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
}`}
>
{mode === 'preset' ? 'Common' : mode === 'custom' ? 'Custom' : 'Advanced'}
</button>
))}
</div>
{/* Preset Mode */}
@@ -418,16 +507,16 @@ function AdminJobsPageContent() {
value={preset.cron}
checked={selectedPreset === preset.cron}
onChange={(e) => setSelectedPreset(e.target.value)}
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
/>
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{preset.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{preset.description}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-1">
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5">
{preset.cron}
</div>
</div>
@@ -445,8 +534,8 @@ function AdminJobsPageContent() {
</label>
<select
value={customSchedule.type}
onChange={(e) => setCustomSchedule({ ...customSchedule, type: e.target.value as any })}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
onChange={(e) => setCustomSchedule({ ...customSchedule, type: e.target.value as CustomSchedule['type'] })}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
>
<option value="minutes">Every X minutes</option>
<option value="hours">Every X hours</option>
@@ -456,7 +545,6 @@ function AdminJobsPageContent() {
</select>
</div>
{/* Minutes/Hours Interval */}
{(customSchedule.type === 'minutes' || customSchedule.type === 'hours') && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -468,7 +556,7 @@ function AdminJobsPageContent() {
max={customSchedule.type === 'minutes' ? 59 : 23}
value={customSchedule.interval || 1}
onChange={(e) => setCustomSchedule({ ...customSchedule, interval: parseInt(e.target.value, 10) })}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Run every {customSchedule.interval || 1} {customSchedule.type}
@@ -476,12 +564,11 @@ function AdminJobsPageContent() {
</div>
)}
{/* Daily/Weekly/Monthly Time */}
{(customSchedule.type === 'daily' || customSchedule.type === 'weekly' || customSchedule.type === 'monthly') && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Hour (0-23)
Hour (023)
</label>
<input
type="number"
@@ -494,12 +581,12 @@ function AdminJobsPageContent() {
time: { hour: parseInt(e.target.value, 10), minute: customSchedule.time?.minute || 0 },
})
}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Minute (0-59)
Minute (059)
</label>
<input
type="number"
@@ -512,13 +599,12 @@ function AdminJobsPageContent() {
time: { hour: customSchedule.time?.hour || 0, minute: parseInt(e.target.value, 10) },
})
}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
/>
</div>
</div>
)}
{/* Weekly Day Selection */}
{customSchedule.type === 'weekly' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -527,7 +613,7 @@ function AdminJobsPageContent() {
<select
value={customSchedule.dayOfWeek || 0}
onChange={(e) => setCustomSchedule({ ...customSchedule, dayOfWeek: parseInt(e.target.value, 10) })}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
>
<option value="0">Sunday</option>
<option value="1">Monday</option>
@@ -540,11 +626,10 @@ function AdminJobsPageContent() {
</div>
)}
{/* Monthly Day Selection */}
{customSchedule.type === 'monthly' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Day of Month (1-31)
Day of Month (131)
</label>
<input
type="number"
@@ -552,12 +637,11 @@ function AdminJobsPageContent() {
max="31"
value={customSchedule.dayOfMonth || 1}
onChange={(e) => setCustomSchedule({ ...customSchedule, dayOfMonth: parseInt(e.target.value, 10) })}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
/>
</div>
)}
{/* Preview */}
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="text-sm font-medium text-blue-900 dark:text-blue-200">
Preview: {cronToHuman(customScheduleToCron(customSchedule))}
@@ -571,30 +655,32 @@ function AdminJobsPageContent() {
{/* Advanced Mode */}
{scheduleMode === 'advanced' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Cron Expression
</label>
<input
type="text"
value={editForm.schedule}
onChange={(e) => setEditForm({ ...editForm, schedule: e.target.value })}
placeholder="0 */6 * * *"
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Format: minute hour day month weekday
</p>
<div className="mt-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div> */15 * * * * = Every 15 minutes</div>
<div> 0 */6 * * * = Every 6 hours</div>
<div> 0 0 * * * = Daily at midnight</div>
<div> 0 0 * * 0 = Weekly on Sunday</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Cron Expression
</label>
<input
type="text"
value={editForm.schedule}
onChange={(e) => setEditForm({ ...editForm, schedule: e.target.value })}
placeholder="0 */6 * * *"
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Format: minute hour day month weekday
</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1 font-mono">
<div>*/15 * * * * = Every 15 minutes</div>
<div>0 */6 * * * = Every 6 hours</div>
<div>0 0 * * * = Daily at midnight</div>
<div>0 0 * * 0 = Weekly on Sunday</div>
</div>
</div>
{editForm.schedule && (
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="text-sm font-medium text-blue-900 dark:text-blue-200">
Preview: {cronToHuman(editForm.schedule)}
</div>
@@ -604,34 +690,34 @@ function AdminJobsPageContent() {
)}
</div>
{/* Enabled Checkbox */}
<div className="flex items-center gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
{/* Enabled toggle */}
<div className="flex items-center gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<input
type="checkbox"
id="enabled"
checked={editForm.enabled}
onChange={(e) => setEditForm({ ...editForm, enabled: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
/>
<label htmlFor="enabled" className="text-sm font-medium text-gray-700 dark:text-gray-300">
<label htmlFor="enabled" className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
Enable this job
</label>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
{/* Dialog footer */}
<div className="sticky bottom-0 bg-white dark:bg-gray-800 px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex gap-3">
<button
onClick={hideEditDialog}
disabled={saving}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 sm:flex-none px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={saveJobSchedule}
disabled={saving}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 sm:flex-none px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
+236 -148
View File
@@ -56,6 +56,119 @@ interface LogsData {
};
}
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { dot: string; text: string; bg: string }> = {
completed: { dot: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-400', bg: 'bg-emerald-500/10' },
failed: { dot: 'bg-red-500', text: 'text-red-700 dark:text-red-400', bg: 'bg-red-500/10' },
active: { dot: 'bg-blue-500', text: 'text-blue-700 dark:text-blue-400', bg: 'bg-blue-500/10' },
pending: { dot: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-400', bg: 'bg-amber-500/10' },
delayed: { dot: 'bg-orange-500', text: 'text-orange-700 dark:text-orange-400', bg: 'bg-orange-500/10' },
stuck: { dot: 'bg-purple-500', text: 'text-purple-700 dark:text-purple-400', bg: 'bg-purple-500/10' },
};
const c = config[status] ?? { dot: 'bg-gray-400', text: 'text-gray-600 dark:text-gray-400', bg: 'bg-gray-500/10' };
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${c.dot}`} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
function LogDetails({ log }: { log: Log }) {
return (
<div className="space-y-4">
{log.bullJobId && (
<div className="flex flex-wrap gap-1.5 items-baseline">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Bull Job ID:</span>
<span className="text-xs text-gray-700 dark:text-gray-300 font-mono break-all">{log.bullJobId}</span>
</div>
)}
{log.events.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Event Log
</h4>
<div className="space-y-px max-h-72 sm:max-h-96 overflow-y-auto bg-gray-950 dark:bg-black/60 rounded-xl p-3 font-mono text-xs">
{log.events.map((event) => {
const timestamp = new Date(event.createdAt).toISOString().split('T')[1].split('.')[0];
const levelColor = event.level === 'error'
? 'text-red-400'
: event.level === 'warn'
? 'text-amber-400'
: 'text-emerald-400';
return (
<div key={event.id} className="text-gray-300 leading-relaxed">
<span className={levelColor}>[{event.context}]</span>
{' '}
<span className="break-words">{event.message}</span>
<span className="text-gray-500 ml-2">{timestamp}</span>
{event.metadata && Object.keys(event.metadata).length > 0 && (
<pre className="ml-4 mt-1 text-gray-400 text-xs overflow-x-auto">
{JSON.stringify(event.metadata, null, 2)}
</pre>
)}
</div>
);
})}
</div>
</div>
)}
{log.result && Object.keys(log.result).length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Job Result
</h4>
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto max-h-48">
{JSON.stringify(log.result, null, 2)}
</pre>
</div>
)}
{log.errorMessage && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Error
</h4>
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-xs text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words">
{log.errorMessage}
</div>
</div>
)}
</div>
);
}
function formatDuration(startedAt: string | null, completedAt: string | null) {
if (!startedAt) return 'N/A';
if (!completedAt) return 'Running…';
const durationMs = new Date(completedAt).getTime() - new Date(startedAt).getTime();
const seconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}
function formatType(type: string) {
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}
function formatDateShort(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export default function AdminLogsPage() {
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState('all');
@@ -65,9 +178,7 @@ export default function AdminLogsPage() {
const { data, error } = useSWR<LogsData>(
`/api/admin/logs?page=${page}&limit=50&status=${statusFilter}&type=${typeFilter}`,
authenticatedFetcher,
{
refreshInterval: 10000, // Refresh every 10 seconds
}
{ refreshInterval: 10000 }
);
const isLoading = !data && !error;
@@ -87,9 +198,7 @@ export default function AdminLogsPage() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error Loading Logs
</h3>
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Logs</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error?.message || 'Failed to load system logs'}
</p>
@@ -101,80 +210,45 @@ export default function AdminLogsPage() {
const logs = data?.logs || [];
const pagination = data?.pagination;
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
case 'failed':
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
case 'active':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
case 'pending':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
case 'delayed':
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';
case 'stuck':
return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400';
}
};
const formatDuration = (startedAt: string | null, completedAt: string | null) => {
if (!startedAt) return 'N/A';
if (!completedAt) return 'Running...';
const start = new Date(startedAt).getTime();
const end = new Date(completedAt).getTime();
const durationMs = end - start;
const seconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
};
const hasDetails = (log: Log) => log.events.length > 0 || !!log.errorMessage || !!log.bullJobId || (log.result && Object.keys(log.result).length > 0);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
System Logs
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
View background jobs and system activity
</p>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{/* Header — stacks on mobile, row on sm+ */}
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
System Logs
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
View background jobs and system activity
</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
{/* Filters */}
<div className="mb-6 flex flex-wrap gap-4">
{/* Filters — full-width stacked on mobile */}
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
Status
</label>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
@@ -186,16 +260,13 @@ export default function AdminLogsPage() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
Job Type
</label>
<select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value);
setPage(1);
}}
className="px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
onChange={(e) => { setTypeFilter(e.target.value); setPage(1); }}
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
>
<option value="all">All Types</option>
<option value="search_indexers">Search Indexers</option>
@@ -215,8 +286,77 @@ export default function AdminLogsPage() {
</div>
</div>
{/* Logs Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{/* Mobile card list — hidden on sm+ */}
<div className="space-y-3 sm:hidden">
{logs.map((log) => (
<div
key={log.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Card header */}
<div className="px-4 py-3">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
{formatType(log.type)}
</div>
<StatusBadge status={log.status} />
</div>
{/* Related item */}
{log.request?.audiobook ? (
<div className="text-sm mb-2">
<div className="text-gray-700 dark:text-gray-300 font-medium leading-snug">
{log.request.audiobook.title}
</div>
<div className="text-gray-500 dark:text-gray-400 text-xs">
by {log.request.audiobook.author} &middot; {log.request.user.plexUsername}
</div>
</div>
) : (
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">System job</div>
)}
{/* Meta row */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<span>{formatDateShort(log.createdAt)}</span>
<span>Duration: {formatDuration(log.startedAt, log.completedAt)}</span>
<span>Attempts: {log.attempts}/{log.maxAttempts}</span>
</div>
</div>
{/* Expandable details */}
{hasDetails(log) && (
<>
<button
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
className="w-full flex items-center justify-between px-4 py-2.5 border-t border-gray-100 dark:border-gray-700/60 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors"
>
<span>{expandedLog === log.id ? 'Hide Details' : 'Show Details'}</span>
<svg
className={`w-4 h-4 transition-transform duration-200 ${expandedLog === log.id ? 'rotate-180' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expandedLog === log.id && (
<div className="px-4 pb-4 pt-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700/60">
<LogDetails log={log} />
</div>
)}
</>
)}
</div>
))}
{logs.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No logs found</p>
</div>
)}
</div>
{/* Desktop table — hidden on mobile */}
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
@@ -253,13 +393,11 @@ export default function AdminLogsPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{log.type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
{formatType(log.type)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusBadgeColor(log.status)}`}>
{log.status.toUpperCase()}
</span>
<StatusBadge status={log.status} />
</td>
<td className="px-6 py-4">
{log.request?.audiobook ? (
@@ -285,7 +423,7 @@ export default function AdminLogsPage() {
{log.attempts}/{log.maxAttempts}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{(log.events.length > 0 || log.errorMessage || log.bullJobId || log.result) && (
{hasDetails(log) && (
<button
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
@@ -298,63 +436,7 @@ export default function AdminLogsPage() {
{expandedLog === log.id && (
<tr>
<td colSpan={7} className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
<div className="space-y-4">
{log.bullJobId && (
<div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Bull Job ID: </span>
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">{log.bullJobId}</span>
</div>
)}
{/* Event Logs */}
{log.events.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Event Log</h4>
<div className="space-y-1 max-h-96 overflow-y-auto bg-black/5 dark:bg-black/30 rounded p-3 font-mono text-xs">
{log.events.map((event) => {
const timestamp = new Date(event.createdAt).toISOString().split('T')[1].split('.')[0];
const levelColor = event.level === 'error'
? 'text-red-500'
: event.level === 'warn'
? 'text-yellow-500'
: 'text-green-500';
return (
<div key={event.id} className="text-gray-800 dark:text-gray-200">
<span className={levelColor}>[{event.context}]</span> {event.message}
<span className="text-gray-500 dark:text-gray-400 ml-2">{timestamp}</span>
{event.metadata && Object.keys(event.metadata).length > 0 && (
<pre className="ml-4 mt-1 text-gray-600 dark:text-gray-400 text-xs">
{JSON.stringify(event.metadata, null, 2)}
</pre>
)}
</div>
);
})}
</div>
</div>
)}
{/* Result Data */}
{log.result && Object.keys(log.result).length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Job Result</h4>
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto">
{JSON.stringify(log.result, null, 2)}
</pre>
</div>
)}
{/* Error Message */}
{log.errorMessage && (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Error</h4>
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-sm text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap">
{log.errorMessage}
</div>
</div>
)}
</div>
<LogDetails log={log} />
</td>
</tr>
)}
@@ -373,24 +455,31 @@ export default function AdminLogsPage() {
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="mt-6 flex items-center justify-between">
<div className="text-sm text-gray-700 dark:text-gray-300">
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total logs)
<div className="mt-6 flex flex-col sm:flex-row items-center gap-3 sm:justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400 order-2 sm:order-1">
Page {pagination.page} of {pagination.totalPages}
<span className="hidden sm:inline"> ({pagination.total} total logs)</span>
</div>
<div className="flex gap-2">
<div className="flex gap-2 order-1 sm:order-2">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed 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>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === pagination.totalPages}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
@@ -403,11 +492,10 @@ export default function AdminLogsPage() {
</h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> Logs are automatically refreshed every 10 seconds</li>
<li> Click "Show Details" to view detailed event logs, job results, and error messages</li>
<li> Event logs show all internal operations with timestamps (similar to Docker logs)</li>
<li> Tap &quot;Show Details&quot; to view event logs, job results, and errors</li>
<li> Event logs show all internal operations with timestamps</li>
<li> Jobs are retried automatically based on their max attempts setting</li>
<li> Use filters to find specific job types or statuses</li>
<li> All job types are tracked: searches, downloads, file organization, library scans, RSS monitoring, and more</li>
</ul>
</div>
</div>
+14
View File
@@ -12,6 +12,7 @@ import { MetricCard } from './components/MetricCard';
import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react';
@@ -328,6 +329,14 @@ function AdminDashboardContent() {
}
);
const { data: reportedIssuesData } = useSWR(
'/api/admin/reported-issues',
authenticatedFetcher,
{
refreshInterval: 10000,
}
);
const { data: settingsData } = useSWR(
'/api/admin/settings',
authenticatedFetcher,
@@ -578,6 +587,11 @@ function AdminDashboardContent() {
<PendingApprovalSection requests={pendingApprovalData.requests} />
)}
{/* Reported Issues */}
{reportedIssuesData?.issues && reportedIssuesData.issues.length > 0 && (
<ReportedIssuesSection issues={reportedIssuesData.issues} />
)}
{/* Active Downloads */}
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
+1
View File
@@ -295,6 +295,7 @@ export default function AdminSettings() {
{activeTab === 'prowlarr' && (
<IndexersTab
settings={settings}
originalSettings={originalSettings}
indexers={configuredIndexers}
flagConfigs={flagConfigs}
onChange={setSettings}
@@ -7,6 +7,7 @@
import React, { useEffect } from 'react';
import { Button } from '@/components/ui/Button';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { Input } from '@/components/ui/Input';
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
@@ -16,6 +17,7 @@ import type { Settings, SavedIndexerConfig } from '../../lib/types';
interface IndexersTabProps {
settings: Settings;
originalSettings: Settings | null;
indexers: SavedIndexerConfig[];
flagConfigs: IndexerFlagConfig[];
onChange: (settings: Settings) => void;
@@ -27,6 +29,7 @@ interface IndexersTabProps {
export function IndexersTab({
settings,
originalSettings,
indexers,
flagConfigs,
onChange,
@@ -35,11 +38,23 @@ export function IndexersTab({
onValidationChange,
onRefreshIndexers,
}: IndexersTabProps) {
const { testing, testResult, testConnection } = useIndexersSettings({
const {
testing,
testResult,
testConnection,
showConnectionChangeConfirm,
confirmConnectionChange,
cancelConnectionChange,
configuredIndexersCount,
} = useIndexersSettings({
prowlarrUrl: settings.prowlarr.url,
prowlarrApiKey: settings.prowlarr.apiKey,
originalProwlarrUrl: originalSettings?.prowlarr.url ?? '',
originalProwlarrApiKey: originalSettings?.prowlarr.apiKey ?? '',
configuredIndexersCount: indexers.length,
onValidationChange,
onRefreshIndexers,
onClearIndexers: () => onIndexersChange([]),
});
// Auto-load indexers when component mounts if prowlarr is configured
@@ -96,7 +111,7 @@ export function IndexersTab({
placeholder="Enter API key"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Found in Prowlarr Settings General Security API Key
Found in Prowlarr Settings &rarr; General &rarr; Security &rarr; API Key
</p>
</div>
@@ -178,6 +193,19 @@ export function IndexersTab({
</p>
)}
</div>
{/* Confirmation modal for Prowlarr connection change */}
<ConfirmModal
isOpen={showConnectionChangeConfirm}
onClose={cancelConnectionChange}
onConfirm={confirmConnectionChange}
title="Prowlarr Connection Change"
message={`Changing your Prowlarr connection will remove your ${configuredIndexersCount} configured indexer${configuredIndexersCount === 1 ? '' : 's'}. Indexer IDs are specific to each Prowlarr instance, so existing configurations cannot be preserved. You will need to re-add indexers from the new instance after saving.`}
confirmText="Continue"
cancelText="Cancel"
variant="danger"
isLoading={testing}
/>
</div>
);
}
@@ -5,30 +5,50 @@
'use client';
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { fetchWithAuth } from '@/lib/utils/api';
import type { TestResult } from '../../lib/types';
interface UseIndexersSettingsProps {
prowlarrUrl: string;
prowlarrApiKey: string;
originalProwlarrUrl: string;
originalProwlarrApiKey: string;
configuredIndexersCount: number;
onValidationChange: (isValid: boolean) => void;
onRefreshIndexers?: () => Promise<void>;
onClearIndexers: () => void;
}
export function useIndexersSettings({
prowlarrUrl,
prowlarrApiKey,
originalProwlarrUrl,
originalProwlarrApiKey,
configuredIndexersCount,
onValidationChange,
onRefreshIndexers,
onClearIndexers,
}: UseIndexersSettingsProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [showConnectionChangeConfirm, setShowConnectionChangeConfirm] = useState(false);
/**
* Test Prowlarr connection
* Detect if the Prowlarr URL or API key has changed from the saved values.
* A masked API key (starting with dots) means the user hasn't touched it.
*/
const testConnection = async () => {
const hasConnectionChanged = useCallback((): boolean => {
const urlChanged = prowlarrUrl.trim() !== originalProwlarrUrl.trim();
const apiKeyChanged = !prowlarrApiKey.startsWith('••••') &&
prowlarrApiKey !== originalProwlarrApiKey;
return urlChanged || apiKeyChanged;
}, [prowlarrUrl, prowlarrApiKey, originalProwlarrUrl, originalProwlarrApiKey]);
/**
* Execute the actual Prowlarr connection test
*/
const executeTest = async (shouldClearIndexers: boolean) => {
setTesting(true);
setTestResult(null);
@@ -46,14 +66,23 @@ export function useIndexersSettings({
if (data.success) {
onValidationChange(true);
setTestResult({
success: true,
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`,
});
// Refresh indexers from database if callback provided
if (onRefreshIndexers) {
await onRefreshIndexers();
if (shouldClearIndexers) {
onClearIndexers();
setTestResult({
success: true,
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers. Previous indexer configurations have been removed — please re-add indexers from the new instance.`,
});
} else {
setTestResult({
success: true,
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`,
});
// Refresh indexers from database if callback provided
if (onRefreshIndexers) {
await onRefreshIndexers();
}
}
} else {
onValidationChange(false);
@@ -74,9 +103,41 @@ export function useIndexersSettings({
}
};
/**
* Handle test connection click — shows confirmation if credentials changed
* and there are existing configured indexers.
*/
const testConnection = async () => {
if (hasConnectionChanged() && configuredIndexersCount > 0) {
setShowConnectionChangeConfirm(true);
return;
}
await executeTest(false);
};
/**
* User confirmed the credential change — proceed with test and clear indexers on success
*/
const confirmConnectionChange = async () => {
setShowConnectionChangeConfirm(false);
await executeTest(true);
};
/**
* User cancelled the credential change confirmation
*/
const cancelConnectionChange = () => {
setShowConnectionChangeConfirm(false);
};
return {
testing,
testResult,
testConnection,
showConnectionChangeConfirm,
confirmConnectionChange,
cancelConnectionChange,
configuredIndexersCount,
};
}
@@ -164,11 +164,11 @@ export function AudiobookshelfSection({
>
{Object.values(AUDIBLE_REGIONS).map((region) => (
<option key={region.code} value={region.code}>
{region.name}{!region.isEnglish ? ' *' : ''}
{region.name}{region.language !== 'en' ? ' *' : ''}
</option>
))}
</select>
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && (
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.language !== 'en' && (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
<div className="flex gap-3">
<svg
@@ -164,11 +164,11 @@ export function PlexSection({
>
{Object.values(AUDIBLE_REGIONS).map((region) => (
<option key={region.code} value={region.code}>
{region.name}{!region.isEnglish ? ' *' : ''}
{region.name}{region.language !== 'en' ? ' *' : ''}
</option>
))}
</select>
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && (
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.language !== 'en' && (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
<div className="flex gap-3">
<svg
@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { RMABLogger } from '@/lib/utils/logger';
import { fetchWithAuth } from '@/lib/utils/api';
import { EVENT_LABELS } from '@/lib/constants/notification-events';
const logger = RMABLogger.create('NotificationsTab');
@@ -43,12 +44,7 @@ interface ModalState {
backend?: NotificationBackend;
}
const eventLabels: Record<string, string> = {
request_pending_approval: 'Request Pending Approval',
request_approved: 'Request Approved',
request_available: 'Audiobook Available',
request_error: 'Request Error',
};
const eventLabels: Record<string, string> = EVENT_LABELS;
export function NotificationsTab() {
const [backends, setBackends] = useState<NotificationBackend[]>([]);
+366 -245
View File
@@ -41,6 +41,144 @@ interface PendingUser {
createdAt: string;
}
// Tinted-dot status badge following admin design system
function RoleBadge({ role, isSetupAdmin }: { role: 'user' | 'admin'; isSetupAdmin: boolean }) {
if (isSetupAdmin) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-700 dark:text-blue-400">
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-blue-500" />
Setup Admin
</span>
);
}
if (role === 'admin') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-500/10 text-purple-700 dark:text-purple-400">
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-purple-500" />
Admin
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-500/10 text-gray-600 dark:text-gray-400">
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-gray-400" />
User
</span>
);
}
function PermissionBadge({
user,
globalAutoApprove,
onClick,
}: {
user: User;
globalAutoApprove: boolean;
onClick: () => void;
}) {
let badge: React.ReactNode;
if (user.role === 'admin') {
badge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-500/10 text-purple-700 dark:text-purple-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Full Access
</span>
);
} else if (globalAutoApprove) {
badge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-500/10 text-blue-700 dark:text-blue-400">
Global Default
</span>
);
} else if (user.autoApproveRequests ?? false) {
badge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
Auto-Approve
</span>
);
} else {
badge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-500/10 text-gray-600 dark:text-gray-400">
Manual
</span>
);
}
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 text-sm transition-opacity hover:opacity-70"
>
{badge}
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
);
}
function UserActionsCell({ user, onEdit, onDelete }: { user: User; onEdit: (u: User) => void; onDelete: (u: User) => void }) {
if (user.isSetupAdmin) {
return (
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="Setup admin role cannot be changed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>Protected</span>
</span>
);
}
if (user.authProvider === 'oidc') {
return (
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="OIDC user roles are managed by the identity provider">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>OIDC Managed</span>
</span>
);
}
if (user.authProvider === 'local') {
return (
<div className="flex items-center gap-3">
<button
onClick={() => onEdit(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
<button
onClick={() => onDelete(user)}
className="inline-flex items-center gap-1 text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Delete user and all their requests"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span>Delete</span>
</button>
</div>
);
}
// plex or other
return (
<button
onClick={() => onEdit(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
);
}
function AdminUsersPageContent() {
const { data, error, mutate } = useSWR('/api/admin/users', authenticatedFetcher);
const { data: pendingData, error: pendingError, mutate: mutatePending } = useSWR(
@@ -86,7 +224,6 @@ function AdminUsersPageContent() {
if (globalAutoApproveData?.autoApproveRequests !== undefined) {
setGlobalAutoApprove(globalAutoApproveData.autoApproveRequests);
} else if (globalAutoApproveData !== undefined && globalAutoApproveData.autoApproveRequests === undefined) {
// API returned but no value - default to true
setGlobalAutoApprove(true);
}
}, [globalAutoApproveData]);
@@ -101,9 +238,7 @@ function AdminUsersPageContent() {
}, [globalInteractiveSearchData]);
const handleGlobalAutoApproveToggle = async (newValue: boolean) => {
// Optimistic update
setGlobalAutoApprove(newValue);
try {
await fetchJSON('/api/admin/settings/auto-approve', {
method: 'PATCH',
@@ -111,20 +246,16 @@ function AdminUsersPageContent() {
});
toast.success(`Global auto-approve ${newValue ? 'enabled' : 'disabled'}`);
mutateGlobalAutoApprove();
mutate(); // Refresh users list to show updated state
mutate();
} catch (err) {
// Revert on error
setGlobalAutoApprove(!newValue);
const errorMsg = err instanceof Error ? err.message : 'Failed to update auto-approve setting';
toast.error(errorMsg);
console.error(err);
}
};
const handleGlobalInteractiveSearchToggle = async (newValue: boolean) => {
// Optimistic update
setGlobalInteractiveSearch(newValue);
try {
await fetchJSON('/api/admin/settings/interactive-search', {
method: 'PATCH',
@@ -132,74 +263,51 @@ function AdminUsersPageContent() {
});
toast.success(`Global interactive search ${newValue ? 'enabled' : 'disabled'}`);
mutateGlobalInteractiveSearch();
mutate(); // Refresh users list to show updated state
mutate();
} catch (err) {
// Revert on error
setGlobalInteractiveSearch(!newValue);
const errorMsg = err instanceof Error ? err.message : 'Failed to update interactive search setting';
toast.error(errorMsg);
console.error(err);
}
};
const handleUserAutoApproveToggle = async (user: User, newValue: boolean) => {
console.log('[AutoApprove] Toggle clicked:', { userId: user.id, username: user.plexUsername, newValue });
// Optimistic update
const previousUsers = data?.users || [];
const optimisticUsers = previousUsers.map((u: User) =>
u.id === user.id ? { ...u, autoApproveRequests: newValue } : u
);
console.log('[AutoApprove] Applying optimistic update');
mutate({ users: optimisticUsers }, false);
try {
console.log('[AutoApprove] Sending API request...');
const response = await fetchJSON(`/api/admin/users/${user.id}`, {
await fetchJSON(`/api/admin/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify({
role: user.role,
autoApproveRequests: newValue
}),
body: JSON.stringify({ role: user.role, autoApproveRequests: newValue }),
});
console.log('[AutoApprove] API response received:', response);
toast.success(`Auto-approve ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
console.log('[AutoApprove] Triggering cache revalidation...');
mutate(); // Refresh users list
mutate();
} catch (err) {
// Revert on error
console.error('[AutoApprove] Error occurred, reverting:', err);
mutate({ users: previousUsers }, false);
const errorMsg = err instanceof Error ? err.message : 'Failed to update user auto-approve setting';
toast.error(errorMsg);
console.error(err);
}
};
const handleUserInteractiveSearchToggle = async (user: User, newValue: boolean) => {
// Optimistic update
const previousUsers = data?.users || [];
const optimisticUsers = previousUsers.map((u: User) =>
u.id === user.id ? { ...u, interactiveSearchAccess: newValue } : u
);
mutate({ users: optimisticUsers }, false);
try {
await fetchJSON(`/api/admin/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify({
role: user.role,
interactiveSearchAccess: newValue
}),
body: JSON.stringify({ role: user.role, interactiveSearchAccess: newValue }),
});
toast.success(`Interactive search ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
mutate(); // Refresh users list
mutate();
} catch (err) {
// Revert on error
mutate({ users: previousUsers }, false);
const errorMsg = err instanceof Error ? err.message : 'Failed to update user interactive search setting';
toast.error(errorMsg);
console.error(err);
}
};
@@ -214,7 +322,6 @@ function AdminUsersPageContent() {
const saveUserRole = async () => {
if (!editDialog.user) return;
try {
setSaving(true);
await fetchJSON(`/api/admin/users/${editDialog.user.id}`, {
@@ -223,11 +330,10 @@ function AdminUsersPageContent() {
});
toast.success(`User "${editDialog.user.plexUsername}" updated successfully`);
hideEditDialog();
mutate(); // Refresh users list
mutate();
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to update user';
toast.error(errorMsg);
console.error(err);
} finally {
setSaving(false);
}
@@ -242,13 +348,12 @@ function AdminUsersPageContent() {
};
const closeConfirmDialog = () => {
if (processingUserId) return; // Don't close while processing
if (processingUserId) return;
setConfirmDialog({ isOpen: false, type: null, user: null });
};
const handleConfirmAction = async () => {
if (!confirmDialog.user) return;
const isApprove = confirmDialog.type === 'approve';
try {
setProcessingUserId(confirmDialog.user.id);
@@ -261,13 +366,12 @@ function AdminUsersPageContent() {
? `User "${confirmDialog.user.plexUsername}" has been approved`
: `User "${confirmDialog.user.plexUsername}" has been rejected`
);
mutatePending(); // Refresh pending users list
if (isApprove) mutate(); // Refresh approved users list
mutatePending();
if (isApprove) mutate();
closeConfirmDialog();
} catch (err) {
const errorMsg = err instanceof Error ? err.message : `Failed to ${isApprove ? 'approve' : 'reject'} user`;
toast.error(errorMsg);
console.error(err);
} finally {
setProcessingUserId(null);
}
@@ -278,25 +382,23 @@ function AdminUsersPageContent() {
};
const closeDeleteDialog = () => {
if (deleting) return; // Don't close while processing
if (deleting) return;
setDeleteDialog({ isOpen: false, user: null });
};
const handleDeleteUser = async () => {
if (!deleteDialog.user) return;
try {
setDeleting(true);
const response = await fetchJSON(`/api/admin/users/${deleteDialog.user.id}`, {
method: 'DELETE',
});
toast.success(response.message || `User "${deleteDialog.user.plexUsername}" has been deleted`);
mutate(); // Refresh users list
mutate();
closeDeleteDialog();
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to delete user';
toast.error(errorMsg);
console.error(err);
} finally {
setDeleting(false);
}
@@ -307,7 +409,6 @@ function AdminUsersPageContent() {
await navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard`);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
toast.error('Failed to copy to clipboard');
}
};
@@ -327,9 +428,7 @@ function AdminUsersPageContent() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error Loading Users
</h3>
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Users</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error?.message || 'Failed to load users'}
</p>
@@ -344,80 +443,81 @@ function AdminUsersPageContent() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
User Management
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Manage user roles and permissions
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setGlobalSettingsOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Global User Permissions</span>
</button>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{/* Header — stacks on mobile, row on sm+ */}
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
User Management
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Manage user roles and permissions
</p>
</div>
<div className="flex items-center gap-2 self-start sm:self-auto flex-shrink-0">
<button
onClick={() => setGlobalSettingsOpen(true)}
className="inline-flex items-center gap-2 px-3 sm:px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="hidden sm:inline">Global User Permissions</span>
<span className="sm:hidden">Permissions</span>
</button>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-3 sm:px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back</span>
</Link>
</div>
</div>
</div>
{/* Pending Users Section */}
{pendingUsers.length > 0 && (
<div className="mb-8">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4">
<h2 className="text-lg font-semibold text-yellow-900 dark:text-yellow-200 mb-4 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="mb-6 sm:mb-8">
<div className="bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800/60 rounded-xl p-4">
<h2 className="text-base font-semibold text-amber-900 dark:text-amber-200 mb-1 flex items-center gap-2">
<svg className="w-4 h-4 flex-shrink-0" 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-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Pending Registrations ({pendingUsers.length})
</h2>
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-4">
<p className="text-xs text-amber-700 dark:text-amber-300/80 mb-4">
The following users are awaiting approval to access the system.
</p>
<div className="space-y-3">
{pendingUsers.map((user) => (
<div
key={user.id}
className="bg-white dark:bg-gray-800 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 flex items-center justify-between"
className="bg-white dark:bg-gray-800 border border-amber-200 dark:border-amber-800/40 rounded-xl overflow-hidden"
>
<div className="flex-1">
<div className="flex items-center gap-3">
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">
{user.plexUsername}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{user.plexEmail || 'No email'}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Registered: {new Date(user.createdAt).toLocaleString()}
Provider: {user.authProvider}
</div>
</div>
{/* Pending card — info */}
<div className="px-4 py-3">
<div className="font-medium text-gray-900 dark:text-gray-100 text-sm">
{user.plexUsername}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{user.plexEmail || 'No email'}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Registered: {new Date(user.createdAt).toLocaleString()} &middot; Provider: {user.authProvider}
</div>
</div>
<div className="flex items-center gap-2">
{/* Pending card — actions, full-width on mobile */}
<div className="px-4 py-3 border-t border-amber-100 dark:border-amber-800/30 flex gap-2">
<button
onClick={() => showApproveDialog(user)}
disabled={processingUserId === user.id}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-emerald-50 dark:bg-emerald-500/10 hover:bg-emerald-100 dark:hover:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 border border-emerald-200/60 dark:border-emerald-500/20 rounded-xl text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
@@ -427,7 +527,7 @@ function AdminUsersPageContent() {
<button
onClick={() => showRejectDialog(user)}
disabled={processingUserId === user.id}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-red-50 dark:bg-red-500/10 hover:bg-red-100 dark:hover:bg-red-500/20 text-red-700 dark:text-red-400 border border-red-200/60 dark:border-red-500/20 rounded-xl text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -442,8 +542,104 @@ function AdminUsersPageContent() {
</div>
)}
{/* Users Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto">
{/* Users — Mobile card list (sm:hidden) */}
<div className="space-y-3 sm:hidden">
{users.map((user) => (
<div
key={user.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Card header — avatar + name + role badge */}
<div className="px-4 py-3 flex items-start gap-3">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.plexUsername}
className="h-10 w-10 rounded-full flex-shrink-0 mt-0.5"
/>
) : (
<div className="h-10 w-10 rounded-full flex-shrink-0 mt-0.5 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug truncate">
{user.plexUsername}
</div>
<RoleBadge role={user.role} isSetupAdmin={user.isSetupAdmin} />
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{user.plexEmail || 'No email'}
</div>
</div>
</div>
{/* Card body — labeled fields */}
<div className="px-4 pb-3 pt-2 space-y-2 border-t border-gray-100 dark:border-gray-700/60">
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Permissions
</div>
<PermissionBadge
user={user}
globalAutoApprove={globalAutoApprove}
onClick={() => setPermissionsUserId(user.id)}
/>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Requests
</div>
<div className="text-sm text-gray-900 dark:text-gray-100">
{user._count.requests}
</div>
</div>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Last Login
</div>
<div className="text-sm text-gray-700 dark:text-gray-300">
{user.lastLoginAt
? new Date(user.lastLoginAt).toLocaleDateString()
: 'Never'}
</div>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
User ID
</div>
<button
onClick={() => copyToClipboard(user.plexId, 'User ID')}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors inline-flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{user.plexId.length > 16 ? `${user.plexId.substring(0, 16)}` : user.plexId}
</button>
</div>
</div>
{/* Card actions */}
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/60">
<UserActionsCell user={user} onEdit={showEditDialog} onDelete={showDeleteDialog} />
</div>
</div>
))}
{users.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No users found</p>
</div>
)}
</div>
{/* Users Table — hidden on mobile, visible on sm+ */}
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
@@ -472,15 +668,21 @@ function AdminUsersPageContent() {
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{users.map((user) => (
<tr key={user.id}>
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{user.avatarUrl && (
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.plexUsername}
className="h-10 w-10 rounded-full mr-3"
className="h-10 w-10 rounded-full mr-3 flex-shrink-0"
/>
) : (
<div className="h-10 w-10 rounded-full mr-3 flex-shrink-0 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
)}
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
@@ -507,52 +709,14 @@ function AdminUsersPageContent() {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.role === 'admin'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{user.role.toUpperCase()}
</span>
{user.isSetupAdmin && (
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
SETUP ADMIN
</span>
)}
</div>
<RoleBadge role={user.role} isSetupAdmin={user.isSetupAdmin} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
<PermissionBadge
user={user}
globalAutoApprove={globalAutoApprove}
onClick={() => setPermissionsUserId(user.id)}
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
>
{user.role === 'admin' ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Full Access
</span>
) : globalAutoApprove ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
Global Default
</span>
) : (user.autoApproveRequests ?? false) ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
Auto-Approve
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Manual
</span>
)}
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{user._count.requests}
@@ -563,65 +727,7 @@ function AdminUsersPageContent() {
: 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-3">
{user.isSetupAdmin ? (
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="Setup admin role cannot be changed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>Protected</span>
</span>
) : user.authProvider === 'oidc' ? (
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="OIDC user roles are managed by the identity provider (use admin role mapping in settings)">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>OIDC Managed</span>
</span>
) : user.authProvider === 'plex' ? (
<button
onClick={() => showEditDialog(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
) : user.authProvider === 'local' ? (
<>
<button
onClick={() => showEditDialog(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
<button
onClick={() => showDeleteDialog(user)}
className="inline-flex items-center gap-1 text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="Delete user and all their requests"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span>Delete</span>
</button>
</>
) : (
<button
onClick={() => showEditDialog(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
)}
</div>
<UserActionsCell user={user} onEdit={showEditDialog} onDelete={showDeleteDialog} />
</td>
</tr>
))}
@@ -643,31 +749,50 @@ function AdminUsersPageContent() {
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> <strong>User:</strong> Can request audiobooks, view own requests, and search the catalog</li>
<li> <strong>Admin:</strong> Full system access including settings, user management, and all requests</li>
<li> <strong>Setup Admin:</strong> The initial admin account created during setup - this account is protected and cannot be changed or deleted</li>
<li> <strong>Permissions:</strong> Click a user&apos;s permission badge to manage individual settings (auto-approve, interactive search). Use Global User Permissions to control system-wide defaults. Admins always have full access.</li>
<li> <strong>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.</li>
<li> <strong>Plex Users:</strong> Can have their roles changed, but cannot be deleted as access is managed by Plex.</li>
<li> <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).</li>
<li> <strong>Setup Admin:</strong> The initial admin account protected, cannot be changed or deleted</li>
<li> <strong>Permissions:</strong> Click a user&apos;s permission badge to manage individual settings. Use Global User Permissions for system-wide defaults. Admins always have full access.</li>
<li> <strong>OIDC Users:</strong> Role management is handled by the identity provider. Cannot be deleted.</li>
<li> <strong>Plex Users:</strong> Role can be changed, but cannot be deleted (access managed by Plex).</li>
<li> <strong>Local Users:</strong> Can have roles freely assigned. Can be deleted (requests are preserved).</li>
<li> You cannot change your own role or delete yourself for security reasons</li>
</ul>
</div>
{/* Edit User Dialog */}
{/* Edit User Dialog — bottom sheet on mobile */}
{editDialog.isOpen && editDialog.user && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Edit User Role
</h3>
<div className="space-y-4 mb-6">
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-md">
{/* Dialog header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 px-5 py-4 border-b border-gray-200 dark:border-gray-700 rounded-t-2xl flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Edit User Role
</h3>
<button
onClick={hideEditDialog}
className="p-2 -mr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Close dialog"
>
<svg className="w-5 h-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>
<div className="px-5 py-5 space-y-4">
{/* User Info */}
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
{editDialog.user.avatarUrl && (
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-xl">
{editDialog.user.avatarUrl ? (
<img
src={editDialog.user.avatarUrl}
alt={editDialog.user.plexUsername}
className="h-12 w-12 rounded-full"
className="h-12 w-12 rounded-full flex-shrink-0"
/>
) : (
<div className="h-12 w-12 rounded-full flex-shrink-0 bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
)}
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
@@ -685,38 +810,34 @@ function AdminUsersPageContent() {
Role
</label>
<div className="space-y-2">
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors">
<input
type="radio"
name="role"
value="user"
checked={editRole === 'user'}
onChange={(e) => setEditRole(e.target.value as 'user' | 'admin')}
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
User
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">User</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Can request audiobooks and view own requests
</div>
</div>
</label>
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors">
<input
type="radio"
name="role"
value="admin"
checked={editRole === 'admin'}
onChange={(e) => setEditRole(e.target.value as 'user' | 'admin')}
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Admin
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Admin</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Full system access including settings and user management
</div>
</div>
@@ -725,19 +846,19 @@ function AdminUsersPageContent() {
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
{/* Dialog footer */}
<div className="sticky bottom-0 bg-white dark:bg-gray-800 px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex gap-3">
<button
onClick={hideEditDialog}
disabled={saving}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={saveUserRole}
disabled={saving}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getNotificationService } from '@/lib/services/notification';
import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events';
import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod';
@@ -15,7 +16,7 @@ const logger = RMABLogger.create('API.Admin.Notifications.Id');
const UpdateBackendSchema = z.object({
name: z.string().min(1).optional(),
config: z.record(z.any()).optional(),
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1).optional(),
events: z.array(z.enum(NOTIFICATION_EVENT_KEYS)).min(1).optional(),
enabled: z.boolean().optional(),
});
+2 -1
View File
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getNotificationService, getRegisteredProviderTypes } from '@/lib/services/notification';
import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events';
import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod';
@@ -16,7 +17,7 @@ const CreateBackendSchema = z.object({
type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }),
name: z.string().min(1),
config: z.record(z.any()),
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1),
events: z.array(z.enum(NOTIFICATION_EVENT_KEYS)).min(1),
enabled: z.boolean().default(true),
});
@@ -68,6 +68,7 @@ export async function POST(request: NextRequest) {
title: "The Hitchhiker's Guide to the Galaxy",
author: 'Douglas Adams',
userName: 'Test User',
requestType: 'audiobook',
timestamp: new Date(),
};
@@ -0,0 +1,87 @@
/**
* Component: Admin Replace Audiobook API
* Documentation: documentation/backend/services/reported-issues.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { replaceAudiobook, ReportedIssueError } from '@/lib/services/reported-issue.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.ReportedIssues.Replace');
const ReplaceSchema = z.object({
torrent: z.object({
guid: z.string(),
title: z.string(),
size: z.number(),
seeders: z.number().optional(),
leechers: z.number().optional(),
indexer: z.string(),
indexerId: z.number().optional(),
downloadUrl: z.string(),
infoUrl: z.string().optional(),
publishDate: z.string().transform((str) => new Date(str)),
infoHash: z.string().optional(),
format: z.enum(['M4B', 'M4A', 'MP3', 'OTHER']).optional(),
bitrate: z.string().optional(),
hasChapters: z.boolean().optional(),
protocol: z.enum(['torrent', 'usenet']).optional(),
}),
});
/**
* POST /api/admin/reported-issues/[id]/replace
* Atomically replace audiobook content: delete old → create new request → start download → resolve issue
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const body = await req.json();
const { torrent } = ReplaceSchema.parse(body);
const result = await replaceAudiobook(id, req.user.id, torrent);
return NextResponse.json({
success: true,
request: result.request,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
if (error instanceof ReportedIssueError) {
return NextResponse.json(
{ error: 'ReplaceError', message: error.message },
{ status: error.statusCode }
);
}
logger.error('Failed to replace audiobook', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to replace audiobook' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,74 @@
/**
* Component: Admin Resolve Reported Issue API
* Documentation: documentation/backend/services/reported-issues.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { dismissIssue, ReportedIssueError } from '@/lib/services/reported-issue.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.ReportedIssues.Resolve');
const ResolveSchema = z.object({
action: z.enum(['dismiss']),
});
/**
* POST /api/admin/reported-issues/[id]/resolve
* Dismiss a reported issue
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const body = await req.json();
const { action } = ResolveSchema.parse(body);
if (action === 'dismiss') {
const issue = await dismissIssue(id, req.user.id);
return NextResponse.json({ success: true, issue });
}
return NextResponse.json(
{ error: 'InvalidAction', message: 'Unknown action' },
{ status: 400 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
if (error instanceof ReportedIssueError) {
return NextResponse.json(
{ error: 'ResolveError', message: error.message },
{ status: error.statusCode }
);
}
logger.error('Failed to resolve issue', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to resolve issue' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,39 @@
/**
* Component: Admin Reported Issues List API
* Documentation: documentation/backend/services/reported-issues.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getOpenIssues } from '@/lib/services/reported-issue.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.ReportedIssues');
/**
* GET /api/admin/reported-issues
* Get all open reported issues with audiobook metadata and reporter info
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const issues = await getOpenIssues();
return NextResponse.json({
success: true,
issues,
count: issues.length,
});
} catch (error) {
logger.error('Failed to fetch reported issues', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to fetch reported issues' },
{ status: 500 }
);
}
});
});
}
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { invalidateProwlarrService } from '@/lib/integrations/prowlarr.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.Prowlarr');
@@ -42,6 +43,9 @@ export async function PUT(request: NextRequest) {
});
}
// Invalidate cached singleton so background jobs use new credentials
invalidateProwlarrService();
logger.info('Prowlarr settings updated');
return NextResponse.json({
@@ -18,6 +18,8 @@ import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
import { getLanguageForRegion } from '@/lib/constants/language-config';
import type { AudibleRegion } from '@/lib/types/audible';
import {
searchByAsin,
searchByTitle,
@@ -227,6 +229,11 @@ export async function POST(
const format = preferredFormat || 'epub';
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
// Get language code from Audible region config
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
const languageCode = langConfig.annasArchiveLang;
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
return NextResponse.json(
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
@@ -250,7 +257,8 @@ export async function POST(
audiobook.author,
format,
annasBaseUrl,
flaresolverrUrl || undefined
flaresolverrUrl || undefined,
languageCode
).catch((err) => {
logger.error(`Anna's Archive search failed: ${err.message}`);
return null;
@@ -322,7 +330,8 @@ async function searchAnnasArchiveForInteractive(
author: string,
preferredFormat: string,
baseUrl: string,
flaresolverrUrl?: string
flaresolverrUrl?: string,
languageCode: string = 'en'
): Promise<EbookSearchResult[]> {
let md5: string | null = null;
let searchMethod: 'asin' | 'title' = 'title';
@@ -330,7 +339,7 @@ async function searchAnnasArchiveForInteractive(
// Try ASIN search first
if (asin) {
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl);
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
if (md5) {
searchMethod = 'asin';
logger.info(`Found via ASIN: ${md5}`);
@@ -340,7 +349,7 @@ async function searchAnnasArchiveForInteractive(
// Fallback to title search
if (!md5) {
logger.info(`Searching Anna's Archive by title: "${title}"`);
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl);
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
if (md5) {
logger.info(`Found via title: ${md5}`);
}
@@ -461,6 +470,10 @@ async function searchIndexersForInteractive(
return [];
}
// Get language-specific stop words for ranking
const rankRegion = await configService.getAudibleRegion() as AudibleRegion;
const rankLangConfig = getLanguageForRegion(rankRegion);
// Rank results with ebook scoring
const rankedResults = rankEbookTorrents(allResults, {
title,
@@ -470,6 +483,8 @@ async function searchIndexersForInteractive(
indexerPriorities,
flagConfigs,
requireAuthor: false,
stopWords: rankLangConfig.stopWords,
characterReplacements: rankLangConfig.characterReplacements,
});
// Convert to unified result type
@@ -0,0 +1,69 @@
/**
* Component: Report Issue API
* Documentation: documentation/backend/services/reported-issues.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { reportIssue, ReportedIssueError } from '@/lib/services/reported-issue.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.ReportIssue');
const ReportIssueSchema = z.object({
reason: z.string().min(1, 'Reason is required').max(250, 'Reason must be 250 characters or less'),
title: z.string().optional(),
author: z.string().optional(),
coverArtUrl: z.string().optional(),
});
/**
* POST /api/audiobooks/[asin]/report-issue
* Report an issue with an available audiobook
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { asin } = await params;
const body = await req.json();
const { reason, title, author, coverArtUrl } = ReportIssueSchema.parse(body);
const issue = await reportIssue(asin, req.user.id, reason, { title, author, coverArtUrl });
return NextResponse.json({ success: true, issue }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
if (error instanceof ReportedIssueError) {
return NextResponse.json(
{ error: 'ReportIssueError', message: error.message },
{ status: error.statusCode }
);
}
logger.error('Failed to report issue', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to report issue' },
{ status: 500 }
);
}
});
}
+1
View File
@@ -46,6 +46,7 @@ export async function GET(
return NextResponse.json({
success: true,
audiobook,
audibleBaseUrl: audibleService.getBaseUrl(),
});
} catch (error) {
logger.error('Failed to get audiobook details', { error: error instanceof Error ? error.message : String(error) });
@@ -10,6 +10,8 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { getLanguageForRegion } from '@/lib/constants/language-config';
import type { AudibleRegion } from '@/lib/types/audible';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
@@ -140,13 +142,19 @@ export async function POST(request: NextRequest) {
logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
}
// Get language-specific stop words for ranking
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally
// requireAuthor: false - interactive search, show all results for user decision
const rankedResults = rankTorrents(results, { title, author, durationMinutes }, {
indexerPriorities,
flagConfigs,
requireAuthor: false // Interactive mode - let user decide
requireAuthor: false, // Interactive mode - let user decide
stopWords: langConfig.stopWords,
characterReplacements: langConfig.characterReplacements,
});
// Log filter results
+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 }
);
}
}
@@ -14,6 +14,8 @@ import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { RMABLogger } from '@/lib/utils/logger';
import { getLanguageForRegion } from '@/lib/constants/language-config';
import type { AudibleRegion } from '@/lib/types/audible';
import {
searchByAsin,
searchByTitle,
@@ -121,6 +123,11 @@ export async function POST(
const format = preferredFormat || 'epub';
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
// Get language code from Audible region config
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
const languageCode = langConfig.annasArchiveLang;
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
return NextResponse.json(
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
@@ -145,7 +152,8 @@ export async function POST(
audiobook.author,
format,
annasBaseUrl,
flaresolverrUrl || undefined
flaresolverrUrl || undefined,
languageCode
).catch((err) => {
logger.error(`Anna's Archive search failed: ${err.message}`);
return null;
@@ -217,7 +225,8 @@ async function searchAnnasArchiveForInteractive(
author: string,
preferredFormat: string,
baseUrl: string,
flaresolverrUrl?: string
flaresolverrUrl?: string,
languageCode: string = 'en'
): Promise<EbookSearchResult[]> {
let md5: string | null = null;
let searchMethod: 'asin' | 'title' = 'title';
@@ -225,7 +234,7 @@ async function searchAnnasArchiveForInteractive(
// Try ASIN search first
if (asin) {
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl);
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
if (md5) {
searchMethod = 'asin';
logger.info(`Found via ASIN: ${md5}`);
@@ -235,7 +244,7 @@ async function searchAnnasArchiveForInteractive(
// Fallback to title search
if (!md5) {
logger.info(`Searching Anna's Archive by title: "${title}"`);
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl);
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
if (md5) {
logger.info(`Found via title: ${md5}`);
}
@@ -356,6 +365,10 @@ async function searchIndexersForInteractive(
return [];
}
// Get language-specific stop words for ranking
const rankRegion = await configService.getAudibleRegion() as AudibleRegion;
const rankLangConfig = getLanguageForRegion(rankRegion);
// Rank results with ebook scoring
// Use requireAuthor=false for interactive mode (let user decide)
const rankedResults = rankEbookTorrents(allResults, {
@@ -366,6 +379,8 @@ async function searchIndexersForInteractive(
indexerPriorities,
flagConfigs,
requireAuthor: false,
stopWords: rankLangConfig.stopWords,
characterReplacements: rankLangConfig.characterReplacements,
});
// Log ranking debug info (same format as search-ebook.processor.ts)
@@ -9,6 +9,8 @@ import { prisma } from '@/lib/db';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { getLanguageForRegion } from '@/lib/constants/language-config';
import type { AudibleRegion } from '@/lib/types/audible';
import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
@@ -189,6 +191,10 @@ export async function POST(
}
}
// Get language-specific stop words for ranking
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Always use the audiobook's title/author for ranking (not custom search query)
// requireAuthor: false - interactive mode, show all results for user decision
@@ -199,7 +205,9 @@ export async function POST(
}, {
indexerPriorities,
flagConfigs,
requireAuthor: false // Interactive mode - let user decide
requireAuthor: false, // Interactive mode - let user decide
stopWords: langConfig.stopWords,
characterReplacements: langConfig.characterReplacements,
});
// No threshold filtering for interactive search - show all results
+17 -259
View File
@@ -6,11 +6,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
import { createRequestForUser } from '@/lib/services/request-creator.service';
const logger = RMABLogger.create('API.Requests');
@@ -45,274 +43,34 @@ export async function POST(request: NextRequest) {
const body = await req.json();
const { audiobook } = CreateRequestSchema.parse(body);
// First check: Is there an existing audiobook request in 'downloaded' or 'available' status?
// This catches the gap where files are organized but Plex hasn't scanned yet
const existingActiveRequest = await prisma.request.findFirst({
where: {
audiobook: {
audibleAsin: audiobook.asin,
},
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
include: {
user: { select: { plexUsername: true } },
},
});
const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
if (existingActiveRequest) {
const status = existingActiveRequest.status;
const isOwnRequest = existingActiveRequest.userId === req.user.id;
return NextResponse.json(
{
error: status === 'available' ? 'AlreadyAvailable' : 'BeingProcessed',
message: status === 'available'
? 'This audiobook is already available in your Plex library'
: 'This audiobook is being processed and will be available soon',
requestStatus: status,
isOwnRequest,
requestedBy: existingActiveRequest.user?.plexUsername,
},
{ status: 409 }
);
}
// Second check: Is audiobook already in Plex library? (fallback for non-requested books)
const plexMatch = await findPlexMatch({
const result = await createRequestForUser(req.user.id, {
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
});
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
}, { skipAutoSearch });
if (plexMatch) {
if (!result.success) {
const statusMap: Record<string, { error: string; status: number }> = {
already_available: { error: 'AlreadyAvailable', status: 409 },
being_processed: { error: 'BeingProcessed', status: 409 },
duplicate: { error: 'DuplicateRequest', status: 409 },
user_not_found: { error: 'UserNotFound', status: 404 },
};
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
return NextResponse.json(
{
error: 'AlreadyAvailable',
message: 'This audiobook is already available in your Plex library',
plexGuid: plexMatch.plexGuid,
},
{ status: 409 }
{ error: mapped.error, message: result.message },
{ status: mapped.status }
);
}
// Fetch full details from Audnexus to get releaseDate, year, and series
let year: number | undefined;
let series: string | undefined;
let seriesPart: string | undefined;
try {
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
if (audnexusData?.releaseDate) {
try {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
}
} catch (error) {
logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Extract series data
if (audnexusData?.series) {
series = audnexusData.series;
logger.debug(`Extracted series: ${series}`);
}
if (audnexusData?.seriesPart) {
seriesPart = audnexusData.seriesPart;
logger.debug(`Extracted seriesPart: ${seriesPart}`);
}
} catch (error) {
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Try to find existing audiobook record by ASIN
let audiobookRecord = await prisma.audiobook.findFirst({
where: { audibleAsin: audiobook.asin },
});
// If not found, create new audiobook record
if (!audiobookRecord) {
audiobookRecord = await prisma.audiobook.create({
data: {
audibleAsin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
year,
series,
seriesPart,
status: 'requested',
},
});
logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}, series: ${series || 'none'}`);
} else if (year || series || seriesPart) {
// Always update year/series if we have them from Audnexus (even if audiobook already has them)
audiobookRecord = await prisma.audiobook.update({
where: { id: audiobookRecord.id },
data: {
...(year && { year }),
...(series && { series }),
...(seriesPart && { seriesPart }),
},
});
logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
}
// Check if user already has an active (non-deleted) audiobook request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
deletedAt: null, // Only check active requests
},
});
if (existingRequest) {
// Allow re-requesting if the status is failed, warn, or cancelled
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
if (!canReRequest) {
return NextResponse.json(
{
error: 'DuplicateRequest',
message: 'You have already requested this audiobook',
request: existingRequest,
},
{ status: 409 }
);
}
// Delete the existing failed/warn/cancelled request
logger.debug(`Deleting existing ${existingRequest.status} request ${existingRequest.id} to allow re-request`);
await prisma.request.delete({
where: { id: existingRequest.id },
});
}
// Check if we should skip auto-search (for interactive search)
const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
// Check if request needs approval
let needsApproval = false;
let shouldTriggerSearch = !skipAutoSearch;
// Fetch user with autoApproveRequests setting
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: {
role: true,
autoApproveRequests: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'UserNotFound', message: 'User not found' },
{ status: 404 }
);
}
// Determine if approval is needed
if (user.role === 'admin') {
// Admins always auto-approve
needsApproval = false;
} else {
// Check user's personal setting first
if (user.autoApproveRequests === true) {
needsApproval = false;
} else if (user.autoApproveRequests === false) {
needsApproval = true;
} else {
// User setting is null, check global setting
const globalConfig = await prisma.configuration.findUnique({
where: { key: 'auto_approve_requests' },
});
// Default to true if not configured (backward compatibility)
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
needsApproval = !globalAutoApprove;
}
}
// Determine initial status
let initialStatus: string;
if (needsApproval) {
initialStatus = 'awaiting_approval';
shouldTriggerSearch = false; // Don't trigger search if awaiting approval
} else if (skipAutoSearch) {
initialStatus = 'awaiting_search';
} else {
initialStatus = 'pending';
}
// Create request with appropriate status
const newRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
status: initialStatus,
type: 'audiobook', // Explicit type for user-created requests
progress: 0,
},
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
},
});
const jobQueue = getJobQueueService();
// Send notification based on approval status
if (initialStatus === 'awaiting_approval') {
// Request needs approval - send pending notification
await jobQueue.addNotificationJob(
'request_pending_approval',
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
newRequest.user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
} else {
// Request was auto-approved (either automatic or interactive search) - send approved notification
await jobQueue.addNotificationJob(
'request_approved',
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
newRequest.user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
}
// Trigger search job only if not skipped and not awaiting approval
if (shouldTriggerSearch) {
await jobQueue.addSearchJob(newRequest.id, {
id: audiobookRecord.id,
title: audiobookRecord.title,
author: audiobookRecord.author,
asin: audiobookRecord.audibleAsin || undefined,
});
}
return NextResponse.json({
success: true,
request: newRequest,
request: result.request,
}, { status: 201 });
} catch (error) {
logger.error('Failed to create request', { error: error instanceof Error ? error.message : String(error) });
+72
View File
@@ -0,0 +1,72 @@
/**
* Component: Series Detail API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
const logger = RMABLogger.create('API.Series.Detail');
/**
* GET /api/series/{asin}
* Fetch series detail: metadata + books (enriched with availability) + similar series
*/
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 series ASIN is required' },
{ status: 400 }
);
}
logger.info(`Fetching series detail: ${asin}`);
const detail = await scrapeSeriesPage(asin);
if (!detail) {
return NextResponse.json(
{ error: 'NotFound', message: 'Series not found' },
{ status: 404 }
);
}
// Enrich books with library availability and request status
const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId);
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books)`);
return NextResponse.json({
success: true,
series: {
...detail,
books: enrichedBooks,
},
});
} catch (error) {
logger.error('Failed to fetch series detail', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'FetchError', message: 'Failed to fetch series details' },
{ status: 500 }
);
}
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Component: Series Search API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { searchForSeries } from '@/lib/integrations/audible-series';
const logger = RMABLogger.create('API.Series.Search');
/**
* GET /api/series/search?q=game+of+thrones
* Search for audiobook series on Audible, de-duplicate, and return enriched summaries
*/
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 query = request.nextUrl.searchParams.get('q');
if (!query || query.trim().length === 0) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Search query is required' },
{ status: 400 }
);
}
logger.info(`Searching series: "${query}"`);
const series = await searchForSeries(query.trim());
logger.info(`Series search complete: "${query}" -> ${series.length} results`);
return NextResponse.json({
success: true,
series,
query: query.trim(),
});
} catch (error) {
logger.error('Failed to search series', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'SearchError', message: 'Failed to search series' },
{ status: 500 }
);
}
}
@@ -0,0 +1,50 @@
/**
* Component: Goodreads Shelf Delete Route
* Documentation: documentation/backend/services/goodreads-sync.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.GoodreadsShelves');
/**
* DELETE /api/user/goodreads-shelves/[id]
* Remove a Goodreads shelf subscription (ownership check)
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const shelf = await prisma.goodreadsShelf.findUnique({
where: { id },
});
if (!shelf) {
return NextResponse.json({ error: 'Shelf not found' }, { status: 404 });
}
// Ownership check
if (shelf.userId !== req.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await prisma.goodreadsShelf.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Failed to delete shelf', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Failed to delete shelf' }, { status: 500 });
}
});
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Component: Goodreads Shelves API Routes
* Documentation: documentation/backend/services/goodreads-sync.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { fetchAndValidateRss } from '@/lib/services/goodreads-sync.service';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.GoodreadsShelves');
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
const AddShelfSchema = z.object({
rssUrl: z.string().url().refine(
(url) => GOODREADS_RSS_PATTERN.test(url),
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
),
});
/**
* GET /api/user/goodreads-shelves
* List the current user's Goodreads shelves with book counts and covers
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const shelves = await prisma.goodreadsShelf.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
});
const shelvesWithMeta = shelves.map((shelf) => {
// Normalize coverUrls: old format (string[]) → new format ({coverUrl,asin,title,author}[])
let books: { coverUrl: string; asin: string | null; title: string; author: string }[] = [];
if (shelf.coverUrls) {
const parsed = JSON.parse(shelf.coverUrls);
if (Array.isArray(parsed)) {
books = parsed.map((item: unknown) => {
if (typeof item === 'string') {
return { coverUrl: item, asin: null, title: '', author: '' };
}
const obj = item as Record<string, unknown>;
return {
coverUrl: (obj.coverUrl as string) || '',
asin: (obj.asin as string) || null,
title: (obj.title as string) || '',
author: (obj.author as string) || '',
};
});
}
}
return {
id: shelf.id,
name: shelf.name,
rssUrl: shelf.rssUrl,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount ?? null,
books,
};
});
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
} catch (error) {
logger.error('Failed to list shelves', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Failed to list shelves' }, { status: 500 });
}
});
}
/**
* POST /api/user/goodreads-shelves
* Add a new Goodreads shelf subscription
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { rssUrl } = AddShelfSchema.parse(body);
// Check for duplicate
const existing = await prisma.goodreadsShelf.findUnique({
where: { userId_rssUrl: { userId: req.user.id, rssUrl } },
});
if (existing) {
return NextResponse.json(
{ error: 'DuplicateShelf', message: 'You have already added this shelf' },
{ status: 409 }
);
}
// Validate by fetching the RSS feed
let shelfName: string;
let bookCount: number;
let initialBooks: { coverUrl: string; asin: null; title: string; author: string }[] = [];
try {
const rssData = await fetchAndValidateRss(rssUrl);
shelfName = rssData.shelfName;
bookCount = rssData.books.length;
initialBooks = rssData.books
.filter(b => b.coverUrl)
.slice(0, 8)
.map(b => ({ coverUrl: b.coverUrl!, asin: null, title: b.title, author: b.author }));
} catch (error) {
return NextResponse.json(
{
error: 'InvalidRSS',
message: `Could not fetch or parse the RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
{ status: 400 }
);
}
const shelf = await prisma.goodreadsShelf.create({
data: {
userId: req.user.id,
name: shelfName,
rssUrl,
bookCount,
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
},
});
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0);
logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`);
} catch (error) {
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
}
return NextResponse.json({
success: true,
shelf: {
id: shelf.id,
name: shelf.name,
rssUrl: shelf.rssUrl,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount,
books: initialBooks,
},
bookCount,
}, { status: 201 });
} catch (error) {
logger.error('Failed to add shelf', { error: error instanceof Error ? error.message : String(error) });
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
return NextResponse.json({ error: 'Failed to add shelf' }, { 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>
);
}
+9
View File
@@ -196,3 +196,12 @@ body {
.animate-toast-in {
animation: toast-slide-in 0.3s ease-out;
}
/* Hide scrollbar while keeping scroll functional */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
+124 -252
View File
@@ -11,80 +11,63 @@ import { RequestCard } from '@/components/requests/RequestCard';
import { useAuth } from '@/contexts/AuthContext';
import { useRequests } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn';
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
const statConfig = [
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
{ key: 'active', label: 'Active', color: 'text-blue-500' },
{ key: 'waiting', label: 'Waiting', color: 'text-amber-500' },
{ key: 'completed', label: 'Complete', color: 'text-emerald-500' },
{ key: 'failed', label: 'Failed', color: 'text-red-500' },
{ key: 'cancelled', label: 'Cancelled', color: 'text-gray-400 dark:text-gray-500' },
] as const;
type StatKey = (typeof statConfig)[number]['key'];
export default function ProfilePage() {
const { user } = useAuth();
// Always show only the current user's own requests (even for admins)
const { requests, isLoading } = useRequests(undefined, 50, true);
// Calculate statistics
const stats = useMemo(() => {
if (!requests.length) {
return {
total: 0,
completed: 0,
active: 0,
waiting: 0,
failed: 0,
cancelled: 0,
};
return { total: 0, completed: 0, active: 0, waiting: 0, failed: 0, cancelled: 0 };
}
return {
total: requests.length,
completed: requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length,
active: requests.filter((r: any) =>
['pending', 'searching', 'downloading', 'processing'].includes(r.status)
).length,
waiting: requests.filter((r: any) =>
['awaiting_search', 'awaiting_import'].includes(r.status)
).length,
active: requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length,
waiting: requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length,
failed: requests.filter((r: any) => r.status === 'failed').length,
cancelled: requests.filter((r: any) => r.status === 'cancelled').length,
};
}, [requests]);
// Get active downloads (downloading or processing)
const activeDownloads = useMemo(() => {
return requests.filter((r: any) =>
['downloading', 'processing'].includes(r.status)
);
return requests.filter((r: any) => ['downloading', 'processing'].includes(r.status));
}, [requests]);
// Get recent requests (last 5)
const recentRequests = useMemo(() => {
return [...requests]
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5);
}, [requests]);
// Redirect to login if not authenticated
if (!user) {
return (
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
<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 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
<main className="container mx-auto px-4 py-20 max-w-5xl text-center">
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mx-auto mb-5">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Authentication Required
</h2>
<p className="text-gray-600 dark:text-gray-400">
Please log in to view your profile
</p>
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Sign in required
</h2>
<p className="text-gray-500 dark:text-gray-400">
Please log in to view your profile
</p>
</main>
</div>
);
@@ -94,183 +77,83 @@ export default function ProfilePage() {
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
{/* User Info Card */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
<main className="container mx-auto px-4 py-8 max-w-5xl space-y-10">
{/* Profile Card — gradient banner + avatar + info + stats */}
<section className="rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 shadow-sm">
{/* Gradient Banner */}
<div className="h-32 sm:h-40 bg-gradient-to-br from-blue-600 via-indigo-500 to-violet-600" />
{/* Profile Content — overlapping the banner */}
<div className="px-6 sm:px-8 pb-8 -mt-14 sm:-mt-16">
{/* Avatar */}
<div className="flex-shrink-0">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.username}
className="w-24 h-24 rounded-full"
/>
) : (
<div className="w-24 h-24 rounded-full bg-blue-600 flex items-center justify-center text-white text-3xl font-bold">
{user.username.charAt(0).toUpperCase()}
</div>
)}
</div>
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.username}
className="w-28 h-28 rounded-full ring-4 ring-white dark:ring-gray-800 shadow-lg object-cover mb-5"
/>
) : (
<div className="w-28 h-28 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-4xl font-bold ring-4 ring-white dark:ring-gray-800 shadow-lg mb-5">
{user.username.charAt(0).toUpperCase()}
</div>
)}
{/* User Details */}
<div className="flex-1 space-y-2 text-center sm:text-left">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
{user.username}
</h1>
{user.email && (
<p className="text-gray-600 dark:text-gray-400">
{user.email}
</p>
)}
<div className="flex items-center gap-2">
<span
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
user.role === 'admin'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
)}
>
{user.role === 'admin' ? 'Administrator' : 'User'}
</span>
<span className="text-sm text-gray-500 dark:text-gray-500">
Plex ID: {user.plexId}
</span>
</div>
</div>
</div>
</div>
{/* Statistics Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4">
{/* Total Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Total</p>
<p className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
{isLoading ? '...' : stats.total}
</p>
</div>
{/* Name + Email + Badge */}
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{user.username}
</h1>
{user.email && (
<p className="text-base text-gray-500 dark:text-gray-400 mt-1">
{user.email}
</p>
)}
<div className="mt-3">
<span
className={cn(
'inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold uppercase tracking-wide',
user.role === 'admin'
? 'bg-purple-50 text-purple-600 dark:bg-purple-500/15 dark:text-purple-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700/50 dark:text-gray-400'
)}
>
{user.role === 'admin' ? 'Administrator' : 'User'}
</span>
</div>
</div>
{/* Active Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{/* Stats Strip */}
<div className="grid grid-cols-3 sm:grid-cols-6 gap-px bg-gray-100 dark:bg-gray-700/30">
{statConfig.map((stat) => (
<div
key={stat.key}
className="py-5 sm:py-6 px-3 text-center bg-white dark:bg-gray-800"
>
<div className={cn('text-2xl sm:text-3xl font-bold tabular-nums', stat.color)}>
{isLoading ? '\u2013' : stats[stat.key as StatKey]}
</div>
<div className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mt-1.5">
{stat.label}
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Active</p>
<p className="text-xl sm:text-2xl font-bold text-blue-600 dark:text-blue-400">
{isLoading ? '...' : stats.active}
</p>
</div>
</div>
))}
</div>
</section>
{/* Waiting Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Waiting</p>
<p className="text-xl sm:text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{isLoading ? '...' : stats.waiting}
</p>
</div>
</div>
</div>
{/* Completed Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Completed</p>
<p className="text-xl sm:text-2xl font-bold text-green-600 dark:text-green-400">
{isLoading ? '...' : stats.completed}
</p>
</div>
</div>
</div>
{/* Failed Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-red-100 dark:bg-red-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Failed</p>
<p className="text-xl sm:text-2xl font-bold text-red-600 dark:text-red-400">
{isLoading ? '...' : stats.failed}
</p>
</div>
</div>
</div>
{/* Cancelled Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Cancelled</p>
<p className="text-xl sm:text-2xl font-bold text-gray-600 dark:text-gray-400">
{isLoading ? '...' : stats.cancelled}
</p>
</div>
</div>
</div>
</div>
{/* Goodreads Shelves */}
<GoodreadsShelvesSection />
{/* Active Downloads */}
{activeDownloads.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
<section>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Active Downloads
</h2>
<a
href="/requests"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
className="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
View All Requests
View All
</a>
</div>
<div className="space-y-4">
@@ -278,21 +161,23 @@ export default function ProfilePage() {
<RequestCard key={request.id} request={request} showActions={false} />
))}
</div>
</div>
</section>
)}
{/* Recent Requests */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
<section>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Recent Requests
</h2>
<a
href="/requests"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View All Requests
</a>
{requests.length > 0 && (
<a
href="/requests"
className="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
View All
</a>
)}
</div>
{isLoading ? (
@@ -300,14 +185,14 @@ export default function ProfilePage() {
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
className="rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-5 animate-pulse"
>
<div className="flex gap-4">
<div className="w-24 h-36 bg-gray-300 dark:bg-gray-700 rounded"></div>
<div className="flex-1 space-y-3">
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-24"></div>
<div className="w-20 h-28 bg-gray-100 dark:bg-gray-700/50 rounded-lg flex-shrink-0" />
<div className="flex-1 space-y-3 py-1">
<div className="h-6 bg-gray-100 dark:bg-gray-700/50 rounded w-3/4" />
<div className="h-4 bg-gray-100 dark:bg-gray-700/50 rounded w-1/2" />
<div className="h-6 bg-gray-100 dark:bg-gray-700/50 rounded w-24" />
</div>
</div>
</div>
@@ -320,47 +205,34 @@ export default function ProfilePage() {
))}
</div>
) : (
<div className="text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow-md space-y-4">
<div className="rounded-2xl border-2 border-dashed border-gray-200 dark:border-gray-700/50 py-16 text-center">
<svg
className="mx-auto h-16 w-16 text-gray-400"
className="mx-auto w-10 h-10 text-gray-300 dark:text-gray-600 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
</svg>
<div className="space-y-2">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
No requests yet
</h3>
<p className="text-gray-600 dark:text-gray-400">
Start by searching for audiobooks and requesting them
</p>
</div>
<div className="pt-4">
<a
href="/search"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
<svg className="w-5 h-5" 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>
Search Audiobooks
</a>
</div>
<p className="text-base font-medium text-gray-500 dark:text-gray-400">
No requests yet
</p>
<p className="text-sm text-gray-400 dark:text-gray-600 mt-1">
Search for audiobooks to get started
</p>
<a
href="/search"
className="inline-flex items-center gap-2 mt-5 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
Search Audiobooks
</a>
</div>
)}
</div>
</section>
</main>
</div>
);
+117
View File
@@ -0,0 +1,117 @@
/**
* Component: Series 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 { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard';
import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow';
import { useSeriesDetail } from '@/lib/hooks/useSeries';
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 SeriesDetailPage({
params,
}: {
params: Promise<{ asin: string }>;
}) {
const { asin } = use(params);
const router = useRouter();
const searchParams = useSearchParams();
const fromSeriesTitle = searchParams.get('from');
const { series, isLoading: seriesLoading } = useSeriesDetail(asin);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
const handleBack = useCallback(() => {
// Use browser back if we came from within the app, otherwise fallback to /series
if (window.history.length > 1) {
router.back();
} else {
router.push('/series');
}
}, [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>
{fromSeriesTitle ? `Back to ${fromSeriesTitle}` : 'Back to Series'}
</button>
{/* Series Detail Card */}
{seriesLoading ? (
<SeriesDetailSkeleton squareCovers={squareCovers} />
) : series ? (
<SeriesDetailCard series={series} squareCovers={squareCovers} />
) : (
<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">Series not found</p>
</div>
)}
{/* Similar Series */}
{seriesLoading ? (
<SimilarSeriesSkeleton squareCovers={squareCovers} />
) : series && series.similarSeries.length > 0 ? (
<SimilarSeriesRow series={series.similarSeries} currentSeriesTitle={series.title} squareCovers={squareCovers} />
) : null}
{/* Books Section */}
{series && (
<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 in Series
</h2>
{series.books.length > 0 && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({series.books.length} title{series.books.length !== 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={series.books}
isLoading={seriesLoading}
emptyMessage={`No books found for ${series.title}`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Component: Series 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 { SeriesGrid } from '@/components/series/SeriesGrid';
import { useSeriesSearch } from '@/lib/hooks/useSeries';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { usePreferences } from '@/contexts/PreferencesContext';
function SeriesPageContent() {
const searchParams = useSearchParams();
const router = useRouter();
const initialQuery = searchParams.get('q') || '';
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = 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(`/series?q=${encodeURIComponent(trimmed)}`, { scroll: false });
} else {
router.replace('/series', { scroll: false });
}
}, 500);
return () => clearTimeout(timer);
}, [query, router]);
const { series, isLoading } = useSeriesSearch(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 Series
</h1>
<p className="text-gray-600 dark:text-gray-400">
Search for your favorite audiobook series
</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 series 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-emerald-500 to-teal-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Series
</h2>
{!isLoading && series.length > 0 && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({series.length} result{series.length !== 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>
{/* Series Grid */}
<SeriesGrid
series={series}
isLoading={!!isLoading}
emptyMessage={`No series found for "${debouncedQuery}"`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
</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="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg>
<p className="text-xl text-gray-600 dark:text-gray-400">
Start typing to search for series
</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
Search by series name to discover audiobook collections
</p>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}
export default function SeriesPage() {
return (
<Suspense>
<SeriesPageContent />
</Suspense>
);
}
+2 -2
View File
@@ -115,11 +115,11 @@ export function BackendSelectionStep({
>
{Object.values(AUDIBLE_REGIONS).map((region) => (
<option key={region.code} value={region.code}>
{region.name}{!region.isEnglish ? ' *' : ''}
{region.name}{region.language !== 'en' ? ' *' : ''}
</option>
))}
</select>
{AUDIBLE_REGIONS[audibleRegion]?.isEnglish === false && (
{AUDIBLE_REGIONS[audibleRegion]?.language !== 'en' && (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
<div className="flex gap-3">
<svg
@@ -29,6 +29,7 @@ export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientC
transmission: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
sabnzbd: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
nzbget: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300',
deluge: 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300',
};
const typeColor = typeColorMap[client.type] || typeColorMap.qbittorrent;
@@ -253,7 +253,7 @@ export function DownloadClientManagement({
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Add Download Client
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{/* qBittorrent Card */}
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
<div className="flex items-start justify-between mb-3">
@@ -316,6 +316,37 @@ export function DownloadClientManagement({
)}
</div>
{/* Deluge Card */}
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
Deluge
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Torrent downloads
</p>
</div>
<span className="inline-block text-xs px-2 py-1 rounded bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 font-medium">
Torrent
</span>
</div>
{hasTorrentClient ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
Protocol already configured
</div>
) : (
<Button
onClick={() => handleAddClient('deluge')}
variant="primary"
size="sm"
disabled={loading}
>
Add Deluge
</Button>
)}
</div>
{/* SABnzbd Card */}
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasUsenetClient ? ' opacity-50' : ''}`}>
<div className="flex items-start justify-between mb-3">
@@ -278,7 +278,7 @@ export function DownloadClientModal({
type,
name,
url,
username: type !== 'sabnzbd' ? username : undefined,
username: type !== 'sabnzbd' && type !== 'deluge' ? username : undefined,
password: password === '********' ? undefined : password, // Don't send masked password on edit
enabled,
disableSSLVerify,
@@ -286,7 +286,7 @@ export function DownloadClientModal({
remotePath: remotePathMappingEnabled ? remotePath : undefined,
localPath: remotePathMappingEnabled ? localPath : undefined,
category,
customPath: sanitizedCustomPath || undefined,
customPath: sanitizedCustomPath,
postImportCategory,
};
@@ -338,7 +338,7 @@ export function DownloadClientModal({
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'}
placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'deluge' ? 'http://localhost:8112' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'}
error={errors.url}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
@@ -346,8 +346,8 @@ export function DownloadClientModal({
</p>
</div>
{/* Username (qBittorrent and Transmission) */}
{type !== 'sabnzbd' && (
{/* Username (qBittorrent, Transmission, NZBGet — not SABnzbd or Deluge) */}
{type !== 'sabnzbd' && type !== 'deluge' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username
@@ -383,6 +383,11 @@ export function DownloadClientModal({
Configured in NZBGet under Settings Security ControlPassword
</p>
)}
{type === 'deluge' && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Web UI password configured in Deluge under Preferences Interface
</p>
)}
</div>
{/* SSL Verification */}
@@ -448,7 +453,7 @@ export function DownloadClientModal({
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Post-Import Category
</label>
{type === 'qbittorrent' && availableCategories.length > 0 ? (
{(type === 'qbittorrent' || type === 'deluge') && availableCategories.length > 0 ? (
<select
value={postImportCategory}
onChange={(e) => setPostImportCategory(e.target.value)}
@@ -244,6 +244,7 @@ export function AudiobookCard({
requestStatus={audiobook.requestStatus}
isAvailable={audiobook.isAvailable}
requestedByUsername={audiobook.requestedByUsername}
hasReportedIssue={audiobook.hasReportedIssue}
/>
</>
);
@@ -10,12 +10,14 @@
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { createPortal } from 'react-dom';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
import { usePreferences } from '@/contexts/PreferencesContext';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
interface AudiobookDetailsModalProps {
asin: string;
@@ -27,6 +29,7 @@ interface AudiobookDetailsModalProps {
isAvailable?: boolean;
requestedByUsername?: string | null;
hideRequestActions?: boolean;
hasReportedIssue?: boolean;
}
// Status helper
@@ -65,10 +68,11 @@ export function AudiobookDetailsModal({
isAvailable = false,
requestedByUsername = null,
hideRequestActions = false,
hasReportedIssue = false,
}: AudiobookDetailsModalProps) {
const { user } = useAuth();
const { squareCovers } = usePreferences();
const { audiobook, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
const { createRequest, isLoading: isRequesting } = useCreateRequest();
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
@@ -79,6 +83,7 @@ export function AudiobookDetailsModal({
const [mounted, setMounted] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showReportIssue, setShowReportIssue] = useState(false);
const [asinCopied, setAsinCopied] = useState(false);
const status = getStatusInfo(isAvailable, requestStatus, requestedByUsername);
@@ -282,13 +287,44 @@ export function AudiobookDetailsModal({
{audiobook.title}
</h2>
<p className="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-300">
{audiobook.author}
{audiobook.authorAsin ? (
<Link
href={`/authors/${audiobook.authorAsin}`}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
{audiobook.author}
</Link>
) : (
audiobook.author
)}
</p>
{audiobook.narrator && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Narrated by {audiobook.narrator}
</p>
)}
{audiobook.series && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{audiobook.seriesAsin ? (
<Link
href={`/series/${audiobook.seriesAsin}`}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors"
>
{audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''}
</Link>
) : (
<span>{audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''}</span>
)}
</p>
)}
{/* Status Badge */}
{status.type !== 'none' && (
@@ -316,6 +352,33 @@ export function AudiobookDetailsModal({
</div>
)}
{/* Issue Reported Badge */}
{isAvailable && hasReportedIssue && (
<div className="mt-2 inline-flex">
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
</svg>
Issue Reported
</span>
</div>
)}
{/* Report Issue Button - inline with metadata, not in action bar */}
{isAvailable && !hasReportedIssue && user && (
<div className="mt-2 inline-flex">
<button
onClick={() => setShowReportIssue(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 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="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
</svg>
Report Issue
</button>
</div>
)}
{/* Quick Metadata */}
<div className="mt-4 flex flex-wrap items-center justify-center sm:justify-start gap-3 text-sm text-gray-500 dark:text-gray-400">
{audiobook.durationMinutes && (
@@ -387,7 +450,7 @@ export function AudiobookDetailsModal({
<div>
<p className="text-gray-500 dark:text-gray-400">Source</p>
<a
href={`https://www.audible.com/pd/${asin}`}
href={`${audibleBaseUrl}/pd/${asin}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-orange-600 dark:text-orange-400 hover:underline"
@@ -526,6 +589,7 @@ export function AudiobookDetailsModal({
)}
</>
)}
</div>
</div>
)}
@@ -594,6 +658,22 @@ export function AudiobookDetailsModal({
</div>,
document.body
)}
{/* Report Issue Modal */}
{showReportIssue && audiobook && (
<ReportIssueModal
isOpen={showReportIssue}
onClose={() => setShowReportIssue(false)}
onSuccess={() => {
setShowReportIssue(false);
showNotification('Issue reported!');
}}
asin={asin}
bookTitle={audiobook.title}
bookAuthor={audiobook.author}
coverArtUrl={audiobook.coverArtUrl}
/>
)}
</>
);
}
@@ -0,0 +1,143 @@
/**
* Component: Report Issue Modal
* Documentation: documentation/frontend/components.md
*
* Sub-modal for reporting problems with available audiobooks.
* Rendered via portal at z-[60] to layer above AudiobookDetailsModal.
*/
'use client';
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useReportIssue } from '@/lib/hooks/useReportedIssues';
interface ReportIssueModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
asin: string;
bookTitle: string;
bookAuthor: string;
coverArtUrl?: string;
}
export function ReportIssueModal({
isOpen,
onClose,
onSuccess,
asin,
bookTitle,
bookAuthor,
coverArtUrl,
}: ReportIssueModalProps) {
const { reportIssue, isLoading } = useReportIssue();
const [reason, setReason] = useState('');
const [error, setError] = useState<string | null>(null);
const maxChars = 250;
const canSubmit = reason.trim().length > 0 && reason.length <= maxChars && !isLoading;
const handleSubmit = async () => {
if (!canSubmit) return;
setError(null);
try {
await reportIssue(asin, reason.trim(), {
title: bookTitle,
author: bookAuthor,
coverArtUrl,
});
setReason('');
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to report issue');
}
};
if (!isOpen) return null;
const modalContent = (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 dark:bg-black/60 backdrop-blur-sm animate-in fade-in duration-150"
onClick={() => !isLoading && onClose()}
>
<div
className="mx-5 w-full max-w-sm bg-white dark:bg-gray-800 rounded-2xl shadow-2xl shadow-black/20 overflow-hidden animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-5 pt-5 pb-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-orange-500/10 dark:bg-orange-400/15 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
</svg>
</div>
<div className="min-w-0">
<h3 className="text-[15px] font-semibold text-gray-900 dark:text-white">
Report Issue
</h3>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 truncate">
{bookTitle}
</p>
</div>
</div>
{/* Reason Textarea */}
<div className="space-y-2">
<textarea
value={reason}
onChange={(e) => {
setReason(e.target.value);
if (error) setError(null);
}}
placeholder="Describe the problem (e.g., corrupted audio, wrong book, missing chapters...)"
rows={3}
maxLength={maxChars}
disabled={isLoading}
className="w-full px-3.5 py-2.5 bg-gray-50 dark:bg-white/[0.06] rounded-xl border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 resize-none focus:outline-none focus:border-orange-500/40 focus:ring-1 focus:ring-orange-500/20 transition-all disabled:opacity-50"
/>
<div className="flex items-center justify-between px-1">
<div className="min-h-[1.25rem]">
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
)}
</div>
<span className={`text-xs tabular-nums ${reason.length > maxChars ? 'text-red-500' : 'text-gray-400 dark:text-gray-500'}`}>
{reason.length}/{maxChars}
</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex border-t border-gray-200/80 dark:border-gray-700/50">
<button
onClick={onClose}
disabled={isLoading}
className="flex-1 px-4 py-3 text-[15px] font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-white/[0.03] transition-colors disabled:opacity-40 border-r border-gray-200/80 dark:border-gray-700/50"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="flex-1 px-4 py-3 text-[15px] font-semibold text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-500/10 transition-colors disabled:opacity-40 disabled:pointer-events-none"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-4 h-4 border-2 border-orange-300 dark:border-orange-600 border-t-orange-600 dark:border-t-orange-400 rounded-full animate-spin" />
Submitting...
</span>
) : (
'Submit Report'
)}
</button>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}
+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>
);
}
+43
View File
@@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/Button';
import { VersionBadge } from '@/components/ui/VersionBadge';
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
export function Header() {
@@ -20,6 +21,7 @@ export function Header() {
const [showMobileMenu, setShowMobileMenu] = useState(false);
const [showBookDate, setShowBookDate] = useState(false);
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const [showAddGoodreadsModal, setShowAddGoodreadsModal] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu);
// Check if user can change password (local users only)
@@ -90,6 +92,15 @@ export function Header() {
>
Profile
</Link>
<button
onClick={() => {
setShowUserMenu(false);
setShowAddGoodreadsModal(true);
}}
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Add Goodreads Shelf
</button>
{canChangePassword && (
<button
onClick={() => {
@@ -149,6 +160,18 @@ export function Header() {
>
Search
</Link>
<Link
href="/authors"
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
Authors
</Link>
<Link
href="/series"
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
Series
</Link>
{showBookDate && (
<Link
href="/bookdate"
@@ -253,6 +276,20 @@ export function Header() {
>
Search
</Link>
<Link
href="/authors"
onClick={() => setShowMobileMenu(false)}
className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
>
Authors
</Link>
<Link
href="/series"
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"
>
Series
</Link>
{showBookDate && (
<Link
href="/bookdate"
@@ -297,6 +334,12 @@ export function Header() {
isOpen={showChangePasswordModal}
onClose={() => setShowChangePasswordModal(false)}
/>
{/* Add Goodreads Shelf Modal */}
<AddGoodreadsShelfModal
isOpen={showAddGoodreadsModal}
onClose={() => setShowAddGoodreadsModal(false)}
/>
</header>
);
}
@@ -0,0 +1,360 @@
/**
* Component: Goodreads Shelves Section (Profile Page)
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState } from 'react';
import { useGoodreadsShelves, useDeleteGoodreadsShelf, GoodreadsShelf, ShelfBook } from '@/lib/hooks/useGoodreadsShelves';
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { usePreferences } from '@/contexts/PreferencesContext';
import { cn } from '@/lib/utils/cn';
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
export function GoodreadsShelvesSection() {
const { shelves, isLoading } = useGoodreadsShelves();
const { deleteShelf, isLoading: isDeleting } = useDeleteGoodreadsShelf();
const { squareCovers } = usePreferences();
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
const handleDelete = async (shelfId: string) => {
try {
await deleteShelf(shelfId);
setConfirmDeleteId(null);
} catch {
// Error handled by hook
}
};
return (
<section>
{/* Section Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10">
<svg className="w-[18px] h-[18px] text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white leading-tight">
Goodreads Shelves
</h2>
{!isLoading && shelves.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'} connected
</p>
)}
</div>
</div>
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Shelf
</button>
</div>
{/* Content */}
{isLoading ? (
<ShelfCardSkeleton squareCovers={squareCovers} />
) : shelves.length > 0 ? (
<div className="space-y-4">
{shelves.map((shelf) => (
<ShelfCard
key={shelf.id}
shelf={shelf}
squareCovers={squareCovers}
isDeleting={isDeleting && confirmDeleteId === shelf.id}
isConfirmingDelete={confirmDeleteId === shelf.id}
onDelete={() => handleDelete(shelf.id)}
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
onCancelDelete={() => setConfirmDeleteId(null)}
onBookClick={(asin) => setSelectedAsin(asin)}
/>
))}
</div>
) : (
<EmptyState onAdd={() => setShowAddModal(true)} />
)}
<AddGoodreadsShelfModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
/>
{/* Audiobook Detail Modal (read-only) */}
{selectedAsin && (
<AudiobookDetailsModal
asin={selectedAsin}
isOpen={true}
onClose={() => setSelectedAsin(null)}
hideRequestActions
/>
)}
</section>
);
}
/* ─── Empty State ─── */
function EmptyState({ onAdd }: { onAdd: () => void }) {
return (
<div className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700/40 p-10 sm:p-14 text-center">
<div className="mx-auto w-14 h-14 rounded-2xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center mb-5 ring-1 ring-amber-200/50 dark:ring-amber-500/10">
<svg className="w-7 h-7 text-amber-500 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
<h3 className="text-base font-semibold text-gray-700 dark:text-gray-200 mb-1.5">
Connect your reading list
</h3>
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs mx-auto mb-7 leading-relaxed">
Link a Goodreads shelf and we&apos;ll automatically request the audiobook for every book you add.
</p>
<button
onClick={onAdd}
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors shadow-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Your First Shelf
</button>
</div>
);
}
/* ─── Loading Skeleton ─── */
function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
return (
<div className="rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7">
<div className="mb-5">
<div className="h-[18px] w-52 bg-gray-100 dark:bg-gray-700/50 rounded-lg animate-pulse mb-2.5" />
<div className="flex items-center gap-2">
<div className="h-[22px] w-16 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
<div className="h-3.5 w-24 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
</div>
</div>
<div className="flex items-end">
{[...Array(5)].map((_, i) => (
<div
key={i}
className={cn(
'rounded-xl bg-gray-100 dark:bg-gray-700/40 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
)}
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }}
/>
))}
</div>
</div>
);
}
/* ─── Shelf Card ─── */
interface ShelfCardProps {
shelf: GoodreadsShelf;
squareCovers: boolean;
isDeleting: boolean;
isConfirmingDelete: boolean;
onDelete: () => void;
onConfirmDelete: () => void;
onCancelDelete: () => void;
onBookClick: (asin: string) => void;
}
function ShelfCard({
shelf,
squareCovers,
isDeleting,
isConfirmingDelete,
onDelete,
onConfirmDelete,
onCancelDelete,
onBookClick,
}: ShelfCardProps) {
const displayBooks = shelf.books.slice(0, 6);
const hasCovers = displayBooks.length > 0;
const remainingCount = Math.max(0, (shelf.bookCount || 0) - displayBooks.length);
const isSyncing = !shelf.lastSyncAt;
return (
<div className="group rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7 transition-all duration-300 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40">
{/* Top: Shelf info + actions */}
<div className={cn('flex items-start justify-between', (hasCovers || isSyncing) && 'mb-5')}>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug">
{shelf.name}
</h3>
<div className="flex items-center gap-2 mt-2">
{shelf.bookCount != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-700/50 text-gray-500 dark:text-gray-400 tabular-nums">
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
</span>
)}
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
{isSyncing ? (
<>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
Syncing&hellip;
</>
) : shelf.lastSyncAt ? (
<>
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
Synced {formatRelativeTime(shelf.lastSyncAt)}
</>
) : (
'Pending sync'
)}
</span>
</div>
</div>
{/* Delete action */}
<div className="flex-shrink-0 ml-4">
{isConfirmingDelete ? (
<div className="flex items-center gap-2">
<button
onClick={onDelete}
disabled={isDeleting}
className="px-3 py-1.5 text-xs font-semibold text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50"
>
{isDeleting ? 'Removing\u2026' : 'Remove'}
</button>
<button
onClick={onCancelDelete}
disabled={isDeleting}
className="px-2 py-1.5 text-xs font-medium text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={onConfirmDelete}
className="p-2 text-gray-300 hover:text-red-400 dark:text-gray-600 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Remove shelf"
>
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
)}
</div>
</div>
{/* Bottom: Stacked book covers */}
{hasCovers ? (
<CoverStack books={displayBooks} remainingCount={remainingCount} squareCovers={squareCovers} onBookClick={onBookClick} />
) : isSyncing ? (
<div className="flex items-end">
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn(
'rounded-xl bg-gray-50 dark:bg-gray-700/30 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
)}
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }}
/>
))}
</div>
) : null}
</div>
);
}
/* ─── Stacked Cover Display ─── */
function CoverStack({
books,
remainingCount,
squareCovers,
onBookClick,
}: {
books: ShelfBook[];
remainingCount: number;
squareCovers: boolean;
onBookClick: (asin: string) => void;
}) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const coverSize = squareCovers
? 'w-[80px] aspect-square'
: 'w-[72px] aspect-[2/3]';
return (
<div className="flex items-end">
{books.map((book, i) => (
<div
key={i}
className={cn(
'relative rounded-xl overflow-hidden shadow-md flex-shrink-0',
'ring-2 ring-white dark:ring-gray-800',
'transition-all duration-300 ease-out',
hoveredIndex === i && 'scale-[1.18] shadow-xl',
coverSize,
book.asin ? 'cursor-pointer' : 'cursor-default'
)}
style={{
marginLeft: i > 0 ? '-16px' : 0,
zIndex: hoveredIndex === i ? 50 : books.length - i,
}}
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => book.asin && onBookClick(book.asin)}
title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined}
>
<img
src={book.coverUrl}
alt=""
className="w-full h-full object-cover"
loading="lazy"
draggable={false}
/>
</div>
))}
{remainingCount > 0 && (
<div
className={cn(
'rounded-xl flex items-center justify-center bg-gray-50 dark:bg-gray-700/30 border border-gray-100 dark:border-gray-700/40 flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
coverSize
)}
style={{ marginLeft: '-16px', zIndex: 0 }}
>
<span className="text-sm font-semibold text-gray-400 dark:text-gray-500 tabular-nums">
+{remainingCount}
</span>
</div>
)}
</div>
);
}
@@ -22,6 +22,7 @@ import {
useInteractiveSearchEbookByAsin,
useSelectEbookByAsin,
} from '@/lib/hooks/useRequests';
import { useReplaceWithTorrent } from '@/lib/hooks/useReportedIssues';
import { Audiobook } from '@/lib/hooks/useAudiobooks';
interface InteractiveTorrentSearchModalProps {
@@ -36,6 +37,7 @@ interface InteractiveTorrentSearchModalProps {
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
onSuccess?: () => void;
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
replaceIssueId?: string; // Optional - when set, confirm handler calls replace endpoint instead
}
// Format relative time from publish date
@@ -87,11 +89,15 @@ export function InteractiveTorrentSearchModal({
fullAudiobook,
onSuccess,
searchMode = 'audiobook',
replaceIssueId,
}: InteractiveTorrentSearchModalProps) {
// Hooks for existing audiobook request flow
const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch();
const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent();
// Hook for reported issue replacement flow
const { replaceWithTorrent, isLoading: isReplacing, error: replaceError } = useReplaceWithTorrent();
// Hooks for new audiobook flow
const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent();
@@ -124,14 +130,18 @@ export function InteractiveTorrentSearchModal({
const isSearching = isEbookMode
? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks)
: (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook);
const isDownloading = isEbookMode
? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook)
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent);
const error = isEbookMode
? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError))
: (hasRequestId
? (searchByRequestError || selectTorrentError)
: (searchByAudiobookError || requestWithTorrentError));
const isDownloading = replaceIssueId
? isReplacing
: isEbookMode
? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook)
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent);
const error = replaceIssueId
? (replaceError || (hasRequestId ? searchByRequestError : searchByAudiobookError))
: isEbookMode
? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError))
: (hasRequestId
? (searchByRequestError || selectTorrentError)
: (searchByAudiobookError || requestWithTorrentError));
// Mount tracking for portal
useEffect(() => { setMounted(true); }, []);
@@ -188,7 +198,7 @@ export function InteractiveTorrentSearchModal({
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
data = await searchByRequestId(requestId, customTitle);
} else {
const audiobookAsin = fullAudiobook?.asin;
const audiobookAsin = fullAudiobook?.asin || asin;
data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin);
}
setResults(data || []);
@@ -208,7 +218,10 @@ export function InteractiveTorrentSearchModal({
const handleConfirmDownload = async () => {
if (!confirmTorrent) return;
try {
if (isEbookMode) {
if (replaceIssueId) {
// Reported issue replacement flow
await replaceWithTorrent(replaceIssueId, confirmTorrent);
} else if (isEbookMode) {
if (useAsinMode && asin) {
await selectEbookByAsin(asin, confirmTorrent);
} else if (requestId) {
+153
View File
@@ -0,0 +1,153 @@
/**
* Component: Series Card
* Documentation: documentation/frontend/components.md
*
* Premium "Cover First" design - metadata integrated into the cover overlay.
* Rating badge top-left, book count top-right, tags in bottom gradient overlay.
* Only the title lives below the cover, ensuring consistent row heights in the grid.
*/
'use client';
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { SeriesSummary } from '@/lib/hooks/useSeries';
interface SeriesCardProps {
series: SeriesSummary;
squareCovers?: boolean;
}
export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) {
const visibleTags = series.tags.slice(0, 2);
const hasTags = visibleTags.length > 0;
const hasRating = series.rating != null && series.rating > 0;
return (
<Link
href={`/series/${series.asin}`}
className="group outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent rounded-2xl block"
aria-label={`View ${series.title} series`}
>
{/* Cover Container — The Hero */}
<div
className={`
relative overflow-hidden rounded-xl
w-full ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'}
shadow-lg shadow-black/20 dark:shadow-black/40
group-hover:shadow-xl group-hover:shadow-black/30 dark:group-hover:shadow-black/55
transform group-hover:scale-[1.02] group-hover:-translate-y-0.5
transition-all duration-300 ease-out
`}
>
{/* Cover Art or Fallback */}
{series.coverArtUrl ? (
<Image
src={series.coverArtUrl}
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-emerald-600 to-teal-800 dark:from-emerald-700 dark:to-teal-900 flex items-center justify-center">
<svg
className="w-1/3 h-1/3 text-white/40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg>
</div>
)}
{/* Top-row badges — Rating (left) + Book count (right) */}
{/* Rating Badge — top-left, matches AudiobookCard pattern exactly */}
{hasRating && (
<div className="
absolute top-2.5 left-2.5
flex items-center gap-1 px-2 py-1
rounded-lg bg-black/50 backdrop-blur-md
text-white text-xs font-medium
transition-opacity duration-300 group-hover:opacity-0
">
<svg className="w-3.5 h-3.5 text-amber-400 shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span>{series.rating!.toFixed(1)}</span>
</div>
)}
{/* Book count badge — top-right */}
{series.bookCount > 0 && (
<div className="
absolute top-2.5 right-2.5
px-2 py-1
text-[11px] font-bold rounded-lg
bg-black/50 backdrop-blur-md
text-white
transition-opacity duration-300 group-hover:opacity-0
">
{series.bookCount} {series.bookCount === 1 ? 'Book' : 'Books'}
</div>
)}
{/* Bottom gradient overlay — always present, deepens on hover */}
<div className={`
absolute inset-x-0 bottom-0
transition-all duration-300
${hasTags
? 'h-20 bg-gradient-to-t from-black/75 via-black/30 to-transparent group-hover:h-24 group-hover:from-black/85'
: 'h-10 bg-gradient-to-t from-black/40 to-transparent opacity-0 group-hover:opacity-100'
}
`} />
{/* Tag pills — pinned to bottom of cover, inside gradient */}
{hasTags && (
<div className="
absolute inset-x-0 bottom-0
flex items-end gap-1.5 p-2.5
pointer-events-none
">
{visibleTags.map(tag => (
<span
key={tag}
className="
inline-block px-2.5 py-0.5
text-[10px] font-medium
rounded-full
bg-black/30 backdrop-blur-md
text-white/90
ring-1 ring-white/15
transition-opacity duration-300
"
>
{tag}
</span>
))}
</div>
)}
</div>
{/* Below-cover: title only — fixed, predictable height across all cards */}
<div className="mt-2.5 px-0.5">
<h3 className="
font-semibold text-[14px] leading-snug
text-gray-900 dark:text-gray-100
line-clamp-2
group-hover:text-emerald-600 dark:group-hover:text-emerald-400
transition-colors duration-200
">
{series.title}
</h3>
</div>
</Link>
);
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Component: Series Detail Card
* Documentation: documentation/frontend/components.md
*
* Hero section for the series detail page with rectangular cover image,
* title, book count, rating, collapsible description, and tag pills.
*/
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { SeriesDetail } from '@/lib/hooks/useSeries';
interface SeriesDetailCardProps {
series: SeriesDetail;
squareCovers?: boolean;
}
export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailCardProps) {
const [expanded, setExpanded] = useState(false);
const hasLongDescription = (series.description?.length || 0) > 300;
return (
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
{/* Rectangular Cover */}
<div className="flex-shrink-0">
<div className={`relative w-36 sm:w-44 lg:w-52 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-xl overflow-hidden shadow-xl shadow-black/20 dark:shadow-black/40`}>
{series.books[0]?.coverArtUrl ? (
<Image
src={series.books[0].coverArtUrl}
alt={series.title}
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-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
<svg className="w-1/3 h-1/3 text-emerald-400 dark:text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
)}
</div>
</div>
{/* Series 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">
{series.title}
</h1>
{/* Meta row: book count + rating */}
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
{series.bookCount > 0 && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-300">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
{series.bookCount} Book{series.bookCount !== 1 ? 's' : ''}
</span>
)}
{series.rating != null && series.rating > 0 && (
<span className="inline-flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
<svg className="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
{series.rating.toFixed(1)}
{series.ratingCount != null && series.ratingCount > 0 && (
<span className="text-gray-400 dark:text-gray-500">
({series.ratingCount.toLocaleString()})
</span>
)}
</span>
)}
</div>
{/* Tag Pills */}
{series.tags.length > 0 && (
<div className="mt-3 flex flex-wrap justify-center sm:justify-start gap-2">
{series.tags.map(tag => (
<span
key={tag}
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"
>
{tag}
</span>
))}
</div>
)}
{/* Audible Link */}
{series.audibleUrl && (
<a
href={series.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 */}
{series.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' : ''
}`}
>
{series.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 SeriesDetailSkeleton({ squareCovers = false }: { squareCovers?: boolean }) {
return (
<div className="animate-pulse flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
{/* Cover skeleton */}
<div className="flex-shrink-0">
<div className={`w-36 sm:w-44 lg:w-52 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-xl 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>
{/* 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-7 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-7 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
</div>
<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>
);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Component: Series Grid
* Documentation: documentation/frontend/components.md
*
* Grid layout for series cards with loading skeletons and empty state.
* Uses the same responsive column system as AudiobookGrid since
* series cards use rectangular (2:3) aspect ratios like book covers.
*/
'use client';
import React from 'react';
import { SeriesCard } from './SeriesCard';
import { SeriesSummary } from '@/lib/hooks/useSeries';
interface SeriesGridProps {
series: SeriesSummary[];
isLoading?: boolean;
emptyMessage?: string;
cardSize?: number;
squareCovers?: boolean;
}
function getGridClasses(size: number): string {
const sizeMap: Record<number, string> = {
1: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
2: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
3: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8',
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7',
5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
7: 'grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
8: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
9: 'grid-cols-1 sm:grid-cols-2',
};
return sizeMap[size] || sizeMap[5];
}
export function SeriesGrid({
series,
isLoading = false,
emptyMessage = 'No series found',
cardSize = 5,
squareCovers = false,
}: SeriesGridProps) {
const gridClasses = getGridClasses(cardSize);
if (isLoading) {
return (
<div className={`grid ${gridClasses} gap-4 sm:gap-5 lg:gap-6`}>
{Array.from({ length: 10 }).map((_, i) => (
<SeriesSkeletonCard key={i} index={i} squareCovers={squareCovers} />
))}
</div>
);
}
if (series.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-2xl 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="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
<p className="text-gray-500 dark:text-gray-400 text-lg">{emptyMessage}</p>
</div>
);
}
return (
<div className={`grid ${gridClasses} gap-4 sm:gap-5 lg:gap-6`}>
{series.map(s => (
<SeriesCard key={s.asin} series={s} squareCovers={squareCovers} />
))}
</div>
);
}
function SeriesSkeletonCard({ index = 0, squareCovers = false }: { index?: number; squareCovers?: boolean }) {
return (
<div
className="animate-pulse"
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Rectangular cover skeleton */}
<div className={`relative overflow-hidden rounded-xl w-full ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} 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>
{/* 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>
);
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Component: Similar Series Row
* Documentation: documentation/frontend/components.md
*
* Horizontal scrollable carousel of similar series cards.
* Desktop: left/right nav arrows. Mobile: drag-to-scroll.
* Each card navigates to the series detail page.
*/
'use client';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { SimilarSeries } from '@/lib/hooks/useSeries';
interface SimilarSeriesRowProps {
series: SimilarSeries[];
currentSeriesTitle?: string;
squareCovers?: boolean;
}
export function SimilarSeriesRow({ series, currentSeriesTitle, squareCovers = false }: SimilarSeriesRowProps) {
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, series]);
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 (series.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-emerald-500 to-teal-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
Similar Series
</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">
({series.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' }}
>
{series.map(s => (
<Link
key={s.asin}
href={`/series/${s.asin}${currentSeriesTitle ? `?from=${encodeURIComponent(currentSeriesTitle)}` : ''}`}
className="flex-shrink-0 w-20 sm:w-24 group/card outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 rounded-xl"
>
{/* Cover */}
<div className={`relative w-20 ${squareCovers ? 'h-20 sm:w-24 sm:h-24' : 'h-[120px] sm:w-24 sm:h-36'} rounded-lg 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`}>
{s.coverArtUrl ? (
<Image
src={s.coverArtUrl}
alt=""
fill
className="object-cover"
sizes="96px"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
<span className="text-lg font-bold text-emerald-400 dark:text-emerald-300">
{s.title.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Title */}
<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-emerald-600 dark:group-hover/card:text-emerald-400 transition-colors">
{s.title}
</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 SimilarSeriesSkeleton({ squareCovers = false }: { squareCovers?: boolean }) {
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-20 sm:w-24" style={{ animationDelay: `${i * 50}ms` }}>
<div className={`w-20 ${squareCovers ? 'h-20 sm:w-24 sm:h-24' : 'h-[120px] sm:w-24 sm:h-36'} rounded-lg 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>
);
}
@@ -0,0 +1,154 @@
/**
* Component: Add Goodreads Shelf Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState } from 'react';
import { Modal } from './Modal';
import { Input } from './Input';
import { Button } from './Button';
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
interface AddGoodreadsShelfModalProps {
isOpen: boolean;
onClose: () => void;
}
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
export function AddGoodreadsShelfModal({ isOpen, onClose }: AddGoodreadsShelfModalProps) {
const [rssUrl, setRssUrl] = useState('');
const [validationError, setValidationError] = useState('');
const [success, setSuccess] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
const { addShelf, isLoading, error } = useAddGoodreadsShelf();
const validateUrl = (url: string): boolean => {
if (!url.trim()) {
setValidationError('RSS URL is required');
return false;
}
if (!GOODREADS_RSS_PATTERN.test(url)) {
setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)');
return false;
}
setValidationError('');
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateUrl(rssUrl)) return;
try {
const shelf = await addShelf(rssUrl);
setSuccess(true);
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
setRssUrl('');
setTimeout(() => {
setSuccess(false);
onClose();
}, 2000);
} catch {
// Error is handled by the hook
}
};
const handleClose = () => {
setRssUrl('');
setValidationError('');
setSuccess(false);
setSuccessMessage('');
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Add Goodreads Shelf" size="sm">
<div className="space-y-5">
{/* Visual header */}
<div className="flex items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-700/50">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10 flex-shrink-0">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.556a4.5 4.5 0 00-6.364-6.364L4.5 8.257a4.5 4.5 0 007.244 1.242" />
</svg>
</div>
<div className="min-w-0">
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
Paste your Goodreads shelf RSS URL. Books will be automatically requested as audiobooks during each sync.
</p>
</div>
</div>
{/* Success alert */}
{success && (
<div className="flex items-center gap-3 p-3.5 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-xl">
<div className="w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">{successMessage}</p>
</div>
)}
{/* Error alert */}
{error && (
<div className="flex items-center gap-3 p-3.5 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl">
<div className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-500/20 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<p className="text-sm font-medium text-red-700 dark:text-red-300">{error}</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<Input
type="url"
label="Goodreads RSS URL"
value={rssUrl}
onChange={(e) => {
setRssUrl(e.target.value);
if (validationError) setValidationError('');
}}
placeholder="https://www.goodreads.com/review/list_rss/..."
error={validationError}
disabled={isLoading || success}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
Find it on Goodreads: My Books &rarr; select a shelf &rarr; RSS link at the bottom of the page.
</p>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClose}
disabled={isLoading || success}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
loading={isLoading}
disabled={isLoading || success}
>
Add Shelf
</Button>
</div>
</form>
</div>
</Modal>
);
}
+257
View File
@@ -0,0 +1,257 @@
/**
* Component: Centralized Language Configuration
* Documentation: documentation/integrations/audible.md
*
* Single source of truth for all language-specific configuration.
* To add a new language:
* 1. Add code to SupportedLanguage union
* 2. Add full LanguageConfig entry in LANGUAGE_CONFIGS
* 3. Map regions in REGION_LANGUAGE_MAP
* 4. Add region to AUDIBLE_REGIONS in audible.ts with language: 'xx'
*/
import type { AudibleRegion } from '../types/audible';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type SupportedLanguage = 'en' | 'de' | 'es';
export interface ScrapingConfig {
/** Audible locale query-param value (e.g. 'english', 'deutsch') */
audibleLocaleParam: string;
/** Author label prefixes to strip (e.g. ['By:', 'Written by:']) */
authorPrefixes: string[];
/** Narrator label prefixes to strip */
narratorPrefixes: string[];
/** Length / duration labels used in Cheerio :contains() selectors */
lengthLabels: string[];
/** Language field labels */
languageLabels: string[];
/** Release date field labels */
releaseDateLabels: string[];
/** Series label prefixes used to find series links in search results */
seriesLabels: string[];
/** Accepted language values for filtering (lowercase) */
acceptedLanguageValues: string[];
/** Regex patterns that match hour portions in runtime strings */
runtimeHourPatterns: RegExp[];
/** Regex patterns that match minute portions in runtime strings */
runtimeMinutePatterns: RegExp[];
/** Regex patterns for extracting numeric rating */
ratingPatterns: RegExp[];
/** Regex patterns for extracting release date text */
releaseDatePatterns: RegExp[];
/** Promotional / non-description text patterns to exclude */
descriptionExcludePatterns: RegExp[];
/** Duration detection pattern for generic element scanning */
durationDetectionPattern: RegExp;
/** Rating text selector pattern (e.g. 'out of 5 stars') */
ratingTextSelector: string;
}
export interface LanguageConfig {
code: SupportedLanguage;
/** Anna's Archive language filter code */
annasArchiveLang: string;
/** EPUB language code */
epubCode: string;
/** Stop words for ranking algorithm (filtered from match scoring) */
stopWords: string[];
/** Character replacements applied before NFD normalization in ranking (e.g. ß→ss) */
characterReplacements: Record<string, string>;
/** All scraping-related config */
scraping: ScrapingConfig;
}
// ---------------------------------------------------------------------------
// Language Configurations
// ---------------------------------------------------------------------------
const ENGLISH_CONFIG: LanguageConfig = {
code: 'en',
annasArchiveLang: 'en',
epubCode: 'en',
stopWords: ['the', 'a', 'an', 'of', 'on', 'in', 'at', 'by', 'for'],
characterReplacements: {},
scraping: {
audibleLocaleParam: 'english',
authorPrefixes: ['By:', 'Written by:'],
narratorPrefixes: ['Narrated by:'],
lengthLabels: ['Length:'],
languageLabels: ['Language:'],
releaseDateLabels: ['Release date:'],
seriesLabels: ['Series:'],
acceptedLanguageValues: ['english'],
runtimeHourPatterns: [/(\d+)\s*hrs?/i, /(\d+)\s*hours?/i],
runtimeMinutePatterns: [/(\d+)\s*mins?/i, /(\d+)\s*minutes?/i],
ratingPatterns: [/(\d+\.?\d*)\s*out of/i],
releaseDatePatterns: [/Release date:\s*(.+)/i],
descriptionExcludePatterns: [
/\$\d+\.\d+/,
/cancel anytime/i,
/free trial/i,
/membership/i,
/subscribe/i,
/offer.*ends/i,
/^\s*by\s+[\w\s,]+$/i,
],
durationDetectionPattern: /\d+\s*(hr|hour|h)\s*\d*\s*(min|minute|m)?/i,
ratingTextSelector: 'out of 5 stars',
},
};
const GERMAN_CONFIG: LanguageConfig = {
code: 'de',
annasArchiveLang: 'de',
epubCode: 'de',
stopWords: ['der', 'die', 'das', 'ein', 'eine', 'und', 'von', 'zu', 'den', 'dem', 'des'],
characterReplacements: { '\u00df': 'ss' },
scraping: {
audibleLocaleParam: 'deutsch',
authorPrefixes: ['Von:', 'Geschrieben von:', 'Autor:'],
narratorPrefixes: ['Gesprochen von:', 'Sprecher:'],
lengthLabels: ['Spieldauer:', 'Dauer:', 'L\u00e4nge:'],
languageLabels: ['Sprache:'],
releaseDateLabels: ['Erscheinungsdatum:'],
seriesLabels: ['Serie:', 'Reihe:'],
acceptedLanguageValues: ['deutsch', 'german'],
runtimeHourPatterns: [/(\d+)\s*Std\.?/i, /(\d+)\s*Stunden?/i],
runtimeMinutePatterns: [/(\d+)\s*Min\.?/i, /(\d+)\s*Minuten?/i],
ratingPatterns: [/(\d+[.,]?\d*)\s*von\s*5/i],
releaseDatePatterns: [/Erscheinungsdatum:\s*(.+)/i],
descriptionExcludePatterns: [
/\$\d+\.\d+/,
/\d+,\d+\s*\u20ac/,
/jederzeit k\u00fcndbar/i,
/kostenlos testen/i,
/Mitgliedschaft/i,
/abonnieren/i,
/Angebot.*endet/i,
/^\s*von\s+[\w\s,]+$/i,
],
durationDetectionPattern: /\d+\s*(Std|Stunden?|h)\s*\.?\s*\d*\s*(Min|Minuten?|m)?/i,
ratingTextSelector: 'von 5 Sternen',
},
};
const SPANISH_CONFIG: LanguageConfig = {
code: 'es',
annasArchiveLang: 'es',
epubCode: 'es',
stopWords: ['el', 'la', 'los', 'las', 'un', 'una', 'de', 'del', 'en', 'y', 'por'],
characterReplacements: {},
scraping: {
audibleLocaleParam: 'espa\u00f1ol',
authorPrefixes: ['De:', 'Escrito por:', 'Autor:'],
narratorPrefixes: ['Narrado por:'],
lengthLabels: ['Duraci\u00f3n:'],
languageLabels: ['Idioma:'],
releaseDateLabels: ['Fecha de lanzamiento:'],
seriesLabels: ['Serie:'],
acceptedLanguageValues: ['espa\u00f1ol', 'spanish'],
runtimeHourPatterns: [/(\d+)\s*h\b/i, /(\d+)\s*horas?/i],
runtimeMinutePatterns: [/(\d+)\s*min/i, /(\d+)\s*minutos?/i],
ratingPatterns: [/(\d+[.,]?\d*)\s*de\s*5/i],
releaseDatePatterns: [/Fecha de lanzamiento:\s*(.+)/i],
descriptionExcludePatterns: [
/\$\d+\.\d+/,
/\d+,\d+\s*\u20ac/,
/cancela cuando quieras/i,
/prueba gratis/i,
/suscripci\u00f3n/i,
/suscr\u00edbete/i,
/oferta.*termina/i,
/^\s*de\s+[\w\s,]+$/i,
],
durationDetectionPattern: /\d+\s*(h|horas?)\s*\d*\s*(min|minutos?)?/i,
ratingTextSelector: 'de 5 estrellas',
},
};
// ---------------------------------------------------------------------------
// Lookup Maps
// ---------------------------------------------------------------------------
export const LANGUAGE_CONFIGS: Record<SupportedLanguage, LanguageConfig> = {
en: ENGLISH_CONFIG,
de: GERMAN_CONFIG,
es: SPANISH_CONFIG,
};
/**
* Maps Audible region codes to language codes.
* All English-speaking regions map to 'en'.
*/
export const REGION_LANGUAGE_MAP: Record<AudibleRegion, SupportedLanguage> = {
us: 'en',
ca: 'en',
uk: 'en',
au: 'en',
in: 'en',
de: 'de',
es: 'es',
};
// ---------------------------------------------------------------------------
// Helper Functions
// ---------------------------------------------------------------------------
/**
* Get the full language configuration for an Audible region.
*/
export function getLanguageForRegion(region: AudibleRegion): LanguageConfig {
const langCode = REGION_LANGUAGE_MAP[region];
return LANGUAGE_CONFIGS[langCode];
}
/**
* Strip any matching prefixes from text (case-insensitive).
* Returns the text with the first matching prefix removed, trimmed.
*
* Example: stripPrefixes('By: Author Name', ['By:', 'Written by:']) => 'Author Name'
*/
export function stripPrefixes(text: string, prefixes: string[]): string {
const trimmed = text.trim();
for (const prefix of prefixes) {
if (trimmed.toLowerCase().startsWith(prefix.toLowerCase())) {
return trimmed.slice(prefix.length).trim();
}
}
return trimmed;
}
/**
* Build a Cheerio selector that matches any of the given labels using :contains().
* Returns a comma-separated selector string.
*
* Example: buildContainsSelector('span', ['Length:', 'Dauer:'])
* => 'span:contains("Length:"), span:contains("Dauer:")'
*/
export function buildContainsSelector(element: string, labels: string[]): string {
return labels.map(label => `${element}:contains("${label}")`).join(', ');
}
/**
* Extract a value from text by trying multiple label patterns.
* Returns the captured group from the first matching pattern, or null.
*/
export function extractByPatterns(text: string, patterns: RegExp[]): string | null {
for (const pattern of patterns) {
const match = text.match(pattern);
if (match?.[1]) {
return match[1].trim();
}
}
return null;
}
/**
* Check if a language value matches the accepted values for a language config.
* Comparison is case-insensitive.
*/
export function isAcceptedLanguage(languageValue: string, config: LanguageConfig): boolean {
const normalized = languageValue.toLowerCase().trim();
return config.scraping.acceptedLanguageValues.includes(normalized);
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Component: Notification Event Constants
* Documentation: documentation/backend/services/notifications.md
*
* Single source of truth for all notification event types and metadata.
* Add new events here all providers, API schemas, and UI labels derive from this.
*/
export type NotificationSeverity = 'info' | 'success' | 'error' | 'warning';
export type NotificationPriority = 'normal' | 'high';
/**
* Central registry of notification events.
*
* Each entry defines:
* - `label`: Human-readable name shown in the UI
* - `title`: Default title used in notification messages
* - `titleByRequestType`: Optional map of request-type-specific titles (e.g. audiobook "Audiobook Available")
* - `emoji`: Emoji prefix for notification titles
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
*/
export const NOTIFICATION_EVENTS = {
request_pending_approval: {
label: 'Request Pending Approval',
title: 'New Request Pending Approval',
emoji: '\u{1F4EC}',
severity: 'info' as const,
priority: 'normal' as const,
},
request_approved: {
label: 'Request Approved',
title: 'Request Approved',
emoji: '\u2705',
severity: 'success' as const,
priority: 'normal' as const,
},
request_available: {
label: 'Request Available',
title: 'Request Available',
titleByRequestType: {
audiobook: 'Audiobook Available',
ebook: 'Ebook Available',
} as Record<string, string>,
emoji: '\u{1F389}',
severity: 'success' as const,
priority: 'high' as const,
},
request_error: {
label: 'Request Error',
title: 'Request Error',
emoji: '\u274C',
severity: 'error' as const,
priority: 'high' as const,
},
issue_reported: {
label: 'Issue Reported',
title: 'Issue Reported',
emoji: '\u{1F6A9}',
severity: 'warning' as const,
priority: 'high' as const,
},
} as const;
/** Union type of all valid notification event keys */
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
/** Ordered array of all notification event keys (for Zod schemas, iteration) */
export const NOTIFICATION_EVENT_KEYS = Object.keys(NOTIFICATION_EVENTS) as [NotificationEvent, ...NotificationEvent[]];
/** Metadata shape for a single notification event */
export type NotificationEventMeta = (typeof NOTIFICATION_EVENTS)[NotificationEvent];
/** Helper: get event metadata by key */
export function getEventMeta(event: NotificationEvent) {
return NOTIFICATION_EVENTS[event];
}
/**
* Helper: get the resolved notification title for an event.
* If the event has a `titleByRequestType` map and a matching requestType is provided,
* returns the type-specific title. Otherwise falls back to the default `title`.
*/
export function getEventTitle(event: NotificationEvent, requestType?: string): string {
const meta = NOTIFICATION_EVENTS[event];
if (requestType && 'titleByRequestType' in meta) {
const typeTitle = (meta as typeof meta & { titleByRequestType: Record<string, string> }).titleByRequestType[requestType];
if (typeTitle) return typeTitle;
}
return meta.title;
}
/** Helper: get the human-readable label for an event */
export function getEventLabel(event: NotificationEvent): string {
return NOTIFICATION_EVENTS[event].label;
}
/** Record mapping all event keys to their labels (for UI dropdowns, etc.) */
export const EVENT_LABELS: Record<NotificationEvent, string> = Object.fromEntries(
Object.entries(NOTIFICATION_EVENTS).map(([key, meta]) => [key, meta.label])
) as Record<NotificationEvent, string>;
+28
View File
@@ -5,6 +5,29 @@
import { PrismaClient } from '@/generated/prisma/client';
/**
* Append connection pool parameters to DATABASE_URL if not already present.
* - connection_limit=20: up from default 9, fits 22 max workers + API routes
* - pool_timeout=30: up from default 10s, gives queued requests time
*/
function getPooledDatabaseUrl(): string {
const baseUrl = process.env.DATABASE_URL || '';
if (!baseUrl) return baseUrl;
const separator = baseUrl.includes('?') ? '&' : '?';
const params: string[] = [];
if (!baseUrl.includes('connection_limit')) {
params.push('connection_limit=20');
}
if (!baseUrl.includes('pool_timeout')) {
params.push('pool_timeout=30');
}
if (params.length === 0) return baseUrl;
return `${baseUrl}${separator}${params.join('&')}`;
}
// Prevent multiple instances of Prisma Client in development
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
@@ -14,6 +37,11 @@ export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
datasources: {
db: {
url: getPooledDatabaseUrl(),
},
},
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
+6
View File
@@ -12,6 +12,7 @@ export interface Audiobook {
asin: string;
title: string;
author: string;
authorAsin?: string;
narrator?: string;
description?: string;
coverArtUrl?: string;
@@ -19,6 +20,9 @@ export interface Audiobook {
releaseDate?: string;
rating?: number;
genres?: string[];
series?: string; // Series name (e.g., "A Song of Ice and Fire")
seriesPart?: string; // Position in series (e.g., "1", "1.5")
seriesAsin?: string; // Audible ASIN for the series (links to /series/{asin})
isAvailable?: boolean; // Set by real-time matching against plex_library
plexGuid?: string | null;
dbId?: string | null;
@@ -26,6 +30,7 @@ export interface Audiobook {
requestStatus?: string | null; // Status of request (if any)
requestId?: string | null; // ID of request (if any)
requestedByUsername?: string | null; // Username who requested (only if not current user)
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
}
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) {
@@ -80,6 +85,7 @@ export function useAudiobookDetails(asin: string | null) {
return {
audiobook: data?.audiobook || null,
audibleBaseUrl: data?.audibleBaseUrl || 'https://www.audible.com',
isLoading,
error,
};
+88
View File
@@ -0,0 +1,88 @@
/**
* Component: Authors Fetching Hooks
* Documentation: documentation/frontend/components.md
*/
'use client';
import useSWR from 'swr';
import { authenticatedFetcher } from '@/lib/utils/api';
import { Audiobook } from './useAudiobooks';
export interface Author {
asin: string;
name: string;
description?: string;
image?: string;
genres: string[];
similarCount: number;
}
export interface SimilarAuthor {
asin: string;
name: string;
image?: string;
}
export interface AuthorDetail {
asin: string;
name: string;
description?: string;
image?: string;
genres: string[];
similar: SimilarAuthor[];
audibleUrl?: string;
}
export function useAuthorSearch(name: string) {
const shouldFetch = name && name.length > 0;
const endpoint = shouldFetch
? `/api/authors/search?name=${encodeURIComponent(name)}`
: null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 30000,
});
return {
authors: (data?.authors || []) as Author[],
query: data?.query || '',
isLoading: shouldFetch && isLoading,
error,
};
}
export function useAuthorDetail(asin: string | null) {
const endpoint = asin ? `/api/authors/${asin}` : null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 300000, // Cache for 5 minutes
});
return {
author: (data?.author || null) as AuthorDetail | null,
isLoading,
error,
};
}
export function useAuthorBooks(asin: string | null, authorName: string | null) {
const shouldFetch = asin && authorName;
const endpoint = shouldFetch
? `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}`
: null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 60000, // Cache for 1 minute
});
return {
books: (data?.books || []) as Audiobook[],
totalBooks: data?.totalBooks || 0,
isLoading: !!shouldFetch && isLoading,
error,
};
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Component: Goodreads Shelves Hook
* Documentation: documentation/frontend/components.md
*/
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
export interface ShelfBook {
coverUrl: string;
asin: string | null;
title: string;
author: string;
}
export interface GoodreadsShelf {
id: string;
name: string;
rssUrl: string;
lastSyncAt: string | null;
createdAt: string;
bookCount: number | null;
books: ShelfBook[];
}
const fetcher = (url: string) =>
fetchWithAuth(url).then((res) => res.json());
export function useGoodreadsShelves() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/user/goodreads-shelves' : null;
const { data, error, isLoading } = useSWR(
endpoint,
fetcher,
{ refreshInterval: 30000 }
);
return {
shelves: (data?.shelves || []) as GoodreadsShelf[],
isLoading,
error,
};
}
export function useAddGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const addShelf = async (rssUrl: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth('/api/user/goodreads-shelves', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rssUrl }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to add shelf');
}
// Revalidate shelves list
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
return data.shelf as GoodreadsShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { addShelf, isLoading, error };
}
export function useDeleteGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const deleteShelf = async (shelfId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/user/goodreads-shelves/${shelfId}`, {
method: 'DELETE',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to remove shelf');
}
// Revalidate shelves list
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { deleteShelf, isLoading, error };
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Component: Reported Issues Hooks
* Documentation: documentation/backend/services/reported-issues.md
*/
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
const fetcher = (url: string) =>
fetchWithAuth(url).then((res) => res.json());
/**
* Hook for reporting an issue with an audiobook (user action)
*/
export function useReportIssue() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const reportIssue = async (
asin: string,
reason: string,
metadata?: { title?: string; author?: string; coverArtUrl?: string }
) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/audiobooks/${asin}/report-issue`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, ...metadata }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to report issue');
}
// Revalidate audiobook lists to show issue indicator
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
return data.issue;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { reportIssue, isLoading, error };
}
/**
* Hook for fetching open reported issues (admin dashboard)
*/
export function useAdminReportedIssues() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/admin/reported-issues' : null;
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
refreshInterval: 10000,
});
return {
issues: data?.issues || [],
count: data?.count || 0,
isLoading,
error,
};
}
/**
* Hook for dismissing a reported issue (admin action)
*/
export function useDismissIssue() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const dismissIssue = async (issueId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/admin/reported-issues/${issueId}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'dismiss' }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to dismiss issue');
}
// Revalidate issues list
mutate((key) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
return data.issue;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { dismissIssue, isLoading, error };
}
/**
* Hook for replacing audiobook content via reported issue (admin action)
*/
export function useReplaceWithTorrent() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const replaceWithTorrent = async (issueId: string, torrent: any) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/admin/reported-issues/${issueId}/replace`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ torrent }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to replace audiobook');
}
// Revalidate issues list and audiobook lists
mutate((key) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
return data.request;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { replaceWithTorrent, isLoading, error };
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Component: Series 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 SeriesSummary {
asin: string;
title: string;
bookCount: number;
rating?: number;
ratingCount?: number;
tags: string[];
coverArtUrl?: string;
audibleUrl: string;
}
export interface SimilarSeries {
asin: string;
title: string;
bookCount?: number;
coverArtUrl?: string;
}
export interface SeriesDetail {
asin: string;
title: string;
bookCount: number;
rating?: number;
ratingCount?: number;
description?: string;
tags: string[];
books: Audiobook[];
similarSeries: SimilarSeries[];
audibleUrl: string;
}
export function useSeriesSearch(query: string) {
const shouldFetch = query && query.length > 0;
const endpoint = shouldFetch
? `/api/series/search?q=${encodeURIComponent(query)}`
: null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 30000,
});
return {
series: (data?.series || []) as SeriesSummary[],
query: data?.query || '',
isLoading: shouldFetch && isLoading,
error,
};
}
export function useSeriesDetail(asin: string | null) {
const endpoint = asin ? `/api/series/${asin}` : null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 300000, // Cache for 5 minutes
});
return {
series: (data?.series || null) as SeriesDetail | null,
isLoading,
error,
};
}
+517
View File
@@ -0,0 +1,517 @@
/**
* Component: Audible Series Scraping
* Documentation: documentation/integrations/audible.md
*
* Standalone series scraping module. Uses the AudibleService fetch wrapper
* for HTTP requests and Cheerio for HTML parsing.
* Kept separate from audible.service.ts to avoid bloating the main service.
*/
import * as cheerio from 'cheerio';
import { getAudibleService, AudibleAudiobook } from './audible.service';
import { AUDIBLE_REGIONS } from '../types/audible';
import {
getLanguageForRegion,
buildContainsSelector,
stripPrefixes,
} from '../constants/language-config';
import { RMABLogger } from '../utils/logger';
import { randomDelay } from '../utils/scrape-resilience';
const logger = RMABLogger.create('Audible.Series');
const AUDIBLE_PAGE_SIZE = 50;
const MAX_SERIES_RESULTS = 15;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SeriesSummary {
asin: string;
title: string;
bookCount: number;
rating?: number;
ratingCount?: number;
tags: string[];
coverArtUrl?: string;
audibleUrl: string;
}
export interface SimilarSeries {
asin: string;
title: string;
bookCount?: number;
coverArtUrl?: string;
}
export interface SeriesDetail {
asin: string;
title: string;
bookCount: number;
rating?: number;
ratingCount?: number;
description?: string;
tags: string[];
books: AudibleAudiobook[];
similarSeries: SimilarSeries[];
audibleUrl: string;
}
// ---------------------------------------------------------------------------
// Search: extract series links from Audible search results
// ---------------------------------------------------------------------------
/**
* Search for series by scraping Audible search results and extracting
* series links. De-duplicates by ASIN, then scrapes each unique series
* page in parallel (capped at MAX_SERIES_RESULTS).
*/
export async function searchForSeries(query: string): Promise<SeriesSummary[]> {
const service = getAudibleService();
const region = service.getRegion();
const baseUrl = service.getBaseUrl();
const langConfig = getLanguageForRegion(region);
const seriesLabels = langConfig.scraping.seriesLabels;
logger.info(`Searching series for "${query}" (region: ${region})`);
// Step 1: Fetch search results page
let $: cheerio.CheerioAPI;
try {
const { data: response } = await service.fetch('/search', {
params: {
ipRedirectOverride: 'true',
keywords: query,
pageSize: AUDIBLE_PAGE_SIZE,
},
});
$ = cheerio.load(response.data);
} catch (error) {
logger.error('Series search fetch failed', {
error: error instanceof Error ? error.message : String(error),
});
return [];
}
// Step 2: Extract unique series ASINs from search results
// Series links appear inside spans containing locale-specific "Series:" text
const seriesMap = new Map<string, { title: string; coverArtUrl?: string }>();
$('.s-result-item, .productListItem').each((_index, element) => {
if (seriesMap.size >= MAX_SERIES_RESULTS) return false;
const $el = $(element);
// Find the span containing a series label (e.g. "Series:")
const seriesSelector = buildContainsSelector('span', seriesLabels);
const seriesContainer = $el.find(seriesSelector).first();
if (seriesContainer.length === 0) return;
// Look for series link within or near the series label container
// The series link is a child or sibling: <a href="/series/Name/B006K1QER6">
const parentEl = seriesContainer.parent();
const seriesLink = parentEl.find('a[href*="/series/"]').first();
if (seriesLink.length === 0) return;
const href = seriesLink.attr('href') || '';
const asinMatch = href.match(/\/series\/[^/]*\/([A-Z0-9]{10})/);
if (!asinMatch) return;
const asin = asinMatch[1];
if (seriesMap.has(asin)) return;
const title = seriesLink.text().trim();
if (!title) return;
// Use the first book's cover as representative image
const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || undefined;
seriesMap.set(asin, { title, coverArtUrl });
});
if (seriesMap.size === 0) {
logger.info(`No series found for "${query}"`);
return [];
}
logger.info(`Found ${seriesMap.size} unique series, scraping detail pages...`);
// Step 3: Scrape each series page in parallel (with rate limiting)
const entries = Array.from(seriesMap.entries());
const BATCH_SIZE = 5;
const results: SeriesSummary[] = [];
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = entries.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(
batch.map(async ([asin, meta]) => {
try {
const detail = await scrapeSeriesPageSummary(asin);
if (!detail) return null;
return {
...detail,
coverArtUrl: detail.coverArtUrl || meta.coverArtUrl,
audibleUrl: `${baseUrl}/series/${asin}`,
} as SeriesSummary;
} catch (error) {
logger.warn(`Failed to scrape series ${asin}`, {
error: error instanceof Error ? error.message : String(error),
});
// Return a minimal result from search data
return {
asin,
title: meta.title,
bookCount: 0,
tags: [],
coverArtUrl: meta.coverArtUrl,
audibleUrl: `${baseUrl}/series/${asin}`,
} as SeriesSummary;
}
})
);
results.push(...batchResults.filter((r): r is SeriesSummary => r !== null));
// Rate limit between batches
if (i + BATCH_SIZE < entries.length) {
await new Promise(resolve => setTimeout(resolve, randomDelay(1500, 3000)));
}
}
logger.info(`Series search complete: "${query}" -> ${results.length} results`);
return results;
}
// ---------------------------------------------------------------------------
// Series page scraping (summary - for search results)
// ---------------------------------------------------------------------------
/**
* Scrape a series page for summary data (title, book count, rating, tags).
* Used during search to enrich each series result.
*/
async function scrapeSeriesPageSummary(asin: string): Promise<Omit<SeriesSummary, 'audibleUrl'> | null> {
const service = getAudibleService();
try {
const { data: response } = await service.fetch(`/series/${asin}`, {
params: { ipRedirectOverride: 'true' },
});
const $ = cheerio.load(response.data);
return parseSeriesPageSummary($, asin);
} catch (error) {
logger.warn(`Failed to fetch series page ${asin}`, {
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Parse summary fields from a series page's Cheerio document.
*/
function parseSeriesPageSummary(
$: cheerio.CheerioAPI,
asin: string
): Omit<SeriesSummary, 'audibleUrl'> {
// Title - from h1
const title = $('h1').first().text().trim() || '';
// Book count - multiple strategies, most specific first
let bookCount = 0;
// Primary: adbl-metadata[slot="child-count"] in the page header (NOT inside carousels)
// Filter out carousel items by excluding those inside adbl-product-carousel
$('adbl-metadata[slot="child-count"]').each((_i, el) => {
if (bookCount > 0) return false;
const $el = $(el);
// Skip if inside a carousel (those are similar-series counts)
if ($el.closest('adbl-product-carousel').length > 0) return;
const text = $el.text().trim();
const match = text.match(/(\d+)/);
if (match) bookCount = parseInt(match[1]);
});
// Secondary: text matching in spans/headings for "X books/titles/Titel/libros/Bucher"
if (bookCount === 0) {
const countText = $('span:contains("book"), span:contains("title"), span:contains("Titel"), span:contains("libro"), span:contains("Buch"), span:contains("B\u00fccher")')
.text().trim();
const countMatch = countText.match(/(\d+)\s*(books?|titles?|Titel|libros?|B(?:uch|\u00fccher))/i);
if (countMatch) {
bookCount = parseInt(countMatch[1]);
}
}
// Fallback: count product items on the page
if (bookCount === 0) {
bookCount = $('.productListItem, .bc-list-item[data-asin]').length;
}
// Rating
const { rating, ratingCount } = parseSeriesRating($);
// Tags/genres: primary from adbl-chip web components, fallback to legacy links
const tags: string[] = [];
const addTag = (text: string) => {
const tag = text.trim();
if (tag && tag.length >= 2 && tag.length <= 50 && !tags.includes(tag)) {
tags.push(tag);
}
};
// Primary: adbl-chip.related-tag elements (modern Audible layout)
$('adbl-chip.related-tag').each((_i, el) => {
addTag($(el).text());
});
// Fallback: legacy category and tag links
if (tags.length === 0) {
$('a[href*="/cat/"], a[href*="/tag/"]').each((_i, el) => {
addTag($(el).text());
});
}
// Cover art from first book image
const coverArtUrl = $('.productListItem img, .bc-list-item img').first()
.attr('src')?.replace(/\._.*_\./, '._SL500_.') || undefined;
return { asin, title, bookCount, rating, ratingCount, tags: tags.slice(0, 5), coverArtUrl };
}
// ---------------------------------------------------------------------------
// Series page scraping (full detail)
// ---------------------------------------------------------------------------
/**
* Scrape a series page for full detail data including books and similar series.
* Used by the detail API endpoint.
*/
export async function scrapeSeriesPage(asin: string): Promise<SeriesDetail | null> {
const service = getAudibleService();
const region = service.getRegion();
const baseUrl = service.getBaseUrl();
const langConfig = getLanguageForRegion(region);
logger.info(`Scraping series detail page: ${asin}`);
try {
const { data: response } = await service.fetch(`/series/${asin}`, {
params: { ipRedirectOverride: 'true', pageSize: AUDIBLE_PAGE_SIZE },
});
const $ = cheerio.load(response.data);
// Parse summary fields
const summary = parseSeriesPageSummary($, asin);
// Description
const description = $('.bc-expander-content').first().text().trim() ||
$('[class*="productPublisherSummary"]').first().text().trim() ||
undefined;
// Parse all books from the series page
const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes);
// Use actual book count if we got more from scraping
const bookCount = Math.max(summary.bookCount, books.length);
// Parse similar series ("Listeners also enjoyed" or similar section)
const similarSeries = parseSimilarSeries($);
logger.info(`Series detail complete: "${summary.title}" (${books.length} books, ${similarSeries.length} similar)`);
return {
asin,
title: summary.title,
bookCount,
rating: summary.rating,
ratingCount: summary.ratingCount,
description,
tags: summary.tags,
books,
similarSeries,
audibleUrl: `${baseUrl}/series/${asin}`,
};
} catch (error) {
logger.error(`Failed to scrape series detail ${asin}`, {
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Extract rating and rating count from a series page.
*
* Real HTML uses:
* <div aria-label="4.5 out of 5 stars" class="bc-review-stars ...">
* <span class="series-rating bc-color-secondary">8,704 ratings</span>
*/
function parseSeriesRating($: cheerio.CheerioAPI): { rating?: number; ratingCount?: number } {
let rating: number | undefined;
let ratingCount: number | undefined;
// Primary: aria-label on div.bc-review-stars (e.g. "4.5 out of 5 stars")
const starsDiv = $('div.bc-review-stars');
let ariaLabel = starsDiv.attr('aria-label') || '';
// Fallback: any element with aria-label containing rating pattern
if (!ariaLabel) {
const fallbackEl = $('[aria-label*="out of"], [aria-label*="von 5"], [aria-label*="de 5"]').first();
ariaLabel = fallbackEl.attr('aria-label') || '';
}
// Extract numeric rating from aria-label (handles "4.5 out of 5", "4,5 von 5", "4,5 de 5")
const ratingMatch = ariaLabel.match(/(\d+[.,]?\d*)\s*(?:out of|von|de)\s*5/i);
if (ratingMatch) {
rating = parseFloat(ratingMatch[1].replace(',', '.'));
}
// Rating count from span.series-rating (e.g. "8,704 ratings")
const seriesRatingSpan = $('span.series-rating').first();
let countText = seriesRatingSpan.text().trim();
// Fallback: look in broader context for rating count text
if (!countText) {
const fallbackContainer = $('[class*="rating"], .ratingsLabel').first();
countText = fallbackContainer.text().trim();
}
const countMatch = countText.match(/([\d,.]+)\s*(?:ratings?|Bewertungen?|calificaciones?)/i);
if (countMatch) {
ratingCount = parseInt(countMatch[1].replace(/[.,]/g, ''));
}
return { rating, ratingCount };
}
/**
* Parse all books from a series page's product list items.
*/
function parseSeriesBooks(
$: cheerio.CheerioAPI,
authorPrefixes: string[],
narratorPrefixes: string[]
): AudibleAudiobook[] {
const books: AudibleAudiobook[] = [];
const seenAsins = new Set<string>();
$('.productListItem, .bc-list-item').each((_index, element) => {
const $el = $(element);
// Extract ASIN
const bookAsin = $el.attr('data-asin') ||
$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);
// Title: h3 a / .bc-heading a hold the real book title;
// h2 on series pages is the position label ("Book 1"), so try it last.
const title = $el.find('h3 a').first().text().trim() ||
$el.find('.bc-heading a').first().text().trim() ||
$el.find('h2 a').first().text().trim() ||
$el.find('h2').first().text().trim() ||
'';
if (!title) return;
// Author
const authorLink = $el.find('a[href*="/author/"]').first();
const authorText = authorLink.text().trim() ||
$el.find('.authorLabel').text().trim() ||
'';
const authorHref = authorLink.attr('href') || '';
const authorAsinMatch = authorHref.match(/\/author\/[^/]+\/([A-Z0-9]{10})/);
// Narrator
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
$el.find('.narratorLabel').text().trim() ||
'';
// Cover art
const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || '';
// Rating
const ratingText = $el.find('.ratingsLabel').text().trim() ||
$el.find('.a-icon-star span').first().text().trim();
const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null;
const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined;
books.push({
asin: bookAsin,
title,
author: stripPrefixes(authorText, authorPrefixes),
authorAsin: authorAsinMatch?.[1] || undefined,
narrator: stripPrefixes(narratorText, narratorPrefixes),
coverArtUrl,
rating,
});
});
return books;
}
/**
* Parse similar series from the "Listeners also enjoyed" carousel.
*
* Real HTML uses web components:
* <adbl-product-carousel id="SeriestoSeries">
* <adbl-product-grid-item>
* <div class="adbl-impression-emitted" data-asin="B0CGS1LPWJ">
* <adbl-metadata slot="title"><a>Hockey Guys</a></adbl-metadata>
* <adbl-metadata slot="child-count">3 titles</adbl-metadata>
* </adbl-product-grid-item>
*/
function parseSimilarSeries($: cheerio.CheerioAPI): SimilarSeries[] {
const similar: SimilarSeries[] = [];
const seenAsins = new Set<string>();
// Scope to the SeriestoSeries carousel to avoid picking up other series links
const carousel = $('adbl-product-carousel#SeriestoSeries');
if (carousel.length === 0) return similar;
carousel.find('adbl-product-grid-item').each((_i, el) => {
if (similar.length >= 15) return false;
const $el = $(el);
// Extract ASIN: prefer data-asin on impression div, fallback to series href
let asin = $el.find('.adbl-impression-emitted, .adbl-asin-impression').first().attr('data-asin') || '';
if (!asin) {
const seriesHref = $el.find('a[href*="/series/"]').first().attr('href') || '';
const hrefMatch = seriesHref.match(/\/series\/[^/]*\/([A-Z0-9]{10})/);
if (hrefMatch) asin = hrefMatch[1];
}
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) return;
if (seenAsins.has(asin)) return;
seenAsins.add(asin);
// Title from metadata slot
const title = $el.find('adbl-metadata[slot="title"] a').first().text().trim() ||
$el.find('adbl-metadata[slot="title"]').first().text().trim() || '';
if (!title || title.length > 200) return;
// Book count from child-count slot (e.g. "3 titles")
const countText = $el.find('adbl-metadata[slot="child-count"]').first().text().trim();
const countMatch = countText.match(/(\d+)/);
const bookCount = countMatch ? parseInt(countMatch[1]) : undefined;
// Cover image from adbl-collection-image
const coverArtUrl = $el.find('adbl-collection-image img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') ||
$el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') ||
undefined;
similar.push({ asin, title, bookCount, coverArtUrl });
});
return similar;
}
+329 -77
View File
@@ -8,14 +8,37 @@ import * as cheerio from 'cheerio';
import { RMABLogger } from '../utils/logger';
import { getConfigService } from '../services/config.service';
import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible';
import {
getLanguageForRegion,
stripPrefixes,
buildContainsSelector,
extractByPatterns,
isAcceptedLanguage,
type LanguageConfig,
} from '../constants/language-config';
import {
pickUserAgent,
getBrowserHeaders,
jitteredBackoff,
AdaptivePacer,
FetchResultMeta,
} from '../utils/scrape-resilience';
// Module-level logger
const logger = RMABLogger.create('Audible');
/**
* Audible supports a pageSize query parameter (default ~20).
* Using 50 significantly reduces the number of HTTP requests needed
* for bulk operations like popular/new-release refreshes and search.
*/
const AUDIBLE_PAGE_SIZE = 50;
export interface AudibleAudiobook {
asin: string;
title: string;
author: string;
authorAsin?: string;
narrator?: string;
description?: string;
coverArtUrl?: string;
@@ -25,6 +48,7 @@ export interface AudibleAudiobook {
genres?: string[];
series?: string;
seriesPart?: string;
seriesAsin?: string;
}
export interface AudibleSearchResult {
@@ -40,11 +64,43 @@ export class AudibleService {
private baseUrl: string = 'https://www.audible.com';
private region: AudibleRegion = 'us';
private initialized: boolean = false;
private sessionUserAgent: string = '';
private pacer: AdaptivePacer = new AdaptivePacer();
constructor() {
// Client will be created lazily on first use
}
/**
* Get the current Audible base URL for the configured region
*/
public getBaseUrl(): string {
return this.baseUrl;
}
/**
* Get the current Audible region code
*/
public getRegion(): AudibleRegion {
return this.region;
}
/**
* Public fetch wrapper for external scraping modules (e.g. audible-series.ts).
* Ensures the service is initialized and delegates to fetchWithRetry.
*/
public async fetch(url: string, config: any = {}): Promise<{ data: any; meta: FetchResultMeta }> {
await this.initialize();
return this.fetchWithRetry(url, config);
}
/**
* Get the language config for the current region
*/
private getLangConfig(): LanguageConfig {
return getLanguageForRegion(this.region);
}
/**
* Force re-initialization (used when region config changes)
*/
@@ -77,21 +133,22 @@ export class AudibleService {
const configService = getConfigService();
this.region = await configService.getAudibleRegion();
this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl;
this.sessionUserAgent = pickUserAgent();
this.pacer.reset();
logger.info(`Initializing Audible service with region: ${this.region} (${this.baseUrl})`);
// Create axios client with region-specific base URL
// Get language config for the region
const langConfig = getLanguageForRegion(this.region);
// Create axios client with region-specific base URL and realistic browser headers
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
},
headers: getBrowserHeaders(this.sessionUserAgent),
params: {
ipRedirectOverride: 'true', // Prevent IP-based region redirects
language: 'english', // Force English locale (prevents IP-based language serving for non-English IPs)
language: langConfig.scraping.audibleLocaleParam, // Force locale (prevents IP-based language serving)
},
});
@@ -101,17 +158,18 @@ export class AudibleService {
// Fallback to default region
this.region = DEFAULT_AUDIBLE_REGION;
this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl;
this.sessionUserAgent = pickUserAgent();
this.pacer.reset();
const fallbackLangConfig = getLanguageForRegion(this.region);
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
},
headers: getBrowserHeaders(this.sessionUserAgent),
params: {
ipRedirectOverride: 'true',
language: 'english',
language: fallbackLangConfig.scraping.audibleLocaleParam,
},
});
this.initialized = true;
@@ -119,24 +177,29 @@ export class AudibleService {
}
/**
* Fetch with retry logic and exponential backoff
* Retries on network errors and rate limiting (503, 429)
* Fetch with retry logic and jittered exponential backoff.
* Returns the axios response plus metadata about retries encountered.
*/
private async fetchWithRetry(
url: string,
config: any = {},
maxRetries: number = 5
): Promise<any> {
): Promise<{ data: any; meta: FetchResultMeta }> {
let lastError: Error | null = null;
let retriesUsed = 0;
let encountered503 = false;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await this.client.get(url, config);
const response = await this.client.get(url, config);
return { data: response, meta: { retriesUsed, encountered503 } };
} catch (error: any) {
lastError = error;
const status = error.response?.status;
const isRetryable = !status || status === 503 || status === 429 || status >= 500;
if (status === 503) encountered503 = true;
// Don't retry on 404, 403, etc.
if (!isRetryable) {
throw error;
@@ -147,8 +210,10 @@ export class AudibleService {
break;
}
// Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s, 8s...)
const backoffMs = Math.pow(2, attempt) * 1000;
retriesUsed++;
// Jittered exponential backoff instead of predictable doubling
const backoffMs = jitteredBackoff(attempt);
logger.info(` Request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`);
await this.delay(backoffMs);
@@ -210,15 +275,18 @@ export class AudibleService {
const audiobooks: AudibleAudiobook[] = [];
let page = 1;
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page
const maxPages = Math.ceil(limit / AUDIBLE_PAGE_SIZE);
this.pacer.reset();
while (audiobooks.length < limit && page <= maxPages) {
try {
logger.info(` Fetching page ${page}/${maxPages}...`);
const response = await this.fetchWithRetry('/adblbestsellers', {
const { data: response, meta } = await this.fetchWithRetry('/adblbestsellers', {
params: {
ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects
pageSize: AUDIBLE_PAGE_SIZE,
...(page > 1 ? { page } : {}),
},
});
@@ -247,6 +315,10 @@ export class AudibleService {
const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
// Extract author ASIN from author link if available
const authorHref = $el.find('a[href*="/author/"]').first().attr('href') || '';
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
const narratorText = $el.find('.narratorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').eq(1).text().trim();
@@ -255,11 +327,14 @@ export class AudibleService {
const ratingText = $el.find('.ratingsLabel').text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
const langConfig = this.getLangConfig();
audiobooks.push({
asin,
title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
narrator: narratorText.replace('Narrated by:', '').trim(),
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
authorAsin: authorAsinMatch?.[1] || undefined,
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
rating,
});
@@ -269,17 +344,17 @@ export class AudibleService {
logger.info(` Found ${foundOnPage} audiobooks on page ${page}`);
// If we got fewer than expected, probably no more pages
if (foundOnPage < 10) {
// If we got significantly fewer than requested, probably no more pages
if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) {
logger.info(` Reached end of available pages`);
break;
}
page++;
// Add delay between pages to respect rate limiting
// Adaptive delay between pages based on retry pressure
if (page <= maxPages && audiobooks.length < limit) {
await this.delay(1500);
await this.delay(this.pacer.reportPageResult(meta));
}
} catch (error) {
logger.error(`Failed to fetch page ${page} of popular audiobooks`, {
@@ -305,15 +380,18 @@ export class AudibleService {
const audiobooks: AudibleAudiobook[] = [];
let page = 1;
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page
const maxPages = Math.ceil(limit / AUDIBLE_PAGE_SIZE);
this.pacer.reset();
while (audiobooks.length < limit && page <= maxPages) {
try {
logger.info(` Fetching page ${page}/${maxPages}...`);
const response = await this.fetchWithRetry('/newreleases', {
const { data: response, meta } = await this.fetchWithRetry('/newreleases', {
params: {
ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects
pageSize: AUDIBLE_PAGE_SIZE,
...(page > 1 ? { page } : {}),
},
});
@@ -342,6 +420,10 @@ export class AudibleService {
const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
// Extract author ASIN from author link if available
const authorHref = $el.find('a[href*="/author/"]').first().attr('href') || '';
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
const narratorText = $el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
@@ -349,11 +431,14 @@ export class AudibleService {
const ratingText = $el.find('.ratingsLabel').text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
const langConfig = this.getLangConfig();
audiobooks.push({
asin,
title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
narrator: narratorText.replace('Narrated by:', '').trim(),
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
authorAsin: authorAsinMatch?.[1] || undefined,
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
rating,
});
@@ -363,17 +448,17 @@ export class AudibleService {
logger.info(` Found ${foundOnPage} audiobooks on page ${page}`);
// If we got fewer than expected, probably no more pages
if (foundOnPage < 10) {
// If we got significantly fewer than requested, probably no more pages
if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) {
logger.info(` Reached end of available pages`);
break;
}
page++;
// Add delay between pages to respect rate limiting
// Adaptive delay between pages based on retry pressure
if (page <= maxPages && audiobooks.length < limit) {
await this.delay(1500);
await this.delay(this.pacer.reportPageResult(meta));
}
} catch (error) {
logger.error(`Failed to fetch page ${page} of new releases`, {
@@ -398,10 +483,11 @@ export class AudibleService {
try {
logger.info(` Searching for "${query}"...`);
const response = await this.fetchWithRetry('/search', {
const { data: response } = await this.fetchWithRetry('/search', {
params: {
ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects
keywords: query,
pageSize: AUDIBLE_PAGE_SIZE,
page,
},
});
@@ -428,19 +514,26 @@ export class AudibleService {
$el.find('.bc-heading a').text().trim();
// Extract author from author link
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
const authorLink = $el.find('a[href*="/author/"]').first();
const authorText = authorLink.text().trim() ||
$el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
// Extract author ASIN from author link href
const authorHref = authorLink.attr('href') || '';
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
// Extract narrator from narrator search link
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
$el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
const langConfig = this.getLangConfig();
// Extract runtime/duration
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
$el.find('span:contains("Length:")').text().trim();
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
const durationMinutes = this.parseRuntime(runtimeText);
// Extract rating
@@ -451,8 +544,9 @@ export class AudibleService {
audiobooks.push({
asin,
title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
narrator: narratorText.replace('Narrated by:', '').trim(),
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
authorAsin: authorAsinMatch?.[1] || undefined,
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes,
rating,
@@ -470,7 +564,7 @@ export class AudibleService {
results: audiobooks,
totalResults,
page,
hasMore: audiobooks.length > 0 && totalResults > page * 20,
hasMore: audiobooks.length > 0 && totalResults > page * AUDIBLE_PAGE_SIZE,
};
} catch (error) {
logger.error('Search failed', { error: error instanceof Error ? error.message : String(error) });
@@ -484,6 +578,131 @@ 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 matching language for region ---
const langConfig = this.getLangConfig();
const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() ||
$el.find('.languageLabel').text().trim();
// Extract language value (e.g. "Language: English" -> "English", "Sprache: Deutsch" -> "Deutsch")
const langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i');
const langMatch = langText.match(langLabelPattern);
const language = langMatch?.[1]?.trim();
if (!language || !isAcceptedLanguage(language, langConfig)) 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(buildContainsSelector('span', langConfig.scraping.lengthLabels)).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: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
authorAsin,
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes,
rating,
});
pageResults++;
});
// Check if there are more pages
const resultsText = $('.resultsInfo').text().trim();
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
const hasMore = totalResults > page * AUDIBLE_PAGE_SIZE;
logger.info(`Author books page ${page}: ${pageResults} valid results (${allBooks.length} total, ${totalResults} Audible total)`);
if (!hasMore || pageResults === 0) break;
// Pace between pages
if (page < MAX_PAGES) {
await this.delay(this.pacer.reportPageResult(meta));
}
}
logger.info(`Author books search complete: "${authorName}" → ${allBooks.length} books`);
return allBooks;
} catch (error) {
logger.error(`Author books search failed for "${authorName}"`, {
error: error instanceof Error ? error.message : String(error),
collectedSoFar: allBooks.length,
});
// Return what we collected before the error
return allBooks;
}
}
/**
* Get detailed audiobook information
* Primary: Audnexus API (reliable, structured data)
@@ -537,6 +756,7 @@ export class AudibleService {
asin,
title: data.title || '',
author: data.authors?.map((a: any) => a.name).join(', ') || '',
authorAsin: data.authors?.[0]?.asin || undefined,
narrator: data.narrators?.map((n: any) => n.name).join(', ') || '',
description: data.description || data.summary || '',
coverArtUrl: data.image || '',
@@ -546,6 +766,7 @@ export class AudibleService {
genres: data.genres?.map((g: any) => typeof g === 'string' ? g : g.name).slice(0, 5) || undefined,
series: data.seriesPrimary?.name || undefined,
seriesPart: data.seriesPrimary?.position || undefined,
seriesAsin: data.seriesPrimary?.asin || undefined,
};
// Ensure cover art URL is high quality
@@ -562,7 +783,8 @@ export class AudibleService {
rating: result.rating,
genreCount: result.genres?.length || 0,
series: result.series,
seriesPart: result.seriesPart
seriesPart: result.seriesPart,
seriesAsin: result.seriesAsin
});
return result;
@@ -581,7 +803,7 @@ export class AudibleService {
*/
private async scrapeAudibleDetails(asin: string): Promise<AudibleAudiobook | null> {
try {
const response = await this.fetchWithRetry(`/pd/${asin}`, {
const { data: response } = await this.fetchWithRetry(`/pd/${asin}`, {
params: {
ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects
},
@@ -693,10 +915,20 @@ export class AudibleService {
result.author = [...new Set(authors)].slice(0, 3).join(', ');
}
result.author = result.author.replace(/^By:\s*/i, '').replace(/^Written by:\s*/i, '').trim();
const authorLangConfig = this.getLangConfig();
result.author = stripPrefixes(result.author, authorLangConfig.scraping.authorPrefixes);
logger.info(` Author from HTML: "${result.author}"`);
}
// Author ASIN - extract from the first author link
if (!result.authorAsin) {
const firstAuthorHref = $('a[href*="/author/"]').first().attr('href') || '';
const authorAsinMatch = firstAuthorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
if (authorAsinMatch) {
result.authorAsin = authorAsinMatch[1];
}
}
// Narrator - try multiple approaches (only in product details area)
if (!result.narrator) {
// Look specifically in the product details section
@@ -728,22 +960,16 @@ export class AudibleService {
}
if (result.narrator) {
result.narrator = result.narrator.replace(/^Narrated by:\s*/i, '').trim();
const detailLangConfig = this.getLangConfig();
result.narrator = stripPrefixes(result.narrator, detailLangConfig.scraping.narratorPrefixes);
}
logger.info(` Narrator from HTML: "${result.narrator || ''}"`);
}
// Description - try multiple approaches with strict filtering
if (!result.description) {
const excludePatterns = [
/\$\d+\.\d+/, // Price patterns
/cancel anytime/i,
/free trial/i,
/membership/i,
/subscribe/i,
/offer.*ends/i,
/^\s*by\s+[\w\s,]+$/i, // Just author names
];
const descLangConfig = this.getLangConfig();
const excludePatterns = descLangConfig.scraping.descriptionExcludePatterns;
const isValidDescription = (text: string): boolean => {
if (!text || text.length < 50 || text.length > 5000) return false;
@@ -799,18 +1025,20 @@ export class AudibleService {
// Runtime/Duration - try multiple approaches
if (!result.durationMinutes) {
const rtLangConfig = this.getLangConfig();
// Look for runtime text in various places
const runtimeText =
$('li.runtimeLabel span').text().trim() ||
$('.runtimeLabel').text().trim() ||
$('span:contains("Length:")').parent().text().trim() ||
$('li:contains("Length:")').text().trim() ||
$(buildContainsSelector('span', rtLangConfig.scraping.lengthLabels)).parent().text().trim() ||
$(buildContainsSelector('li', rtLangConfig.scraping.lengthLabels)).text().trim() ||
(() => {
// Look for any text matching duration pattern
let found = '';
$('li, span, div').each((_, elem) => {
const text = $(elem).text().trim();
if (text.match(/\d+\s*(hr|hour|h)\s*\d*\s*(min|minute|m)?/i) && text.length < 100) {
if (text.match(rtLangConfig.scraping.durationDetectionPattern) && text.length < 100) {
found = text;
return false; // break
}
@@ -824,41 +1052,55 @@ export class AudibleService {
// Rating - try multiple approaches
if (!result.rating) {
const ratingLangConfig = this.getLangConfig();
const ratingText =
$('.ratingsLabel').text().trim() ||
$('[class*="rating"]').first().text().trim() ||
$('span:contains("out of 5 stars")').parent().text().trim() ||
$(`span:contains("${ratingLangConfig.scraping.ratingTextSelector}")`).parent().text().trim() ||
(() => {
// Look for rating pattern
// Look for rating pattern using language-specific patterns
let found = '';
$('span, div').each((_, elem) => {
const text = $(elem).text().trim();
if (text.match(/\d+\.?\d*\s*out of\s*5/i) && text.length < 50) {
found = text;
return false;
if (text.length < 50) {
for (const pattern of ratingLangConfig.scraping.ratingPatterns) {
if (pattern.test(text)) {
found = text;
return false;
}
}
}
});
return found;
})();
if (ratingText) {
const ratingMatch = ratingText.match(/(\d+\.?\d*)\s*out of/i);
result.rating = ratingMatch ? parseFloat(ratingMatch[1]) : undefined;
let ratingValue: number | undefined;
for (const pattern of ratingLangConfig.scraping.ratingPatterns) {
const ratingMatch = ratingText.match(pattern);
if (ratingMatch) {
// Handle comma as decimal separator (e.g. "4,5" in German/Spanish)
ratingValue = parseFloat(ratingMatch[1].replace(',', '.'));
break;
}
}
result.rating = ratingValue;
}
logger.info(` Rating from "${ratingText}": ${result.rating}`);
}
// Release date - try multiple selectors
if (!result.releaseDate) {
const rdLangConfig = this.getLangConfig();
const releaseDateText =
$('li:contains("Release date:")').text().trim() ||
$('span:contains("Release date:")').parent().text().trim() ||
$(buildContainsSelector('li', rdLangConfig.scraping.releaseDateLabels)).text().trim() ||
$(buildContainsSelector('span', rdLangConfig.scraping.releaseDateLabels)).parent().text().trim() ||
$('[class*="release"]').text().trim();
const dateMatch = releaseDateText.match(/Release date:\s*(.+)/i) ||
releaseDateText.match(/(\w+ \d{1,2},? \d{4})/);
const dateMatch = extractByPatterns(releaseDateText, rdLangConfig.scraping.releaseDatePatterns) ||
releaseDateText.match(/(\w+ \d{1,2},? \d{4})/)?.[1];
if (dateMatch) {
result.releaseDate = dateMatch[1].trim();
result.releaseDate = dateMatch.trim();
}
logger.info(` Release date from "${releaseDateText}": ${result.releaseDate}`);
}
@@ -895,20 +1137,30 @@ export class AudibleService {
}
/**
* Parse runtime text to minutes
* Parse runtime text to minutes using language-specific patterns
*/
private parseRuntime(runtimeText: string): number | undefined {
if (!runtimeText) return undefined;
const hoursMatch = runtimeText.match(/(\d+)\s*hrs?/i);
const minutesMatch = runtimeText.match(/(\d+)\s*mins?/i);
const langConfig = this.getLangConfig();
let totalMinutes = 0;
if (hoursMatch) {
totalMinutes += parseInt(hoursMatch[1]) * 60;
// Try each hour pattern until one matches
for (const pattern of langConfig.scraping.runtimeHourPatterns) {
const match = runtimeText.match(pattern);
if (match) {
totalMinutes += parseInt(match[1]) * 60;
break;
}
}
if (minutesMatch) {
totalMinutes += parseInt(minutesMatch[1]);
// Try each minute pattern until one matches
for (const pattern of langConfig.scraping.runtimeMinutePatterns) {
const match = runtimeText.match(pattern);
if (match) {
totalMinutes += parseInt(match[1]);
break;
}
}
return totalMinutes > 0 ? totalMinutes : undefined;
+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;
}
}
+385
View File
@@ -0,0 +1,385 @@
/**
* Component: Deluge Integration Service
* Documentation: documentation/phase3/download-clients.md
*/
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import path from 'path';
import * as parseTorrentModule from 'parse-torrent';
import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import {
IDownloadClient, DownloadClientType, ProtocolType,
DownloadInfo, DownloadStatus, AddDownloadOptions, ConnectionTestResult,
} from '../interfaces/download-client.interface';
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
const logger = RMABLogger.create('Deluge');
export class DelugeService implements IDownloadClient {
readonly clientType: DownloadClientType = 'deluge';
readonly protocol: ProtocolType = 'torrent';
private client: AxiosInstance;
private baseUrl: string;
private password: string;
private defaultSavePath: string;
private defaultCategory: string;
private pathMappingConfig: PathMappingConfig;
private sessionCookie: string = '';
private requestId: number = 0;
constructor(
baseUrl: string,
_username: string, // Unused — Deluge uses password-only auth; kept for consistent signature
password: string,
defaultSavePath: string = '/downloads',
defaultCategory: string = 'readmeabook',
disableSSLVerify: boolean = false,
pathMappingConfig?: PathMappingConfig
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.password = password;
this.defaultSavePath = defaultSavePath;
this.defaultCategory = defaultCategory;
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
const httpsAgent = disableSSLVerify && this.baseUrl.startsWith('https')
? new https.Agent({ rejectUnauthorized: false }) : undefined;
if (httpsAgent) logger.info('[Deluge] SSL certificate verification disabled');
this.client = axios.create({ baseURL: this.baseUrl, timeout: 30000, httpsAgent });
}
/** JSON-RPC call with automatic re-authentication on auth failure */
private async rpc(method: string, params: any[] = [], retried = false): Promise<any> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.sessionCookie) headers['Cookie'] = this.sessionCookie;
try {
const reqId = ++this.requestId;
const { data } = await this.client.post('/json', { method, params, id: reqId }, { headers });
// Deluge error.code === 1: "Not authenticated" — re-login then retry
if (data.error?.code === 1 && !retried) {
await this.login();
return this.rpc(method, params, true);
}
// Deluge error.code === 2: "Unknown method" — daemon disconnected, force reconnect
// Only retry for core.* methods — plugin methods (label.*) fail because the plugin
// isn't enabled, not because the daemon is disconnected.
if (data.error?.code === 2 && !retried && method.startsWith('core.')) {
await this.login(true);
return this.rpc(method, params, true);
}
return data;
} catch (error) {
if (!retried) { await this.login(); return this.rpc(method, params, true); }
throw error;
}
}
private async login(forceReconnect: boolean = false): Promise<void> {
const { data, headers } = await this.client.post(
'/json',
{ method: 'auth.login', params: [this.password], id: ++this.requestId },
{ headers: { 'Content-Type': 'application/json' } }
);
if (!data?.result) throw new Error('Failed to authenticate with Deluge — check your password');
const cookies = headers['set-cookie'];
if (cookies?.length) this.sessionCookie = cookies[0].split(';')[0];
logger.info('Successfully authenticated with Deluge');
// Deluge Web UI requires a daemon connection before core.* methods work.
// When forceReconnect is true, skip the web.connected check and force a fresh connection.
await this.ensureDaemonConnected(forceReconnect);
}
/**
* Ensure the Web UI is connected to a deluged daemon host.
* Uses web.connected (returns boolean) as the check daemon.info is NOT a valid
* method through the Deluge Web UI JSON-RPC; only web.* and core.* methods work.
*/
private async ensureDaemonConnected(force: boolean = false): Promise<void> {
if (!force) {
const test = await this.rpc('web.connected', [], true);
if (test.result === true) return;
}
logger.info('Connecting to daemon...');
const hostsData = await this.rpc('web.get_hosts', [], true);
const hosts: any[] = hostsData.result || [];
if (hosts.length === 0) {
throw new Error('Deluge has no daemon hosts configured. Add a host in the Deluge Web UI under Connection Manager.');
}
const hostId = hosts[0][0];
const connectResult = await this.rpc('web.connect', [hostId], true);
if (connectResult.error) {
throw new Error(`Failed to connect to Deluge daemon: ${connectResult.error.message}`);
}
// Verify connection is established
const verify = await this.rpc('web.connected', [], true);
if (verify.result !== true) {
throw new Error('Deluge daemon failed to respond after web.connect. Check that deluged is running.');
}
logger.info('Connected to Deluge daemon');
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
async testConnection(): Promise<ConnectionTestResult> {
try {
await this.login();
return { success: true, message: 'Connected to Deluge' };
} catch (error) {
const msg = error instanceof Error ? error.message : 'Connection failed';
if (axios.isAxiosError(error)) {
const c = error.code;
if (c?.includes('CERT') || c?.includes('SSL')) return { success: false, message: `SSL verification failed (${c}). Enable "Disable SSL Verification".` };
if (c === 'ECONNREFUSED') return { success: false, message: `Connection refused at: ${this.baseUrl}` };
if (c === 'ETIMEDOUT' || c === 'ECONNABORTED') return { success: false, message: `Connection timeout: ${this.baseUrl}` };
if (c === 'ENOTFOUND') return { success: false, message: `Host not found: ${this.baseUrl}` };
if (error.response?.status === 401) return { success: false, message: 'Authentication failed. Check your password.' };
}
logger.error('Connection test failed', { error: msg });
return { success: false, message: msg };
}
}
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
if (!url || typeof url !== 'string' || url.trim() === '') {
throw new Error('Invalid download URL: URL is required and must be a non-empty string');
}
const category = options?.category || this.defaultCategory;
return url.startsWith('magnet:')
? this.addMagnetLink(url, category, options)
: this.addTorrentFile(url, category, options);
}
private async addMagnetLink(magnetUrl: string, category: string, options?: AddDownloadOptions): Promise<string> {
const infoHash = this.extractHashFromMagnet(magnetUrl);
if (!infoHash) throw new Error('Invalid magnet link - could not extract info_hash');
logger.info(`Extracted info_hash from magnet: ${infoHash}`);
const existing = await this.rpc('core.get_torrent_status', [infoHash, ['name']]);
if (existing.result && Object.keys(existing.result).length > 0) {
logger.info(`Torrent ${infoHash} already exists (duplicate)`);
return infoHash;
}
const opts = this.buildTorrentOptions(options?.paused);
const data = await this.rpc('core.add_torrent_magnet', [magnetUrl, opts]);
if (!data.result) throw new Error(`Deluge rejected magnet link: ${data.error?.message || 'unknown error'}`);
await this.postAddSetup(data.result, category);
logger.info(`Successfully added magnet link: ${infoHash}`);
return infoHash;
}
private async addTorrentFile(torrentUrl: string, category: string, options?: AddDownloadOptions): Promise<string> {
logger.info(`Downloading .torrent file from: ${torrentUrl}`);
let torrentResponse;
try {
torrentResponse = await axios.get(torrentUrl, {
responseType: 'arraybuffer', maxRedirects: 0,
validateStatus: (s) => s >= 200 && s < 300, timeout: 30000,
});
if (torrentResponse.data.length > 0) {
const magnetMatch = torrentResponse.data.toString().match(/^magnet:\?[^\s]+$/);
if (magnetMatch) return this.addMagnetLink(magnetMatch[0], category, options);
}
} catch (error) {
if (!axios.isAxiosError(error) || !error.response) throw error;
const status = error.response.status;
if (status >= 300 && status < 400) {
const loc = error.response.headers['location'];
if (loc?.startsWith('magnet:')) return this.addMagnetLink(loc, category, options);
if (loc?.startsWith('http://') || loc?.startsWith('https://')) {
try { torrentResponse = await axios.get(loc, { responseType: 'arraybuffer', timeout: 30000, maxRedirects: 5 }); }
catch { throw new Error('Failed to download torrent file after redirect'); }
} else { throw new Error(`Invalid redirect location: ${loc}`); }
} else { throw new Error(`Failed to download torrent: HTTP ${status}`); }
}
const torrentBuffer = Buffer.from(torrentResponse.data);
let parsed: any;
try { parsed = await parseTorrent(torrentBuffer); }
catch { throw new Error('Invalid .torrent file - failed to parse'); }
const infoHash = parsed.infoHash;
if (!infoHash) throw new Error('Failed to extract info_hash from .torrent file');
logger.info(`Extracted info_hash: ${infoHash}`);
const existing = await this.rpc('core.get_torrent_status', [infoHash, ['name']]);
if (existing.result && Object.keys(existing.result).length > 0) {
logger.info(`Torrent ${infoHash} already exists (duplicate)`);
return infoHash;
}
const filename = parsed.name ? `${parsed.name}.torrent` : 'torrent.torrent';
const opts = this.buildTorrentOptions(options?.paused);
const data = await this.rpc('core.add_torrent_file', [filename, torrentBuffer.toString('base64'), opts]);
if (!data.result) throw new Error(`Deluge rejected .torrent file: ${data.error?.message || 'unknown error'}`);
await this.postAddSetup(infoHash, category);
logger.info(`Successfully added torrent: ${infoHash}`);
return infoHash;
}
async getDownload(id: string): Promise<DownloadInfo | null> {
const fields = ['name', 'total_size', 'total_done', 'progress', 'state',
'download_payload_rate', 'eta', 'label', 'save_path',
'time_added', 'is_finished', 'seeding_time', 'ratio', 'message'];
for (let attempt = 0; attempt <= 3; attempt++) {
const { result } = await this.rpc('core.get_torrent_status', [id, fields]);
if (result && Object.keys(result).length > 0) return this.mapToDownloadInfo(id, result);
if (attempt === 3) return null;
const delay = 500 * Math.pow(2, attempt);
logger.warn(`Torrent ${id} not found, retrying in ${delay}ms (${attempt + 1}/3)`);
await new Promise(r => setTimeout(r, delay));
}
return null;
}
async pauseDownload(id: string): Promise<void> {
await this.rpc('core.pause_torrent', [[id]]);
logger.info(`Paused torrent: ${id}`);
}
async resumeDownload(id: string): Promise<void> {
await this.rpc('core.resume_torrent', [[id]]);
logger.info(`Resumed torrent: ${id}`);
}
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
await this.rpc('core.remove_torrent', [id, deleteFiles]);
logger.info(`Deleted torrent: ${id}`);
}
async postProcess(_id: string): Promise<void> {} // No-op: seeding cleanup scheduler manages lifecycle
async getCategories(): Promise<string[]> {
try { const { result } = await this.rpc('label.get_labels'); return Array.isArray(result) ? result : []; }
catch { return []; }
}
async setCategory(id: string, category: string): Promise<void> {
await this.applyLabel(id, category);
logger.info(`Set label for torrent ${id}: ${category}`);
}
// =========================================================================
// Internal Helpers
// =========================================================================
private buildTorrentOptions(paused?: boolean): Record<string, any> {
const remoteSavePath = PathMapper.reverseTransform(this.defaultSavePath, this.pathMappingConfig);
const opts: Record<string, any> = { download_location: remoteSavePath, move_completed: false, move_completed_path: '' };
if (paused) opts.add_paused = true;
return opts;
}
private async postAddSetup(hash: string, category: string): Promise<void> {
await this.disableSeedLimits(hash);
await this.applyLabel(hash, category);
}
private async applyLabel(hash: string, label: string): Promise<void> {
try {
try { await this.rpc('label.add', [label]); } catch { /* may already exist */ }
await this.rpc('label.set_torrent', [hash, label]);
} catch (error) {
logger.warn(`Failed to apply label "${label}" to ${hash}: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async disableSeedLimits(hash: string): Promise<void> {
try {
await this.rpc('core.set_torrent_options', [[hash], { stop_at_ratio: false, seed_time_limit: -1 }]);
} catch (error) {
logger.warn(`Failed to disable seed limits for ${hash}: ${error instanceof Error ? error.message : String(error)}`);
}
}
private mapToDownloadInfo(hash: string, t: Record<string, any>): DownloadInfo {
return {
id: hash, name: t.name || '', size: t.total_size || 0,
bytesDownloaded: t.total_done || 0, progress: (t.progress || 0) / 100,
status: this.mapStatus(t.state), downloadSpeed: t.download_payload_rate || 0,
eta: t.eta > 0 ? t.eta : 0, category: t.label || '',
downloadPath: t.save_path ? path.join(t.save_path, t.name || '') : undefined,
completedAt: t.is_finished && t.time_added ? new Date(t.time_added * 1000) : undefined,
errorMessage: t.message || undefined, seedingTime: t.seeding_time,
ratio: t.ratio >= 0 ? t.ratio : undefined,
};
}
private mapStatus(state: string): DownloadStatus {
const map: Record<string, DownloadStatus> = {
'Downloading': 'downloading', 'Seeding': 'seeding', 'Paused': 'paused',
'Checking': 'checking', 'Queued': 'queued', 'Error': 'failed', 'Moving': 'downloading',
};
return map[state] || 'downloading';
}
private extractHashFromMagnet(magnetUrl: string): string | null {
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
return match ? match[1].toLowerCase() : null;
}
}
// Singleton factory (matches Transmission/qBittorrent pattern)
let delugeServiceInstance: DelugeService | null = null;
let configLoaded = false;
export async function getDelugeService(): Promise<DelugeService> {
if (delugeServiceInstance && configLoaded) return delugeServiceInstance;
try {
const { getConfigService } = await import('../services/config.service');
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
const configService = await getConfigService();
const manager = getDownloadClientManager(configService);
const clientConfig = await manager.getClientForProtocol('torrent');
if (!clientConfig) throw new Error('Deluge is not configured. Please configure a Deluge client in admin settings.');
if (clientConfig.type !== 'deluge') throw new Error(`Expected Deluge client but found ${clientConfig.type}`);
if (!clientConfig.url) throw new Error('Deluge is not fully configured. Check your configuration in admin settings.');
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath ? require('path').join(baseDir, clientConfig.customPath) : baseDir;
delugeServiceInstance = new DelugeService(
clientConfig.url, clientConfig.username || '', clientConfig.password || '',
downloadDir, clientConfig.category || 'readmeabook', clientConfig.disableSSLVerify,
{ enabled: clientConfig.remotePathMappingEnabled || false, remotePath: clientConfig.remotePath || '', localPath: clientConfig.localPath || '' }
);
const result = await delugeServiceInstance.testConnection();
if (!result.success) throw new Error(result.message || 'Deluge connection test failed.');
logger.info('[Deluge] Connection test successful');
configLoaded = true;
return delugeServiceInstance;
} catch (error) {
logger.error('[Deluge] Failed to initialize service', { error: error instanceof Error ? error.message : String(error) });
delugeServiceInstance = null;
configLoaded = false;
throw error;
}
}
export function invalidateDelugeService(): void {
delugeServiceInstance = null;
configLoaded = false;
logger.info('[Deluge] Service singleton invalidated');
}
+14 -2
View File
@@ -87,7 +87,7 @@ export class ProwlarrService {
headers: {
'X-Api-Key': this.apiKey,
},
timeout: 30000, // 30 seconds
timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download
paramsSerializer: {
serialize: (params) => {
// Custom serializer to handle arrays correctly for Prowlarr API
@@ -314,7 +314,7 @@ export class ProwlarrService {
limit: 100,
extended: 1,
},
timeout: 30000,
timeout: 60000,
responseType: 'text', // Get XML as text
});
@@ -640,6 +640,18 @@ export class ProwlarrService {
// Singleton instance
let prowlarrService: ProwlarrService | null = null;
/**
* Invalidate the cached ProwlarrService singleton.
* Must be called after updating Prowlarr URL or API key so that
* background jobs (search, RSS monitor, etc.) pick up the new credentials.
*/
export function invalidateProwlarrService(): void {
if (prowlarrService) {
logger.info('Prowlarr service singleton invalidated — will reconnect with new credentials on next use');
}
prowlarrService = null;
}
export async function getProwlarrService(): Promise<ProwlarrService> {
if (!prowlarrService) {
// Get configuration from database
+38 -28
View File
@@ -27,12 +27,10 @@ const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
const logger = RMABLogger.create('QBittorrent');
export interface AddTorrentOptions {
savePath?: string;
category?: string;
tags?: string[];
paused?: boolean;
skipChecking?: boolean;
sequentialDownload?: boolean;
}
export interface TorrentInfo {
@@ -276,7 +274,7 @@ export class QBittorrentService implements IDownloadClient {
/**
* Add magnet link - hash is extractable from URI (deterministic)
*/
private async addMagnetLink(
protected async addMagnetLink(
magnetUrl: string,
category: string,
options?: AddTorrentOptions
@@ -299,20 +297,18 @@ export class QBittorrentService implements IDownloadClient {
// Torrent doesn't exist, continue with adding
}
// Apply reverse path mapping (local → remote) to savepath
const localSavePath = options?.savePath || this.defaultSavePath;
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
// Upload via 'urls' parameter
// Set ratioLimit and seedingTimeLimit to -1 (unlimited) so qBittorrent's
// Note: savepath is intentionally omitted — the category (managed by ensureCategory)
// defines the save path. Omitting per-torrent savepath allows qBittorrent to use
// Automatic Torrent Management, respecting the user's "incomplete downloads" temp folder.
// sequentialDownload is also omitted — left to qBittorrent's own settings.
// ratioLimit and seedingTimeLimit are set to -1 (unlimited) so qBittorrent's
// global seeding rules don't remove the torrent prematurely.
// RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor.
const form = new URLSearchParams({
urls: magnetUrl,
savepath: remoteSavePath,
category,
paused: options?.paused ? 'true' : 'false',
sequentialDownload: (options?.sequentialDownload !== false).toString(),
ratioLimit: '-1',
seedingTimeLimit: '-1',
});
@@ -341,7 +337,7 @@ export class QBittorrentService implements IDownloadClient {
/**
* Add .torrent file - download, parse, extract hash, upload content (deterministic)
*/
private async addTorrentFile(
protected async addTorrentFile(
torrentUrl: string,
category: string,
options?: AddTorrentOptions
@@ -446,11 +442,13 @@ export class QBittorrentService implements IDownloadClient {
// Torrent doesn't exist, continue with adding
}
// Apply reverse path mapping (local → remote) to savepath
const localSavePath = options?.savePath || this.defaultSavePath;
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
// Upload .torrent file content via multipart/form-data
// Note: savepath is intentionally omitted — the category (managed by ensureCategory)
// defines the save path. Omitting per-torrent savepath allows qBittorrent to use
// Automatic Torrent Management, respecting the user's "incomplete downloads" temp folder.
// sequentialDownload is also omitted — left to qBittorrent's own settings.
// ratioLimit and seedingTimeLimit override qBittorrent's global seeding rules —
// RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor.
const formData = new FormData();
const filename = parsedTorrent.name ? `${parsedTorrent.name}.torrent` : 'torrent.torrent';
@@ -458,11 +456,8 @@ export class QBittorrentService implements IDownloadClient {
filename,
contentType: 'application/x-bittorrent',
});
formData.append('savepath', remoteSavePath);
formData.append('category', category);
formData.append('paused', options?.paused ? 'true' : 'false');
formData.append('sequentialDownload', (options?.sequentialDownload !== false).toString());
// Override qBittorrent's global seeding rules — RMAB manages torrent lifecycle
formData.append('ratioLimit', '-1');
formData.append('seedingTimeLimit', '-1');
@@ -494,7 +489,7 @@ export class QBittorrentService implements IDownloadClient {
* Checks existing categories first, then creates or updates as needed
* Applies reverse path mapping (local remote) for remote seedbox scenarios
*/
private async ensureCategory(category: string): Promise<void> {
protected async ensureCategory(category: string): Promise<void> {
if (!this.cookie) {
await this.login();
}
@@ -591,7 +586,19 @@ export class QBittorrentService implements IDownloadClient {
throw new Error(`Torrent ${hash} not found`);
}
return torrents[0];
// Find the torrent with the exact matching hash.
// Some qBittorrent-compatible clients (e.g. RDTClient) ignore the hashes
// filter and return all torrents, so we must verify the hash ourselves.
const normalizedHash = hash.toLowerCase();
const match = torrents.find(
(t: TorrentInfo) => t.hash?.toLowerCase() === normalizedHash
);
if (!match) {
throw new Error(`Torrent ${hash} not found`);
}
return match;
} catch (error) {
// Don't log error here - caller handles it (e.g., duplicate checking)
throw error;
@@ -1013,7 +1020,6 @@ export class QBittorrentService implements IDownloadClient {
category: options?.category,
paused: options?.paused,
tags: ['audiobook'],
sequentialDownload: true,
});
}
@@ -1081,7 +1087,7 @@ export class QBittorrentService implements IDownloadClient {
/**
* Map a TorrentInfo object to the unified DownloadInfo format.
*/
private mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo {
protected mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo {
return {
id: torrent.hash,
name: torrent.name,
@@ -1109,7 +1115,8 @@ export class QBittorrentService implements IDownloadClient {
stalledDL: 'downloading',
stalledUP: 'seeding',
pausedDL: 'paused',
pausedUP: 'paused',
// pausedUP = download finished, paused on upload side (e.g. ratio met)
pausedUP: 'seeding',
queuedDL: 'queued',
queuedUP: 'seeding',
checkingDL: 'checking',
@@ -1125,7 +1132,8 @@ export class QBittorrentService implements IDownloadClient {
forcedMetaDL: 'downloading',
// qBittorrent v5.0+ renamed paused → stopped
stoppedDL: 'paused',
stoppedUP: 'paused',
// stoppedUP = download finished, stopped on upload side (qBittorrent v5.0+)
stoppedUP: 'seeding',
// Other states
checkingResumeData: 'checking',
moving: 'downloading',
@@ -1162,11 +1170,12 @@ export class QBittorrentService implements IDownloadClient {
stalledDL: 'downloading',
stalledUP: 'completed',
pausedDL: 'paused',
pausedUP: 'paused',
// pausedUP = download finished, paused on upload side (e.g. ratio met)
pausedUP: 'completed',
queuedDL: 'queued',
queuedUP: 'completed',
checkingDL: 'checking',
checkingUP: 'checking',
checkingUP: 'completed',
error: 'failed',
missingFiles: 'failed',
allocating: 'downloading',
@@ -1178,7 +1187,8 @@ export class QBittorrentService implements IDownloadClient {
forcedMetaDL: 'downloading',
// qBittorrent v5.0+ renamed paused → stopped
stoppedDL: 'paused',
stoppedUP: 'paused',
// stoppedUP = download finished, stopped on upload side (qBittorrent v5.0+)
stoppedUP: 'completed',
// Other states
checkingResumeData: 'checking',
moving: 'downloading',
@@ -1190,7 +1200,7 @@ export class QBittorrentService implements IDownloadClient {
/**
* Extract info_hash from magnet link
*/
private extractHashFromMagnet(magnetUrl: string): string | null {
protected extractHashFromMagnet(magnetUrl: string): string | null {
// Extract hash from magnet:?xt=urn:btih:HASH
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
if (match) {
@@ -11,7 +11,7 @@
// =========================================================================
/** Supported download client types — single source of truth */
export const SUPPORTED_CLIENT_TYPES = ['qbittorrent', 'sabnzbd', 'nzbget', 'transmission'] as const;
export const SUPPORTED_CLIENT_TYPES = ['qbittorrent', 'sabnzbd', 'nzbget', 'transmission', 'deluge'] as const;
/** Identifies the specific download client software */
export type DownloadClientType = (typeof SUPPORTED_CLIENT_TYPES)[number];
@@ -22,6 +22,7 @@ export const CLIENT_DISPLAY_NAMES: Record<DownloadClientType, string> = {
sabnzbd: 'SABnzbd',
nzbget: 'NZBGet',
transmission: 'Transmission',
deluge: 'Deluge',
};
/** Get display name for a client type, falling back to the raw type */
@@ -38,6 +39,7 @@ export const CLIENT_PROTOCOL_MAP: Record<DownloadClientType, ProtocolType> = {
sabnzbd: 'usenet',
nzbget: 'usenet',
transmission: 'torrent',
deluge: 'torrent',
};
/** Unified download status across all clients */
@@ -44,6 +44,12 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
// Fetch popular and new releases - 200 items each
const popular = await audibleService.getPopularAudiobooks(200);
// Batch cooldown between popular and new releases to reduce detection
const batchCooldownMs = 15000 + Math.floor(Math.random() * 15000);
logger.info(`Batch cooldown: waiting ${Math.round(batchCooldownMs / 1000)}s before fetching new releases...`);
await new Promise(resolve => setTimeout(resolve, batchCooldownMs));
const newReleases = await audibleService.getNewReleases(200);
logger.info(`Fetched ${popular.length} popular, ${newReleases.length} new releases from Audible`);
@@ -316,6 +316,7 @@ async function downloadFileWithProgress(
let bytesDownloaded = 0;
let lastLogTime = Date.now();
let lastDbUpdateTime = Date.now();
let dbUpdatePending = false; // Guard against stacking unresolved DB updates
response.data.on('data', (chunk: Buffer) => {
bytesDownloaded += chunk.length;
@@ -332,18 +333,18 @@ async function downloadFileWithProgress(
logger.info(`Download progress: ${percent}% (${(bytesDownloaded / (1024 * 1024)).toFixed(1)} MB, ${speedMBps.toFixed(2)} MB/s)`);
lastLogTime = now;
// Update database with progress (non-blocking)
if (now - lastDbUpdateTime >= PROGRESS_UPDATE_INTERVAL_MS) {
// Update database with progress (non-blocking, at most 1 in-flight at a time)
if (now - lastDbUpdateTime >= PROGRESS_UPDATE_INTERVAL_MS && !dbUpdatePending) {
lastDbUpdateTime = now;
dbUpdatePending = true;
// Non-blocking update - fire and forget
prisma.request.update({
where: { id: tracking.requestId },
data: {
progress: Math.min(percent, 99), // Cap at 99% until fully complete
updatedAt: new Date(),
},
}).catch(() => {}); // Ignore errors during progress update
}).catch(() => {}).finally(() => { dbUpdatePending = false; });
}
}
});
@@ -16,8 +16,23 @@ import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-
* Checks download progress from download client and updates request status
* Re-schedules itself if download is still in progress
*/
/** Base polling interval in seconds */
const BASE_POLL_INTERVAL = 10;
/** Maximum polling interval in seconds (5 minutes) */
const MAX_POLL_INTERVAL = 300;
/**
* Compute next poll delay with exponential backoff for stalled downloads.
* Active downloads poll every 10s; stalled downloads back off up to 5 min.
*/
function getBackoffDelay(stallCount: number): number {
if (stallCount <= 0) return BASE_POLL_INTERVAL;
return Math.min(BASE_POLL_INTERVAL * Math.pow(2, stallCount), MAX_POLL_INTERVAL);
}
export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> {
const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId } = payload;
const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId,
lastProgress: prevProgress, stallCount: prevStallCount } = payload;
const logger = RMABLogger.forJob(jobId, 'MonitorDownload');
@@ -199,22 +214,35 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
progress: progressPercent,
};
} else {
// Still downloading - schedule another check in 10 seconds
// Still downloading — compute adaptive poll interval
const isStalled = info.downloadSpeed === 0
|| progressPercent === (prevProgress ?? -1)
|| progressState === 'paused'
|| progressState === 'queued'
|| progressState === 'checking';
const stallCount = isStalled ? (prevStallCount ?? 0) + 1 : 0;
const delay = getBackoffDelay(stallCount);
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId,
downloadHistoryId,
downloadClientId,
downloadClient,
10 // Delay 10 seconds between checks
delay,
progressPercent,
stallCount
);
// Only log every 5% progress to reduce log spam
const shouldLog = progressPercent % 5 === 0 || progressPercent < 5;
// Only log every 5% progress to reduce log spam, but always log stall transitions
const shouldLog = progressPercent % 5 === 0 || progressPercent < 5
|| (stallCount === 1) || (stallCount > 0 && stallCount % 10 === 0);
if (shouldLog) {
logger.info(`Request ${requestId}: ${progressPercent}% complete (${progressState})`, {
speed: info.downloadSpeed,
eta: info.eta,
...(stallCount > 0 && { stallCount, nextPollSec: delay }),
});
}
@@ -227,6 +255,8 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
speed: info.downloadSpeed,
eta: info.eta,
state: progressState,
stallCount,
nextPollSec: delay,
};
}
} catch (error) {
@@ -124,6 +124,9 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
break;
}
}
// Spread DB operations over time to avoid connection pool exhaustion
await new Promise(resolve => setTimeout(resolve, 100));
}
logger.info(`RSS monitoring complete: ${matched} matches found and queued for processing`);
@@ -622,7 +622,9 @@ async function processEbookOrganization(
requestId,
book.title,
book.author,
request.user.plexUsername || 'Unknown User'
request.user.plexUsername || 'Unknown User',
undefined,
'ebook'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
@@ -862,8 +864,10 @@ async function cleanupDownloadAfterOrganize(
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
});
// Check if this is a non-torrent indexer with cleanup enabled
if (!indexer || indexer.protocol?.toLowerCase() === 'torrent' || !indexer.removeAfterProcessing) {
// Check if this is a non-torrent indexer with cleanup enabled.
const isTorrentProtocol = indexer?.protocol?.toLowerCase() === 'torrent';
if (!indexer || isTorrentProtocol || !indexer.removeAfterProcessing) {
return;
}
@@ -325,7 +325,9 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
request.id,
audiobook.title,
audiobook.author,
request.user.plexUsername || 'Unknown User'
request.user.plexUsername || 'Unknown User',
undefined,
'audiobook'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
@@ -157,6 +157,9 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
);
triggered++;
logger.info(`Triggered organize job for ${request.type || 'audiobook'} request ${request.id}: ${request.audiobook.title}`);
// Spread DB operations over time to avoid connection pool exhaustion
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
logger.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++;
@@ -44,6 +44,7 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
}
// Trigger appropriate search job for each request based on type
// Throttle: 100ms delay between jobs to avoid connection pool burst
const jobQueue = getJobQueueService();
let triggered = 0;
@@ -73,6 +74,9 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
} catch (error) {
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Spread DB operations over time to avoid connection pool exhaustion
await new Promise(resolve => setTimeout(resolve, 100));
}
logger.info(`Triggered ${triggered}/${requests.length} search jobs`);
+3 -1
View File
@@ -514,7 +514,9 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
request.id,
audiobook.title,
audiobook.author,
request.user.plexUsername || 'Unknown User'
request.user.plexUsername || 'Unknown User',
undefined,
'audiobook'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
+15 -2
View File
@@ -14,6 +14,8 @@ import { RMABLogger } from '../utils/logger';
import { getProwlarrService } from '../integrations/prowlarr.service';
import { rankEbookTorrents, RankedEbookTorrent } from '../utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping';
import { getLanguageForRegion } from '../constants/language-config';
import type { AudibleRegion } from '../types/audible';
// Import ebook scraper functions for Anna's Archive
import {
@@ -151,6 +153,11 @@ async function searchAnnasArchive(
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
// Get language code from Audible region config
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
const languageCode = langConfig.annasArchiveLang;
if (flaresolverrUrl) {
logger.info(`Using FlareSolverr at ${flaresolverrUrl}`);
}
@@ -161,7 +168,7 @@ async function searchAnnasArchive(
// Try ASIN search first (exact match - best)
if (audiobook.asin) {
logger.info(`Searching Anna's Archive by ASIN: ${audiobook.asin} (format: ${preferredFormat})...`);
md5 = await searchByAsin(audiobook.asin, preferredFormat, baseUrl, logger, flaresolverrUrl);
md5 = await searchByAsin(audiobook.asin, preferredFormat, baseUrl, logger, flaresolverrUrl, languageCode);
if (md5) {
logger.info(`Found via ASIN: ${md5}`);
@@ -174,7 +181,7 @@ async function searchAnnasArchive(
// Fallback to title + author search
if (!md5) {
logger.info(`Searching Anna's Archive by title + author: "${audiobook.title}" by ${audiobook.author}...`);
md5 = await searchByTitle(audiobook.title, audiobook.author, preferredFormat, baseUrl, logger, flaresolverrUrl);
md5 = await searchByTitle(audiobook.title, audiobook.author, preferredFormat, baseUrl, logger, flaresolverrUrl, languageCode);
if (md5) {
logger.info(`Found via title search: ${md5}`);
@@ -301,6 +308,10 @@ async function searchIndexers(
logger.info(`Will filter ${aboveThreshold.length} results > 20 MB (too large for ebooks)`);
}
// Get language-specific stop words for ranking
const ebookRegion = await configService.getAudibleRegion() as AudibleRegion;
const ebookLangConfig = getLanguageForRegion(ebookRegion);
// Rank results with ebook-specific scoring
// This filters out > 20MB and uses inverted size scoring
const rankedResults = rankEbookTorrents(allResults, {
@@ -311,6 +322,8 @@ async function searchIndexers(
indexerPriorities,
flagConfigs,
requireAuthor: true, // Automatic mode - prevent wrong authors
stopWords: ebookLangConfig.stopWords,
characterReplacements: ebookLangConfig.characterReplacements,
});
// Log filter results
@@ -9,6 +9,8 @@ import { getProwlarrService } from '../integrations/prowlarr.service';
import { getRankingAlgorithm } from '../utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping';
import { RMABLogger } from '../utils/logger';
import { getLanguageForRegion } from '../constants/language-config';
import type { AudibleRegion } from '../types/audible';
/**
* Process search indexers job
@@ -146,8 +148,10 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
}
// Get ranking algorithm
// Get ranking algorithm and language-specific stop words
const ranker = getRankingAlgorithm();
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
// Rank results with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally
@@ -159,7 +163,9 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
}, {
indexerPriorities,
flagConfigs,
requireAuthor: true // Automatic mode - prevent wrong authors
requireAuthor: true, // Automatic mode - prevent wrong authors
stopWords: langConfig.stopWords,
characterReplacements: langConfig.characterReplacements,
});
// Log filter results
@@ -8,38 +8,33 @@
import { getNotificationService } from '../services/notification';
import { RMABLogger } from '../utils/logger';
import type { SendNotificationPayload } from '../services/job-queue.service';
export interface SendNotificationPayload {
jobId?: string;
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error';
requestId: string;
title: string;
author: string;
userName: string;
message?: string;
timestamp: Date;
}
// Re-export for consumers that import from this module
export type { SendNotificationPayload } from '../services/job-queue.service';
/**
* Process send notification job
* Calls NotificationService to send notifications to all enabled backends
*/
export async function processSendNotification(payload: SendNotificationPayload): Promise<void> {
const { event, requestId, title, author, userName, message, jobId } = payload;
const { event, requestId, issueId, title, author, userName, message, requestType, jobId } = payload;
const logger = RMABLogger.forJob(jobId, 'SendNotification');
logger.info(`Processing notification: ${event}`, { requestId });
logger.info(`Processing notification: ${event}`, { requestId: requestId || issueId });
try {
const notificationService = getNotificationService();
await notificationService.sendNotification({
event,
requestId,
issueId,
title,
author,
userName,
message,
requestType,
timestamp: new Date(),
});
@@ -0,0 +1,42 @@
/**
* Component: Sync Goodreads Shelves Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Dedicated processor for syncing Goodreads shelf RSS feeds.
* Resolves books to Audible ASINs and creates requests.
*/
import { RMABLogger } from '../utils/logger';
export interface SyncGoodreadsShelvesPayload {
jobId?: string;
scheduledJobId?: string;
/** If set, only process this specific shelf (used for immediate sync on add) */
shelfId?: string;
/** Max Audible lookups per shelf. 0 = unlimited. */
maxLookupsPerShelf?: number;
}
export async function processSyncGoodreadsShelves(payload: SyncGoodreadsShelvesPayload): Promise<any> {
const { jobId, shelfId, maxLookupsPerShelf } = payload;
const logger = RMABLogger.forJob(jobId, 'SyncGoodreadsShelves');
logger.info(shelfId
? `Starting immediate Goodreads sync for shelf ${shelfId}...`
: 'Starting scheduled Goodreads shelves sync...'
);
const { processGoodreadsShelves } = await import('../services/goodreads-sync.service');
const stats = await processGoodreadsShelves(logger, {
shelfId,
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
});
logger.info('Goodreads sync complete', { stats });
return {
success: true,
message: shelfId ? 'Goodreads shelf synced' : 'Goodreads shelves synced',
...stats,
};
}
+1 -1
View File
@@ -186,7 +186,7 @@ export async function deleteABSItem(itemId: string): Promise<void> {
throw new Error('Audiobookshelf not configured');
}
const url = `${serverUrl.replace(/\/$/, '')}/api/items/${itemId}`;
const url = `${serverUrl.replace(/\/$/, '')}/api/items/${itemId}?hard=1`;
const response = await fetch(url, {
method: 'DELETE',

Some files were not shown because too many files have changed in this diff Show More