mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ae8f66a2d | |||
| 09cff5b68d | |||
| da7ad7cac1 | |||
| 8aac63715a | |||
| 0a405f2313 | |||
| 98c89db0a7 | |||
| 309a7960a8 | |||
| 06e77b8eba | |||
| dfc34df3d1 | |||
| 5d2e33e369 | |||
| 789a2e50ef | |||
| c0cff56b47 | |||
| e2ae4c7eef | |||
| a564fefd7c |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
# ========================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -387,6 +387,7 @@ PORT=$PORT
|
||||
HOSTNAME=$HOSTNAME
|
||||
PUID=${PUID:-}
|
||||
PGID=${PGID:-}
|
||||
UMASK=${UMASK:-}
|
||||
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
|
||||
EOF
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -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;
|
||||
+35
-6
@@ -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)
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -265,7 +265,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 100,
|
||||
|
||||
});
|
||||
|
||||
if (matchableRequests.length > 0) {
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -95,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -137,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)', () => {
|
||||
@@ -155,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user