Compare commits

..

20 Commits

Author SHA1 Message Date
kikootwo 0ae8f66a2d Bump package version to 1.1.5
Update package.json version from 1.1.4 to 1.1.5 to prepare a patch release.
2026-03-11 11:57:37 -04:00
kikootwo 09cff5b68d Add per-user ignored audiobooks feature
Introduce a per-user "ignored audiobooks" feature to suppress auto-requests. Changes include:

- Database: add Prisma model IgnoredAudiobook and SQL migration to create ignored_audiobooks table with indexes and FK to users.
- Backend: new API routes to list, add, delete, and check ignored audiobooks (/api/user/ignored-audiobooks, /check/:asin, /:id). Add annotateWithIgnoreStatus utility and integrate it into multiple audiobook list endpoints (popular, new-releases, category, search, authors, series).
- Request creator: add ignore-list check (with sibling-ASIN expansion) and a bypassIgnore option for manual requests; return an 'ignored' reason when blocked.
- Frontend: hooks (useIsIgnored, useToggleIgnore, useIgnoredList) and UI updates — AudiobookCard shows an "Ignored" indicator and AudiobookDetailsModal adds an ignore toggle and propagates local state changes.
- Misc: adjust deduplication duration tolerance (to 5% / min 10 minutes), tweak SWR refresh intervals for shelves/syncing, and small logging/info updates.
- Tests: add unit tests for request-creator ignore logic and update existing tests/mocks to account for ignore annotation; extend prisma test helper with ignoredAudiobook mock.

This commit implements the ignore-list end-to-end (DB, server, client, and tests) so users can ignore specific ASINs and have auto-request flows respect that preference.
2026-03-11 11:56:35 -04:00
kikootwo da7ad7cac1 Merge branch 'toggleable-shelves' 2026-03-11 10:02:57 -04:00
kikootwo 8aac63715a Pass user ID to addSyncShelvesJob
Include the requesting user's ID as an additional argument when enqueueing immediate shelf sync jobs so the job has user context. Updated the route implementation and adjusted affected tests (goodreads-shelves-id, hardcover-shelves-id, and hardcover-shelves routes tests) to expect the extra 'user-1' parameter.
2026-03-11 09:59:54 -04:00
kikootwo 0a405f2313 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-03-11 09:55:06 -04:00
kikootwo 98c89db0a7 Add per-shelf autoRequest toggle
Introduce an autoRequest boolean on Goodreads and Hardcover shelves (default true) so users can pause/resume automatic request creation. Schema, API handlers, hooks and types were updated to accept and persist autoRequest when creating or updating shelves; add endpoints only trigger an immediate resync when the feed/token changes. The shelf sync core and service code now respect autoRequest (skipping request creation and annotating logs when disabled). UI updates include an AddShelf toggle, manage/update payload changes, shelf list props, and visual indicators + toggle actions in the shelf cards.
2026-03-11 09:55:00 -04:00
kikootwo 309a7960a8 Merge pull request #136 from brombomb/fix-shelf-sync
Add Shelf Syncing button
2026-03-11 09:53:57 -04:00
Rob Walsh 06e77b8eba Fix user id routes and job 2026-03-10 20:52:45 -06:00
kikootwo dfc34df3d1 Add configurable file/dir perms and UMASK support
Introduce file and directory permission settings (fileChmod, dirChmod) end-to-end. UI: new controls in Paths settings with octal validation and defaults (664/775). API: GET exposes defaults; PUT validates octal strings and upserts configuration keys (file_chmod, dir_chmod) and clears related cache keys. Runtime: read config values in file utilities and services (FileOrganizer, direct-download, chapter-merger, epub-fixer) to apply mkdir modes and chmod files/dirs; FileOrganizer now accepts fileMode/dirMode and getFileOrganizer reads/parses DB settings. Docker: add UMASK option to docker-compose and propagate/apply UMASK in entrypoint/app-start scripts. Tests: update mocks to account for config service usage.
2026-03-09 16:37:30 -04:00
Rob Walsh 5d2e33e369 feat: Add user ID parameter to shelf synchronization jobs and improve Prisma query type safety for shelf where clauses. 2026-03-09 13:52:18 -06:00
kikootwo 789a2e50ef Add sourceHeaders and conditional OIDC groups
Add support for passing sourceHeaders when fetching NZB/torrent files: extend AddDownloadOptions and SABnzbd AddNZBOptions, forward headers in sabnzbd and nzbget clients, and populate sourceHeaders in download-torrent.processor (injecting Prowlarr API key as X-Api-Key for proxy URLs). Make OIDC request scope conditional: only include the 'groups' scope when group-based access control or admin-claim is enabled (update provider logic, add tests, and update setup UI text). Also remove explicit take:100 in Plex processors and add CLAUDE guidance about requesting approval before implementing code changes.
2026-03-09 10:33:52 -04:00
kikootwo 9cb9d06144 Bump version to 1.1.4
Update package.json version from 1.1.3 to 1.1.4 to reflect a new patch release.
2026-03-06 10:41:34 -05:00
kikootwo a81549768c Add paginated requests API and My Requests UI
Introduce cursor-based pagination and group counts for /api/requests (status groups, nextCursor, counts) and fetch one extra record to detect next page. Add a client-side My Requests experience: useSWRInfinite hook (useMyRequests) with smart polling for active requests, tabbed filters, badges, skeletons, load-more, and animated list entries. Update RequestCard and admin actions to treat awaiting_search as cancellable. Adjust Plex processors to ignore requests with status 'denied' when matching new media. Add static ffmpeg in the Docker image and remove preinstalled ImageMagick to avoid transitive deps. Update tests to account for pagination/take+1 and the new hook/UX behavior.
2026-03-06 10:41:17 -05:00
Rob Walsh c0cff56b47 Fix sync ui 2026-03-05 22:31:42 -07:00
Rob Walsh e2ae4c7eef Add tests 2026-03-05 22:27:05 -07:00
Rob Walsh a564fefd7c Add refresh shelf capability 2026-03-05 22:24:42 -07:00
kikootwo 01b59fae9d Bump package version to 1.1.3
Update package.json version from 1.1.2 to 1.1.3. No other changes in this diff; version increment for the next release/patch.
2026-03-05 17:14:45 -05:00
kikootwo 137e2b5607 Propagate and use customSearchTerms for ebooks
Persist and apply customSearchTerms across ebook workflows and searches. Updated admin search-terms PATCH to enqueue addSearchEbookJob for ebook requests. Included customSearchTerms when creating ebook request records in audiobooks/[asin]/fetch-ebook, audiobooks/[asin]/select-ebook and requests/[id]/fetch-ebook. Reworked requests/[id]/select-ebook to handle being passed either an audiobook or ebook request (resolve parent audiobook, reuse existing ebook request if present) and to propagate parent.customSearchTerms when creating new ebook requests. Modified search-ebook.processor to read customSearchTerms from the request record, use it as the effective search title (with logging), and pass the modified audiobook title into Anna's Archive and indexer searches so custom terms are honored.
2026-03-05 17:14:26 -05:00
kikootwo f09931f352 Bump package version to 1.1.2
Update package.json version from 1.1.1 to 1.1.2 to mark a new patch release.
2026-03-05 16:46:09 -05:00
kikootwo 5b4aa3fa15 Add data-migration tracking; prevent subtitle dedup
Track and run run-once SQL data migrations: entrypoint now checks _data_migrations before executing each prisma data-migration file, records successful runs, and skips already-applied scripts. Adds a Prisma DataMigration model mapped to _data_migrations and a new reset-works-table.sql migration to clear work tables for a dedup rebuild. Also improves dedup logic: extractSubtitle and subtitle-compatibility checks are added so series entries like "Series: Book A" vs "Series: Book B" are not collapsed, with accompanying unit tests for extraction and behavior.
2026-03-05 16:45:56 -05:00
83 changed files with 2455 additions and 362 deletions
+8
View File
@@ -4,6 +4,14 @@
**ALWAYS DO:** When you feel work is complete, use the docker compose build readmebook to confirm you have no errors. If the build succeeds, then you can tell me it is ready to be tested.
**NEVER implement without approval.** When asked to assess, investigate, or fix a problem:
1. **Research & analyze** — Read code, trace the issue, identify root cause.
2. **Present a solution plan** — Explain the root cause, list the specific files and changes needed, and describe the approach clearly.
3. **Wait for explicit approval** — Do NOT write any code until the user confirms the plan.
4. Only after approval: implement, build, and report results.
This applies to bug fixes, feature requests, and any code changes. Investigation and analysis are always fine — writing code is not until approved.
---
## 1. Token-Efficient Documentation System
+9
View File
@@ -49,6 +49,15 @@ services:
PUID: 1000
PGID: 1000
# ========================================================================
# OPTIONAL: File Permission Mask
# ========================================================================
# Set a umask to control default file permissions for all files created
# by the application. Common values:
# - 002: Group-writable (files: 664, dirs: 775) - recommended for shared access
# - 022: Group-readable only (files: 644, dirs: 755) - more restrictive
# UMASK: "002"
# ========================================================================
# OPTIONAL: Secrets (auto-generated on first run if not provided)
# ========================================================================
+6
View File
@@ -22,6 +22,12 @@ PGID=${PGID:-$(id -g node)}
echo "[App] Starting Next.js server..."
echo "[App] Process will run as UID:GID = $PUID:$PGID"
# Apply UMASK if set (controls default file permissions)
if [ -n "$UMASK" ]; then
echo "[App] Applying umask: $UMASK"
umask "$UMASK"
fi
cd /app
# =============================================================================
+18 -3
View File
@@ -387,6 +387,7 @@ PORT=$PORT
HOSTNAME=$HOSTNAME
PUID=${PUID:-}
PGID=${PGID:-}
UMASK=${UMASK:-}
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
EOF
@@ -403,12 +404,26 @@ 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..."
# Run data migrations (idempotent SQL scripts that prisma db push doesn't handle)
# Run data migrations (run-once SQL scripts tracked in _data_migrations table)
echo "🔄 Running data migrations..."
for sql_file in /app/prisma/data-migrations/*.sql; do
if [ -f "$sql_file" ]; then
echo " Running $(basename "$sql_file")..."
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db execute --schema prisma/schema.prisma --file '$sql_file'" || echo "⚠️ Data migration $(basename "$sql_file") may have failed, continuing..."
migration_name=$(basename "$sql_file")
already_run=$(psql "$DATABASE_URL" -tA -c "SELECT 1 FROM _data_migrations WHERE name = '$migration_name' LIMIT 1;")
if [ "$already_run" = "1" ]; then
echo " Skipping $migration_name (already executed)"
continue
fi
echo " Running $migration_name..."
if su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db execute --schema prisma/schema.prisma --file '$sql_file'"; then
psql "$DATABASE_URL" -c "INSERT INTO _data_migrations (name) VALUES ('$migration_name');"
echo "$migration_name completed"
else
echo "⚠️ Data migration $migration_name failed, will retry on next start"
fi
fi
done
+13 -1
View File
@@ -24,14 +24,26 @@ RUN apt-get update && apt-get install -y \
supervisor \
curl \
openssl \
ffmpeg \
locales \
gosu \
xz-utils \
&& sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \
&& locale-gen en_US.UTF-8 \
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
&& rm -rf /var/lib/apt/lists/*
# Install static ffmpeg (no transitive dependencies like imagemagick)
ADD https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz /tmp/ffmpeg.tar.xz
RUN cd /tmp && tar xf ffmpeg.tar.xz && \
cp ffmpeg-*-static/ffmpeg ffmpeg-*-static/ffprobe /usr/local/bin/ && \
rm -rf /tmp/ffmpeg*
# Remove imagemagick (pre-installed in node:20-bookworm base image, not needed)
RUN apt-get purge -y imagemagick imagemagick-6-common 'imagemagick-6*' \
'libmagickcore*' 'libmagickwand*' && \
apt-get autoremove -y --purge && \
rm -rf /var/lib/apt/lists/*
ENV LANG=en_US.UTF-8
ENV LC_ALL=en_US.UTF-8
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "readmeabook",
"version": "1.1.1",
"version": "1.1.5",
"private": true,
"scripts": {
"dev": "next dev",
@@ -0,0 +1,7 @@
-- Reset works table to fix incorrect dedup groupings (v1.1.2)
-- Books with "Series: Title" naming (e.g. "Eden's Gate: The Reborn" vs
-- "Eden's Gate: The Spartan") were incorrectly merged into the same work
-- because subtitle stripping collapsed them to the same base title.
-- The works table auto-rebuilds from dedup logic as users browse.
DELETE FROM work_asins;
DELETE FROM works;
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "ignored_audiobooks" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"asin" TEXT NOT NULL,
"title" TEXT NOT NULL,
"author" TEXT NOT NULL,
"cover_art_url" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ignored_audiobooks_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ignored_audiobooks_user_id_idx" ON "ignored_audiobooks"("user_id");
-- CreateIndex
CREATE INDEX "ignored_audiobooks_asin_idx" ON "ignored_audiobooks"("asin");
-- CreateIndex
CREATE UNIQUE INDEX "ignored_audiobooks_user_id_asin_key" ON "ignored_audiobooks"("user_id", "asin");
-- AddForeignKey
ALTER TABLE "ignored_audiobooks" ADD CONSTRAINT "ignored_audiobooks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+47 -6
View File
@@ -74,6 +74,7 @@ model User {
watchedSeries WatchedSeries[]
watchedAuthors WatchedAuthor[]
homeSections UserHomeSection[]
ignoredAudiobooks IgnoredAudiobook[]
@@index([plexId])
@@index([role])
@@ -527,9 +528,10 @@ model GoodreadsShelf {
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")
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -577,9 +579,10 @@ model HardcoverShelf {
apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api
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")
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -673,6 +676,32 @@ model WatchedAuthor {
@@map("watched_authors")
}
// ============================================================================
// IGNORED AUDIOBOOK TABLE
// Per-user ignore list for auto-request suppression.
// Stores the ASIN the user clicked ignore on; works-system expansion
// happens at check-time in request-creator.service.ts.
// Documentation: documentation/features/ignored-audiobooks.md
// ============================================================================
model IgnoredAudiobook {
id String @id @default(uuid())
userId String @map("user_id")
asin String // Audible ASIN that was explicitly ignored
title String // Display only — snapshot at ignore time
author String // Display only — snapshot at ignore time
coverArtUrl String? @map("cover_art_url") @db.Text
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, asin])
@@index([userId])
@@index([asin])
@@map("ignored_audiobooks")
}
// ============================================================================
// USER HOME SECTION TABLE
// Per-user configurable home page sections (popular, new_releases, category)
@@ -718,3 +747,15 @@ model AudibleCacheCategory {
@@index([categoryId, rank])
@@map("audible_cache_categories")
}
// ============================================================================
// DATA MIGRATION TRACKING
// Tracks which data migration SQL scripts have been executed (run-once).
// ============================================================================
model DataMigration {
name String @id
executedAt DateTime @default(now()) @map("executed_at")
@@map("_data_migrations")
}
@@ -66,7 +66,7 @@ export function RequestActionsDropdown({
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
const canDelete = true; // Admins can always delete
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
+2
View File
@@ -102,6 +102,8 @@ export interface PathsSettings {
chapterMergingEnabled: boolean;
fileRenameEnabled: boolean;
fileRenameTemplate?: string;
fileChmod?: string;
dirChmod?: string;
}
/**
@@ -439,6 +439,54 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
</div>
</div>
{/* File Permissions */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
File Permissions
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Octal permissions applied when organizing files into the media library. These may be further restricted by the container&apos;s UMASK setting.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
File Permissions
</label>
<Input
type="text"
value={paths.fileChmod || '664'}
onChange={(e) => updatePath('fileChmod', e.target.value)}
placeholder="664"
className={`font-mono max-w-32 ${paths.fileChmod && !/^[0-7]{3,4}$/.test(paths.fileChmod) ? 'border-red-500 dark:border-red-500' : ''}`}
/>
{paths.fileChmod && !/^[0-7]{3,4}$/.test(paths.fileChmod) && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">Must be 3-4 octal digits (0-7)</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
e.g. 664 = owner/group read-write, others read
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Directory Permissions
</label>
<Input
type="text"
value={paths.dirChmod || '775'}
onChange={(e) => updatePath('dirChmod', e.target.value)}
placeholder="775"
className={`font-mono max-w-32 ${paths.dirChmod && !/^[0-7]{3,4}$/.test(paths.dirChmod) ? 'border-red-500 dark:border-red-500' : ''}`}
/>
{paths.dirChmod && !/^[0-7]{3,4}$/.test(paths.dirChmod) && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">Must be 3-4 octal digits (0-7)</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
e.g. 775 = owner/group full access, others read-execute
</p>
</div>
</div>
</div>
{/* Test Paths Button */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<Button
@@ -100,15 +100,21 @@ export async function PATCH(
},
});
// Queue search job
// Queue search job based on request type
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(id, {
const audiobookData = {
id: existingRequest.audiobook.id,
title: existingRequest.audiobook.title,
author: existingRequest.audiobook.author,
asin: existingRequest.audiobook.audibleAsin || undefined,
});
};
if (existingRequest.type === 'ebook') {
await jobQueue.addSearchEbookJob(id, audiobookData);
} else {
await jobQueue.addSearchJob(id, audiobookData);
}
searchTriggered = true;
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
+46 -1
View File
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate } = await request.json();
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate, fileChmod, dirChmod } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -32,6 +32,21 @@ export async function PUT(request: NextRequest) {
);
}
// Validate octal permission strings (3-4 digits, each 0-7)
const octalRegex = /^[0-7]{3,4}$/;
if (fileChmod !== undefined && !octalRegex.test(fileChmod)) {
return NextResponse.json(
{ error: 'File permissions must be 3-4 octal digits (0-7), e.g. 664' },
{ status: 400 }
);
}
if (dirChmod !== undefined && !octalRegex.test(dirChmod)) {
return NextResponse.json(
{ error: 'Directory permissions must be 3-4 octal digits (0-7), e.g. 775' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'download_dir' },
@@ -123,6 +138,34 @@ export async function PUT(request: NextRequest) {
});
}
// Update file permissions (octal chmod)
if (fileChmod !== undefined) {
await prisma.configuration.upsert({
where: { key: 'file_chmod' },
update: { value: fileChmod },
create: {
key: 'file_chmod',
value: fileChmod,
category: 'automation',
description: 'Octal permissions applied to organized files',
},
});
}
// Update directory permissions (octal chmod)
if (dirChmod !== undefined) {
await prisma.configuration.upsert({
where: { key: 'dir_chmod' },
update: { value: dirChmod },
create: {
key: 'dir_chmod',
value: dirChmod,
category: 'automation',
description: 'Octal permissions applied to created directories',
},
});
}
logger.info('Paths settings updated');
// Clear config cache for all updated keys so services get fresh values
@@ -135,6 +178,8 @@ export async function PUT(request: NextRequest) {
configService.clearCache('chapter_merging_enabled');
configService.clearCache('file_rename_enabled');
configService.clearCache('file_rename_template');
configService.clearCache('file_chmod');
configService.clearCache('dir_chmod');
// Invalidate all download client singletons to force reload of download_dir
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+2
View File
@@ -130,6 +130,8 @@ export async function GET(request: NextRequest) {
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
fileChmod: configMap.get('file_chmod') || '664',
dirChmod: configMap.get('dir_chmod') || '775',
},
ebook: {
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
@@ -260,6 +260,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null, // Link to parent if exists
status: 'awaiting_approval',
progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
@@ -292,6 +293,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null,
status: 'pending',
progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
@@ -252,6 +252,7 @@ export async function POST(
status: 'awaiting_approval',
progress: 0,
selectedTorrent: selectedEbook as any,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
@@ -296,6 +297,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null,
status: 'searching',
progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
logger.info(`Created new ebook request ${ebookRequest.id}`);
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Audiobooks.Category');
@@ -129,12 +130,15 @@ export async function GET(
const userId = currentUser?.sub || undefined;
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
// Annotate with per-user ignore status
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages;
return NextResponse.json({
success: true,
audiobooks: enrichedAudiobooks,
audiobooks: annotatedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
+5 -1
View File
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
@@ -136,12 +137,15 @@ export async function GET(request: NextRequest) {
// Enrich with real-time Plex library matching and request status
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
// Annotate with per-user ignore status
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages;
return NextResponse.json({
success: true,
audiobooks: enrichedAudiobooks,
audiobooks: annotatedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
+5 -1
View File
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
const logger = RMABLogger.create('API.Audiobooks.Popular');
@@ -136,12 +137,15 @@ export async function GET(request: NextRequest) {
// Enrich with real-time Plex library matching and request status
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
// Annotate with per-user ignore status
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages;
return NextResponse.json({
success: true,
audiobooks: enrichedAudiobooks,
audiobooks: annotatedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
+5 -1
View File
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
import { persistDedupGroups } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Audiobooks.Search');
@@ -51,10 +52,13 @@ export async function GET(request: NextRequest) {
// Enrich search results with availability and request status information
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
// Annotate with per-user ignore status
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
return NextResponse.json({
success: true,
query: results.query,
results: enrichedResults,
results: annotatedResults,
totalResults: enrichedResults.length,
page: results.page,
hasMore: results.hasMore,
+6 -2
View File
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
import { persistDedupGroups } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Authors.Books');
@@ -67,11 +68,14 @@ export async function GET(
const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
// Annotate with per-user ignore status
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
logger.info(`Author books complete: "${authorName}" → ${annotatedBooks.length} books (page ${page})`);
return NextResponse.json({
success: true,
books: enrichedBooks,
books: annotatedBooks,
authorName: authorName.trim(),
authorAsin: asin,
totalBooks: enrichedBooks.length,
@@ -123,6 +123,7 @@ export async function POST(
parentRequestId,
status: 'pending',
progress: 0,
customSearchTerms: parentRequest.customSearchTerms,
},
});
+31 -12
View File
@@ -52,17 +52,32 @@ export async function POST(
return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
}
// Get the parent audiobook request
const parentRequest = await prisma.request.findUnique({
// Get the request - could be an audiobook request or an existing ebook request
const foundRequest = await prisma.request.findUnique({
where: { id: parentRequestId },
include: { audiobook: true },
});
if (!parentRequest) {
if (!foundRequest) {
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
}
if (parentRequest.type !== 'audiobook') {
// If this is an ebook request, find the parent audiobook request
let parentRequest;
if (foundRequest.type === 'ebook') {
if (!foundRequest.parentRequestId) {
return NextResponse.json({ error: 'Ebook request has no parent audiobook request' }, { status: 400 });
}
parentRequest = await prisma.request.findUnique({
where: { id: foundRequest.parentRequestId },
include: { audiobook: true },
});
if (!parentRequest) {
return NextResponse.json({ error: 'Parent audiobook request not found' }, { status: 404 });
}
} else if (foundRequest.type === 'audiobook') {
parentRequest = foundRequest;
} else {
return NextResponse.json({ error: 'Can only select ebooks for audiobook requests' }, { status: 400 });
}
@@ -74,13 +89,16 @@ export async function POST(
}
// Check for existing ebook request
let ebookRequest = await prisma.request.findFirst({
where: {
parentRequestId,
type: 'ebook',
deletedAt: null,
},
});
// If we were given an ebook request ID directly, use that; otherwise search by parent
let ebookRequest = foundRequest.type === 'ebook'
? foundRequest
: await prisma.request.findFirst({
where: {
parentRequestId: parentRequest.id,
type: 'ebook',
deletedAt: null,
},
});
if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) {
return NextResponse.json({
@@ -109,9 +127,10 @@ export async function POST(
userId: parentRequest.userId,
audiobookId: parentRequest.audiobookId,
type: 'ebook',
parentRequestId,
parentRequestId: parentRequest.id,
status: 'searching',
progress: 0,
customSearchTerms: parentRequest.customSearchTerms,
},
});
logger.info(`Created new ebook request ${ebookRequest.id}`);
+90 -30
View File
@@ -53,7 +53,7 @@ export async function POST(request: NextRequest) {
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
}, { skipAutoSearch });
}, { skipAutoSearch, bypassIgnore: true });
if (!result.success) {
const statusMap: Record<string, { error: string; status: number }> = {
@@ -61,6 +61,7 @@ export async function POST(request: NextRequest) {
being_processed: { error: 'BeingProcessed', status: 409 },
duplicate: { error: 'DuplicateRequest', status: 409 },
user_not_found: { error: 'UserNotFound', status: 404 },
ignored: { error: 'Ignored', status: 409 },
};
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
return NextResponse.json(
@@ -97,9 +98,27 @@ export async function POST(request: NextRequest) {
});
}
// Status groups for server-side filtering and count aggregation
const STATUS_GROUPS: Record<string, string[]> = {
active: ['pending', 'searching', 'downloading', 'processing'],
waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval'],
completed: ['available', 'downloaded'],
failed: ['failed'],
cancelled: ['cancelled', 'denied'],
};
/**
* GET /api/requests?status=pending&limit=50
* Get user's audiobook requests (or all requests for admins)
* GET /api/requests
* Get user's audiobook requests with cursor-based pagination and accurate counts.
*
* Query params:
* status - filter group: 'active'|'waiting'|'completed'|'failed'|'cancelled'|specific status
* cursor - request ID for cursor-based pagination (exclusive start)
* take - page size (default 20, max 100)
* myOnly - 'true' to restrict to current user even for admins
* type - 'audiobook'|'ebook'
*
* Response: { requests, nextCursor, counts: { all, active, waiting, completed, failed, cancelled } }
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
@@ -112,61 +131,102 @@ export async function GET(request: NextRequest) {
}
const searchParams = req.nextUrl.searchParams;
const status = searchParams.get('status');
const limit = parseInt(searchParams.get('limit') || '50', 10);
const statusParam = searchParams.get('status');
const cursor = searchParams.get('cursor');
const take = Math.min(parseInt(searchParams.get('take') || '20', 10), 100);
// Legacy support: honour `limit` if `take` not supplied
const limit = searchParams.has('take')
? take
: Math.min(parseInt(searchParams.get('limit') || '20', 10), 100);
const myOnly = searchParams.get('myOnly') === 'true';
const type = searchParams.get('type'); // 'audiobook', 'ebook', or null for all
const type = searchParams.get('type');
const isAdmin = req.user.role === 'admin';
// Build query
// If myOnly=true, always filter by current user (even for admins)
// Otherwise, admins see all requests, users see only their own
const where: any = myOnly || !isAdmin ? { userId: req.user.id } : {};
if (status) {
where.status = status;
}
// Filter by type if specified (otherwise returns all types)
if (type && ['audiobook', 'ebook'].includes(type)) {
where.type = type;
}
// Only show active (non-deleted) requests
where.deletedAt = null;
// Base ownership filter
const baseWhere: any = myOnly || !isAdmin ? { userId: req.user.id } : {};
baseWhere.deletedAt = null;
if (type && ['audiobook', 'ebook'].includes(type)) {
baseWhere.type = type;
}
// Resolve status filter
const statusFilter: any = {};
if (statusParam) {
const group = STATUS_GROUPS[statusParam];
if (group) {
statusFilter.status = { in: group };
} else {
// Treat as a specific status literal
statusFilter.status = statusParam;
}
}
const where = { ...baseWhere, ...statusFilter };
// ── Paginated request fetch ──────────────────────────────────────────
const requests = await prisma.request.findMany({
where,
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
select: { id: true, plexUsername: true },
},
},
orderBy: { createdAt: 'desc' },
take: limit,
take: limit + 1, // fetch one extra to determine if there's a next page
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
const enriched = requests.map(r => {
const hasNextPage = requests.length > limit;
const page = hasNextPage ? requests.slice(0, limit) : requests;
const nextCursor = hasNextPage ? page[page.length - 1].id : null;
const enriched = page.map(r => {
const isCompleted = COMPLETED_STATUSES.includes(r.status as typeof COMPLETED_STATUSES[number]);
const downloadAvailable = isCompleted && !!r.audiobook?.filePath;
// Strip server-side absolute path from client response
const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook;
return { ...r, audiobook, downloadAvailable };
});
// ── Accurate counts per group (always scoped to ownership/type filter) ──
const countWhere = { ...baseWhere };
const [
totalAll,
totalActive,
totalWaiting,
totalCompleted,
totalFailed,
totalCancelled,
] = await Promise.all([
prisma.request.count({ where: countWhere }),
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.active } } }),
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.waiting } } }),
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.completed } } }),
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.failed } } }),
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.cancelled } } }),
]);
return NextResponse.json({
success: true,
requests: enriched,
nextCursor,
counts: {
all: totalAll,
active: totalActive,
waiting: totalWaiting,
completed: totalCompleted,
failed: totalFailed,
cancelled: totalCancelled,
},
// Legacy field for callers that still read `count`
count: enriched.length,
});
} catch (error) {
logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch requests',
},
{ error: 'FetchError', message: 'Failed to fetch requests' },
{ status: 500 }
);
}
+6 -2
View File
@@ -10,6 +10,7 @@ import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups } from '@/lib/services/works.service';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Series.Detail');
@@ -63,13 +64,16 @@ export async function GET(
const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
// Annotate with per-user ignore status
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
logger.info(`Series detail complete: "${detail.title}" (${annotatedBooks.length} books, page ${page})`);
return NextResponse.json({
success: true,
series: {
...detail,
books: enrichedBooks,
books: annotatedBooks,
},
hasMore: detail.hasMore,
page: detail.page,
@@ -13,7 +13,8 @@ import { z } from 'zod';
const logger = RMABLogger.create('API.GoodreadsShelves');
const UpdateGoodreadsSchema = z.object({
rssUrl: z.string().url('Must be a valid URL'),
rssUrl: z.string().url('Must be a valid URL').optional(),
autoRequest: z.boolean().optional(),
});
/**
@@ -81,21 +82,37 @@ export async function PATCH(
}
const body = await request.json();
const { rssUrl } = UpdateGoodreadsSchema.parse(body);
const { rssUrl, autoRequest } = UpdateGoodreadsSchema.parse(body);
const updateData: Record<string, unknown> = {};
let needsResync = false;
if (rssUrl !== undefined) {
updateData.rssUrl = rssUrl;
updateData.lastSyncAt = null;
updateData.bookCount = null;
updateData.coverUrls = null;
needsResync = true;
}
if (autoRequest !== undefined) {
updateData.autoRequest = autoRequest;
}
// Force re-fetch by clearing metadata
const updated = await prisma.goodreadsShelf.update({
where: { id },
data: { rssUrl, lastSyncAt: null, bookCount: null, coverUrls: null },
data: updateData,
});
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0);
} catch (error) {
logger.error('Failed to trigger immediate list sync', {
error: error instanceof Error ? error.message : String(error),
});
if (needsResync) {
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0, req.user.id);
} catch (error) {
logger.error('Failed to trigger immediate list sync', {
error: error instanceof Error ? error.message : String(error),
});
}
}
return NextResponse.json({ success: true, shelf: updated });
+6 -2
View File
@@ -20,6 +20,7 @@ const AddShelfSchema = z.object({
(url) => GOODREADS_RSS_PATTERN.test(url),
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
),
autoRequest: z.boolean().optional().default(true),
});
/**
@@ -66,6 +67,7 @@ export async function GET(request: NextRequest) {
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount ?? null,
autoRequest: shelf.autoRequest,
books,
};
});
@@ -90,7 +92,7 @@ export async function POST(request: NextRequest) {
}
const body = await req.json();
const { rssUrl } = AddShelfSchema.parse(body);
const { rssUrl, autoRequest } = AddShelfSchema.parse(body);
// Check for duplicate
const existing = await prisma.goodreadsShelf.findUnique({
@@ -132,6 +134,7 @@ export async function POST(request: NextRequest) {
name: shelfName,
rssUrl,
bookCount,
autoRequest,
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
},
});
@@ -139,7 +142,7 @@ export async function POST(request: NextRequest) {
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0);
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0, req.user.id);
logger.info(`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`);
} catch (error) {
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
@@ -154,6 +157,7 @@ export async function POST(request: NextRequest) {
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount,
autoRequest: shelf.autoRequest,
books: initialBooks,
},
bookCount,
@@ -17,6 +17,8 @@ const logger = RMABLogger.create('API.HardcoverShelves');
const UpdateHardcoverSchema = z.object({
listId: z.string().min(1, 'List ID is required').optional(),
apiToken: z.string().optional(),
forceSync: z.boolean().optional(),
autoRequest: z.boolean().optional(),
});
/**
@@ -89,10 +91,14 @@ export async function PATCH(
}
const body = await request.json();
const { listId, apiToken } = UpdateHardcoverSchema.parse(body);
const { listId, apiToken, forceSync, autoRequest } = UpdateHardcoverSchema.parse(body);
const updateData: { listId?: string; apiToken?: string; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
let needsResync = false;
const updateData: { listId?: string; apiToken?: string; autoRequest?: boolean; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
if (autoRequest !== undefined) {
updateData.autoRequest = autoRequest;
}
let needsResync = !!forceSync;
let cleanedToken: string | undefined;
if (apiToken && apiToken.trim() !== '') {
@@ -155,7 +161,7 @@ export async function PATCH(
if (needsResync) {
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0);
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0, req.user.id);
} catch (error) {
logger.error('Failed to trigger immediate list sync', {
error: error instanceof Error ? error.message : String(error),
+8 -2
View File
@@ -18,6 +18,7 @@ const logger = RMABLogger.create('API.HardcoverShelves');
const AddShelfSchema = z.object({
listId: z.string().min(1, { message: 'List ID is required' }),
apiToken: z.string().min(1, { message: 'API Token is required' }),
autoRequest: z.boolean().optional().default(true),
});
/**
@@ -46,6 +47,7 @@ export async function GET(request: NextRequest) {
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount ?? null,
autoRequest: shelf.autoRequest,
books,
};
});
@@ -75,7 +77,9 @@ export async function POST(request: NextRequest) {
}
const body = await req.json();
let { listId, apiToken } = AddShelfSchema.parse(body);
const parsed = AddShelfSchema.parse(body);
let { listId, apiToken } = parsed;
const { autoRequest } = parsed;
// Clean up token in case user pasted "Bearer " prefix
apiToken = apiToken.trim();
@@ -139,6 +143,7 @@ export async function POST(request: NextRequest) {
name: listName,
listId,
apiToken: encryptedToken,
autoRequest,
bookCount,
coverUrls:
initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
@@ -148,7 +153,7 @@ export async function POST(request: NextRequest) {
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0);
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0, req.user.id);
logger.info(
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
);
@@ -168,6 +173,7 @@ export async function POST(request: NextRequest) {
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount,
autoRequest: shelf.autoRequest,
books: initialBooks,
},
bookCount,
@@ -0,0 +1,65 @@
/**
* Component: Ignored Audiobook Delete Route
* Documentation: documentation/features/ignored-audiobooks.md
*
* DELETE removes a single entry from the user's ignore list (un-ignore).
*/
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.IgnoredAudiobooks');
/**
* DELETE /api/user/ignored-audiobooks/[id]
* Remove an audiobook from the user's ignore list
*/
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;
// Verify ownership before deleting
const existing = await prisma.ignoredAudiobook.findUnique({
where: { id },
});
if (!existing) {
return NextResponse.json(
{ error: 'NotFound', message: 'Ignored audiobook entry not found' },
{ status: 404 }
);
}
if (existing.userId !== req.user.id) {
return NextResponse.json(
{ error: 'Forbidden', message: 'Cannot modify another user\'s ignore list' },
{ status: 403 }
);
}
await prisma.ignoredAudiobook.delete({ where: { id } });
logger.info(`User ${req.user.id} un-ignored ASIN ${existing.asin} ("${existing.title}")`);
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Failed to remove ignored audiobook', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'DeleteError', message: 'Failed to remove ignored audiobook' },
{ status: 500 }
);
}
});
}
@@ -0,0 +1,79 @@
/**
* Component: Ignored Audiobook Check Route
* Documentation: documentation/features/ignored-audiobooks.md
*
* Quick check whether a specific ASIN is ignored by the current user.
* Includes works-system expansion to catch sibling ASINs.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getSiblingAsins } from '@/lib/services/works.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.IgnoredAudiobooks.Check');
/**
* GET /api/user/ignored-audiobooks/check/[asin]
* Returns { ignored: boolean, ignoredId?: string } for the given ASIN.
* ignoredId is the ID of the matching IgnoredAudiobook record (for un-ignore).
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { asin } = await params;
// Direct check
const directIgnore = await prisma.ignoredAudiobook.findUnique({
where: { userId_asin: { userId: req.user.id, asin } },
});
if (directIgnore) {
return NextResponse.json({
ignored: true,
ignoredId: directIgnore.id,
});
}
// Works-system expansion: check sibling ASINs
try {
const siblingMap = await getSiblingAsins([asin]);
const siblings = siblingMap.get(asin);
if (siblings && siblings.length > 0) {
const siblingIgnore = await prisma.ignoredAudiobook.findFirst({
where: {
userId: req.user.id,
asin: { in: siblings },
},
});
if (siblingIgnore) {
return NextResponse.json({
ignored: true,
ignoredId: siblingIgnore.id,
});
}
}
} catch {
// Works expansion is best-effort
}
return NextResponse.json({ ignored: false });
} catch (error) {
logger.error('Failed to check ignored status', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'CheckError', message: 'Failed to check ignored status' },
{ status: 500 }
);
}
});
}
@@ -0,0 +1,123 @@
/**
* Component: Ignored Audiobooks API Routes
* Documentation: documentation/features/ignored-audiobooks.md
*
* Per-user ignore list for auto-request suppression.
* GET returns the user's full ignore list; POST adds a new entry.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.IgnoredAudiobooks');
const AddIgnoredSchema = z.object({
asin: z.string().min(1).max(20),
title: z.string().min(1).max(500),
author: z.string().min(1).max(500),
coverArtUrl: z.string().optional(),
});
/**
* GET /api/user/ignored-audiobooks
* List the current user's ignored audiobooks
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const ignored = await prisma.ignoredAudiobook.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
});
return NextResponse.json({
success: true,
ignoredAudiobooks: ignored.map((item) => ({
id: item.id,
asin: item.asin,
title: item.title,
author: item.author,
coverArtUrl: item.coverArtUrl,
createdAt: item.createdAt.toISOString(),
})),
});
} catch (error) {
logger.error('Failed to list ignored audiobooks', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'FetchError', message: 'Failed to fetch ignored audiobooks' },
{ status: 500 }
);
}
});
}
/**
* POST /api/user/ignored-audiobooks
* Add an audiobook to the user's ignore list
*/
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 data = AddIgnoredSchema.parse(body);
// Upsert to handle duplicate gracefully
const ignored = await prisma.ignoredAudiobook.upsert({
where: {
userId_asin: { userId: req.user.id, asin: data.asin },
},
update: {}, // Already exists — no-op
create: {
userId: req.user.id,
asin: data.asin,
title: data.title,
author: data.author,
coverArtUrl: data.coverArtUrl,
},
});
logger.info(`User ${req.user.id} ignored ASIN ${data.asin} ("${data.title}")`);
return NextResponse.json({
success: true,
ignoredAudiobook: {
id: ignored.id,
asin: ignored.asin,
title: ignored.title,
author: ignored.author,
coverArtUrl: ignored.coverArtUrl,
createdAt: ignored.createdAt.toISOString(),
},
}, { status: 201 });
} catch (error) {
logger.error('Failed to add ignored audiobook', {
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: 'CreateError', message: 'Failed to ignore audiobook' },
{ status: 500 }
);
}
});
}
+2
View File
@@ -42,6 +42,7 @@ export async function GET(request: NextRequest) {
lastSyncAt: s.lastSyncAt,
createdAt: s.createdAt,
bookCount: s.bookCount ?? null,
autoRequest: s.autoRequest,
books: processBooks(s.coverUrls),
})),
...hardcover.map((s) => ({
@@ -52,6 +53,7 @@ export async function GET(request: NextRequest) {
lastSyncAt: s.lastSyncAt,
createdAt: s.createdAt,
bookCount: s.bookCount ?? null,
autoRequest: s.autoRequest,
books: processBooks(s.coverUrls),
})),
].sort(
+79
View File
@@ -0,0 +1,79 @@
/**
* Component: Manual Shelf Sync API 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 { getJobQueueService } from '@/lib/services/job-queue.service';
import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod';
const logger = RMABLogger.create('API.ShelvesSync');
const SyncSchema = z.object({
shelfId: z.string().optional(),
shelfType: z.enum(['goodreads', 'hardcover']).optional(),
});
/**
* POST /api/user/shelves/sync
* Trigger a manual sync for all or a specific shelf belonging to the user.
*/
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 request.json().catch(() => ({}));
const { shelfId, shelfType } = SyncSchema.parse(body);
// Set lastSyncAt to null so the frontend SWR refresh catches the "Syncing..." state immediately
if (!shelfType || shelfType === 'goodreads') {
await prisma.goodreadsShelf.updateMany({
where: { userId: req.user.id, ...(shelfId ? { id: shelfId } : {}) },
data: { lastSyncAt: null },
});
}
if (!shelfType || shelfType === 'hardcover') {
await prisma.hardcoverShelf.updateMany({
where: { userId: req.user.id, ...(shelfId ? { id: shelfId } : {}) },
data: { lastSyncAt: null },
});
}
const jobQueue = getJobQueueService();
// Trigger sync job with userId filter
await jobQueue.addSyncShelvesJob(
undefined,
shelfId,
shelfType,
0, // unlimited lookups for manual trigger
req.user.id
);
logger.info(`Manual sync triggered for user ${req.user.id}${shelfId ? ` (shelf: ${shelfId})` : ' (all shelves)'}`);
return NextResponse.json({
success: true,
message: shelfId ? 'Shelf sync triggered' : 'All shelves sync triggered'
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 });
}
logger.error('Failed to trigger manual sync', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'Failed to trigger manual sync' },
{ status: 500 },
);
}
});
}
+17
View File
@@ -197,6 +197,23 @@ body {
animation: toast-slide-in 0.3s ease-out;
}
/* Requests page list entry animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Confirmation Dialog */
@keyframes dialog-backdrop-in {
from { opacity: 0; }
+344 -160
View File
@@ -1,221 +1,405 @@
/**
* Component: Requests Page
* Component: My Requests Page
* Documentation: documentation/frontend/components.md
*/
'use client';
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Header } from '@/components/layout/Header';
import { RequestCard } from '@/components/requests/RequestCard';
import { useRequests } from '@/lib/hooks/useRequests';
import { useMyRequests, RequestFilterGroup, RequestCounts } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
import { usePreferences } from '@/contexts/PreferencesContext';
import { cn } from '@/lib/utils/cn';
type FilterStatus = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled';
// ── Tab configuration ────────────────────────────────────────────────────────
interface TabOption {
value: RequestFilterGroup;
label: string;
countKey: keyof RequestCounts;
}
const TABS: TabOption[] = [
{ value: 'all', label: 'All', countKey: 'all' },
{ value: 'active', label: 'Active', countKey: 'active' },
{ value: 'waiting', label: 'Waiting', countKey: 'waiting' },
{ value: 'completed', label: 'Completed', countKey: 'completed' },
{ value: 'failed', label: 'Failed', countKey: 'failed' },
{ value: 'cancelled', label: 'Cancelled', countKey: 'cancelled' },
];
// ── Count badge ──────────────────────────────────────────────────────────────
function CountBadge({ count, active }: { count: number; active: boolean }) {
if (count === 0) return null;
return (
<span
className={cn(
'inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-semibold tabular-nums transition-all duration-200',
active
? 'bg-blue-500/20 text-blue-600 dark:bg-blue-400/20 dark:text-blue-400'
: 'bg-gray-200/80 text-gray-500 dark:bg-white/[0.07] dark:text-gray-400'
)}
>
{count > 999 ? '999+' : count}
</span>
);
}
// ── Skeleton card ────────────────────────────────────────────────────────────
function SkeletonCard() {
return (
<div className="bg-white dark:bg-gray-800/60 rounded-xl overflow-hidden border border-gray-100 dark:border-white/[0.06]">
<div className="flex gap-3 sm:gap-4 p-3 sm:p-4">
{/* Cover placeholder */}
<div className="flex-shrink-0 w-16 sm:w-24 aspect-[2/3] rounded-lg bg-gray-200 dark:bg-white/[0.06] animate-pulse" />
{/* Content placeholder */}
<div className="flex-1 min-w-0 space-y-3 pt-1">
<div className="space-y-2">
<div className="h-4 bg-gray-200 dark:bg-white/[0.06] rounded-md animate-pulse w-3/4" />
<div className="h-3 bg-gray-200 dark:bg-white/[0.06] rounded-md animate-pulse w-1/2" />
</div>
<div className="h-5 bg-gray-200 dark:bg-white/[0.06] rounded-full animate-pulse w-20" />
<div className="pt-3 border-t border-gray-100 dark:border-white/[0.05]">
<div className="h-3 bg-gray-200 dark:bg-white/[0.06] rounded animate-pulse w-28" />
</div>
</div>
</div>
</div>
);
}
// ── Empty state ──────────────────────────────────────────────────────────────
function EmptyState({ filter }: { filter: RequestFilterGroup }) {
const isAll = filter === 'all';
return (
<div className="flex flex-col items-center justify-center py-20 text-center space-y-5">
<div className="w-16 h-16 rounded-2xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center">
<svg
className="w-8 h-8 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="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 className="space-y-1.5">
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
{isAll ? 'No requests yet' : `No ${filter} requests`}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs">
{isAll
? 'Start by searching for audiobooks and requesting them'
: `You don't have any ${filter} requests right now`}
</p>
</div>
{isAll && (
<a
href="/search"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-medium rounded-xl transition-all duration-150 shadow-sm hover:shadow-md"
>
<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>
Browse Audiobooks
</a>
)}
</div>
);
}
// ── Load More button ─────────────────────────────────────────────────────────
function LoadMoreButton({ onClick, isLoading }: { onClick: () => void; isLoading: boolean }) {
return (
<div className="flex justify-center pt-2 pb-4">
<button
onClick={onClick}
disabled={isLoading}
className={cn(
'inline-flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-medium transition-all duration-150',
'border border-gray-200 dark:border-white/[0.1]',
'text-gray-700 dark:text-gray-300',
'bg-white dark:bg-white/[0.04]',
'hover:bg-gray-50 dark:hover:bg-white/[0.07]',
'active:scale-[0.98]',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100',
'shadow-sm'
)}
>
{isLoading ? (
<>
<svg className="w-4 h-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading more...
</>
) : (
<>
Load more
<svg className="w-4 h-4 text-gray-400" 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>
</div>
);
}
// ── Live indicator ───────────────────────────────────────────────────────────
function LiveIndicator({ hasActive }: { hasActive: boolean }) {
if (!hasActive) return null;
return (
<div className="flex items-center gap-1.5 text-[11px] text-gray-400 dark:text-gray-500">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500" />
</span>
Live
</div>
);
}
// ── Tab bar ──────────────────────────────────────────────────────────────────
interface TabBarProps {
filter: RequestFilterGroup;
counts: RequestCounts;
countsLoaded: boolean;
onChange: (f: RequestFilterGroup) => void;
}
function TabBar({ filter, counts, countsLoaded, onChange }: TabBarProps) {
const scrollRef = useRef<HTMLDivElement>(null);
// Scroll active tab into view on mount/change
useEffect(() => {
const container = scrollRef.current;
if (!container) return;
const active = container.querySelector('[data-active="true"]') as HTMLElement | null;
if (active) {
const { offsetLeft, offsetWidth } = active;
const { scrollLeft, clientWidth } = container;
if (offsetLeft < scrollLeft || offsetLeft + offsetWidth > scrollLeft + clientWidth) {
container.scrollTo({ left: offsetLeft - 16, behavior: 'smooth' });
}
}
}, [filter]);
return (
<div className="relative -mx-4 sm:mx-0">
{/* Left fade */}
<div className="pointer-events-none absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-white dark:from-gray-950 to-transparent z-10 sm:hidden" />
{/* Right fade */}
<div className="pointer-events-none absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-white dark:from-gray-950 to-transparent z-10 sm:hidden" />
<div
ref={scrollRef}
className="flex gap-1 overflow-x-auto scrollbar-hide px-4 sm:px-0"
role="tablist"
>
{TABS.map((tab) => {
const isActive = filter === tab.value;
const count = counts[tab.countKey];
// Hide tabs with 0 count unless it's 'all' or currently active
if (!isActive && tab.value !== 'all' && countsLoaded && count === 0) return null;
return (
<button
key={tab.value}
role="tab"
aria-selected={isActive}
data-active={isActive}
onClick={() => onChange(tab.value)}
className={cn(
'flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-all duration-150 outline-none flex-shrink-0',
'focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2',
isActive
? 'bg-white dark:bg-white/[0.08] text-gray-900 dark:text-white shadow-[0_1px_3px_rgba(0,0,0,0.08),0_1px_6px_rgba(0,0,0,0.05)] dark:shadow-[0_1px_3px_rgba(0,0,0,0.4)] border border-gray-200/80 dark:border-white/[0.1]'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-black/[0.03] dark:hover:bg-white/[0.04]'
)}
>
{tab.label}
{countsLoaded
? <CountBadge count={count} active={isActive} />
: tab.value !== 'all' && (
<span className="inline-block w-5 h-3.5 rounded bg-gray-200 dark:bg-white/[0.07] animate-pulse" />
)
}
</button>
);
})}
</div>
</div>
);
}
// ── Showing count bar ────────────────────────────────────────────────────────
function ShowingBar({ showing, total, hasActive }: { showing: number; total: number; hasActive: boolean }) {
if (showing === 0) return null;
return (
<div className="flex items-center justify-between text-xs text-gray-400 dark:text-gray-500 px-0.5">
<span>
Showing <span className="text-gray-600 dark:text-gray-300 font-medium tabular-nums">{showing}</span>
{' of '}
<span className="text-gray-600 dark:text-gray-300 font-medium tabular-nums">{total}</span>
{total === 1 ? ' request' : ' requests'}
</span>
<LiveIndicator hasActive={hasActive} />
</div>
);
}
// ── Main page ────────────────────────────────────────────────────────────────
export default function RequestsPage() {
const { user } = useAuth();
const { squareCovers } = usePreferences();
const [filter, setFilter] = useState<FilterStatus>('all');
const [filter, setFilter] = useState<RequestFilterGroup>('all');
// Always fetch only the current user's requests (even for admins)
// This ensures "My Requests" truly shows only the user's own requests
// Admins can see all requests in the admin panel
const { requests, isLoading } = useRequests(undefined, 50, true);
const {
requests,
counts,
hasMore,
isLoading,
isLoadingMore,
isEmpty,
loadMore,
} = useMyRequests(filter);
// Filter requests client-side based on selected filter
const filteredRequests = filter === 'all'
? requests
: filter === 'active'
? requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status))
: filter === 'waiting'
? requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status))
: filter === 'completed'
? requests.filter((r: any) => ['available', 'downloaded'].includes(r.status))
: requests.filter((r: any) => r.status === filter);
const countsLoaded = !isLoading || requests.length > 0;
const totalForFilter = counts[filter === 'all' ? 'all' : filter as keyof RequestCounts] ?? 0;
const hasActiveRequests = requests.some(r =>
['pending', 'awaiting_search', 'awaiting_approval', 'searching', 'downloading', 'processing', 'awaiting_import'].includes(r.status)
);
const filterOptions: { value: FilterStatus; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'waiting', label: 'Waiting' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'cancelled', label: 'Cancelled' },
];
const handleFilterChange = (f: RequestFilterGroup) => {
setFilter(f);
};
// Redirect to login if not authenticated
// ── Unauthenticated ────────────────────────────────────────────────────────
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"
/>
</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 audiobook requests
</p>
<main className="container mx-auto px-4 py-8 max-w-4xl">
<div className="flex flex-col items-center justify-center py-20 text-center space-y-5">
<div className="w-16 h-16 rounded-2xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
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>
</div>
<div className="space-y-1.5">
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Authentication Required</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Please log in to view your audiobook requests</p>
</div>
</div>
</main>
</div>
);
}
// ── Authenticated ──────────────────────────────────────────────────────────
return (
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-6 sm:space-y-8">
{/* Page Header */}
<div className="space-y-2 sm:space-y-4">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100">
<main className="container mx-auto px-4 py-6 sm:py-10 max-w-4xl">
{/* Page header */}
<div className="mb-6 sm:mb-8">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50">
My Requests
</h1>
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Track the status of your audiobook requests in real-time
</p>
</div>
{/* Filter Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700 -mx-4 px-4 sm:mx-0 sm:px-0">
<div className="flex gap-2 sm:gap-4 -mb-px overflow-x-auto scrollbar-hide">
{filterOptions.map((option) => (
<button
key={option.value}
onClick={() => setFilter(option.value)}
className={cn(
'px-3 sm:px-4 py-2 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
filter === option.value
? 'border-blue-600 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:border-gray-300'
)}
>
{option.label}
{!isLoading && (
<span className="ml-2 text-xs">
({option.value === 'all'
? requests.length
: option.value === 'active'
? requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length
: option.value === 'waiting'
? requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length
: option.value === 'completed'
? requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length
: requests.filter((r: any) => r.status === option.value).length
})
</span>
)}
</button>
))}
</div>
{/* Tab bar */}
<div className="mb-5">
<TabBar
filter={filter}
counts={counts}
countsLoaded={countsLoaded}
onChange={handleFilterChange}
/>
</div>
{/* Loading State */}
{/* Showing bar */}
{!isLoading && requests.length > 0 && (
<div className="mb-4">
<ShowingBar
showing={requests.length}
total={totalForFilter}
hasActive={hasActiveRequests}
/>
</div>
)}
{/* Loading state — skeleton cards */}
{isLoading && (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
style={{ animationDelay: `${i * 60}ms` }}
className="animate-[fadeIn_0.3s_ease-out_both]"
>
<div className="flex gap-4">
<div className={cn(
'w-24 bg-gray-300 dark:bg-gray-700 rounded',
squareCovers ? 'aspect-square' : 'aspect-[2/3]'
)}></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>
</div>
<SkeletonCard />
</div>
))}
</div>
)}
{/* Requests List */}
{!isLoading && filteredRequests.length > 0 && (
<div className="space-y-4">
{filteredRequests.map((request: any) => (
<RequestCard key={request.id} request={request} showActions={true} />
{/* Request list */}
{!isLoading && requests.length > 0 && (
<div className="space-y-3">
{requests.map((request, i) => (
<div
key={request.id}
style={{ animationDelay: `${Math.min(i, 8) * 40}ms` }}
className="animate-[fadeInUp_0.25s_ease-out_both]"
>
<RequestCard request={request} showActions={true} />
</div>
))}
</div>
)}
{/* Empty State */}
{!isLoading && filteredRequests.length === 0 && (
<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="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 className="space-y-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{filter === 'all' ? 'No requests yet' : `No ${filter} requests`}
</h2>
<p className="text-gray-600 dark:text-gray-400">
{filter === 'all'
? 'Start by searching for audiobooks and requesting them'
: `You don't have any ${filter} requests at the moment`
}
</p>
</div>
{filter === 'all' && (
<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>
)}
{/* Empty state */}
{!isLoading && isEmpty && (
<EmptyState filter={filter} />
)}
{/* Load more */}
{!isLoading && hasMore && (
<div className="mt-4">
<LoadMoreButton onClick={loadMore} isLoading={isLoadingMore} />
</div>
)}
{/* Auto-refresh indicator */}
{!isLoading && filteredRequests.length > 0 && (
<div className="text-center text-xs text-gray-500 dark:text-gray-500 py-4">
<div className="flex items-center justify-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span>Auto-refreshing every 5 seconds</span>
</div>
{/* Load more skeleton (when fetching additional pages) */}
{isLoadingMore && (
<div className="mt-3 space-y-3">
{Array.from({ length: 2 }).map((_, i) => (
<SkeletonCard key={`more-${i}`} />
))}
</div>
)}
</main>
</div>
);
+1 -1
View File
@@ -272,7 +272,7 @@ export function OIDCConfigStep({
<ul className="text-sm text-blue-700 dark:text-blue-300 mt-1 space-y-1">
<li> The redirect URI will be: {typeof window !== 'undefined' ? `${window.location.origin}/api/auth/oidc/callback` : '[Your Domain]/api/auth/oidc/callback'}</li>
<li> Configure this redirect URI in your OIDC provider settings</li>
<li> Required scopes: openid, profile, email, groups</li>
<li> Required scopes: openid, profile, email (groups is added automatically when group-based access control is enabled)</li>
</ul>
</div>
</div>
+17 -1
View File
@@ -59,13 +59,15 @@ export function AudiobookCard({
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
const [localIsIgnored, setLocalIsIgnored] = useState<boolean | undefined>(undefined);
const [coverError, setCoverError] = useState(false);
// Build a display-only audiobook with the local status override
// Build a display-only audiobook with local overrides
const displayAudiobook = localRequestStatus !== undefined
? { ...audiobook, requestStatus: localRequestStatus }
: audiobook;
const status = getStatusConfig(displayAudiobook);
const isIgnored = localIsIgnored !== undefined ? localIsIgnored : audiobook.isIgnored;
const handleRequest = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -218,6 +220,19 @@ export function AudiobookCard({
<span>{audiobook.rating.toFixed(1)}</span>
</div>
)}
{/* Ignored Indicator - Bottom Left */}
{isIgnored && (
<div
className="absolute bottom-3 left-3 flex items-center gap-1 px-2 py-1 rounded-lg bg-black/50 backdrop-blur-md text-gray-300 text-xs font-medium transition-opacity duration-300 group-hover:opacity-0"
title="Ignored from auto-requests"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
<span>Ignored</span>
</div>
)}
</div>
</div>
@@ -253,6 +268,7 @@ export function AudiobookCard({
onClose={() => setShowModal(false)}
onRequestSuccess={onRequestSuccess}
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
onIgnoreChange={(ignored) => setLocalIsIgnored(ignored)}
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
requestStatus={displayAudiobook.requestStatus}
isAvailable={audiobook.isAvailable}
@@ -19,8 +19,10 @@ import { usePreferences } from '@/contexts/PreferencesContext';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
import { FolderArrowDownIcon } from '@heroicons/react/24/outline';
import { FolderArrowDownIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import { EyeSlashIcon as EyeSlashSolidIcon } from '@heroicons/react/24/solid';
import { fetchWithAuth } from '@/lib/utils/api';
import { useIsIgnored, useToggleIgnore } from '@/lib/hooks/useIgnoredAudiobooks';
interface AudiobookDetailsModalProps {
asin: string;
@@ -28,6 +30,7 @@ interface AudiobookDetailsModalProps {
onClose: () => void;
onRequestSuccess?: () => void;
onStatusChange?: (newStatus: string) => void;
onIgnoreChange?: (isIgnored: boolean) => void;
isRequested?: boolean;
requestStatus?: string | null;
isAvailable?: boolean;
@@ -69,6 +72,7 @@ export function AudiobookDetailsModal({
onClose,
onRequestSuccess,
onStatusChange,
onIgnoreChange,
isRequested = false,
requestStatus = null,
isAvailable = false,
@@ -85,6 +89,9 @@ export function AudiobookDetailsModal({
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
const { isIgnored, ignoredId, isLoading: isLoadingIgnore } = useIsIgnored(isOpen ? asin : null);
const { addIgnore, removeIgnore } = useToggleIgnore();
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const [toastType, setToastType] = useState<'success' | 'error'>('success');
@@ -97,6 +104,7 @@ export function AudiobookDetailsModal({
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
const [isDownloading, setIsDownloading] = useState(false);
const [coverError, setCoverError] = useState(false);
const [isTogglingIgnore, setIsTogglingIgnore] = useState(false);
// Sync local status when the prop changes (e.g. page data refreshes)
useEffect(() => {
@@ -196,6 +204,31 @@ export function AudiobookDetailsModal({
}
};
const handleToggleIgnore = async () => {
if (!user || !audiobook) return;
setIsTogglingIgnore(true);
try {
if (isIgnored && ignoredId) {
await removeIgnore(ignoredId, asin);
onIgnoreChange?.(false);
showNotification('Removed from ignore list');
} else {
await addIgnore({
asin,
title: audiobook.title,
author: audiobook.author,
coverArtUrl: audiobook.coverArtUrl,
});
onIgnoreChange?.(true);
showNotification('Added to ignore list — auto-requests will skip this book');
}
} catch (err) {
showNotification(err instanceof Error ? err.message : 'Failed to update ignore status', 'error');
} finally {
setIsTogglingIgnore(false);
}
};
const formatDuration = (minutes?: number) => {
if (!minutes) return null;
const hours = Math.floor(minutes / 60);
@@ -685,6 +718,26 @@ export function AudiobookDetailsModal({
</>
)}
{/* Ignore Toggle - always visible when user is logged in */}
{user && !isLoadingIgnore && (
<button
onClick={handleToggleIgnore}
disabled={isTogglingIgnore}
className={`p-3 rounded-xl transition-colors disabled:opacity-50 ${
isIgnored
? 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
: 'bg-gray-100 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
title={isIgnored ? 'Stop Ignoring — auto-requests will resume for this book' : 'Ignore from Auto-Requests'}
>
{isIgnored ? (
<EyeSlashSolidIcon className="w-6 h-6" />
) : (
<EyeSlashIcon className="w-6 h-6" />
)}
</button>
)}
</div>
</div>
)}
+134 -23
View File
@@ -6,9 +6,13 @@
'use client';
import React, { useState } from 'react';
import { useShelves, GenericShelf } from '@/lib/hooks/useShelves';
import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
import {
useShelves,
GenericShelf,
useSyncShelves,
} from '@/lib/hooks/useShelves';
import { useDeleteGoodreadsShelf, useUpdateGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
import { useDeleteHardcoverShelf, useUpdateHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
import { AddShelfModal } from '@/components/ui/AddShelfModal';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { usePreferences } from '@/contexts/PreferencesContext';
@@ -37,6 +41,9 @@ export function ShelvesSection() {
useDeleteGoodreadsShelf();
const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } =
useDeleteHardcoverShelf();
const { syncShelves, isSyncing: isSyncingAll } = useSyncShelves();
const { updateShelf: updateGoodreads } = useUpdateGoodreadsShelf();
const { updateShelf: updateHardcover } = useUpdateHardcoverShelf();
const { squareCovers } = usePreferences();
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
@@ -57,6 +64,18 @@ export function ShelvesSection() {
}
};
const handleToggleAutoRequest = async (shelf: GenericShelf) => {
try {
if (shelf.type === 'goodreads') {
await updateGoodreads(shelf.id, { autoRequest: !shelf.autoRequest });
} else {
await updateHardcover(shelf.id, { autoRequest: !shelf.autoRequest });
}
} catch {
// Error handled by hook
}
};
const isDeleting = isDeletingGoodreads || isDeletingHardcover;
return (
@@ -93,25 +112,48 @@ export function ShelvesSection() {
</div>
{shelves.length > 0 && (
<button
onClick={() => setShowAddShelf(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}
<div className="flex items-center gap-3">
<button
onClick={() => syncShelves()}
disabled={isSyncingAll}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 rounded-xl hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-all duration-200 shadow-sm disabled:opacity-50"
title="Resync all shelves"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
Add Shelf
</button>
<svg
className={cn('w-4 h-4', isSyncingAll && 'animate-spin')}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
{isSyncingAll ? 'Syncing...' : 'Resync All'}
</button>
<button
onClick={() => setShowAddShelf(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>
)}
</div>
@@ -131,6 +173,7 @@ export function ShelvesSection() {
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
onCancelDelete={() => setConfirmDeleteId(null)}
onManage={() => setManageShelf(shelf)}
onToggleAutoRequest={() => handleToggleAutoRequest(shelf)}
onBookClick={(asin) => setSelectedAsin(asin)}
/>
))}
@@ -254,6 +297,7 @@ interface ShelfCardProps {
onConfirmDelete: () => void;
onCancelDelete: () => void;
onManage: () => void;
onToggleAutoRequest: () => void;
onBookClick: (asin: string) => void;
}
@@ -266,8 +310,10 @@ function ShelfCard({
onConfirmDelete,
onCancelDelete,
onManage,
onToggleAutoRequest,
onBookClick,
}: ShelfCardProps) {
const { syncShelves, isSyncing: isManualSyncing } = useSyncShelves();
const displayBooks = shelf.books.slice(0, 6);
const hasCovers = displayBooks.length > 0;
const remainingCount = Math.max(
@@ -292,7 +338,12 @@ function ShelfCard({
);
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">
<div className={cn(
'group rounded-2xl bg-white dark:bg-gray-800 border p-6 sm:p-7 transition-all duration-300',
shelf.autoRequest
? 'border-gray-100 dark:border-gray-700/30 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40'
: 'border-gray-200/60 dark:border-gray-700/20 bg-gray-50/50 dark:bg-gray-800/60',
)}>
{/* Top: Shelf info + actions */}
<div
className={cn(
@@ -301,7 +352,12 @@ function ShelfCard({
)}
>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug flex items-center">
<h3 className={cn(
'font-semibold text-[15px] truncate leading-snug flex items-center',
shelf.autoRequest
? 'text-gray-900 dark:text-white'
: 'text-gray-400 dark:text-gray-500',
)}>
{shelf.name} {providerIcon}
</h3>
<div className="flex items-center gap-2 mt-2">
@@ -310,6 +366,14 @@ function ShelfCard({
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
</span>
)}
{!shelf.autoRequest && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-1 ring-amber-200/50 dark:ring-amber-500/20">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
Paused
</span>
)}
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
{isSyncing ? (
<>
@@ -352,6 +416,27 @@ function ShelfCard({
</div>
) : (
<div className="flex items-center gap-1">
<button
onClick={onToggleAutoRequest}
className={cn(
'p-2 transition-all duration-200 rounded-xl outline-none',
shelf.autoRequest
? 'text-gray-400 hover:text-amber-500 dark:text-gray-500 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-amber-500/40'
: 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 opacity-100',
)}
title={shelf.autoRequest ? 'Pause auto-requesting' : 'Resume auto-requesting'}
aria-label={shelf.autoRequest ? 'Pause auto-requesting' : 'Resume auto-requesting'}
>
{shelf.autoRequest ? (
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
) : (
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
)}
</button>
<button
onClick={onManage}
className="p-2 text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 transition-all duration-200 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-blue-500/40 outline-none"
@@ -372,6 +457,30 @@ function ShelfCard({
/>
</svg>
</button>
<button
onClick={() => syncShelves(shelf.id, shelf.type)}
disabled={isManualSyncing}
className="p-2 text-gray-400 hover:text-emerald-500 dark:text-gray-500 dark:hover:text-emerald-400 transition-all duration-200 rounded-xl hover:bg-emerald-50 dark:hover:bg-emerald-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-emerald-500/40 outline-none disabled:opacity-30"
title="Resync shelf"
aria-label="Resync shelf"
>
<svg
className={cn(
'w-[18px] h-[18px]',
isManualSyncing && 'animate-spin',
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button
onClick={onConfirmDelete}
className="p-2 text-gray-400 hover:text-red-400 dark:text-gray-500 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500/40 outline-none"
@@ -398,6 +507,7 @@ function ShelfCard({
</div>
{/* Bottom: Stacked book covers */}
<div className={cn(!shelf.autoRequest && 'opacity-50 grayscale-[30%]')}>
{hasCovers ? (
<CoverStack
books={displayBooks}
@@ -419,6 +529,7 @@ function ShelfCard({
))}
</div>
) : null}
</div>
</div>
);
}
+1 -1
View File
@@ -50,7 +50,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
const isEbook = requestType === 'ebook';
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
const isFailed = request.status === 'failed';
+31 -2
View File
@@ -32,6 +32,8 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
const [statusId, setStatusId] = useState('1');
const [customListId, setCustomListId] = useState('');
// Shared State
const [autoRequest, setAutoRequest] = useState(true);
const [validationError, setValidationError] = useState('');
const [success, setSuccess] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
@@ -72,12 +74,12 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
try {
if (provider === 'goodreads') {
const shelf = await addGoodreads(rssUrl);
const shelf = await addGoodreads(rssUrl, autoRequest);
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
setRssUrl('');
} else {
const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim();
const shelf = await addHardcover(apiToken.trim(), finalId);
const shelf = await addHardcover(apiToken.trim(), finalId, autoRequest);
setSuccessMessage(`Added list "${shelf.name}" successfully!`);
setApiToken('');
setCustomListId('');
@@ -98,6 +100,7 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
setRssUrl('');
setApiToken('');
setCustomListId('');
setAutoRequest(true);
setValidationError('');
setSuccess(false);
setSuccessMessage('');
@@ -215,6 +218,32 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
/>
)}
{/* Auto-Request Toggle */}
<label className="flex items-center justify-between gap-3 p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700/30 cursor-pointer select-none">
<div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Auto-request books</span>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
Automatically request audiobooks from this shelf
</p>
</div>
<button
type="button"
role="switch"
aria-checked={autoRequest}
onClick={() => setAutoRequest(!autoRequest)}
disabled={isLoading || success}
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 ${
autoRequest ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
} ${(isLoading || success) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out ${
autoRequest ? 'translate-x-4' : 'translate-x-0.5'
} mt-0.5`}
/>
</button>
</label>
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="ghost" size="sm" onClick={handleClose} disabled={isLoading || success}>
Cancel
+2 -1
View File
@@ -45,12 +45,13 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro
try {
if (shelf.type === 'goodreads') {
if (!rssUrl.trim()) return;
await updateGoodreads(shelf.id, rssUrl.trim());
await updateGoodreads(shelf.id, { rssUrl: rssUrl.trim() });
} else {
if (!listId.trim()) return;
await updateHardcover(shelf.id, {
listId: listId.trim(),
apiToken: apiToken.trim() || undefined,
forceSync: true,
});
}
onClose();
+7 -1
View File
@@ -46,7 +46,13 @@ export function createShelfHooks<TShelf>(endpoint: string) {
const key = accessToken ? endpoint : null;
const { data, error, isLoading } = useSWR(key, fetcher, {
refreshInterval: 30000,
refreshInterval: (latestData: { shelves: TShelf[] } | undefined) => {
const shelves = latestData?.shelves || [];
const hasSyncing = shelves.some(
(s) => !(s as Record<string, unknown>)['lastSyncAt'],
);
return hasSyncing ? 3000 : 30000;
},
});
return {
+1
View File
@@ -33,6 +33,7 @@ export interface Audiobook {
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
isIgnored?: boolean; // True if this user has ignored this audiobook from auto-requests
}
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
+5 -4
View File
@@ -16,6 +16,7 @@ export interface GoodreadsShelf {
lastSyncAt: string | null;
createdAt: string;
bookCount: number | null;
autoRequest: boolean;
books: ShelfBook[];
}
@@ -27,8 +28,8 @@ export const useGoodreadsShelves = useList;
export function useAddGoodreadsShelf() {
const { addShelf: addGeneric, isLoading, error } = useAdd();
const addShelf = async (rssUrl: string) => {
return addGeneric({ rssUrl });
const addShelf = async (rssUrl: string, autoRequest: boolean = true) => {
return addGeneric({ rssUrl, autoRequest });
};
return { addShelf, isLoading, error };
@@ -39,8 +40,8 @@ export const useDeleteGoodreadsShelf = useDelete;
export function useUpdateGoodreadsShelf() {
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
const updateShelf = async (shelfId: string, rssUrl: string) => {
return updateGeneric(shelfId, { rssUrl });
const updateShelf = async (shelfId: string, updates: { rssUrl?: string; autoRequest?: boolean }) => {
return updateGeneric(shelfId, updates);
};
return { updateShelf, isLoading, error };
+4 -3
View File
@@ -16,6 +16,7 @@ export interface HardcoverShelf {
lastSyncAt: string | null;
createdAt: string;
bookCount: number | null;
autoRequest: boolean;
books: ShelfBook[];
}
@@ -27,8 +28,8 @@ export const useHardcoverShelves = useList;
export function useAddHardcoverShelf() {
const { addShelf: addGeneric, isLoading, error } = useAdd();
const addShelf = async (apiToken: string, listId: string) => {
return addGeneric({ apiToken, listId });
const addShelf = async (apiToken: string, listId: string, autoRequest: boolean = true) => {
return addGeneric({ apiToken, listId, autoRequest });
};
return { addShelf, isLoading, error };
@@ -41,7 +42,7 @@ export function useUpdateHardcoverShelf() {
const updateShelf = async (
shelfId: string,
updates: { listId?: string; apiToken?: string },
updates: { listId?: string; apiToken?: string; forceSync?: boolean; autoRequest?: boolean },
) => {
return updateGeneric(shelfId, updates);
};
+123
View File
@@ -0,0 +1,123 @@
/**
* Component: Ignored Audiobooks Hook
* Documentation: documentation/features/ignored-audiobooks.md
*
* Provides hooks for checking and toggling audiobook ignore status.
* - useIsIgnored(asin): check if a specific book is ignored
* - useToggleIgnore(): toggle ignore on/off for a book
* - useIgnoredList(): list all ignored books for the current user
*/
'use client';
import useSWR, { mutate } from 'swr';
import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api';
interface IgnoredAudiobook {
id: string;
asin: string;
title: string;
author: string;
coverArtUrl?: string;
createdAt: string;
}
interface IgnoreCheckResult {
ignored: boolean;
ignoredId?: string;
}
/**
* Check if a specific ASIN is ignored by the current user.
* Includes works-system expansion on the server side.
*/
export function useIsIgnored(asin: string | null) {
const endpoint = asin ? `/api/user/ignored-audiobooks/check/${asin}` : null;
const { data, error, isLoading } = useSWR<IgnoreCheckResult>(
endpoint,
authenticatedFetcher,
{
revalidateOnFocus: false,
dedupingInterval: 30000,
}
);
return {
isIgnored: data?.ignored ?? false,
ignoredId: data?.ignoredId ?? null,
isLoading,
error,
};
}
/**
* Toggle ignore status for an audiobook.
* Returns { addIgnore, removeIgnore } functions.
*/
export function useToggleIgnore() {
const addIgnore = async (book: {
asin: string;
title: string;
author: string;
coverArtUrl?: string;
}): Promise<IgnoredAudiobook> => {
const res = await fetchWithAuth('/api/user/ignored-audiobooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(book),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || 'Failed to ignore audiobook');
}
const result = await res.json();
// Invalidate the check cache for this ASIN
mutate(`/api/user/ignored-audiobooks/check/${book.asin}`);
// Invalidate the full list
mutate('/api/user/ignored-audiobooks');
return result.ignoredAudiobook;
};
const removeIgnore = async (id: string, asin: string): Promise<void> => {
const res = await fetchWithAuth(`/api/user/ignored-audiobooks/${id}`, {
method: 'DELETE',
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || 'Failed to un-ignore audiobook');
}
// Invalidate the check cache for this ASIN
mutate(`/api/user/ignored-audiobooks/check/${asin}`);
// Invalidate the full list
mutate('/api/user/ignored-audiobooks');
};
return { addIgnore, removeIgnore };
}
/**
* List all ignored audiobooks for the current user.
*/
export function useIgnoredList() {
const { data, error, isLoading } = useSWR<{ ignoredAudiobooks: IgnoredAudiobook[] }>(
'/api/user/ignored-audiobooks',
authenticatedFetcher,
{
revalidateOnFocus: false,
dedupingInterval: 60000,
}
);
return {
ignoredAudiobooks: data?.ignoredAudiobooks ?? [],
isLoading,
error,
};
}
+91 -1
View File
@@ -5,8 +5,9 @@
'use client';
import { useState } from 'react';
import { useState, useMemo } from 'react';
import useSWR, { mutate } from 'swr';
import useSWRInfinite from 'swr/infinite';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
import { Audiobook } from './useAudiobooks';
@@ -59,6 +60,95 @@ export function useRequests(status?: string, limit: number = 50, myOnly: boolean
};
}
// ── Active statuses that warrant live polling ────────────────────────────────
const ACTIVE_STATUSES = new Set([
'pending', 'awaiting_search', 'awaiting_approval',
'searching', 'downloading', 'processing', 'awaiting_import',
]);
export type RequestFilterGroup = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled';
export interface RequestCounts {
all: number;
active: number;
waiting: number;
completed: number;
failed: number;
cancelled: number;
}
export interface RequestPage {
requests: Request[];
nextCursor: string | null;
counts: RequestCounts;
}
const PAGE_SIZE = 20;
/**
* Paginated hook for "My Requests" page.
* Uses SWRInfinite for cursor-based pagination.
* Polls only when active requests are present.
*/
export function useMyRequests(filter: RequestFilterGroup) {
const { accessToken } = useAuth();
const getKey = (pageIndex: number, previousPage: RequestPage | null): string | null => {
if (!accessToken) return null;
if (previousPage && !previousPage.nextCursor) return null; // reached end
const params = new URLSearchParams();
params.set('myOnly', 'true');
params.set('take', String(PAGE_SIZE));
if (filter !== 'all') params.set('status', filter);
if (pageIndex > 0 && previousPage?.nextCursor) {
params.set('cursor', previousPage.nextCursor);
}
return `/api/requests?${params.toString()}`;
};
const { data, error, isLoading, isValidating, size, setSize, mutate: revalidate } =
useSWRInfinite<RequestPage>(getKey, fetcher, {
revalidateFirstPage: true,
revalidateOnFocus: false,
// Smart polling: refresh every 5s only when active requests exist
refreshInterval: (data) => {
if (!data) return 5000;
const hasActive = data.some(page =>
page.requests.some(r => ACTIVE_STATUSES.has(r.status))
);
return hasActive ? 5000 : 0;
},
});
const allRequests = useMemo(
() => data?.flatMap(page => page.requests) ?? [],
[data]
);
// Counts come from the first page (always the authoritative totals)
const counts: RequestCounts = data?.[0]?.counts ?? {
all: 0, active: 0, waiting: 0, completed: 0, failed: 0, cancelled: 0,
};
const hasMore = data ? !!data[data.length - 1]?.nextCursor : false;
const isLoadingMore = isValidating && size > 1 && !data?.[size - 1];
const isEmpty = !isLoading && allRequests.length === 0;
const loadMore = () => setSize(s => s + 1);
return {
requests: allRequests,
counts,
hasMore,
isLoading,
isLoadingMore,
isEmpty,
loadMore,
revalidate,
};
}
export function useRequest(requestId: string) {
const { accessToken } = useAuth();
+57 -3
View File
@@ -2,10 +2,10 @@
* Component: Shelves Hook
* Documentation: documentation/frontend/components.md
*/
'use client';
import useSWR from 'swr';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
import { ShelfBook } from './useGoodreadsShelves';
@@ -18,6 +18,7 @@ export interface GenericShelf {
lastSyncAt: string | null;
createdAt: string;
bookCount: number | null;
autoRequest: boolean;
books: ShelfBook[];
}
@@ -29,7 +30,11 @@ export function useShelves() {
const endpoint = accessToken ? '/api/user/shelves' : null;
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
refreshInterval: 30000,
refreshInterval: (latestData: { shelves: GenericShelf[] } | undefined) => {
const shelves = latestData?.shelves || [];
const hasSyncing = shelves.some((s) => !s.lastSyncAt);
return hasSyncing ? 3000 : 30000;
},
});
return {
@@ -38,3 +43,52 @@ export function useShelves() {
error,
};
}
export function useSyncShelves() {
const { accessToken } = useAuth();
const [isSyncing, setIsSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const syncShelves = async (
shelfId?: string,
shelfType?: 'goodreads' | 'hardcover',
) => {
if (!accessToken) throw new Error('Not authenticated');
setIsSyncing(true);
setError(null);
try {
const response = await fetchWithAuth('/api/user/shelves/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shelfId, shelfType }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to trigger sync');
}
// Invalidate both the provider-specific endpoints and the combined endpoint
mutate(
(key) =>
typeof key === 'string' &&
(key.includes('/api/user/shelves') ||
key.includes('/api/user/goodreads-shelves') ||
key.includes('/api/user/hardcover-shelves')),
);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsSyncing(false);
}
};
return { syncShelves, isSyncing, error };
}
+1
View File
@@ -226,6 +226,7 @@ export class NZBGetService implements IDownloadClient {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 5,
headers: options?.sourceHeaders,
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
});
+4
View File
@@ -24,6 +24,8 @@ export interface AddNZBOptions {
category?: string;
priority?: 'low' | 'normal' | 'high' | 'force';
paused?: boolean;
/** Headers to include when fetching the NZB from the source URL */
sourceHeaders?: Record<string, string>;
}
export interface NZBInfo {
@@ -492,6 +494,7 @@ export class SABnzbdService implements IDownloadClient {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 5,
headers: options?.sourceHeaders,
// Use the same SSL settings as the SABnzbd client if the NZB URL
// happens to be served over HTTPS with a self-signed cert
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
@@ -787,6 +790,7 @@ export class SABnzbdService implements IDownloadClient {
category: options?.category,
priority: options?.priority ? priorityMap[options.priority] || 'normal' : undefined,
paused: options?.paused,
sourceHeaders: options?.sourceHeaders,
});
}
@@ -102,6 +102,8 @@ export interface AddDownloadOptions {
priority?: string;
/** Whether to add in paused state */
paused?: boolean;
/** Headers to include when fetching the source file (e.g. Prowlarr API key for proxy URLs) */
sourceHeaders?: Record<string, string>;
}
/** Result of a connection test */
@@ -289,8 +289,11 @@ async function downloadFileWithProgress(
logger: RMABLogger
): Promise<boolean> {
try {
// Ensure target directory exists
await fs.mkdir(path.dirname(targetPath), { recursive: true });
// Ensure target directory exists with configured permissions
const configService = getConfigService();
const dirChmodStr = await configService.get('dir_chmod') || '775';
const dirMode = parseInt(dirChmodStr, 8);
await fs.mkdir(path.dirname(targetPath), { recursive: true, mode: dirMode });
// Start download with axios streaming
const response = await axios({
@@ -58,10 +58,19 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
logger.info(`Routing to ${client.clientType} (${client.protocol})`);
// Include Prowlarr API key as source header so NZB/torrent downloads from
// Prowlarr proxy URLs are authenticated (fixes 403 for indexers like NZBFinder)
const prowlarrApiKey = (await config.getMany(['prowlarr_api_key'])).prowlarr_api_key || process.env.PROWLARR_API_KEY;
const sourceHeaders: Record<string, string> = {};
if (prowlarrApiKey) {
sourceHeaders['X-Api-Key'] = prowlarrApiKey;
}
// Add download via unified interface
const downloadClientId = await client.addDownload(torrent.downloadUrl, {
category,
priority: 'normal',
sourceHeaders,
});
logger.info(`Download added with ID: ${downloadClientId}`);
@@ -254,7 +254,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
const matchableRequests = await prisma.request.findMany({
where: {
type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available')
status: { notIn: ['available', 'cancelled'] },
status: { notIn: ['available', 'cancelled', 'denied'] },
deletedAt: null,
},
include: {
@@ -265,7 +265,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
},
},
},
take: 100,
});
if (matchableRequests.length > 0) {
+2 -2
View File
@@ -439,7 +439,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
const matchableRequests = await prisma.request.findMany({
where: {
type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available')
status: { notIn: ['available', 'cancelled'] },
status: { notIn: ['available', 'cancelled', 'denied'] },
deletedAt: null,
},
include: {
@@ -450,7 +450,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
},
},
},
take: 100, // Increased from 50 to handle more eligible requests
});
logger.info(`Found ${matchableRequests.length} matchable requests (all non-terminal statuses)`);
+13 -4
View File
@@ -36,16 +36,25 @@ export async function processSearchEbook(payload: SearchEbookPayload): Promise<a
logger.info(`Processing ebook request ${requestId} for "${audiobook.title}"`);
try {
// Update request status to searching
await prisma.request.update({
// Update request status to searching and fetch custom search terms
const requestRecord = await prisma.request.update({
where: { id: requestId },
data: {
status: 'searching',
searchAttempts: { increment: 1 },
updatedAt: new Date(),
},
select: { customSearchTerms: true },
});
// Use custom search terms if set, otherwise use audiobook title
const effectiveSearchTitle = requestRecord?.customSearchTerms || audiobook.title;
const searchAudiobook = { ...audiobook, title: effectiveSearchTitle };
if (requestRecord?.customSearchTerms) {
logger.info(`Using custom search terms: "${effectiveSearchTitle}" (original: "${audiobook.title}")`);
}
// Get ebook configuration
const configService = getConfigService();
const preferredFormat = payloadFormat || await configService.get('ebook_sidecar_preferred_format') || 'epub';
@@ -62,7 +71,7 @@ export async function processSearchEbook(payload: SearchEbookPayload): Promise<a
// ========== STEP 1: Try Anna's Archive (if enabled) ==========
if (annasArchiveEnabled) {
logger.info(`Searching Anna's Archive...`);
annasArchiveResult = await searchAnnasArchive(audiobook, preferredFormat, logger);
annasArchiveResult = await searchAnnasArchive(searchAudiobook, preferredFormat, logger);
if (annasArchiveResult) {
logger.info(`Found ebook via Anna's Archive (score: ${annasArchiveResult.score})`);
@@ -74,7 +83,7 @@ export async function processSearchEbook(payload: SearchEbookPayload): Promise<a
// ========== STEP 2: Try Indexer Search (if enabled and no Anna's Archive result) ==========
if (!annasArchiveResult && indexerSearchEnabled) {
logger.info(`Searching indexers...`);
indexerResult = await searchIndexers(requestId, audiobook, preferredFormat, logger);
indexerResult = await searchIndexers(requestId, searchAudiobook, preferredFormat, logger);
if (indexerResult) {
logger.info(`Found ebook via indexer search (score: ${indexerResult.finalScore.toFixed(1)})`);
+5 -1
View File
@@ -15,6 +15,8 @@ export interface SyncShelvesPayload {
shelfId?: string;
/** The type of shelf, if shelfId is specified */
shelfType?: 'goodreads' | 'hardcover';
/** If set, only process shelves for this user */
userId?: string;
/** Max Audible lookups per shelf. 0 = unlimited. */
maxLookupsPerShelf?: number;
}
@@ -22,7 +24,7 @@ export interface SyncShelvesPayload {
export async function processSyncShelves(
payload: SyncShelvesPayload,
): Promise<any> {
const { jobId, shelfId, shelfType, maxLookupsPerShelf } = payload;
const { jobId, shelfId, shelfType, userId, maxLookupsPerShelf } = payload;
const logger = RMABLogger.forJob(jobId, 'SyncShelves');
const stats = {
@@ -48,6 +50,7 @@ export async function processSyncShelves(
await import('../services/goodreads-sync.service');
const grStats = await processGoodreadsShelves(logger, {
shelfId: shelfType === 'goodreads' ? shelfId : undefined,
userId,
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
});
@@ -70,6 +73,7 @@ export async function processSyncShelves(
await import('../services/hardcover-sync.service');
const hcStats = await processHardcoverShelves(logger, {
shelfId: shelfType === 'hardcover' ? shelfId : undefined,
userId,
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
});
+7 -1
View File
@@ -98,9 +98,15 @@ export class OIDCAuthProvider implements IAuthProvider {
timestamp: Date.now(),
});
// Only request 'groups' scope when group-based features are configured
const accessMethod = await this.configService.get('oidc.access_control_method');
const adminClaimEnabled = await this.configService.get('oidc.admin_claim_enabled');
const needsGroups = accessMethod === 'group_claim' || adminClaimEnabled === 'true';
const scope = needsGroups ? 'openid profile email groups' : 'openid profile email';
// Generate authorization URL
const redirectUrl = client.authorizationUrl({
scope: 'openid profile email groups',
scope,
state,
nonce,
code_challenge: codeChallenge,
+7 -3
View File
@@ -9,6 +9,7 @@
import axios from 'axios';
import { XMLParser } from 'fast-xml-parser';
import { prisma } from '@/lib/db';
import { Prisma } from '@/generated/prisma/client';
import { RMABLogger } from '@/lib/utils/logger';
import {
ShelfBook,
@@ -118,7 +119,10 @@ export async function processGoodreadsShelves(
const stats = createEmptyStats();
const maxLookups = resolveMaxLookups(options);
const whereClause = options.shelfId ? { id: options.shelfId } : {};
const whereClause: Prisma.GoodreadsShelfWhereInput = {};
if (options.shelfId) whereClause.id = options.shelfId;
if (options.userId) whereClause.userId = options.userId;
const shelves = await prisma.goodreadsShelf.findMany({
where: whereClause,
include: { user: { select: { id: true, plexUsername: true } } },
@@ -144,10 +148,10 @@ export async function processGoodreadsShelves(
continue;
}
log.info(`Found ${rssData.books.length} books in shelf "${shelf.name}"`);
log.info(`Found ${rssData.books.length} books in shelf "${shelf.name}"${!shelf.autoRequest ? ' (auto-request disabled)' : ''}`);
const bookData = await processShelfBooks(
'goodreads', rssData.books, shelf.user.id, shelf.id, stats, log, maxLookups,
'goodreads', rssData.books, shelf.user.id, shelf.id, stats, log, maxLookups, shelf.autoRequest,
);
await prisma.goodreadsShelf.update({
+6 -3
View File
@@ -7,6 +7,7 @@
*/
import { prisma } from '@/lib/db';
import { Prisma } from '@/generated/prisma/client';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
import { fetchHardcoverList, HardcoverApiBook } from '@/lib/services/hardcover-api.service';
@@ -38,8 +39,10 @@ export async function processHardcoverShelves(
const log = jobLogger || logger;
const stats = createEmptyStats();
const maxLookups = resolveMaxLookups(options);
const whereClause: Prisma.HardcoverShelfWhereInput = {};
if (options.shelfId) whereClause.id = options.shelfId;
if (options.userId) whereClause.userId = options.userId;
const whereClause = options.shelfId ? { id: options.shelfId } : {};
const shelves = await prisma.hardcoverShelf.findMany({
where: whereClause,
include: { user: { select: { id: true, plexUsername: true } } },
@@ -85,10 +88,10 @@ export async function processHardcoverShelves(
continue;
}
log.info(`Found ${fetchedData.books.length} books in list "${shelf.name}" (Hardcover API)`);
log.info(`Found ${fetchedData.books.length} books in list "${shelf.name}" (Hardcover API)${!shelf.autoRequest ? ' (auto-request disabled)' : ''}`);
const bookData = await processShelfBooks(
'hardcover', fetchedData.books, shelf.user.id, shelf.id, stats, log, maxLookups,
'hardcover', fetchedData.books, shelf.user.id, shelf.id, stats, log, maxLookups, shelf.autoRequest,
);
const finalListName =
+9 -1
View File
@@ -112,6 +112,7 @@ export interface SyncShelvesPayload extends JobPayload {
scheduledJobId?: string;
shelfId?: string;
shelfType?: 'goodreads' | 'hardcover';
userId?: string;
maxLookupsPerShelf?: number;
}
@@ -770,7 +771,13 @@ export class JobQueueService {
/**
* Add sync reading shelves job
*/
async addSyncShelvesJob(scheduledJobId?: string, shelfId?: string, shelfType?: 'goodreads' | 'hardcover', maxLookupsPerShelf?: number): Promise<string> {
async addSyncShelvesJob(
scheduledJobId?: string,
shelfId?: string,
shelfType?: 'goodreads' | 'hardcover',
maxLookupsPerShelf?: number,
userId?: string
): Promise<string> {
return await this.addJob(
'sync_reading_shelves',
{
@@ -778,6 +785,7 @@ export class JobQueueService {
shelfId,
shelfType,
maxLookupsPerShelf,
userId,
} as SyncShelvesPayload,
{
priority: 7,
+48 -3
View File
@@ -12,7 +12,7 @@ import { getJobQueueService } from '@/lib/services/job-queue.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
import { seedAsin } from '@/lib/services/works.service';
import { seedAsin, getSiblingAsins } from '@/lib/services/works.service';
const logger = RMABLogger.create('RequestCreator');
@@ -27,11 +27,13 @@ export interface CreateRequestInput {
export interface CreateRequestOptions {
skipAutoSearch?: boolean;
/** When true, skip the per-user ignore list check (used for manual requests) */
bypassIgnore?: boolean;
}
export type CreateRequestResult =
| { success: true; request: any }
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found'; message: string };
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found' | 'ignored'; message: string };
/**
* Create a request for a user, with full duplicate detection, library checks,
@@ -42,7 +44,7 @@ export async function createRequestForUser(
audiobook: CreateRequestInput,
options: CreateRequestOptions = {}
): Promise<CreateRequestResult> {
const { skipAutoSearch = false } = options;
const { skipAutoSearch = false, bypassIgnore = false } = options;
// Check for existing active request (downloaded/available) for this ASIN
const existingActiveRequest = await prisma.request.findFirst({
@@ -81,6 +83,18 @@ export async function createRequestForUser(
};
}
// Check per-user ignore list (skipped for manual requests via bypassIgnore)
if (!bypassIgnore) {
const isIgnored = await checkIgnoreList(userId, audiobook.asin);
if (isIgnored) {
return {
success: false,
reason: 'ignored',
message: 'This audiobook is on your ignore list',
};
}
}
// Fetch full details from Audnexus for year/series
let year: number | undefined;
let series: string | undefined;
@@ -279,3 +293,34 @@ export async function createRequestForUser(
return { success: true, request: newRequest };
}
/**
* Check if an ASIN (or any of its sibling ASINs via the works table)
* is on the user's ignore list. Returns true if the book should be blocked.
*/
async function checkIgnoreList(userId: string, asin: string): Promise<boolean> {
// Direct check: is this exact ASIN ignored?
const directIgnore = await prisma.ignoredAudiobook.findUnique({
where: { userId_asin: { userId, asin } },
});
if (directIgnore) return true;
// Works-system expansion: check sibling ASINs
try {
const siblingMap = await getSiblingAsins([asin]);
const siblings = siblingMap.get(asin);
if (siblings && siblings.length > 0) {
const siblingIgnore = await prisma.ignoredAudiobook.findFirst({
where: {
userId,
asin: { in: siblings },
},
});
if (siblingIgnore) return true;
}
} catch {
// Works expansion is best-effort — if it fails, only direct check applies
}
return false;
}
+3 -1
View File
@@ -39,6 +39,7 @@ export interface ShelfSyncStats {
/** Common sync options */
export interface ShelfSyncOptions {
shelfId?: string;
userId?: string;
maxLookupsPerShelf?: number;
}
@@ -72,6 +73,7 @@ export async function processShelfBooks(
stats: ShelfSyncStats,
log: LoggerType,
maxLookups: number,
autoRequest: boolean = true,
): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> {
stats.booksFound += books.length;
@@ -111,7 +113,7 @@ export async function processShelfBooks(
}
}
if (mapping.audibleAsin) {
if (mapping.audibleAsin && autoRequest) {
try {
const result = await createRequestForUser(userId, {
asin: mapping.audibleAsin,
+2 -1
View File
@@ -62,6 +62,7 @@ export interface MergeOptions {
year?: number;
asin?: string;
outputPath: string;
dirMode?: number;
}
export interface MergeResult {
@@ -616,7 +617,7 @@ export async function mergeChapters(
await logger?.info(`✓ All ${chapters.length} source files validated`);
// Ensure temp directory exists
await fs.mkdir(tempDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true, ...(options.dirMode !== undefined && { mode: options.dirMode }) });
// Create concat file
const concatContent = chapters
+51 -9
View File
@@ -6,7 +6,7 @@
* under different ASINs (publisher re-listings, rights transfers, etc.).
*
* Dedup key: normalized title + normalized narrator
* Duration tolerance: max(longerDuration * 0.01, 5) minutes
* Duration tolerance: max(longerDuration * 0.05, 10) minutes
* Missing duration treated as compatible (graceful degradation).
*/
@@ -19,7 +19,7 @@ import type { AudibleAudiobook } from '../integrations/audible.service';
/** Patterns in parentheses or brackets to strip (edition markers, format labels) */
const EDITION_PAREN_RE = /[([][^)\]]*?(?:unabridged|abridged|edition|remaster(?:ed)?|anniversary|complete|original|version|narrat(?:ed|or)?|audio(?:book)?|full cast|dramatiz(?:ed|ation))[^)\]]*[)\]]/gi;
/** Trailing subtitle after colon or long dash */
/** Trailing subtitle after colon or long dash (used for extraction, not blind stripping) */
const SUBTITLE_RE = /\s*[:]\s+.+$/;
const LONG_DASH_SUBTITLE_RE = /\s+[-\u2013\u2014]\s+.+$/;
@@ -44,6 +44,44 @@ export function normalizeTitle(title: string): string {
return t.replace(/\s+/g, ' ').trim();
}
/**
* Extract the subtitle portion from a title (part after colon or long dash).
* Returns empty string if no subtitle found.
* Used to prevent false dedup of series books like "Series: Book A" vs "Series: Book B".
*/
export function extractSubtitle(title: string): string {
let t = title.toLowerCase();
// Remove parenthesized/bracketed edition markers first (same as normalizeTitle)
t = t.replace(EDITION_PAREN_RE, '');
// Remove trailing descriptors
t = t.replace(TRAILING_DESCRIPTOR_RE, '');
t = t.replace(/\s+/g, ' ').trim();
// Try colon subtitle
const colonMatch = t.match(/\s*[:]\s+(.+)$/);
if (colonMatch) return colonMatch[1].trim();
// Try long dash subtitle
const dashMatch = t.match(/\s+[-\u2013\u2014]\s+(.+)$/);
if (dashMatch) return dashMatch[1].trim();
return '';
}
/**
* Check if two titles' subtitles are compatible for dedup purposes.
* - Both have no subtitle compatible
* - One has a subtitle, other doesn't compatible (re-listing with/without subtitle)
* - Both have the SAME subtitle compatible
* - Both have DIFFERENT subtitles NOT compatible (different books, e.g. series entries)
*/
function areSubtitlesCompatible(titleA: string, titleB: string): boolean {
const subA = extractSubtitle(titleA);
const subB = extractSubtitle(titleB);
if (!subA || !subB) return true; // one or both missing → compatible
return subA === subB;
}
/** Normalize narrator for comparison. Sorts individual names so order doesn't matter. */
function normalizeNarrator(narrator?: string): string {
const raw = (narrator || '').toLowerCase().trim();
@@ -57,13 +95,13 @@ function normalizeNarrator(narrator?: string): string {
/**
* Check if two durations are compatible (represent the same recording).
* Tolerance: max(longerDuration * 0.01, 5) minutes.
* Tolerance: max(longerDuration * 0.05, 10) minutes.
* Missing duration on either side is treated as compatible.
*/
export function areDurationsCompatible(a?: number, b?: number): boolean {
if (a == null || b == null) return true;
const longer = Math.max(a, b);
const tolerance = Math.max(longer * 0.01, 5);
const tolerance = Math.max(longer * 0.05, 10);
return Math.abs(a - b) <= tolerance;
}
@@ -152,16 +190,20 @@ export function deduplicateAndCollectGroups(books: AudibleAudiobook[]): Deduplic
continue;
}
// Within a title+narrator group, further split by duration compatibility.
// Build sub-groups where all members are duration-compatible with the
// representative (first member). A book joins the first compatible sub-group.
// Within a title+narrator group, further split by duration AND subtitle
// compatibility. Build sub-groups where all members are compatible with
// the representative (first member). A book joins the first compatible sub-group.
// This prevents false dedup of series entries like "Series: Book A" vs "Series: Book B".
const subGroups: AudibleAudiobook[][] = [];
for (const book of group) {
let placed = false;
for (const sg of subGroups) {
// Check compatibility against the representative (first member)
if (areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes)) {
// Check both duration and subtitle compatibility against the representative
if (
areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes) &&
areSubtitlesCompatible(sg[0].title, book.title)
) {
sg.push(book);
placed = true;
break;
+5 -1
View File
@@ -17,6 +17,7 @@ import * as cheerio from 'cheerio';
import path from 'path';
import fs from 'fs/promises';
import { RMABLogger } from './logger';
import { getConfigService } from '../services/config.service';
const moduleLogger = RMABLogger.create('EpubFixer');
@@ -204,7 +205,10 @@ export async function fixEpubForKindle(
// Create unique temp subdirectory to avoid filename conflicts
// This preserves the original filename for the final organized file
const uniqueDir = path.join(tempDir, `kindle-fix-${Date.now()}`);
await fs.mkdir(uniqueDir, { recursive: true });
const configService = getConfigService();
const dirChmodStr = await configService.get('dir_chmod') || '775';
const dirMode = parseInt(dirChmodStr, 8);
await fs.mkdir(uniqueDir, { recursive: true, mode: dirMode });
// Keep original filename
const sourceFilename = path.basename(sourcePath);
+23 -10
View File
@@ -64,10 +64,14 @@ export interface LoggerConfig {
export class FileOrganizer {
private mediaDir: string;
private tempDir: string;
private fileMode: number;
private dirMode: number;
constructor(mediaDir: string = '/media/audiobooks', tempDir: string = '/tmp/readmeabook') {
constructor(mediaDir: string = '/media/audiobooks', tempDir: string = '/tmp/readmeabook', fileMode: number = 0o664, dirMode: number = 0o775) {
this.mediaDir = mediaDir;
this.tempDir = tempDir;
this.fileMode = fileMode;
this.dirMode = dirMode;
}
/**
@@ -166,6 +170,7 @@ export class FileOrganizer {
year: audiobook.year,
asin: audiobook.asin,
outputPath,
dirMode: this.dirMode,
},
logger ?? undefined
);
@@ -293,7 +298,7 @@ export class FileOrganizer {
await logger?.info(`Target path: ${targetPath}`);
// Create target directory
await fs.mkdir(targetPath, { recursive: true });
await fs.mkdir(targetPath, { recursive: true, mode: this.dirMode });
// Determine if file renaming should be applied
const shouldRename = renameConfig?.enabled && renameConfig.template;
@@ -386,7 +391,7 @@ export class FileOrganizer {
// Copy file via streams (avoids copy_file_range EPERM on NFS/FUSE)
await copyFile(sourcePath, targetFilePath);
// Set explicit permissions after copy
await fs.chmod(targetFilePath, 0o644);
await fs.chmod(targetFilePath, this.fileMode);
result.audioFiles.push(targetFilePath);
result.filesMovedCount++;
@@ -422,7 +427,7 @@ export class FileOrganizer {
try {
await fs.access(originalSourcePath, fs.constants.R_OK);
await copyFile(originalSourcePath, targetFilePath);
await fs.chmod(targetFilePath, 0o644);
await fs.chmod(targetFilePath, this.fileMode);
result.audioFiles.push(targetFilePath);
result.filesMovedCount++;
await logger?.info(`Fallback copy succeeded (without metadata tags): ${filename}`);
@@ -457,7 +462,7 @@ export class FileOrganizer {
try {
// Copy cover art (do NOT delete original)
await copyFile(sourcePath, targetCoverPath);
await fs.chmod(targetCoverPath, 0o644);
await fs.chmod(targetCoverPath, this.fileMode);
result.coverArtFile = targetCoverPath;
result.filesMovedCount++;
await logger?.info(`Copied cover art`);
@@ -718,7 +723,7 @@ export class FileOrganizer {
// Copy from local cache instead of downloading
await copyFile(cachedPath, targetPath);
await fs.chmod(targetPath, 0o644);
await fs.chmod(targetPath, this.fileMode);
moduleLogger.debug(`Copied cover art from cache: ${filename}`);
} else {
// Download from external URL (e.g., Audible CDN)
@@ -846,7 +851,7 @@ export class FileOrganizer {
await logger?.info(`Target directory: ${targetDir}`);
// Create target directory
await fs.mkdir(targetDir, { recursive: true });
await fs.mkdir(targetDir, { recursive: true, mode: this.dirMode });
// Build target filename (apply rename template if enabled, otherwise sanitize source filename)
const sourceFilename = path.basename(ebookFile);
@@ -882,7 +887,7 @@ export class FileOrganizer {
// Copy ebook file (do NOT delete original - may need for seeding or retry)
await copyFile(sourceFilePath, targetPath);
await fs.chmod(targetPath, 0o644);
await fs.chmod(targetPath, this.fileMode);
await logger?.info(`Copied ebook: ${targetFilename}`);
@@ -968,7 +973,7 @@ export class FileOrganizer {
/**
* Get FileOrganizer instance configured from database settings
* Reads media_dir from database configuration, falls back to /media/audiobooks if not configured
* Reads media_dir, file_chmod, dir_chmod from database configuration
*/
export async function getFileOrganizer(): Promise<FileOrganizer> {
// Read media_dir from database config
@@ -979,7 +984,15 @@ export async function getFileOrganizer(): Promise<FileOrganizer> {
const mediaDir = config?.value || process.env.MEDIA_DIR || '/media/audiobooks';
const tempDir = process.env.TEMP_DIR || '/tmp/readmeabook';
return new FileOrganizer(mediaDir, tempDir);
// Read file/directory permission settings
const { getConfigService } = await import('../services/config.service');
const configService = getConfigService();
const fileChmodStr = await configService.get('file_chmod') || '664';
const dirChmodStr = await configService.get('dir_chmod') || '775';
const fileMode = parseInt(fileChmodStr, 8);
const dirMode = parseInt(dirChmodStr, 8);
return new FileOrganizer(mediaDir, tempDir, fileMode, dirMode);
}
/**
+37
View File
@@ -0,0 +1,37 @@
/**
* Component: Ignored Audiobooks Utility
* Documentation: documentation/features/ignored-audiobooks.md
*
* Shared utility for annotating audiobook lists with per-user ignore status.
* Uses a single bulk query for the user's full ignore list, then annotates in-memory.
*/
import { prisma } from '@/lib/db';
/**
* Annotate an array of audiobook objects with `isIgnored: boolean`.
* Fetches the user's full ignore list in one query and matches by ASIN.
*
* If userId is undefined (unauthenticated), all books get `isIgnored: false`.
*/
export async function annotateWithIgnoreStatus<T extends { asin: string }>(
audiobooks: T[],
userId?: string
): Promise<(T & { isIgnored: boolean })[]> {
if (!userId || audiobooks.length === 0) {
return audiobooks.map((book) => ({ ...book, isIgnored: false }));
}
// Single query: get all ASINs this user has ignored
const ignoredEntries = await prisma.ignoredAudiobook.findMany({
where: { userId },
select: { asin: true },
});
const ignoredAsinSet = new Set(ignoredEntries.map((e) => e.asin));
return audiobooks.map((book) => ({
...book,
isIgnored: ignoredAsinSet.has(book.asin),
}));
}
@@ -27,6 +27,13 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
enrichAudiobooksWithMatches: enrichMock,
}));
// Mock ignore status annotation — pass-through that adds isIgnored: false
vi.mock('@/lib/utils/ignored-audiobooks', () => ({
annotateWithIgnoreStatus: vi.fn(async (books: any[]) =>
books.map((b: any) => ({ ...b, isIgnored: false }))
),
}));
vi.mock('@/lib/middleware/auth', () => ({
getCurrentUser: currentUserMock,
}));
@@ -164,7 +164,7 @@ describe('PATCH /api/user/goodreads-shelves/[id]', () => {
where: { id: 'shelf-1' },
data: { rssUrl: NEW_RSS, lastSyncAt: null, bookCount: null, coverUrls: null },
});
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updatedShelf.id, 'goodreads', 0);
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updatedShelf.id, 'goodreads', 0, 'user-1');
});
it('still returns 200 even when the sync job fails to enqueue', async () => {
+36 -1
View File
@@ -164,6 +164,41 @@ describe('PATCH /api/user/hardcover-shelves/[id]', () => {
expect(jobQueueMock.addSyncShelvesJob).not.toHaveBeenCalled();
});
it('triggers a sync when forceSync is true, even if no fields changed', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF);
const { PATCH } =
await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await PATCH(
{
json: vi
.fn()
.mockResolvedValue({ listId: SHELF.listId, forceSync: true }),
} as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) },
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(prismaMock.hardcoverShelf.update).toHaveBeenCalledWith({
where: { id: 'hc-shelf-1' },
data: expect.objectContaining({
lastSyncAt: null,
bookCount: null,
coverUrls: null,
}),
});
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(
undefined,
SHELF.id,
'hardcover',
0,
'user-1',
);
});
it('updates listId, clears metadata, and triggers a sync job', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
const updated = { ...SHELF, listId: 'status-3', lastSyncAt: null };
@@ -182,7 +217,7 @@ describe('PATCH /api/user/hardcover-shelves/[id]', () => {
where: { id: 'hc-shelf-1' },
data: expect.objectContaining({ listId: 'status-3', lastSyncAt: null }),
});
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updated.id, 'hardcover', 0);
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updated.id, 'hardcover', 0, 'user-1');
});
it('encrypts the apiToken before persisting', async () => {
+1 -1
View File
@@ -137,7 +137,7 @@ describe('POST /api/user/hardcover-shelves', () => {
);
// Immediate background sync must have been triggered
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, 'new-shelf', 'hardcover', 0);
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, 'new-shelf', 'hardcover', 0, 'user-1');
});
it('strips Bearer prefix from apiToken before encrypting', async () => {
+2 -1
View File
@@ -202,6 +202,7 @@ describe('Requests API routes', () => {
it('filters requests for current user when not admin', async () => {
authRequest.nextUrl = new URL('http://localhost/api/requests?status=pending&limit=5');
prismaMock.request.findMany.mockResolvedValueOnce([]);
prismaMock.request.count.mockResolvedValue(0);
const { GET } = await import('@/app/api/requests/route');
const response = await GET({} as any);
@@ -212,7 +213,7 @@ describe('Requests API routes', () => {
expect(prismaMock.request.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ userId: 'user-1', status: 'pending' }),
take: 5,
take: 6, // limit + 1 for cursor pagination next-page detection
})
);
});
+121
View File
@@ -0,0 +1,121 @@
/**
* Component: Shelves Sync API Route Tests
* Documentation: documentation/backend/services/goodreads-sync.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const prismaMock = createPrismaMock();
const jobQueueMock = vi.hoisted(() => ({
addSyncShelvesJob: vi.fn(() => Promise.resolve()),
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
describe('POST /api/user/shelves/sync', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'user-1', role: 'user' } };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('triggers a manual sync for all shelves when no parameters provided', async () => {
const { POST } = await import('@/app/api/user/shelves/sync/route');
const response = await POST(
{ json: vi.fn().mockResolvedValue({}) } as any,
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
// Both tables should have updateMany called to clear lastSyncAt
expect(prismaMock.goodreadsShelf.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
data: { lastSyncAt: null },
});
expect(prismaMock.hardcoverShelf.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
data: { lastSyncAt: null },
});
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(
undefined, // scheduledJobId
undefined, // shelfId
undefined, // shelfType
0, // maxLookupsPerShelf (unlimited for manual)
'user-1' // userId
);
});
it('triggers a manual sync for a specific shelf', async () => {
const { POST } = await import('@/app/api/user/shelves/sync/route');
const response = await POST(
{ json: vi.fn().mockResolvedValue({ shelfId: 'shelf-123', shelfType: 'goodreads' }) } as any,
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
// Only goodreads should be updated since shelfType is specified
expect(prismaMock.goodreadsShelf.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-1', id: 'shelf-123' },
data: { lastSyncAt: null },
});
expect(prismaMock.hardcoverShelf.updateMany).not.toHaveBeenCalled();
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(
undefined, // scheduledJobId
'shelf-123', // shelfId
'goodreads', // shelfType
0, // maxLookupsPerShelf
'user-1' // userId
);
});
it('handles invalid body gracefully', async () => {
const { POST } = await import('@/app/api/user/shelves/sync/route');
const response = await POST(
{ json: vi.fn().mockRejectedValue(new Error('Invalid JSON')) } as any,
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
// Since body parsing fails gracefully with catching () => ({}), it treats it as sync all
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(
undefined,
undefined,
undefined,
0,
'user-1'
);
});
it('validates wrong shelfType', async () => {
const { POST } = await import('@/app/api/user/shelves/sync/route');
const response = await POST(
{ json: vi.fn().mockResolvedValue({ shelfType: 'invalid-type' }) } as any,
);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('ValidationError');
expect(jobQueueMock.addSyncShelvesJob).not.toHaveBeenCalled();
});
});
+27 -10
View File
@@ -11,10 +11,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth';
import { resetMockRouter } from '../helpers/mock-next-navigation';
const useRequestsMock = vi.hoisted(() => vi.fn());
const useMyRequestsMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/hooks/useRequests', () => ({
useRequests: useRequestsMock,
useMyRequests: useMyRequestsMock,
}));
vi.mock('@/components/layout/Header', () => ({
@@ -41,13 +41,18 @@ describe('RequestsPage', () => {
beforeEach(() => {
resetMockAuthState();
resetMockRouter();
useRequestsMock.mockReset();
useMyRequestsMock.mockReset();
vi.resetModules();
});
const defaultCounts = { all: 0, active: 0, waiting: 0, completed: 0, failed: 0, cancelled: 0 };
it('prompts for authentication when no user is available', async () => {
setMockAuthState({ user: null });
useRequestsMock.mockReturnValue({ requests: [], isLoading: false });
useMyRequestsMock.mockReturnValue({
requests: [], counts: defaultCounts, hasMore: false,
isLoading: false, isLoadingMore: false, isEmpty: true, loadMore: vi.fn(),
});
const { default: RequestsPage } = await import('@/app/requests/page');
render(<RequestsPage />);
@@ -62,23 +67,35 @@ describe('RequestsPage', () => {
isLoading: false,
});
const requests = [
const allRequests = [
{ id: 'req-active', status: 'pending', audiobook: { title: 'Active', author: 'Author' } },
{ id: 'req-wait', status: 'awaiting_search', audiobook: { title: 'Wait', author: 'Author' } },
{ id: 'req-complete', status: 'downloaded', audiobook: { title: 'Done', author: 'Author' } },
{ id: 'req-failed', status: 'failed', audiobook: { title: 'Fail', author: 'Author' } },
];
useRequestsMock.mockReturnValue({ requests, isLoading: false });
const counts = { all: 4, active: 1, waiting: 1, completed: 1, failed: 1, cancelled: 0 };
// The hook is called with the current filter; mock returns different data per filter
useMyRequestsMock.mockImplementation((filter: string) => {
let requests = allRequests;
if (filter === 'active') requests = allRequests.filter(r => r.status === 'pending');
else if (filter === 'waiting') requests = allRequests.filter(r => r.status === 'awaiting_search');
return {
requests, counts, hasMore: false,
isLoading: false, isLoadingMore: false, isEmpty: requests.length === 0, loadMore: vi.fn(),
};
});
const { default: RequestsPage } = await import('@/app/requests/page');
render(<RequestsPage />);
const activeTab = screen.getByRole('button', { name: /Active/i });
const waitingTab = screen.getByRole('button', { name: /Waiting/i });
// Counts now render as badge numbers inside tabs, not "(1)" format
const activeTab = screen.getByRole('tab', { name: /Active/i });
const waitingTab = screen.getByRole('tab', { name: /Waiting/i });
expect(activeTab).toHaveTextContent('(1)');
expect(waitingTab).toHaveTextContent('(1)');
expect(activeTab).toHaveTextContent('1');
expect(waitingTab).toHaveTextContent('1');
fireEvent.click(activeTab);
+1
View File
@@ -57,6 +57,7 @@ export const createPrismaMock = () => ({
watchedAuthor: createModelMock(),
userHomeSection: createModelMock(),
audibleCacheCategory: createModelMock(),
ignoredAudiobook: createModelMock(),
$queryRaw: vi.fn(),
$transaction: vi.fn(),
$disconnect: vi.fn(),
@@ -8,7 +8,10 @@ import { createPrismaMock } from '../helpers/prisma';
import { createJobQueueMock } from '../helpers/job-queue';
const prismaMock = createPrismaMock();
const configMock = vi.hoisted(() => ({ get: vi.fn() }));
const configMock = vi.hoisted(() => ({
get: vi.fn(),
getMany: vi.fn().mockResolvedValue({ prowlarr_api_key: null }),
}));
const jobQueueMock = createJobQueueMock();
const qbtMock = vi.hoisted(() => ({ addTorrent: vi.fn() }));
const sabMock = vi.hoisted(() => ({ addNZB: vi.fn() }));
@@ -54,6 +57,8 @@ vi.mock('@/lib/integrations/prowlarr.service', () => ({
describe('processDownloadTorrent', () => {
beforeEach(() => {
vi.clearAllMocks();
// Restore default implementations cleared by clearAllMocks
configMock.getMany.mockResolvedValue({ prowlarr_api_key: null });
});
const torrentPayload = {
@@ -128,6 +128,64 @@ describe('OIDCAuthProvider', () => {
expect(result.state).toBe('state-1');
});
it('omits groups scope when access control does not need it', async () => {
setConfig({
'oidc.issuer_url': 'https://issuer',
'oidc.client_id': 'client',
'oidc.client_secret': 'secret',
'oidc.access_control_method': 'open',
});
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
await provider.initiateLogin();
expect(clientMock.authorizationUrl).toHaveBeenCalledWith(
expect.objectContaining({ scope: 'openid profile email' })
);
});
it('includes groups scope when access control uses group_claim', async () => {
setConfig({
'oidc.issuer_url': 'https://issuer',
'oidc.client_id': 'client',
'oidc.client_secret': 'secret',
'oidc.access_control_method': 'group_claim',
});
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
await provider.initiateLogin();
expect(clientMock.authorizationUrl).toHaveBeenCalledWith(
expect.objectContaining({ scope: 'openid profile email groups' })
);
});
it('includes groups scope when admin claim is enabled', async () => {
setConfig({
'oidc.issuer_url': 'https://issuer',
'oidc.client_id': 'client',
'oidc.client_secret': 'secret',
'oidc.access_control_method': 'allowed_list',
'oidc.admin_claim_enabled': 'true',
});
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
await provider.initiateLogin();
expect(clientMock.authorizationUrl).toHaveBeenCalledWith(
expect.objectContaining({ scope: 'openid profile email groups' })
);
});
it('throws when OIDC is not fully configured', async () => {
setConfig({
'oidc.issuer_url': null,
@@ -0,0 +1,200 @@
/**
* Component: Request Creator Ignore Tests
* Documentation: documentation/features/ignored-audiobooks.md
*
* Tests the per-user ignore list check in createRequestForUser,
* including direct ASIN match, works-system sibling expansion,
* and the bypassIgnore option.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/utils/logger', () => ({
RMABLogger: {
create: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
},
}));
// Mock findPlexMatch to return null (not in library)
vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: vi.fn().mockResolvedValue(null),
}));
// Mock AudibleService
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => ({
getAudiobookDetails: vi.fn().mockResolvedValue(null),
}),
}));
// Mock job queue
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => ({
addSearchJob: vi.fn().mockResolvedValue(undefined),
addNotificationJob: vi.fn().mockResolvedValue(undefined),
}),
}));
// Mock getSiblingAsins from works.service
const mockGetSiblingAsins = vi.fn().mockResolvedValue(new Map());
const mockSeedAsin = vi.fn().mockResolvedValue(undefined);
vi.mock('@/lib/services/works.service', () => ({
getSiblingAsins: (...args: any[]) => mockGetSiblingAsins(...args),
seedAsin: (...args: any[]) => mockSeedAsin(...args),
}));
const TEST_AUDIOBOOK = {
asin: 'B00TEST001',
title: 'Test Book',
author: 'Test Author',
};
const TEST_USER_ID = 'user-123';
describe('createRequestForUser — ignore list', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: no existing requests, no library matches
prismaMock.request.findFirst.mockResolvedValue(null);
prismaMock.audiobook.findFirst.mockResolvedValue(null);
prismaMock.audiobook.create.mockResolvedValue({
id: 'audiobook-1',
audibleAsin: TEST_AUDIOBOOK.asin,
title: TEST_AUDIOBOOK.title,
author: TEST_AUDIOBOOK.author,
narrator: null,
});
prismaMock.request.create.mockResolvedValue({
id: 'request-1',
userId: TEST_USER_ID,
audiobookId: 'audiobook-1',
status: 'pending',
audiobook: { id: 'audiobook-1', title: 'Test Book' },
user: { id: TEST_USER_ID, plexUsername: 'testuser' },
});
prismaMock.user.findUnique.mockResolvedValue({
role: 'user',
autoApproveRequests: true,
plexUsername: 'testuser',
});
// Default: not ignored
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
mockGetSiblingAsins.mockResolvedValue(new Map());
mockSeedAsin.mockResolvedValue(undefined);
});
it('blocks auto-request when ASIN is directly ignored', async () => {
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue({
id: 'ignored-1',
userId: TEST_USER_ID,
asin: TEST_AUDIOBOOK.asin,
});
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.reason).toBe('ignored');
expect(result.message).toContain('ignore list');
}
// Should NOT create a request
expect(prismaMock.request.create).not.toHaveBeenCalled();
});
it('blocks auto-request when sibling ASIN is ignored', async () => {
// Direct ASIN not ignored
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
// But a sibling is ignored
mockGetSiblingAsins.mockResolvedValue(new Map([
[TEST_AUDIOBOOK.asin, ['B00SIBLING']],
]));
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue({
id: 'ignored-sibling',
userId: TEST_USER_ID,
asin: 'B00SIBLING',
});
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.reason).toBe('ignored');
}
expect(prismaMock.request.create).not.toHaveBeenCalled();
});
it('allows manual request with bypassIgnore even when ignored', async () => {
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue({
id: 'ignored-1',
userId: TEST_USER_ID,
asin: TEST_AUDIOBOOK.asin,
});
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK, {
bypassIgnore: true,
});
expect(result.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalled();
// Should NOT have even checked the ignore list
expect(prismaMock.ignoredAudiobook.findUnique).not.toHaveBeenCalled();
});
it('allows request when ASIN is not ignored', async () => {
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
expect(result.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalled();
});
it('falls through gracefully when works expansion fails', async () => {
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
mockGetSiblingAsins.mockRejectedValue(new Error('DB error'));
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
// Should still succeed since direct check passed and expansion is best-effort
expect(result.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalled();
});
it('does not check siblings when no sibling ASINs exist', async () => {
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
mockGetSiblingAsins.mockResolvedValue(new Map());
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
expect(result.success).toBe(true);
// Should not have queried findFirst for sibling check since map was empty
expect(prismaMock.ignoredAudiobook.findFirst).not.toHaveBeenCalled();
});
});
+66 -16
View File
@@ -8,6 +8,7 @@ import {
deduplicateAudiobooks,
deduplicateAndCollectGroups,
normalizeTitle,
extractSubtitle,
areDurationsCompatible,
} from '@/lib/utils/deduplicate-audiobooks';
import type { AudibleAudiobook } from '@/lib/integrations/audible.service';
@@ -92,6 +93,32 @@ describe('normalizeTitle', () => {
});
});
// ---------------------------------------------------------------------------
// extractSubtitle
// ---------------------------------------------------------------------------
describe('extractSubtitle', () => {
it('extracts subtitle after colon', () => {
expect(extractSubtitle('Eden\'s Gate: The Reborn')).toBe('the reborn');
});
it('extracts subtitle after long dash', () => {
expect(extractSubtitle('Eden\'s Gate \u2014 The Reborn')).toBe('the reborn');
});
it('returns empty for title without subtitle', () => {
expect(extractSubtitle('The Black Prism')).toBe('');
});
it('strips edition markers before extracting', () => {
expect(extractSubtitle('The Hobbit (Unabridged): Extended')).toBe('extended');
});
it('returns empty string for empty input', () => {
expect(extractSubtitle('')).toBe('');
});
});
// ---------------------------------------------------------------------------
// areDurationsCompatible
// ---------------------------------------------------------------------------
@@ -110,16 +137,18 @@ describe('areDurationsCompatible', () => {
expect(areDurationsCompatible(600, 600)).toBe(true);
});
it('uses 1% of longer duration as tolerance for long books', () => {
// Two 40-hour books (2400 min): tolerance = max(2400*0.01, 5) = 24 min
expect(areDurationsCompatible(2400, 2424)).toBe(true); // exactly at tolerance
expect(areDurationsCompatible(2400, 2425)).toBe(false); // just over
it('uses 5% of longer duration as tolerance for long books', () => {
// tolerance = max(longer*0.05, 10). When b > a, longer = b, so threshold shifts.
// 2400 vs 2526: longer=2526, tol=126.3, diff=126 → true
expect(areDurationsCompatible(2400, 2526)).toBe(true);
// 2400 vs 2527: longer=2527, tol=126.35, diff=127 → false
expect(areDurationsCompatible(2400, 2527)).toBe(false);
});
it('uses 5-minute minimum tolerance for short books', () => {
// Two 2-hour books (120 min): tolerance = max(120*0.01, 5) = max(1.2, 5) = 5 min
expect(areDurationsCompatible(120, 125)).toBe(true); // exactly at 5-min minimum
expect(areDurationsCompatible(120, 126)).toBe(false); // just over
it('uses 10-minute minimum tolerance for short books', () => {
// Two 2-hour books (120 min): tolerance = max(120*0.05, 10) = max(6, 10) = 10 min
expect(areDurationsCompatible(120, 130)).toBe(true); // exactly at 10-min minimum
expect(areDurationsCompatible(120, 131)).toBe(false); // just over
});
it('keeps abridged vs unabridged separate (large duration gap)', () => {
@@ -128,10 +157,10 @@ describe('areDurationsCompatible', () => {
});
it('symmetry: order does not matter', () => {
expect(areDurationsCompatible(2400, 2424)).toBe(true);
expect(areDurationsCompatible(2424, 2400)).toBe(true);
expect(areDurationsCompatible(120, 126)).toBe(false);
expect(areDurationsCompatible(126, 120)).toBe(false);
expect(areDurationsCompatible(2400, 2526)).toBe(true);
expect(areDurationsCompatible(2526, 2400)).toBe(true);
expect(areDurationsCompatible(120, 131)).toBe(false);
expect(areDurationsCompatible(131, 120)).toBe(false);
});
});
@@ -278,17 +307,17 @@ describe('deduplicateAudiobooks', () => {
});
it('uses percentage tolerance for very long audiobooks', () => {
// Two 40-hour books: tolerance = max(2400*0.01, 5) = 24 min
// tolerance = max(longer*0.05, 10). 2400 vs 2526: longer=2526, tol=126.3, diff=126 → same
const books = [
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2420 }),
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2526 }),
];
expect(deduplicateAudiobooks(books)).toHaveLength(1);
// Beyond tolerance
// Beyond tolerance: 2400 vs 2600: longer=2600, tol=130, diff=200 → different
const booksFar = [
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2430 }),
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2600 }),
];
expect(deduplicateAudiobooks(booksFar)).toHaveLength(2);
});
@@ -302,6 +331,27 @@ describe('deduplicateAudiobooks', () => {
expect(deduplicateAudiobooks(books)).toHaveLength(1);
});
it('does NOT collapse series entries with different subtitles (Eden\'s Gate bug)', () => {
// Series format: "Series Name: Book Title" — different books, NOT duplicates
const books = [
makeBook({ asin: 'A1', title: 'Eden\'s Gate: The Reborn', author: 'Edward Brody', narrator: 'Pavi Proczko', durationMinutes: 510 }),
makeBook({ asin: 'A2', title: 'Eden\'s Gate: The Spartan', author: 'Edward Brody', narrator: 'Pavi Proczko', durationMinutes: 540 }),
makeBook({ asin: 'A3', title: 'Eden\'s Gate: The Sapper', author: 'Edward Brody', narrator: 'Pavi Proczko', durationMinutes: 600 }),
];
const result = deduplicateAudiobooks(books);
expect(result).toHaveLength(3); // All 3 are different books!
});
it('still collapses when one has subtitle and other does not', () => {
// Same book re-listed: "The Black Prism: Lightbringer, Book 1" vs "The Black Prism"
const books = [
makeBook({ asin: 'A1', title: 'The Black Prism: Lightbringer, Book 1', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }),
];
const result = deduplicateAudiobooks(books);
expect(result).toHaveLength(1);
});
it('does not collapse empty-narrator with named narrator', () => {
const books = [
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }),