Compare commits

...

6 Commits

Author SHA1 Message Date
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
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
23 changed files with 777 additions and 235 deletions
+17 -3
View File
@@ -403,12 +403,26 @@ echo "🔄 Running Prisma migrations..."
cd /app cd /app
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..." su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..."
# 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..." echo "🔄 Running data migrations..."
for sql_file in /app/prisma/data-migrations/*.sql; do for sql_file in /app/prisma/data-migrations/*.sql; do
if [ -f "$sql_file" ]; then if [ -f "$sql_file" ]; then
echo " Running $(basename "$sql_file")..." migration_name=$(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..."
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 fi
done done
+13 -1
View File
@@ -24,14 +24,26 @@ RUN apt-get update && apt-get install -y \
supervisor \ supervisor \
curl \ curl \
openssl \ openssl \
ffmpeg \
locales \ locales \
gosu \ gosu \
xz-utils \
&& sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \ && sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \
&& locale-gen en_US.UTF-8 \ && locale-gen en_US.UTF-8 \
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \ && update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
&& rm -rf /var/lib/apt/lists/* && 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 LANG=en_US.UTF-8
ENV LC_ALL=en_US.UTF-8 ENV LC_ALL=en_US.UTF-8
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.1.1", "version": "1.1.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "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;
+12
View File
@@ -718,3 +718,15 @@ model AudibleCacheCategory {
@@index([categoryId, rank]) @@index([categoryId, rank])
@@map("audible_cache_categories") @@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 canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status); const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload; 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 const canDelete = true; // Admins can always delete
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive // View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
@@ -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 { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService(); const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(id, { const audiobookData = {
id: existingRequest.audiobook.id, id: existingRequest.audiobook.id,
title: existingRequest.audiobook.title, title: existingRequest.audiobook.title,
author: existingRequest.audiobook.author, author: existingRequest.audiobook.author,
asin: existingRequest.audiobook.audibleAsin || undefined, asin: existingRequest.audiobook.audibleAsin || undefined,
}); };
if (existingRequest.type === 'ebook') {
await jobQueue.addSearchEbookJob(id, audiobookData);
} else {
await jobQueue.addSearchJob(id, audiobookData);
}
searchTriggered = true; searchTriggered = true;
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id }); logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
@@ -260,6 +260,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null, // Link to parent if exists parentRequestId: availableRequest?.id || null, // Link to parent if exists
status: 'awaiting_approval', status: 'awaiting_approval',
progress: 0, progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
}, },
}); });
@@ -292,6 +293,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null, parentRequestId: availableRequest?.id || null,
status: 'pending', status: 'pending',
progress: 0, progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
}, },
}); });
@@ -252,6 +252,7 @@ export async function POST(
status: 'awaiting_approval', status: 'awaiting_approval',
progress: 0, progress: 0,
selectedTorrent: selectedEbook as any, selectedTorrent: selectedEbook as any,
customSearchTerms: availableRequest?.customSearchTerms || null,
}, },
}); });
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`); logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
@@ -296,6 +297,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null, parentRequestId: availableRequest?.id || null,
status: 'searching', status: 'searching',
progress: 0, progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
}, },
}); });
logger.info(`Created new ebook request ${ebookRequest.id}`); logger.info(`Created new ebook request ${ebookRequest.id}`);
@@ -123,6 +123,7 @@ export async function POST(
parentRequestId, parentRequestId,
status: 'pending', status: 'pending',
progress: 0, 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 }); return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
} }
// Get the parent audiobook request // Get the request - could be an audiobook request or an existing ebook request
const parentRequest = await prisma.request.findUnique({ const foundRequest = await prisma.request.findUnique({
where: { id: parentRequestId }, where: { id: parentRequestId },
include: { audiobook: true }, include: { audiobook: true },
}); });
if (!parentRequest) { if (!foundRequest) {
return NextResponse.json({ error: 'Request not found' }, { status: 404 }); 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 }); 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 // Check for existing ebook request
let ebookRequest = await prisma.request.findFirst({ // If we were given an ebook request ID directly, use that; otherwise search by parent
where: { let ebookRequest = foundRequest.type === 'ebook'
parentRequestId, ? foundRequest
type: 'ebook', : await prisma.request.findFirst({
deletedAt: null, where: {
}, parentRequestId: parentRequest.id,
}); type: 'ebook',
deletedAt: null,
},
});
if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) { if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) {
return NextResponse.json({ return NextResponse.json({
@@ -109,9 +127,10 @@ export async function POST(
userId: parentRequest.userId, userId: parentRequest.userId,
audiobookId: parentRequest.audiobookId, audiobookId: parentRequest.audiobookId,
type: 'ebook', type: 'ebook',
parentRequestId, parentRequestId: parentRequest.id,
status: 'searching', status: 'searching',
progress: 0, progress: 0,
customSearchTerms: parentRequest.customSearchTerms,
}, },
}); });
logger.info(`Created new ebook request ${ebookRequest.id}`); logger.info(`Created new ebook request ${ebookRequest.id}`);
+88 -29
View File
@@ -97,9 +97,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 /api/requests
* Get user's audiobook requests (or all requests for admins) * 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) { export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAuth(request, async (req: AuthenticatedRequest) => {
@@ -112,61 +130,102 @@ export async function GET(request: NextRequest) {
} }
const searchParams = req.nextUrl.searchParams; const searchParams = req.nextUrl.searchParams;
const status = searchParams.get('status'); const statusParam = searchParams.get('status');
const limit = parseInt(searchParams.get('limit') || '50', 10); 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 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'; const isAdmin = req.user.role === 'admin';
// Build query // Base ownership filter
// If myOnly=true, always filter by current user (even for admins) const baseWhere: any = myOnly || !isAdmin ? { userId: req.user.id } : {};
// Otherwise, admins see all requests, users see only their own baseWhere.deletedAt = null;
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;
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({ const requests = await prisma.request.findMany({
where, where,
include: { include: {
audiobook: true, audiobook: true,
user: { user: {
select: { select: { id: true, plexUsername: true },
id: true,
plexUsername: true,
},
}, },
}, },
orderBy: { createdAt: 'desc' }, 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 isCompleted = COMPLETED_STATUSES.includes(r.status as typeof COMPLETED_STATUSES[number]);
const downloadAvailable = isCompleted && !!r.audiobook?.filePath; const downloadAvailable = isCompleted && !!r.audiobook?.filePath;
// Strip server-side absolute path from client response
const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook; const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook;
return { ...r, audiobook, downloadAvailable }; 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({ return NextResponse.json({
success: true, success: true,
requests: enriched, 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, count: enriched.length,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json( return NextResponse.json(
{ { error: 'FetchError', message: 'Failed to fetch requests' },
error: 'FetchError',
message: 'Failed to fetch requests',
},
{ status: 500 } { status: 500 }
); );
} }
+17
View File
@@ -197,6 +197,23 @@ body {
animation: toast-slide-in 0.3s ease-out; 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 */ /* Confirmation Dialog */
@keyframes dialog-backdrop-in { @keyframes dialog-backdrop-in {
from { opacity: 0; } from { opacity: 0; }
+344 -160
View File
@@ -1,221 +1,405 @@
/** /**
* Component: Requests Page * Component: My Requests Page
* Documentation: documentation/frontend/components.md * Documentation: documentation/frontend/components.md
*/ */
'use client'; 'use client';
import { useState } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Header } from '@/components/layout/Header'; import { Header } from '@/components/layout/Header';
import { RequestCard } from '@/components/requests/RequestCard'; 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 { useAuth } from '@/contexts/AuthContext';
import { usePreferences } from '@/contexts/PreferencesContext';
import { cn } from '@/lib/utils/cn'; 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() { export default function RequestsPage() {
const { user } = useAuth(); const { user } = useAuth();
const { squareCovers } = usePreferences(); const [filter, setFilter] = useState<RequestFilterGroup>('all');
const [filter, setFilter] = useState<FilterStatus>('all');
// Always fetch only the current user's requests (even for admins) const {
// This ensures "My Requests" truly shows only the user's own requests requests,
// Admins can see all requests in the admin panel counts,
const { requests, isLoading } = useRequests(undefined, 50, true); hasMore,
isLoading,
isLoadingMore,
isEmpty,
loadMore,
} = useMyRequests(filter);
// Filter requests client-side based on selected filter const countsLoaded = !isLoading || requests.length > 0;
const filteredRequests = filter === 'all' const totalForFilter = counts[filter === 'all' ? 'all' : filter as keyof RequestCounts] ?? 0;
? requests const hasActiveRequests = requests.some(r =>
: filter === 'active' ['pending', 'awaiting_search', 'awaiting_approval', 'searching', 'downloading', 'processing', 'awaiting_import'].includes(r.status)
? 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 filterOptions: { value: FilterStatus; label: string }[] = [ const handleFilterChange = (f: RequestFilterGroup) => {
{ value: 'all', label: 'All' }, setFilter(f);
{ value: 'active', label: 'Active' }, };
{ value: 'waiting', label: 'Waiting' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'cancelled', label: 'Cancelled' },
];
// Redirect to login if not authenticated // ── Unauthenticated ────────────────────────────────────────────────────────
if (!user) { if (!user) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header /> <Header />
<main className="container mx-auto px-4 py-8 max-w-7xl"> <main className="container mx-auto px-4 py-8 max-w-4xl">
<div className="text-center py-16 space-y-4"> <div className="flex flex-col items-center justify-center py-20 text-center space-y-5">
<svg <div className="w-16 h-16 rounded-2xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center">
className="mx-auto h-16 w-16 text-gray-400" <svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
fill="none" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
stroke="currentColor" 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" />
viewBox="0 0 24 24" </svg>
> </div>
<path <div className="space-y-1.5">
strokeLinecap="round" <h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Authentication Required</h2>
strokeLinejoin="round" <p className="text-sm text-gray-500 dark:text-gray-400">Please log in to view your audiobook requests</p>
strokeWidth={2} </div>
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>
</div> </div>
</main> </main>
</div> </div>
); );
} }
// ── Authenticated ──────────────────────────────────────────────────────────
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header /> <Header />
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-6 sm:space-y-8"> <main className="container mx-auto px-4 py-6 sm:py-10 max-w-4xl">
{/* Page Header */}
<div className="space-y-2 sm:space-y-4"> {/* Page header */}
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100"> <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 My Requests
</h1> </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 Track the status of your audiobook requests in real-time
</p> </p>
</div> </div>
{/* Filter Tabs */} {/* Tab bar */}
<div className="border-b border-gray-200 dark:border-gray-700 -mx-4 px-4 sm:mx-0 sm:px-0"> <div className="mb-5">
<div className="flex gap-2 sm:gap-4 -mb-px overflow-x-auto scrollbar-hide"> <TabBar
{filterOptions.map((option) => ( filter={filter}
<button counts={counts}
key={option.value} countsLoaded={countsLoaded}
onClick={() => setFilter(option.value)} onChange={handleFilterChange}
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>
</div> </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 && ( {isLoading && (
<div className="space-y-4"> <div className="space-y-3">
{[1, 2, 3].map((i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div <div
key={i} 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"> <SkeletonCard />
<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>
</div> </div>
))} ))}
</div> </div>
)} )}
{/* Requests List */} {/* Request list */}
{!isLoading && filteredRequests.length > 0 && ( {!isLoading && requests.length > 0 && (
<div className="space-y-4"> <div className="space-y-3">
{filteredRequests.map((request: any) => ( {requests.map((request, i) => (
<RequestCard key={request.id} request={request} showActions={true} /> <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> </div>
)} )}
{/* Empty State */} {/* Empty state */}
{!isLoading && filteredRequests.length === 0 && ( {!isLoading && isEmpty && (
<div className="text-center py-16 space-y-4"> <EmptyState filter={filter} />
<svg )}
className="mx-auto h-16 w-16 text-gray-400"
fill="none" {/* Load more */}
stroke="currentColor" {!isLoading && hasMore && (
viewBox="0 0 24 24" <div className="mt-4">
> <LoadMoreButton onClick={loadMore} isLoading={isLoadingMore} />
<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>
)}
</div> </div>
)} )}
{/* Auto-refresh indicator */} {/* Load more skeleton (when fetching additional pages) */}
{!isLoading && filteredRequests.length > 0 && ( {isLoadingMore && (
<div className="text-center text-xs text-gray-500 dark:text-gray-500 py-4"> <div className="mt-3 space-y-3">
<div className="flex items-center justify-center gap-2"> {Array.from({ length: 2 }).map((_, i) => (
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> <SkeletonCard key={`more-${i}`} />
<span>Auto-refreshing every 5 seconds</span> ))}
</div>
</div> </div>
)} )}
</main> </main>
</div> </div>
); );
+1 -1
View File
@@ -50,7 +50,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
const isEbook = requestType === 'ebook'; const isEbook = requestType === 'ebook';
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]); 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 isActive = ['searching', 'downloading', 'processing'].includes(request.status);
const isFailed = request.status === 'failed'; const isFailed = request.status === 'failed';
+91 -1
View File
@@ -5,8 +5,9 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useMemo } from 'react';
import useSWR, { mutate } from 'swr'; import useSWR, { mutate } from 'swr';
import useSWRInfinite from 'swr/infinite';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api'; import { fetchWithAuth } from '@/lib/utils/api';
import { Audiobook } from './useAudiobooks'; 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) { export function useRequest(requestId: string) {
const { accessToken } = useAuth(); const { accessToken } = useAuth();
@@ -254,7 +254,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
const matchableRequests = await prisma.request.findMany({ const matchableRequests = await prisma.request.findMany({
where: { where: {
type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available') type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available')
status: { notIn: ['available', 'cancelled'] }, status: { notIn: ['available', 'cancelled', 'denied'] },
deletedAt: null, deletedAt: null,
}, },
include: { include: {
+1 -1
View File
@@ -439,7 +439,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
const matchableRequests = await prisma.request.findMany({ const matchableRequests = await prisma.request.findMany({
where: { where: {
type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available') type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available')
status: { notIn: ['available', 'cancelled'] }, status: { notIn: ['available', 'cancelled', 'denied'] },
deletedAt: null, deletedAt: null,
}, },
include: { include: {
+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}"`); logger.info(`Processing ebook request ${requestId} for "${audiobook.title}"`);
try { try {
// Update request status to searching // Update request status to searching and fetch custom search terms
await prisma.request.update({ const requestRecord = await prisma.request.update({
where: { id: requestId }, where: { id: requestId },
data: { data: {
status: 'searching', status: 'searching',
searchAttempts: { increment: 1 }, searchAttempts: { increment: 1 },
updatedAt: new Date(), 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 // Get ebook configuration
const configService = getConfigService(); const configService = getConfigService();
const preferredFormat = payloadFormat || await configService.get('ebook_sidecar_preferred_format') || 'epub'; 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) ========== // ========== STEP 1: Try Anna's Archive (if enabled) ==========
if (annasArchiveEnabled) { if (annasArchiveEnabled) {
logger.info(`Searching Anna's Archive...`); logger.info(`Searching Anna's Archive...`);
annasArchiveResult = await searchAnnasArchive(audiobook, preferredFormat, logger); annasArchiveResult = await searchAnnasArchive(searchAudiobook, preferredFormat, logger);
if (annasArchiveResult) { if (annasArchiveResult) {
logger.info(`Found ebook via Anna's Archive (score: ${annasArchiveResult.score})`); 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) ========== // ========== STEP 2: Try Indexer Search (if enabled and no Anna's Archive result) ==========
if (!annasArchiveResult && indexerSearchEnabled) { if (!annasArchiveResult && indexerSearchEnabled) {
logger.info(`Searching indexers...`); logger.info(`Searching indexers...`);
indexerResult = await searchIndexers(requestId, audiobook, preferredFormat, logger); indexerResult = await searchIndexers(requestId, searchAudiobook, preferredFormat, logger);
if (indexerResult) { if (indexerResult) {
logger.info(`Found ebook via indexer search (score: ${indexerResult.finalScore.toFixed(1)})`); logger.info(`Found ebook via indexer search (score: ${indexerResult.finalScore.toFixed(1)})`);
+48 -6
View File
@@ -19,7 +19,7 @@ import type { AudibleAudiobook } from '../integrations/audible.service';
/** Patterns in parentheses or brackets to strip (edition markers, format labels) */ /** 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; 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 SUBTITLE_RE = /\s*[:]\s+.+$/;
const LONG_DASH_SUBTITLE_RE = /\s+[-\u2013\u2014]\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(); 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. */ /** Normalize narrator for comparison. Sorts individual names so order doesn't matter. */
function normalizeNarrator(narrator?: string): string { function normalizeNarrator(narrator?: string): string {
const raw = (narrator || '').toLowerCase().trim(); const raw = (narrator || '').toLowerCase().trim();
@@ -152,16 +190,20 @@ export function deduplicateAndCollectGroups(books: AudibleAudiobook[]): Deduplic
continue; continue;
} }
// Within a title+narrator group, further split by duration compatibility. // Within a title+narrator group, further split by duration AND subtitle
// Build sub-groups where all members are duration-compatible with the // compatibility. Build sub-groups where all members are compatible with
// representative (first member). A book joins the first compatible sub-group. // 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[][] = []; const subGroups: AudibleAudiobook[][] = [];
for (const book of group) { for (const book of group) {
let placed = false; let placed = false;
for (const sg of subGroups) { for (const sg of subGroups) {
// Check compatibility against the representative (first member) // Check both duration and subtitle compatibility against the representative
if (areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes)) { if (
areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes) &&
areSubtitlesCompatible(sg[0].title, book.title)
) {
sg.push(book); sg.push(book);
placed = true; placed = true;
break; break;
+2 -1
View File
@@ -202,6 +202,7 @@ describe('Requests API routes', () => {
it('filters requests for current user when not admin', async () => { it('filters requests for current user when not admin', async () => {
authRequest.nextUrl = new URL('http://localhost/api/requests?status=pending&limit=5'); authRequest.nextUrl = new URL('http://localhost/api/requests?status=pending&limit=5');
prismaMock.request.findMany.mockResolvedValueOnce([]); prismaMock.request.findMany.mockResolvedValueOnce([]);
prismaMock.request.count.mockResolvedValue(0);
const { GET } = await import('@/app/api/requests/route'); const { GET } = await import('@/app/api/requests/route');
const response = await GET({} as any); const response = await GET({} as any);
@@ -212,7 +213,7 @@ describe('Requests API routes', () => {
expect(prismaMock.request.findMany).toHaveBeenCalledWith( expect(prismaMock.request.findMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: expect.objectContaining({ userId: 'user-1', status: 'pending' }), where: expect.objectContaining({ userId: 'user-1', status: 'pending' }),
take: 5, take: 6, // limit + 1 for cursor pagination next-page detection
}) })
); );
}); });
+27 -10
View File
@@ -11,10 +11,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth'; import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth';
import { resetMockRouter } from '../helpers/mock-next-navigation'; import { resetMockRouter } from '../helpers/mock-next-navigation';
const useRequestsMock = vi.hoisted(() => vi.fn()); const useMyRequestsMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/hooks/useRequests', () => ({ vi.mock('@/lib/hooks/useRequests', () => ({
useRequests: useRequestsMock, useMyRequests: useMyRequestsMock,
})); }));
vi.mock('@/components/layout/Header', () => ({ vi.mock('@/components/layout/Header', () => ({
@@ -41,13 +41,18 @@ describe('RequestsPage', () => {
beforeEach(() => { beforeEach(() => {
resetMockAuthState(); resetMockAuthState();
resetMockRouter(); resetMockRouter();
useRequestsMock.mockReset(); useMyRequestsMock.mockReset();
vi.resetModules(); 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 () => { it('prompts for authentication when no user is available', async () => {
setMockAuthState({ user: null }); 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'); const { default: RequestsPage } = await import('@/app/requests/page');
render(<RequestsPage />); render(<RequestsPage />);
@@ -62,23 +67,35 @@ describe('RequestsPage', () => {
isLoading: false, isLoading: false,
}); });
const requests = [ const allRequests = [
{ id: 'req-active', status: 'pending', audiobook: { title: 'Active', author: 'Author' } }, { id: 'req-active', status: 'pending', audiobook: { title: 'Active', author: 'Author' } },
{ id: 'req-wait', status: 'awaiting_search', audiobook: { title: 'Wait', 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-complete', status: 'downloaded', audiobook: { title: 'Done', author: 'Author' } },
{ id: 'req-failed', status: 'failed', audiobook: { title: 'Fail', 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'); const { default: RequestsPage } = await import('@/app/requests/page');
render(<RequestsPage />); render(<RequestsPage />);
const activeTab = screen.getByRole('button', { name: /Active/i }); // Counts now render as badge numbers inside tabs, not "(1)" format
const waitingTab = screen.getByRole('button', { name: /Waiting/i }); const activeTab = screen.getByRole('tab', { name: /Active/i });
const waitingTab = screen.getByRole('tab', { name: /Waiting/i });
expect(activeTab).toHaveTextContent('(1)'); expect(activeTab).toHaveTextContent('1');
expect(waitingTab).toHaveTextContent('(1)'); expect(waitingTab).toHaveTextContent('1');
fireEvent.click(activeTab); fireEvent.click(activeTab);
@@ -8,6 +8,7 @@ import {
deduplicateAudiobooks, deduplicateAudiobooks,
deduplicateAndCollectGroups, deduplicateAndCollectGroups,
normalizeTitle, normalizeTitle,
extractSubtitle,
areDurationsCompatible, areDurationsCompatible,
} from '@/lib/utils/deduplicate-audiobooks'; } from '@/lib/utils/deduplicate-audiobooks';
import type { AudibleAudiobook } from '@/lib/integrations/audible.service'; 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 // areDurationsCompatible
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -302,6 +329,27 @@ describe('deduplicateAudiobooks', () => {
expect(deduplicateAudiobooks(books)).toHaveLength(1); 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', () => { it('does not collapse empty-narrator with named narrator', () => {
const books = [ const books = [
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }), makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }),