mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-17 03:30:10 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53c1e0dad7 | |||
| 45c8b614e3 | |||
| 24aa6afefc | |||
| 81813dc625 | |||
| a5e7af1a53 | |||
| 95917715b1 | |||
| a50fbc721e | |||
| d6eca611fc | |||
| 45e818c181 | |||
| 85977d123c | |||
| 441724c378 | |||
| d0ce485bdc | |||
| cbf02d3e24 | |||
| f0b2476b87 | |||
| 04b6a2c135 | |||
| 61b183542c | |||
| 610873af6b | |||
| ff80d995c5 | |||
| bfd624e120 | |||
| b559835390 | |||
| d25a6ebf79 | |||
| b3dad47aba | |||
| 7891e31893 | |||
| bff74446fe | |||
| 038c92e49f |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.15",
|
||||
"version": "1.0.16",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "requests" ADD COLUMN "custom_search_terms" TEXT;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "works" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"author" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "works_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "work_asins" (
|
||||
"id" TEXT NOT NULL,
|
||||
"work_id" TEXT NOT NULL,
|
||||
"asin" TEXT NOT NULL,
|
||||
"narrator" TEXT,
|
||||
"duration_minutes" INTEGER,
|
||||
"is_canonical" BOOLEAN NOT NULL DEFAULT false,
|
||||
"source" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "work_asins_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "works_title_idx" ON "works"("title");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "works_author_idx" ON "works"("author");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "work_asins_asin_key" ON "work_asins"("asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "work_asins_work_id_idx" ON "work_asins"("work_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "work_asins_asin_idx" ON "work_asins"("asin");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "work_asins" ADD CONSTRAINT "work_asins_work_id_fkey" FOREIGN KEY ("work_id") REFERENCES "works"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "watched_series" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"series_asin" TEXT NOT NULL,
|
||||
"series_title" TEXT NOT NULL,
|
||||
"cover_art_url" TEXT,
|
||||
"last_checked_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "watched_series_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "watched_authors" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"author_asin" TEXT NOT NULL,
|
||||
"author_name" TEXT NOT NULL,
|
||||
"cover_art_url" TEXT,
|
||||
"last_checked_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "watched_authors_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "watched_series_user_id_idx" ON "watched_series"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "watched_series_series_asin_idx" ON "watched_series"("series_asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "watched_series_user_id_series_asin_key" ON "watched_series"("user_id", "series_asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "watched_authors_user_id_idx" ON "watched_authors"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "watched_authors_author_asin_idx" ON "watched_authors"("author_asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "watched_authors_user_id_author_asin_key" ON "watched_authors"("user_id", "author_asin");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "watched_series" ADD CONSTRAINT "watched_series_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "watched_authors" ADD CONSTRAINT "watched_authors_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Normalize existing local usernames to lowercase
|
||||
UPDATE users SET plex_username = LOWER(plex_username) WHERE auth_provider = 'local' AND deleted_at IS NULL;
|
||||
UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7)) WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%';
|
||||
@@ -0,0 +1,33 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "api_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"token_prefix" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'user',
|
||||
"created_by_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"last_used_at" TIMESTAMP(3),
|
||||
"expires_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "api_tokens_token_hash_key" ON "api_tokens"("token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "api_tokens_token_hash_idx" ON "api_tokens"("token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "api_tokens_created_by_id_idx" ON "api_tokens"("created_by_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+118
-1
@@ -68,6 +68,10 @@ model User {
|
||||
goodreadsShelves GoodreadsShelf[]
|
||||
reportedIssues ReportedIssue[] @relation("Reporter")
|
||||
resolvedIssues ReportedIssue[] @relation("Resolver")
|
||||
createdApiTokens ApiToken[] @relation("CreatedApiTokens")
|
||||
apiTokens ApiToken[] @relation("UserApiTokens")
|
||||
watchedSeries WatchedSeries[]
|
||||
watchedAuthors WatchedAuthor[]
|
||||
|
||||
@@index([plexId])
|
||||
@@index([role])
|
||||
@@ -232,6 +236,7 @@ model Request {
|
||||
importAttempts Int @default(0) @map("import_attempts")
|
||||
maxImportRetries Int @default(5) @map("max_import_retries")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
customSearchTerms String? @map("custom_search_terms") @db.Text
|
||||
lastImportAt DateTime? @map("last_import_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -391,7 +396,7 @@ model ScheduledJob {
|
||||
|
||||
model BookDateConfig {
|
||||
id String @id @default(uuid())
|
||||
provider String // 'openai' | 'claude' | 'custom'
|
||||
provider String // 'openai' | 'claude' | 'gemini' | 'custom'
|
||||
apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256)
|
||||
model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929'
|
||||
baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints)
|
||||
@@ -495,6 +500,34 @@ model ReportedIssue {
|
||||
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// API TOKEN TABLE
|
||||
// Static API tokens for programmatic access (alternative to JWT)
|
||||
// Documentation: documentation/backend/services/api-tokens.md
|
||||
// ============================================================================
|
||||
|
||||
model ApiToken {
|
||||
id String @id @default(uuid())
|
||||
name String // User-friendly label (e.g., "Home Assistant", "Webhook")
|
||||
tokenHash String @unique @map("token_hash") // SHA-256 hash of the token (never store plaintext)
|
||||
tokenPrefix String @map("token_prefix") // First 8 chars for display (e.g., "rmab_a1b2")
|
||||
role String @default("user") // Token role: 'admin' or 'user'
|
||||
createdById String @map("created_by_id") // Who created the token (may differ from userId for admin-created tokens)
|
||||
userId String @map("user_id") // The user identity this token acts as
|
||||
lastUsedAt DateTime? @map("last_used_at")
|
||||
expiresAt DateTime? @map("expires_at") // null = never expires
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
createdBy User @relation("CreatedApiTokens", fields: [createdById], references: [id], onDelete: Cascade)
|
||||
tokenUser User @relation("UserApiTokens", fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tokenHash])
|
||||
@@index([createdById])
|
||||
@@index([userId])
|
||||
@@map("api_tokens")
|
||||
}
|
||||
|
||||
model GoodreadsShelf {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
@@ -530,3 +563,87 @@ model GoodreadsBookMapping {
|
||||
@@index([audibleAsin])
|
||||
@@map("goodreads_book_mappings")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WORKS TABLE
|
||||
// Cross-ASIN audiobook identity mapping — links multiple Audible ASINs
|
||||
// to a single logical work for library matching across editions.
|
||||
// Documentation: documentation/integrations/audible.md
|
||||
// ============================================================================
|
||||
|
||||
model Work {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
author String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
asins WorkAsin[]
|
||||
|
||||
@@index([title])
|
||||
@@index([author])
|
||||
@@map("works")
|
||||
}
|
||||
|
||||
model WorkAsin {
|
||||
id String @id @default(uuid())
|
||||
workId String @map("work_id")
|
||||
asin String @unique
|
||||
narrator String?
|
||||
durationMinutes Int? @map("duration_minutes")
|
||||
isCanonical Boolean @default(false) @map("is_canonical")
|
||||
source String // 'dedup_auto' | 'admin_manual'
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([workId])
|
||||
@@index([asin])
|
||||
@@map("work_asins")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WATCHED LISTS TABLES
|
||||
// Per-user series and author subscriptions for automatic new-release requests.
|
||||
// Documentation: documentation/features/watched-lists.md
|
||||
// ============================================================================
|
||||
|
||||
model WatchedSeries {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
seriesAsin String @map("series_asin")
|
||||
seriesTitle String @map("series_title")
|
||||
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||
lastCheckedAt DateTime? @map("last_checked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, seriesAsin])
|
||||
@@index([userId])
|
||||
@@index([seriesAsin])
|
||||
@@map("watched_series")
|
||||
}
|
||||
|
||||
model WatchedAuthor {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
authorAsin String @map("author_asin")
|
||||
authorName String @map("author_name")
|
||||
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||
lastCheckedAt DateTime? @map("last_checked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, authorAsin])
|
||||
@@index([userId])
|
||||
@@index([authorAsin])
|
||||
@@map("watched_authors")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Component: Adjust Search Terms Modal
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
interface AdjustSearchTermsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
currentSearchTerms?: string | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function AdjustSearchTermsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
requestId,
|
||||
title,
|
||||
author,
|
||||
currentSearchTerms,
|
||||
onSuccess,
|
||||
}: AdjustSearchTermsModalProps) {
|
||||
const toast = useToast();
|
||||
const [searchTerms, setSearchTerms] = useState(currentSearchTerms || title);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSavingAndSearching, setIsSavingAndSearching] = useState(false);
|
||||
|
||||
// Reset state when modal opens
|
||||
const handleClose = () => {
|
||||
setSearchTerms(currentSearchTerms || title);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const save = async (triggerSearch: boolean) => {
|
||||
const setter = triggerSearch ? setIsSavingAndSearching : setIsSaving;
|
||||
setter(true);
|
||||
|
||||
try {
|
||||
// If terms match the original title, clear the override
|
||||
const termsToSave = searchTerms.trim() === title ? null : searchTerms.trim() || null;
|
||||
|
||||
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/search-terms`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ searchTerms: termsToSave, triggerSearch }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to update search terms');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.searchTriggered) {
|
||||
toast.success('Search terms saved and search triggered');
|
||||
} else {
|
||||
toast.success('Search terms saved');
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(`Failed to save: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setter(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchTerms(title);
|
||||
};
|
||||
|
||||
const isLoading = isSaving || isSavingAndSearching;
|
||||
const hasChanges = searchTerms.trim() !== (currentSearchTerms || title);
|
||||
const isCustom = searchTerms.trim() !== title;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Adjust Search Terms" size="sm">
|
||||
<div className="space-y-4">
|
||||
{/* Original info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 space-y-1">
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Original Title
|
||||
</div>
|
||||
<div className="text-sm text-gray-900 dark:text-gray-100 font-medium">{title}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">by {author}</div>
|
||||
</div>
|
||||
|
||||
{/* Search terms input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="search-terms"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
|
||||
>
|
||||
Search Terms
|
||||
</label>
|
||||
<input
|
||||
id="search-terms"
|
||||
type="text"
|
||||
value={searchTerms}
|
||||
onChange={(e) => setSearchTerms(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
|
||||
placeholder="Enter custom search terms..."
|
||||
/>
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={isLoading}
|
||||
className="mt-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Reset to original title
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => save(false)}
|
||||
disabled={isLoading || !searchTerms.trim()}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => save(true)}
|
||||
disabled={isLoading || !searchTerms.trim()}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSavingAndSearching ? 'Saving...' : 'Save & Search'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,13 @@
|
||||
* Component: Confirm Dialog
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Reusable confirmation dialog for destructive actions
|
||||
* Reusable confirmation dialog for destructive actions.
|
||||
* Features: backdrop blur, smooth enter animation, Escape to close, focus trap, ARIA.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -30,99 +31,177 @@ export function ConfirmDialog({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus the cancel button on open (safer default for destructive dialogs)
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Small delay to let animation start before stealing focus
|
||||
const t = setTimeout(() => cancelRef.current?.focus(), 50);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Escape to close + focus trap
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus trap: tab cycles only within dialog
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (!focusable || focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onCancel]);
|
||||
|
||||
// Prevent body scroll while open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const confirmButtonClasses =
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white';
|
||||
const isDestructive = confirmVariant === 'danger';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-desc"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
className="animate-dialog-backdrop fixed inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-red-100 dark:bg-red-900'
|
||||
: 'bg-blue-100 dark:bg-blue-900'
|
||||
} sm:mx-0 sm:h-10 sm:w-10`}
|
||||
>
|
||||
{/* Panel */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="animate-dialog-panel relative w-full max-w-sm rounded-2xl overflow-hidden bg-white dark:bg-gray-900 shadow-2xl ring-1 ring-black/10 dark:ring-white/10"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon well */}
|
||||
<div className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full ${
|
||||
isDestructive
|
||||
? 'bg-red-50 dark:bg-red-500/10'
|
||||
: 'bg-blue-50 dark:bg-blue-500/10'
|
||||
}`}>
|
||||
{isDestructive ? (
|
||||
<svg
|
||||
className={`h-6 w-6 ${
|
||||
confirmVariant === 'danger'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}
|
||||
className="w-5 h-5 text-red-500 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="1.75"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{confirmVariant === 'danger' ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5 text-blue-500 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.75"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left flex-1">
|
||||
<h3 className="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-pre-line">
|
||||
{message}
|
||||
</p>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<h3
|
||||
id="confirm-dialog-title"
|
||||
className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-50"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<div id="confirm-dialog-desc" className="mt-1.5">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`inline-flex w-full justify-center rounded-lg px-4 py-2 text-sm font-semibold shadow-sm sm:w-auto transition-colors ${confirmButtonClasses}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="mt-3 inline-flex w-full justify-center rounded-lg bg-white dark:bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:mt-0 sm:w-auto transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 bg-gray-50/80 dark:bg-white/[0.03] border-t border-gray-100 dark:border-white/[0.06]">
|
||||
<button
|
||||
ref={cancelRef}
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium rounded-xl text-gray-700 dark:text-gray-300 bg-white dark:bg-white/[0.06] hover:bg-gray-100 dark:hover:bg-white/[0.1] border border-gray-200 dark:border-white/[0.1] transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-xl text-white transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900 active:scale-[0.97] ${
|
||||
isDestructive
|
||||
? 'bg-red-600 hover:bg-red-700 focus-visible:ring-red-500'
|
||||
: 'bg-blue-600 hover:bg-blue-700 focus-visible:ring-blue-500'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,8 @@ interface RecentRequest {
|
||||
completedAt: Date | null;
|
||||
errorMessage: string | null;
|
||||
torrentUrl?: string | null;
|
||||
downloadAttempts?: number;
|
||||
customSearchTerms?: string | null;
|
||||
}
|
||||
|
||||
interface User {
|
||||
@@ -444,6 +446,29 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryDownload = async (requestId: string) => {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/retry-download`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(responseData.message || 'Failed to retry download');
|
||||
}
|
||||
|
||||
toast.success(responseData.message || 'Download retry initiated');
|
||||
await mutate(apiUrl);
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to retry download:', error);
|
||||
toast.error(`Failed to retry download: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Render loading state
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
@@ -638,6 +663,17 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
||||
Ebook
|
||||
</span>
|
||||
)}
|
||||
{request.customSearchTerms && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200"
|
||||
title={`Custom search: ${request.customSearchTerms}`}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Custom Search
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{request.author}
|
||||
@@ -673,12 +709,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
||||
type: request.type,
|
||||
asin: request.asin,
|
||||
torrentUrl: request.torrentUrl,
|
||||
downloadAttempts: request.downloadAttempts,
|
||||
customSearchTerms: request.customSearchTerms,
|
||||
}}
|
||||
onDelete={handleDeleteClick}
|
||||
onManualSearch={handleManualSearch}
|
||||
onCancel={handleCancel}
|
||||
onRetryDownload={handleRetryDownload}
|
||||
onViewDetails={(asin) => handleViewDetails(asin, request.status)}
|
||||
onFetchEbook={handleFetchEbook}
|
||||
onSearchTermsUpdated={() => mutate(apiUrl)}
|
||||
ebookSidecarEnabled={ebookSidecarEnabled}
|
||||
annasArchiveBaseUrl={annasArchiveBaseUrl}
|
||||
isLoading={isDeleting || isFetchingEbook}
|
||||
@@ -835,7 +875,6 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
||||
}}
|
||||
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
|
||||
requestStatus={viewDetailsStatus}
|
||||
hideRequestActions
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
|
||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||
|
||||
export interface RequestActionsDropdownProps {
|
||||
@@ -21,12 +22,16 @@ export interface RequestActionsDropdownProps {
|
||||
type?: 'audiobook' | 'ebook';
|
||||
asin?: string | null;
|
||||
torrentUrl?: string | null;
|
||||
downloadAttempts?: number;
|
||||
customSearchTerms?: string | null;
|
||||
};
|
||||
onDelete: (requestId: string, title: string) => void;
|
||||
onManualSearch: (requestId: string) => Promise<void>;
|
||||
onCancel: (requestId: string) => Promise<void>;
|
||||
onRetryDownload?: (requestId: string) => Promise<void>;
|
||||
onViewDetails?: (asin: string) => void;
|
||||
onFetchEbook?: (requestId: string) => Promise<void>;
|
||||
onSearchTermsUpdated?: () => void;
|
||||
ebookSidecarEnabled?: boolean;
|
||||
annasArchiveBaseUrl?: string;
|
||||
isLoading?: boolean;
|
||||
@@ -37,8 +42,10 @@ export function RequestActionsDropdown({
|
||||
onDelete,
|
||||
onManualSearch,
|
||||
onCancel,
|
||||
onRetryDownload,
|
||||
onViewDetails,
|
||||
onFetchEbook,
|
||||
onSearchTermsUpdated,
|
||||
ebookSidecarEnabled = false,
|
||||
annasArchiveBaseUrl = 'https://annas-archive.li',
|
||||
isLoading = false,
|
||||
@@ -46,6 +53,7 @@ export function RequestActionsDropdown({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
||||
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
|
||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
|
||||
|
||||
// Determine request type
|
||||
@@ -57,6 +65,8 @@ export function RequestActionsDropdown({
|
||||
// Determine available actions based on status and type
|
||||
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canAdjustSearchTerms = !isEbook && ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
|
||||
@@ -123,11 +133,27 @@ export function RequestActionsDropdown({
|
||||
setShowInteractiveSearch(true);
|
||||
};
|
||||
|
||||
const handleAdjustSearchTerms = () => {
|
||||
setIsOpen(false);
|
||||
setShowAdjustSearchTerms(true);
|
||||
};
|
||||
|
||||
const handleInteractiveSearchEbook = () => {
|
||||
setIsOpen(false);
|
||||
setShowInteractiveSearchEbook(true);
|
||||
};
|
||||
|
||||
const handleRetryDownload = async () => {
|
||||
setIsOpen(false);
|
||||
if (onRetryDownload) {
|
||||
try {
|
||||
await onRetryDownload(request.requestId);
|
||||
} catch (error) {
|
||||
console.error('Failed to retry download:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setIsOpen(false);
|
||||
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
|
||||
@@ -253,6 +279,35 @@ export function RequestActionsDropdown({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Adjust Search Terms */}
|
||||
{canAdjustSearchTerms && (
|
||||
<button
|
||||
onClick={handleAdjustSearchTerms}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="flex items-center gap-1.5">
|
||||
Adjust Search Terms
|
||||
{request.customSearchTerms && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* View Source */}
|
||||
{canViewSource && viewSourceUrl && (
|
||||
<a
|
||||
@@ -328,8 +383,32 @@ export function RequestActionsDropdown({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider if we have search/view actions and other actions */}
|
||||
{(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && (
|
||||
{/* Retry Download */}
|
||||
{canRetryDownload && (
|
||||
<button
|
||||
onClick={handleRetryDownload}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Retry Download
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider if we have search/view/retry actions and other actions */}
|
||||
{(canSearch || canViewSource || canFetchEbook || canRetryDownload) && (canCancel || canDelete) && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
)}
|
||||
|
||||
@@ -358,7 +437,7 @@ export function RequestActionsDropdown({
|
||||
)}
|
||||
|
||||
{/* Divider before delete */}
|
||||
{canDelete && (canSearch || canCancel) && (
|
||||
{canDelete && (canSearch || canRetryDownload || canCancel) && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
)}
|
||||
|
||||
@@ -421,6 +500,7 @@ export function RequestActionsDropdown({
|
||||
title: request.title,
|
||||
author: request.author,
|
||||
}}
|
||||
customSearchTerms={request.customSearchTerms}
|
||||
/>
|
||||
|
||||
{/* Interactive Search Modal (Ebook) */}
|
||||
@@ -434,6 +514,17 @@ export function RequestActionsDropdown({
|
||||
}}
|
||||
searchMode="ebook"
|
||||
/>
|
||||
|
||||
{/* Adjust Search Terms Modal */}
|
||||
<AdjustSearchTermsModal
|
||||
isOpen={showAdjustSearchTerms}
|
||||
onClose={() => setShowAdjustSearchTerms(false)}
|
||||
requestId={request.requestId}
|
||||
title={request.title}
|
||||
author={request.author}
|
||||
currentSearchTerms={request.customSearchTerms}
|
||||
onSuccess={onSearchTermsUpdated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ export const getTabValidation = (
|
||||
return validated.paths;
|
||||
case 'ebook':
|
||||
case 'bookdate':
|
||||
case 'api':
|
||||
return true; // These tabs handle their own saving
|
||||
default:
|
||||
return false;
|
||||
@@ -228,4 +229,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [
|
||||
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
|
||||
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
|
||||
{ id: 'notifications' as const, label: 'Notifications', icon: '🔔' },
|
||||
{ id: 'api' as const, label: 'API', icon: '🔑' },
|
||||
];
|
||||
|
||||
@@ -243,4 +243,4 @@ export interface BookDateModel {
|
||||
/**
|
||||
* Tab identifier type
|
||||
*/
|
||||
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications';
|
||||
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications' | 'api';
|
||||
|
||||
@@ -23,6 +23,7 @@ import { PathsTab } from './tabs/PathsTab/PathsTab';
|
||||
import { EbookTab } from './tabs/EbookTab/EbookTab';
|
||||
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
|
||||
import { NotificationsTab } from './tabs/NotificationsTab';
|
||||
import { ApiTab } from './tabs/ApiTab/ApiTab';
|
||||
|
||||
// Types and Helpers
|
||||
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
|
||||
@@ -346,8 +347,11 @@ export default function AdminSettings() {
|
||||
{/* Notifications Tab */}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
|
||||
{/* API Tab */}
|
||||
{activeTab === 'api' && <ApiTab />}
|
||||
|
||||
{/* Save Button (only for tabs that save through main page) */}
|
||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && (
|
||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && activeTab !== 'api' && (
|
||||
<div className="mt-8 flex gap-4">
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Component: API Token Management Tab (Admin)
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog';
|
||||
import { useApiTokens } from '@/lib/hooks/useApiTokens';
|
||||
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||
import Link from 'next/link';
|
||||
import type { AdminApiToken } from '@/lib/types/api-tokens';
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
plexUsername: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function ApiTab() {
|
||||
const api = useApiTokens<AdminApiToken>({ basePath: '/api/admin/api-tokens' });
|
||||
|
||||
// Admin-specific state
|
||||
const [users, setUsers] = useState<UserOption[]>([]);
|
||||
const [newTokenUserId, setNewTokenUserId] = useState('');
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/users');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data.users.map((u: any) => ({ id: u.id, plexUsername: u.plexUsername, role: u.role })));
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, user selector just won't populate
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const extraBody: Record<string, string> = {};
|
||||
if (newTokenUserId) extraBody.userId = newTokenUserId;
|
||||
const created = await api.handleCreate(extraBody);
|
||||
// Reset admin-specific fields only when create succeeds
|
||||
if (created) {
|
||||
setNewTokenUserId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
api.resetForm();
|
||||
setNewTokenUserId('');
|
||||
};
|
||||
|
||||
if (api.loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">API Tokens</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage API tokens for all users. Create tokens for any user for programmatic access.{' '}
|
||||
<Link href="/api-docs" className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View API documentation
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{api.error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
|
||||
{api.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newly created token banner */}
|
||||
{api.createdToken && (
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
Token created successfully! Copy it now — it won't be shown again.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{api.createdToken}
|
||||
</code>
|
||||
<button
|
||||
onClick={api.handleCopy}
|
||||
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||
>
|
||||
{api.copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss token banner"
|
||||
onClick={api.dismissCreatedToken}
|
||||
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create token form */}
|
||||
{api.showCreateForm ? (
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
|
||||
<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-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={api.newTokenName}
|
||||
onChange={(e) => api.setNewTokenName(e.target.value)}
|
||||
placeholder="e.g., Home Assistant, Webhook"
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Expiration
|
||||
</label>
|
||||
<select
|
||||
value={api.newTokenExpiry}
|
||||
onChange={(e) => api.setNewTokenExpiry(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="never">Never</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="1y">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
User (acts as)
|
||||
</label>
|
||||
<select
|
||||
value={newTokenUserId}
|
||||
onChange={(e) => setNewTokenUserId(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Current user (default)</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.plexUsername} ({u.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Token will inherit the selected user's role
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={api.creating || !api.newTokenName.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
|
||||
>
|
||||
{api.creating ? 'Creating...' : 'Create Token'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => api.setShowCreateForm(true)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
||||
>
|
||||
Create New Token
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{api.tokens.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm">No API tokens yet</p>
|
||||
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Acts As</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Role</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Created By</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
|
||||
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{api.tokens.map((token) => (
|
||||
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
|
||||
<td className="py-3 px-2">
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
|
||||
{token.tokenPrefix}...
|
||||
</code>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.tokenUser}</td>
|
||||
<td className="py-3 px-2">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
token.role === 'admin'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{token.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.createdBy}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{api.formatDate(token.lastUsedAt)}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
|
||||
{token.expiresAt ? (
|
||||
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
|
||||
{api.formatDate(token.expiresAt)}
|
||||
{new Date(token.expiresAt) < new Date() && ' (expired)'}
|
||||
</span>
|
||||
) : (
|
||||
'Never'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-2 text-right">
|
||||
<button
|
||||
onClick={() => api.setConfirmRevokeId(token.id)}
|
||||
disabled={api.deletingId === token.id}
|
||||
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{api.deletingId === token.id ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage instructions */}
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
|
||||
</p>
|
||||
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
|
||||
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||
${getInstanceUrl()}/api/requests`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Revoke confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={api.confirmRevokeId !== null}
|
||||
title="Revoke API token"
|
||||
message={
|
||||
<>
|
||||
Are you sure you want to revoke{' '}
|
||||
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||
“{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}”
|
||||
</span>
|
||||
? Any integrations using this token will immediately lose access. This cannot be undone.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Revoke token"
|
||||
cancelLabel="Cancel"
|
||||
confirmVariant="danger"
|
||||
onConfirm={api.handleDeleteConfirmed}
|
||||
onCancel={() => api.setConfirmRevokeId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="gemini">Google Gemini</option>
|
||||
<option value="custom">Custom (OpenAI-compatible)</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -136,7 +137,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
|
||||
? 'Leave blank for local models'
|
||||
: configured
|
||||
? '••••••••••••••••'
|
||||
: (provider === 'openai' ? 'sk-...' : 'sk-ant-...')
|
||||
: (provider === 'openai' ? 'sk-...' : provider === 'gemini' ? 'AIza...' : 'sk-ant-...')
|
||||
}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Component: Interactive API Documentation Page
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Lists all API token-accessible endpoints with "Try it out" functionality.
|
||||
* Users can test with a custom API token or their current browser session.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { TokenInput } from '@/components/api-docs/TokenInput';
|
||||
import { EndpointCard } from '@/components/api-docs/EndpointCard';
|
||||
import { API_TOKEN_ENDPOINT_DOCS } from '@/lib/constants/api-tokens';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const { user } = useAuth();
|
||||
const [token, setToken] = useState('');
|
||||
const [useSession, setUseSession] = useState(false);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<Header />
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 pt-8 pb-16">
|
||||
{/* Page header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
<Link
|
||||
href="/profile"
|
||||
className="hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-gray-900 dark:text-white font-medium">API Documentation</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||
API Reference
|
||||
</h1>
|
||||
<p className="mt-2 text-base text-gray-500 dark:text-gray-400 leading-relaxed max-w-2xl">
|
||||
Interact with ReadMeABook programmatically using API tokens. These endpoints are
|
||||
available for external integrations, dashboards, and automation tools.
|
||||
</p>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="flex flex-wrap gap-3 mt-4">
|
||||
<Link
|
||||
href="/profile"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
Manage your tokens
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Admin token management
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication section */}
|
||||
<div className="mb-8">
|
||||
<TokenInput
|
||||
token={token}
|
||||
onTokenChange={setToken}
|
||||
useSession={useSession}
|
||||
onUseSessionChange={setUseSession}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Usage instructions card */}
|
||||
<div className="mb-8 rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Quick Start
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
Include your API token in the <code className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-900 rounded text-xs font-mono">Authorization</code> header as a Bearer token:
|
||||
</p>
|
||||
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-4 rounded-xl overflow-x-auto font-mono leading-relaxed">
|
||||
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||
${getInstanceUrl()}/api/requests`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Endpoints section header */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Available Endpoints
|
||||
</h2>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400">
|
||||
{API_TOKEN_ENDPOINT_DOCS.length} endpoints
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Endpoint cards */}
|
||||
<div className="space-y-4">
|
||||
{API_TOKEN_ENDPOINT_DOCS.map((endpoint) => (
|
||||
<EndpointCard
|
||||
key={`${endpoint.method}:${endpoint.path}`}
|
||||
endpoint={endpoint}
|
||||
token={token}
|
||||
useSession={useSession}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<div className="mt-10 text-center">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
API tokens are restricted to the endpoints listed above.
|
||||
JWT session authentication has access to all endpoints.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Component: API Token Delete Route
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/api-tokens/[id]
|
||||
* Revoke (delete) an API token
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||
requireAdmin(req, async () => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token revoke attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const token = await prisma.apiToken.findUnique({ where: { id } });
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.apiToken.delete({ where: { id } });
|
||||
|
||||
logger.info('API token revoked', { tokenId: id, name: token.name, revokedBy: req.user!.username });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke API token', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Component: Admin API Token Management Routes
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||
import { generateApiToken } from '@/lib/utils/api-token';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||
|
||||
const CreateTokenSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
userId: z.string().uuid().optional(), // Admin can specify which user the token acts as
|
||||
role: z.enum(['admin', 'user']).optional(), // Accepted for compatibility, but cannot differ from target user role
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/api-tokens
|
||||
* List ALL API tokens across all users
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||
requireAdmin(req, async () => {
|
||||
try {
|
||||
const tokens = await prisma.apiToken.findMany({
|
||||
include: {
|
||||
createdBy: {
|
||||
select: { id: true, plexUsername: true },
|
||||
},
|
||||
tokenUser: {
|
||||
select: { id: true, plexUsername: true, role: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const sanitized = tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
role: t.role,
|
||||
createdBy: t.createdBy.plexUsername,
|
||||
createdById: t.createdBy.id,
|
||||
tokenUser: t.tokenUser.plexUsername,
|
||||
tokenUserId: t.tokenUser.id,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
expiresAt: t.expiresAt,
|
||||
createdAt: t.createdAt,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ tokens: sanitized });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list API tokens', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/api-tokens
|
||||
* Create a new API token. Admin can optionally specify userId.
|
||||
* Token role is always derived from the target user's current role.
|
||||
* Returns the full token ONCE.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||
requireAdmin(req, async () => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenCreateRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token create attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { name, expiresAt, userId, role } = CreateTokenSchema.parse(body);
|
||||
|
||||
// Determine target user (defaults to the admin themselves)
|
||||
const targetUserId = userId || req.user!.id;
|
||||
|
||||
// Verify the target user exists
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { id: true, role: true, plexUsername: true },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'Target user not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Enforce per-user token cap (count only active, non-expired tokens)
|
||||
const activeTokenCount = await prisma.apiToken.count({
|
||||
where: {
|
||||
userId: targetUserId,
|
||||
OR: [
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: new Date() } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (activeTokenCount >= MAX_TOKENS_PER_USER) {
|
||||
return NextResponse.json(
|
||||
{ error: `Token limit reached. Users may have at most ${MAX_TOKENS_PER_USER} active API tokens.` },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Security guard: token role must always match the target user's persisted role.
|
||||
// This avoids role/identity mismatch (for example: acting as user A with admin role).
|
||||
if (role && role !== targetUser.role) {
|
||||
logger.warn('Admin attempted token role override that differs from target user role', {
|
||||
requestedRole: role,
|
||||
userActualRole: targetUser.role,
|
||||
targetUser: targetUser.plexUsername,
|
||||
createdBy: req.user!.username,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Token role must match target user's role (${targetUser.role}).`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tokenRole = targetUser.role;
|
||||
|
||||
// Generate the token
|
||||
const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
|
||||
|
||||
const apiToken = await prisma.apiToken.create({
|
||||
data: {
|
||||
name,
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
role: tokenRole,
|
||||
createdById: req.user!.id,
|
||||
userId: targetUserId,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Admin API token created', {
|
||||
tokenId: apiToken.id,
|
||||
name,
|
||||
createdBy: req.user!.username,
|
||||
targetUser: targetUser.plexUsername,
|
||||
role: tokenRole,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
token: {
|
||||
id: apiToken.id,
|
||||
name: apiToken.name,
|
||||
tokenPrefix: apiToken.tokenPrefix,
|
||||
role: apiToken.role,
|
||||
expiresAt: apiToken.expiresAt,
|
||||
createdAt: apiToken.createdAt,
|
||||
},
|
||||
// Full token is returned ONLY on creation
|
||||
fullToken,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create API token', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to create API token' }, { status: 500 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ManualImport');
|
||||
|
||||
@@ -54,7 +55,7 @@ export async function POST(request: NextRequest) {
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
const body = await request.json();
|
||||
const { folderPath, asin } = body;
|
||||
const { folderPath, asin, cleanupSource } = body;
|
||||
let { audiobookId } = body;
|
||||
|
||||
// Validate required fields
|
||||
@@ -174,6 +175,48 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Enrich missing series/year data from Audnexus (mirrors request-creator.service.ts)
|
||||
if (audiobook.audibleAsin && (!audiobook.series || !audiobook.year)) {
|
||||
try {
|
||||
const audibleService = getAudibleService();
|
||||
const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin);
|
||||
|
||||
if (audnexusData) {
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
if (!audiobook.series && audnexusData.series) {
|
||||
updates.series = audnexusData.series;
|
||||
}
|
||||
if (!audiobook.seriesPart && audnexusData.seriesPart) {
|
||||
updates.seriesPart = audnexusData.seriesPart;
|
||||
}
|
||||
if (!audiobook.seriesAsin && audnexusData.seriesAsin) {
|
||||
updates.seriesAsin = audnexusData.seriesAsin;
|
||||
}
|
||||
if (!audiobook.year && audnexusData.releaseDate) {
|
||||
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
||||
if (!isNaN(releaseYear)) {
|
||||
updates.year = releaseYear;
|
||||
}
|
||||
}
|
||||
if (!audiobook.narrator && audnexusData.narrator) {
|
||||
updates.narrator = audnexusData.narrator;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobook.id },
|
||||
data: updates,
|
||||
});
|
||||
logger.info(`Enriched audiobook metadata from Audnexus for ASIN ${audiobook.audibleAsin}`, updates);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal: series enrichment failure should never block the import
|
||||
logger.warn(`Failed to enrich metadata from Audnexus for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing requests
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
@@ -242,7 +285,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Queue organize_files job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath);
|
||||
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath, undefined, cleanupSource === true);
|
||||
|
||||
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Component: Admin Retry Download API
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*
|
||||
* Retries a failed download by either resuming monitoring of a still-alive
|
||||
* download in the client, or re-adding the download using metadata from the
|
||||
* most recent selected DownloadHistory record.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
|
||||
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Requests.RetryDownload');
|
||||
|
||||
/** Download statuses considered "alive" — monitoring can be resumed */
|
||||
const ALIVE_STATUSES = new Set([
|
||||
'downloading',
|
||||
'queued',
|
||||
'paused',
|
||||
'checking',
|
||||
'seeding',
|
||||
'completed',
|
||||
]);
|
||||
|
||||
/**
|
||||
* POST /api/admin/requests/[id]/retry-download
|
||||
* Retry a failed download for an admin request.
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Fetch the request with audiobook info
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: {
|
||||
audiobook: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingRequest) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Request not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existingRequest.status !== 'failed') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InvalidStatus',
|
||||
message: `Request is not in a failed state (current status: ${existingRequest.status})`,
|
||||
currentStatus: existingRequest.status,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the most recent selected DownloadHistory record
|
||||
const downloadHistory = await prisma.downloadHistory.findFirst({
|
||||
where: { requestId: id, selected: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!downloadHistory) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'NoHistory',
|
||||
message: 'No previous download attempt found to retry',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Require a download URL to be able to re-add
|
||||
if (!downloadHistory.magnetLink) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'NoDownloadUrl',
|
||||
message: 'No download URL available in history to retry',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const jobQueue = getJobQueueService();
|
||||
let retryPath: 'resumed_monitoring' | 're_added';
|
||||
|
||||
// Determine if we can attempt to resume monitoring.
|
||||
// downloadClient is stored as a plain string in the DB (can be 'qbittorrent', 'sabnzbd',
|
||||
// 'nzbget', 'transmission', 'deluge', 'direct', or null).
|
||||
const rawClientType: string | null = downloadHistory.downloadClient;
|
||||
const clientId = downloadHistory.downloadClientId;
|
||||
const isDirect = rawClientType === 'direct';
|
||||
|
||||
// Only attempt to query the download client if we have a known DownloadClientType,
|
||||
// a clientId, and it is not a direct (HTTP) download.
|
||||
const canCheckClient = !isDirect && !!rawClientType && !!clientId;
|
||||
// Safe to cast here: we have already confirmed rawClientType is non-null and non-direct
|
||||
const clientType = rawClientType as DownloadClientType | null;
|
||||
|
||||
if (canCheckClient) {
|
||||
// Try to look up the download in the client
|
||||
try {
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const client = await manager.getClientServiceForProtocol(protocol);
|
||||
|
||||
if (client) {
|
||||
const downloadInfo = await client.getDownload(clientId!);
|
||||
|
||||
if (downloadInfo && ALIVE_STATUSES.has(downloadInfo.status)) {
|
||||
// Download is still alive — restart monitoring
|
||||
logger.info(`Retry download: resuming monitoring for request ${id}`, {
|
||||
requestId: id,
|
||||
downloadClientId: clientId,
|
||||
downloadStatus: downloadInfo.status,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await jobQueue.addMonitorJob(
|
||||
id,
|
||||
downloadHistory.id,
|
||||
clientId!, // canCheckClient guard ensures clientId is non-null
|
||||
clientType as DownloadClientType,
|
||||
0 // no delay — start immediately
|
||||
);
|
||||
|
||||
retryPath = 'resumed_monitoring';
|
||||
} else {
|
||||
// Download not found or is failed — re-add
|
||||
logger.info(`Retry download: download not alive (status: ${downloadInfo?.status ?? 'not found'}), re-adding for request ${id}`, {
|
||||
requestId: id,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
|
||||
retryPath = 're_added';
|
||||
}
|
||||
} else {
|
||||
// No client configured for that protocol — fall through to re-add
|
||||
logger.warn(`Retry download: no ${protocol} client configured, re-adding for request ${id}`, {
|
||||
requestId: id,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
|
||||
retryPath = 're_added';
|
||||
}
|
||||
} catch (clientError) {
|
||||
// Client lookup failed (connection error etc.) — re-add to be safe
|
||||
logger.warn(`Retry download: client check failed, re-adding for request ${id}`, {
|
||||
requestId: id,
|
||||
error: clientError instanceof Error ? clientError.message : String(clientError),
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
|
||||
retryPath = 're_added';
|
||||
}
|
||||
} else {
|
||||
// Direct download (ebook), no clientId, or no clientType — re-add
|
||||
logger.info(`Retry download: re-adding for request ${id} (direct=${isDirect}, hasClientId=${!!clientId})`, {
|
||||
requestId: id,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
|
||||
retryPath = 're_added';
|
||||
}
|
||||
|
||||
// Increment downloadAttempts, clear errorMessage, set status to downloading
|
||||
await prisma.request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'downloading',
|
||||
errorMessage: null,
|
||||
downloadAttempts: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const message =
|
||||
retryPath === 'resumed_monitoring'
|
||||
? 'Download monitoring resumed'
|
||||
: 'Download re-added to client';
|
||||
|
||||
logger.info(`Retry download completed for request ${id} via ${retryPath}`, {
|
||||
requestId: id,
|
||||
adminId: req.user.sub,
|
||||
path: retryPath,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message,
|
||||
path: retryPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to retry download', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'RetryError',
|
||||
message: 'Failed to retry download',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-add the download to the queue using metadata from DownloadHistory.
|
||||
* Reconstructs a TorrentResult from the stored history fields.
|
||||
*/
|
||||
async function reAddDownload(
|
||||
jobQueue: ReturnType<typeof getJobQueueService>,
|
||||
requestId: string,
|
||||
audiobook: { id: string; title: string; author: string },
|
||||
history: {
|
||||
torrentName: string | null;
|
||||
magnetLink: string | null;
|
||||
indexerName: string;
|
||||
indexerId: number | null;
|
||||
torrentSizeBytes: bigint | null;
|
||||
seeders: number | null;
|
||||
leechers: number | null;
|
||||
torrentHash: string | null;
|
||||
torrentUrl: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const torrent: TorrentResult = {
|
||||
title: history.torrentName ?? audiobook.title,
|
||||
downloadUrl: history.magnetLink!, // Validated non-null before calling this function
|
||||
indexer: history.indexerName,
|
||||
indexerId: history.indexerId ?? undefined,
|
||||
size: history.torrentSizeBytes !== null ? Number(history.torrentSizeBytes) : 0,
|
||||
seeders: history.seeders ?? undefined,
|
||||
leechers: history.leechers ?? undefined,
|
||||
infoHash: history.torrentHash ?? undefined,
|
||||
infoUrl: history.torrentUrl ?? undefined,
|
||||
guid: history.torrentUrl ?? history.magnetLink!,
|
||||
publishDate: new Date(), // Not stored; use current date as a safe default
|
||||
};
|
||||
|
||||
await jobQueue.addDownloadJob(requestId, audiobook, torrent);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Component: Admin Custom Search Terms API
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.SearchTerms');
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/requests/[id]/search-terms
|
||||
* Update custom search terms for a request (admin only)
|
||||
* Body: { searchTerms: string | null, triggerSearch?: boolean }
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Parse body
|
||||
let body;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'BadRequest', message: 'Invalid JSON body' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchTerms, triggerSearch } = body;
|
||||
|
||||
// Validate searchTerms is string or null
|
||||
if (searchTerms !== null && searchTerms !== undefined && typeof searchTerms !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'BadRequest', message: 'searchTerms must be a string or null' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Trim and normalize
|
||||
const normalizedTerms = typeof searchTerms === 'string' ? searchTerms.trim() || null : null;
|
||||
|
||||
// Find the request
|
||||
const existingRequest = await prisma.request.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
audiobook: {
|
||||
select: { id: true, title: true, author: true, audibleAsin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingRequest || existingRequest.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Request not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update custom search terms
|
||||
await prisma.request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
customSearchTerms: normalizedTerms,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Custom search terms ${normalizedTerms ? 'set' : 'cleared'} for request ${id}`, {
|
||||
requestId: id,
|
||||
customSearchTerms: normalizedTerms,
|
||||
adminId: req.user.id,
|
||||
});
|
||||
|
||||
// Optionally trigger a new search
|
||||
let searchTriggered = false;
|
||||
if (triggerSearch && ['pending', 'failed', 'awaiting_search'].includes(existingRequest.status)) {
|
||||
// Reset status to pending and clear error
|
||||
await prisma.request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'pending',
|
||||
errorMessage: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Queue search job
|
||||
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchJob(id, {
|
||||
id: existingRequest.audiobook.id,
|
||||
title: existingRequest.audiobook.title,
|
||||
author: existingRequest.audiobook.author,
|
||||
asin: existingRequest.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
searchTriggered = true;
|
||||
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
customSearchTerms: normalizedTerms,
|
||||
searchTriggered,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update search terms', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'ServerError', message: 'Failed to update search terms' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -139,6 +139,8 @@ export async function GET(request: NextRequest) {
|
||||
completedAt: request.completedAt,
|
||||
errorMessage: request.errorMessage,
|
||||
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
|
||||
downloadAttempts: request.downloadAttempts,
|
||||
customSearchTerms: request.customSearchTerms || null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||
|
||||
// Validate pagination parameters
|
||||
if (page < 1 || limit < 1 || limit > 100) {
|
||||
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
|
||||
let excludedAsins: string[] = [];
|
||||
if (hideAvailable) {
|
||||
const availableSet = await getAvailableAsins();
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
const whereClause = {
|
||||
isNewRelease: true,
|
||||
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||
};
|
||||
|
||||
// Query audible_cache for new release audiobooks
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
where: {
|
||||
isNewRelease: true,
|
||||
},
|
||||
where: whereClause,
|
||||
orderBy: {
|
||||
newReleaseRank: 'asc',
|
||||
},
|
||||
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: {
|
||||
isNewRelease: true,
|
||||
},
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||
|
||||
// Validate pagination parameters
|
||||
if (page < 1 || limit < 1 || limit > 100) {
|
||||
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
|
||||
let excludedAsins: string[] = [];
|
||||
if (hideAvailable) {
|
||||
const availableSet = await getAvailableAsins();
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
const whereClause = {
|
||||
isPopular: true,
|
||||
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||
};
|
||||
|
||||
// Query audible_cache for popular audiobooks
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
where: {
|
||||
isPopular: true,
|
||||
},
|
||||
where: whereClause,
|
||||
orderBy: {
|
||||
popularRank: 'asc',
|
||||
},
|
||||
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: {
|
||||
isPopular: true,
|
||||
},
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
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';
|
||||
|
||||
@@ -38,14 +40,22 @@ export async function GET(request: NextRequest) {
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
|
||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||
const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results);
|
||||
|
||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
// Enrich search results with availability and request status information
|
||||
const enrichedResults = await enrichAudiobooksWithMatches(results.results, userId);
|
||||
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
query: results.query,
|
||||
results: enrichedResults,
|
||||
totalResults: results.totalResults,
|
||||
totalResults: enrichedResults.length,
|
||||
page: results.page,
|
||||
hasMore: results.hasMore,
|
||||
});
|
||||
|
||||
@@ -38,9 +38,11 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedUsername = username.trim().toLowerCase();
|
||||
|
||||
// Find user by local admin identifier
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { plexId: `local-${username}` },
|
||||
where: { plexId: `local-${normalizedUsername}` },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
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';
|
||||
|
||||
@@ -46,16 +48,26 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin})`);
|
||||
const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10);
|
||||
|
||||
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin}), page ${page}`);
|
||||
|
||||
const audibleService = getAudibleService();
|
||||
const books = await audibleService.searchByAuthorAsin(authorName.trim(), asin);
|
||||
const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page);
|
||||
|
||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books);
|
||||
|
||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
// Enrich with library availability and request status
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(books, userId);
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||
|
||||
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books`);
|
||||
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -63,6 +75,8 @@ export async function GET(
|
||||
authorName: authorName.trim(),
|
||||
authorAsin: asin,
|
||||
totalBooks: enrichedBooks.length,
|
||||
hasMore: result.hasMore,
|
||||
page: result.page,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
@@ -59,9 +59,9 @@ async function saveConfig(req: AuthenticatedRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude', 'custom'].includes(provider)) {
|
||||
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ async function saveConfig(req: AuthenticatedRequest) {
|
||||
// No new API key, use existing one
|
||||
encryptedApiKeyToUse = existingConfig.apiKey;
|
||||
} else {
|
||||
// API key required for OpenAI/Claude
|
||||
// API key required for OpenAI/Claude/Gemini
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required' },
|
||||
{ status: 400 }
|
||||
|
||||
@@ -52,6 +52,30 @@ async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: st
|
||||
return allModels;
|
||||
}
|
||||
|
||||
// Fetch available Gemini models from the Google API
|
||||
async function fetchGeminiModels(apiKey: string): Promise<{ id: string; name: string }[]> {
|
||||
const response = await fetch(
|
||||
'https://generativelanguage.googleapis.com/v1beta/models',
|
||||
{ headers: { 'x-goog-api-key': apiKey } }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Gemini API error', { error: errorText });
|
||||
throw new Error('Invalid Gemini API key or connection failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return (data.models || [])
|
||||
.filter((m: any) => m.name?.startsWith('models/gemini-') && m.supportedGenerationMethods?.includes('generateContent'))
|
||||
.map((m: any) => ({
|
||||
id: m.name.replace('models/', ''),
|
||||
name: m.displayName || m.name.replace('models/', ''),
|
||||
}))
|
||||
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Helper functions for custom provider
|
||||
function isValidBaseUrl(url: string): boolean {
|
||||
try {
|
||||
@@ -79,9 +103,9 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude', 'custom'].includes(provider)) {
|
||||
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -193,6 +217,16 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (provider === 'gemini') {
|
||||
// Gemini: Fetch models dynamically from the Google API
|
||||
try {
|
||||
models = await fetchGeminiModels(testApiKey);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid Gemini API key or connection failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (provider === 'custom') {
|
||||
// Custom: Fetch models from custom OpenAI-compatible endpoint
|
||||
const normalizedUrl = normalizeBaseUrl(testBaseUrl);
|
||||
@@ -291,9 +325,9 @@ async function unauthenticatedHandler(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude', 'custom'].includes(provider)) {
|
||||
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -363,6 +397,16 @@ async function unauthenticatedHandler(req: NextRequest) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (provider === 'gemini') {
|
||||
// Gemini: Fetch models dynamically
|
||||
try {
|
||||
models = await fetchGeminiModels(apiKey);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid Gemini API key or connection failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (provider === 'custom') {
|
||||
// Custom: Fetch models from custom OpenAI-compatible endpoint
|
||||
const normalizedUrl = normalizeBaseUrl(baseUrl);
|
||||
|
||||
@@ -125,8 +125,8 @@ export async function POST(
|
||||
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
|
||||
}
|
||||
|
||||
// Use custom title if provided, otherwise use audiobook's title
|
||||
const searchTitle = customTitle || requestRecord.audiobook.title;
|
||||
// Use custom title if provided, then custom search terms, then audiobook's title
|
||||
const searchTitle = customTitle || requestRecord.customSearchTerms || requestRecord.audiobook.title;
|
||||
const searchAuthor = requestRecord.audiobook.author;
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle });
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||
|
||||
const logger = RMABLogger.create('API.Series.Detail');
|
||||
|
||||
@@ -37,9 +39,11 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Fetching series detail: ${asin}`);
|
||||
const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10);
|
||||
|
||||
const detail = await scrapeSeriesPage(asin);
|
||||
logger.info(`Fetching series detail: ${asin}, page ${page}`);
|
||||
|
||||
const detail = await scrapeSeriesPage(asin, page);
|
||||
if (!detail) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Series not found' },
|
||||
@@ -47,11 +51,19 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(detail.books);
|
||||
|
||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
// Enrich books with library availability and request status
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId);
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||
|
||||
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books)`);
|
||||
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -59,6 +71,8 @@ export async function GET(
|
||||
...detail,
|
||||
books: enrichedBooks,
|
||||
},
|
||||
hasMore: detail.hasMore,
|
||||
page: detail.page,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch series detail', {
|
||||
|
||||
@@ -140,14 +140,15 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedAdminUsername = admin.username.trim().toLowerCase();
|
||||
const hashedPassword = await bcrypt.hash(admin.password, 10);
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedPassword = encryptionService.encrypt(hashedPassword);
|
||||
|
||||
adminUser = await prisma.user.create({
|
||||
data: {
|
||||
plexId: `local-${admin.username}`,
|
||||
plexUsername: admin.username,
|
||||
plexId: `local-${normalizedAdminUsername}`,
|
||||
plexUsername: normalizedAdminUsername,
|
||||
plexEmail: null,
|
||||
role: 'admin',
|
||||
isSetupAdmin: true, // Mark as setup admin - role cannot be changed
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Component: User API Token Delete Route (self-service)
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
|
||||
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||
|
||||
/**
|
||||
* DELETE /api/user/api-tokens/[id]
|
||||
* Revoke (delete) one of the current user's own API tokens
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token revoke attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const token = await prisma.apiToken.findUnique({ where: { id } });
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only allow deleting own tokens
|
||||
if (token.userId !== req.user!.id) {
|
||||
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.apiToken.delete({ where: { id } });
|
||||
|
||||
logger.info('User API token revoked', { tokenId: id, name: token.name, userId: req.user!.id });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke user API token', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Component: User API Token Routes (self-service)
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||
import { generateApiToken } from '@/lib/utils/api-token';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||
|
||||
const CreateTokenSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/user/api-tokens
|
||||
* List the current user's own API tokens
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const tokens = await prisma.apiToken.findMany({
|
||||
where: { userId: req.user!.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const sanitized = tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
role: t.role,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
expiresAt: t.expiresAt,
|
||||
createdAt: t.createdAt,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ tokens: sanitized });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list user API tokens', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/api-tokens
|
||||
* Create a token for the current user with their own role. Returns full token ONCE.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenCreateRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token create attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { name, expiresAt } = CreateTokenSchema.parse(body);
|
||||
|
||||
// Look up the user's actual role from the database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Enforce per-user token cap (count only active, non-expired tokens)
|
||||
const activeTokenCount = await prisma.apiToken.count({
|
||||
where: {
|
||||
userId: req.user!.id,
|
||||
OR: [
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: new Date() } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (activeTokenCount >= MAX_TOKENS_PER_USER) {
|
||||
return NextResponse.json(
|
||||
{ error: `Token limit reached. Users may have at most ${MAX_TOKENS_PER_USER} active API tokens.` },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate the token
|
||||
const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
|
||||
|
||||
const apiToken = await prisma.apiToken.create({
|
||||
data: {
|
||||
name,
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
role: user.role, // Always the user's own role
|
||||
createdById: req.user!.id,
|
||||
userId: req.user!.id, // Token acts as the current user
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('User API token created', { tokenId: apiToken.id, name, userId: req.user!.id });
|
||||
|
||||
return NextResponse.json({
|
||||
token: {
|
||||
id: apiToken.id,
|
||||
name: apiToken.name,
|
||||
tokenPrefix: apiToken.tokenPrefix,
|
||||
role: apiToken.role,
|
||||
expiresAt: apiToken.expiresAt,
|
||||
createdAt: apiToken.createdAt,
|
||||
},
|
||||
fullToken,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create user API token', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to create API token' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Component: Watched Author Delete Route
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.WatchedAuthors');
|
||||
|
||||
/**
|
||||
* DELETE /api/user/watched-authors/[id]
|
||||
* Remove an author from the user's watch list (ownership check)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const watched = await prisma.watchedAuthor.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!watched) {
|
||||
return NextResponse.json({ error: 'Watched author not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Ownership check
|
||||
if (watched.userId !== req.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.watchedAuthor.delete({ where: { id } });
|
||||
|
||||
logger.info(`User ${req.user.id} stopped watching author "${watched.authorName}" (${watched.authorAsin})`);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete watched author', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to delete watched author' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Component: Watched Authors API Routes
|
||||
* Documentation: documentation/features/watched-lists.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 { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.WatchedAuthors');
|
||||
|
||||
const AddWatchedAuthorSchema = z.object({
|
||||
authorAsin: z.string().regex(/^[A-Z0-9]{10}$/, 'Invalid author ASIN'),
|
||||
authorName: z.string().min(1).max(500),
|
||||
coverArtUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/user/watched-authors
|
||||
* List the current user's watched authors
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const authors = await prisma.watchedAuthor.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
authors: authors.map((a) => ({
|
||||
id: a.id,
|
||||
authorAsin: a.authorAsin,
|
||||
authorName: a.authorName,
|
||||
coverArtUrl: a.coverArtUrl,
|
||||
lastCheckedAt: a.lastCheckedAt,
|
||||
createdAt: a.createdAt,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list watched authors', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to list watched authors' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/watched-authors
|
||||
* Add an author to the user's watch 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 { authorAsin, authorName, coverArtUrl } = AddWatchedAuthorSchema.parse(body);
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await prisma.watchedAuthor.findUnique({
|
||||
where: { userId_authorAsin: { userId: req.user.id, authorAsin } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AlreadyWatching', message: 'You are already watching this author' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const watched = await prisma.watchedAuthor.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
authorAsin,
|
||||
authorName,
|
||||
coverArtUrl: coverArtUrl || null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`User ${req.user.id} started watching author "${authorName}" (${authorAsin})`);
|
||||
|
||||
// Trigger immediate targeted check for this author (fire-and-forget)
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addCheckWatchedItemJob(req.user.id, undefined, authorAsin);
|
||||
logger.info(`Triggered immediate check for watched author "${authorName}" (${authorAsin})`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate watched author check', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
author: {
|
||||
id: watched.id,
|
||||
authorAsin: watched.authorAsin,
|
||||
authorName: watched.authorName,
|
||||
coverArtUrl: watched.coverArtUrl,
|
||||
lastCheckedAt: watched.lastCheckedAt,
|
||||
createdAt: watched.createdAt,
|
||||
},
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to add watched author', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to add watched author' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Component: Watched Series Delete Route
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.WatchedSeries');
|
||||
|
||||
/**
|
||||
* DELETE /api/user/watched-series/[id]
|
||||
* Remove a series from the user's watch list (ownership check)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const watched = await prisma.watchedSeries.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!watched) {
|
||||
return NextResponse.json({ error: 'Watched series not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Ownership check
|
||||
if (watched.userId !== req.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.watchedSeries.delete({ where: { id } });
|
||||
|
||||
logger.info(`User ${req.user.id} stopped watching series "${watched.seriesTitle}" (${watched.seriesAsin})`);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete watched series', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to delete watched series' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Component: Watched Series API Routes
|
||||
* Documentation: documentation/features/watched-lists.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 { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.WatchedSeries');
|
||||
|
||||
const AddWatchedSeriesSchema = z.object({
|
||||
seriesAsin: z.string().regex(/^[A-Z0-9]{10}$/, 'Invalid series ASIN'),
|
||||
seriesTitle: z.string().min(1).max(500),
|
||||
coverArtUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/user/watched-series
|
||||
* List the current user's watched series
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const series = await prisma.watchedSeries.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
series: series.map((s) => ({
|
||||
id: s.id,
|
||||
seriesAsin: s.seriesAsin,
|
||||
seriesTitle: s.seriesTitle,
|
||||
coverArtUrl: s.coverArtUrl,
|
||||
lastCheckedAt: s.lastCheckedAt,
|
||||
createdAt: s.createdAt,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list watched series', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to list watched series' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/watched-series
|
||||
* Add a series to the user's watch 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 { seriesAsin, seriesTitle, coverArtUrl } = AddWatchedSeriesSchema.parse(body);
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await prisma.watchedSeries.findUnique({
|
||||
where: { userId_seriesAsin: { userId: req.user.id, seriesAsin } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AlreadyWatching', message: 'You are already watching this series' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const watched = await prisma.watchedSeries.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
seriesAsin,
|
||||
seriesTitle,
|
||||
coverArtUrl: coverArtUrl || null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`User ${req.user.id} started watching series "${seriesTitle}" (${seriesAsin})`);
|
||||
|
||||
// Trigger immediate targeted check for this series (fire-and-forget)
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addCheckWatchedItemJob(req.user.id, seriesAsin);
|
||||
logger.info(`Triggered immediate check for watched series "${seriesTitle}" (${seriesAsin})`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate watched series check', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
series: {
|
||||
id: watched.id,
|
||||
seriesAsin: watched.seriesAsin,
|
||||
seriesTitle: watched.seriesTitle,
|
||||
coverArtUrl: watched.coverArtUrl,
|
||||
lastCheckedAt: watched.lastCheckedAt,
|
||||
createdAt: watched.createdAt,
|
||||
},
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to add watched series', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to add watched series' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5,16 +5,17 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { use, useCallback } from 'react';
|
||||
import { use, useCallback, useMemo } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
|
||||
import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard';
|
||||
import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow';
|
||||
import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors';
|
||||
import { Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
export default function AuthorDetailPage({
|
||||
@@ -27,11 +28,11 @@ export default function AuthorDetailPage({
|
||||
const searchParams = useSearchParams();
|
||||
const fromAuthorName = searchParams.get('from');
|
||||
const { author, isLoading: authorLoading } = useAuthorDetail(asin);
|
||||
const { books, totalBooks, isLoading: booksLoading } = useAuthorBooks(
|
||||
const { books, totalBooks, hasMore, isLoading: booksLoading, isLoadingMore, loadMore } = useAuthorBooks(
|
||||
asin,
|
||||
author?.name || null
|
||||
);
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
// Use browser back if we came from within the app, otherwise fallback to /authors
|
||||
@@ -42,6 +43,20 @@ export default function AuthorDetailPage({
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Filter out available titles when hideAvailable is enabled
|
||||
const filteredBooks = useMemo(
|
||||
() => hideAvailable ? books.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : books,
|
||||
[books, hideAvailable]
|
||||
);
|
||||
|
||||
// Header count text: reflects filtered counts
|
||||
const visibleCount = filteredBooks.length;
|
||||
const booksCountText = hasMore && totalBooks > books.length
|
||||
? `${visibleCount.toLocaleString()} of ${totalBooks.toLocaleString()} title${totalBooks !== 1 ? 's' : ''}`
|
||||
: visibleCount > 0
|
||||
? `${visibleCount.toLocaleString()} title${visibleCount !== 1 ? 's' : ''}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen">
|
||||
@@ -91,27 +106,42 @@ export default function AuthorDetailPage({
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Books
|
||||
</h2>
|
||||
{!booksLoading && totalBooks > 0 && (
|
||||
{!booksLoading && booksCountText && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||
({totalBooks} title{totalBooks !== 1 ? 's' : ''})
|
||||
({booksCountText})
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
<SectionToolbar
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={setHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={setSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={setCardSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Books Grid */}
|
||||
<AudiobookGrid
|
||||
audiobooks={books}
|
||||
audiobooks={filteredBooks}
|
||||
isLoading={booksLoading}
|
||||
emptyMessage={`No books found for ${author.name}`}
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
|
||||
{/* Load More Bar */}
|
||||
{filteredBooks.length > 0 && (
|
||||
<LoadMoreBar
|
||||
loadedCount={filteredBooks.length}
|
||||
totalCount={totalBooks > 0 ? totalBooks : undefined}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoadingMore}
|
||||
onLoadMore={loadMore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -197,6 +197,31 @@ body {
|
||||
animation: toast-slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Confirmation Dialog */
|
||||
@keyframes dialog-backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes dialog-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-dialog-backdrop {
|
||||
animation: dialog-backdrop-in 0.15s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-dialog-panel {
|
||||
animation: dialog-panel-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
/* Hide scrollbar while keeping scroll functional */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
|
||||
+52
-30
@@ -5,20 +5,19 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { StickyPagination } from '@/components/ui/StickyPagination';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||
import { UnifiedPagination } from '@/components/ui/UnifiedPagination';
|
||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
export default function HomePage() {
|
||||
const [popularPage, setPopularPage] = useState(1);
|
||||
const [newReleasesPage, setNewReleasesPage] = useState(1);
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||
|
||||
// Refs for auto-scrolling to section tops
|
||||
const popularSectionRef = useRef<HTMLElement>(null);
|
||||
@@ -30,14 +29,20 @@ export default function HomePage() {
|
||||
isLoading: loadingPopular,
|
||||
totalPages: popularTotalPages,
|
||||
message: popularMessage,
|
||||
} = useAudiobooks('popular', 20, popularPage);
|
||||
} = useAudiobooks('popular', 20, popularPage, hideAvailable);
|
||||
|
||||
const {
|
||||
audiobooks: newReleases,
|
||||
isLoading: loadingNewReleases,
|
||||
totalPages: newReleasesTotalPages,
|
||||
message: newReleasesMessage,
|
||||
} = useAudiobooks('new-releases', 20, newReleasesPage);
|
||||
} = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable);
|
||||
|
||||
// Reset to page 1 when hideAvailable changes (total pages may differ)
|
||||
useEffect(() => {
|
||||
setPopularPage(1);
|
||||
setNewReleasesPage(1);
|
||||
}, [hideAvailable]);
|
||||
|
||||
// Handle page changes with auto-scroll to section top
|
||||
const handlePopularPageChange = (page: number) => {
|
||||
@@ -66,10 +71,14 @@ export default function HomePage() {
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Popular Audiobooks
|
||||
</h2>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
<SectionToolbar
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={setHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={setSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={setCardSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,10 +116,14 @@ export default function HomePage() {
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
New Releases
|
||||
</h2>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
<SectionToolbar
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={setHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={setSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={setCardSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,22 +177,31 @@ export default function HomePage() {
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Sticky Pagination Controls */}
|
||||
<StickyPagination
|
||||
currentPage={popularPage}
|
||||
totalPages={popularTotalPages}
|
||||
onPageChange={handlePopularPageChange}
|
||||
sectionRef={popularSectionRef}
|
||||
{/* Unified Pagination — single context-aware pill for both sections */}
|
||||
<UnifiedPagination
|
||||
footerRef={footerRef}
|
||||
label="Popular Audiobooks"
|
||||
/>
|
||||
<StickyPagination
|
||||
currentPage={newReleasesPage}
|
||||
totalPages={newReleasesTotalPages}
|
||||
onPageChange={handleNewReleasesPageChange}
|
||||
sectionRef={newReleasesSectionRef}
|
||||
footerRef={footerRef}
|
||||
label="New Releases"
|
||||
sections={[
|
||||
{
|
||||
label: 'Popular Audiobooks',
|
||||
accentColor: 'bg-blue-500',
|
||||
currentPage: popularPage,
|
||||
totalPages: popularTotalPages,
|
||||
onPageChange: handlePopularPageChange,
|
||||
sectionRef: popularSectionRef,
|
||||
onScrollToSection: () =>
|
||||
popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
||||
},
|
||||
{
|
||||
label: 'New Releases',
|
||||
accentColor: 'bg-emerald-500',
|
||||
currentPage: newReleasesPage,
|
||||
totalPages: newReleasesTotalPages,
|
||||
onPageChange: handleNewReleasesPageChange,
|
||||
sectionRef: newReleasesSectionRef,
|
||||
onScrollToSection: () =>
|
||||
newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRequests } from '@/lib/hooks/useRequests';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
||||
import { ApiTokensSection } from '@/components/profile/ApiTokensSection';
|
||||
import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection';
|
||||
|
||||
const statConfig = [
|
||||
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
||||
@@ -142,6 +144,12 @@ export default function ProfilePage() {
|
||||
{/* Goodreads Shelves */}
|
||||
<GoodreadsShelvesSection />
|
||||
|
||||
{/* Watched Series */}
|
||||
<WatchedSeriesSection />
|
||||
|
||||
{/* Watched Authors */}
|
||||
<WatchedAuthorsSection />
|
||||
|
||||
{/* Active Downloads */}
|
||||
{activeDownloads.length > 0 && (
|
||||
<section>
|
||||
@@ -233,6 +241,9 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* API Tokens */}
|
||||
<ApiTokensSection />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
+41
-37
@@ -5,41 +5,48 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { useSearch } from '@/lib/hooks/useAudiobooks';
|
||||
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
|
||||
import { useSearch, Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||
|
||||
// Debounce search query
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
setPage(1); // Reset to first page on new search
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
const { results, totalResults, hasMore, isLoading } = useSearch(debouncedQuery, page);
|
||||
const { results, totalResults, hasMore, isLoading, isLoadingMore, loadMore } = useSearch(debouncedQuery);
|
||||
|
||||
// Filter out available titles when hideAvailable is enabled
|
||||
const filteredResults = useMemo(
|
||||
() => hideAvailable ? results.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : results,
|
||||
[results, hideAvailable]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setPage((prev) => prev + 1);
|
||||
}, []);
|
||||
// Header count text: reflects filtered counts
|
||||
const visibleCount = filteredResults.length;
|
||||
const countText = hasMore && totalResults > 0
|
||||
? `${visibleCount.toLocaleString()} of ${totalResults.toLocaleString()} result${totalResults !== 1 ? 's' : ''}`
|
||||
: visibleCount > 0
|
||||
? `${visibleCount.toLocaleString()} result${visibleCount !== 1 ? 's' : ''}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
@@ -113,45 +120,42 @@ export default function SearchPage() {
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Search Results
|
||||
</h2>
|
||||
{!isLoading && totalResults > 0 && (
|
||||
{!isLoading && countText && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||
({totalResults.toLocaleString()} result{totalResults !== 1 ? 's' : ''})
|
||||
({countText})
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
<SectionToolbar
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={setHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={setSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={setCardSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Grid */}
|
||||
<AudiobookGrid
|
||||
audiobooks={results}
|
||||
isLoading={!!(isLoading && page === 1)}
|
||||
audiobooks={filteredResults}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={`No results found for "${debouncedQuery}"`}
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
|
||||
{/* Load More */}
|
||||
{hasMore && !isLoading && (
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
className="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Load More Results
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading More Indicator */}
|
||||
{isLoading && page > 1 && (
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
{/* Load More Bar */}
|
||||
{filteredResults.length > 0 && (
|
||||
<LoadMoreBar
|
||||
loadedCount={filteredResults.length}
|
||||
totalCount={totalResults}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoadingMore}
|
||||
onLoadMore={loadMore}
|
||||
itemLabel="results"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -5,16 +5,17 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { use, useCallback } from 'react';
|
||||
import { use, useCallback, useMemo } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
|
||||
import { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard';
|
||||
import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow';
|
||||
import { useSeriesDetail } from '@/lib/hooks/useSeries';
|
||||
import { Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
export default function SeriesDetailPage({
|
||||
@@ -26,8 +27,8 @@ export default function SeriesDetailPage({
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const fromSeriesTitle = searchParams.get('from');
|
||||
const { series, isLoading: seriesLoading } = useSeriesDetail(asin);
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||
const { series, hasMore, isLoading: seriesLoading, isLoadingMore, loadMore } = useSeriesDetail(asin);
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
// Use browser back if we came from within the app, otherwise fallback to /series
|
||||
@@ -38,6 +39,24 @@ export default function SeriesDetailPage({
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Filter out available titles when hideAvailable is enabled
|
||||
const filteredBooks = useMemo(
|
||||
() => series && hideAvailable
|
||||
? series.books.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed')
|
||||
: series?.books ?? [],
|
||||
[series, hideAvailable]
|
||||
);
|
||||
|
||||
// Header count text: reflects filtered counts
|
||||
const visibleCount = filteredBooks.length;
|
||||
const booksCountText = series
|
||||
? hasMore && series.bookCount > series.books.length
|
||||
? `${visibleCount.toLocaleString()} of ${series.bookCount.toLocaleString()} title${series.bookCount !== 1 ? 's' : ''}`
|
||||
: visibleCount > 0
|
||||
? `${visibleCount.toLocaleString()} title${visibleCount !== 1 ? 's' : ''}`
|
||||
: ''
|
||||
: '';
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen">
|
||||
@@ -87,27 +106,42 @@ export default function SeriesDetailPage({
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Books in Series
|
||||
</h2>
|
||||
{series.books.length > 0 && (
|
||||
{booksCountText && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||
({series.books.length} title{series.books.length !== 1 ? 's' : ''})
|
||||
({booksCountText})
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
<SectionToolbar
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={setHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={setSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={setCardSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Books Grid */}
|
||||
<AudiobookGrid
|
||||
audiobooks={series.books}
|
||||
audiobooks={filteredBooks}
|
||||
isLoading={seriesLoading}
|
||||
emptyMessage={`No books found for ${series.title}`}
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
|
||||
{/* Load More Bar */}
|
||||
{filteredBooks.length > 0 && (
|
||||
<LoadMoreBar
|
||||
loadedCount={filteredBooks.length}
|
||||
totalCount={series.bookCount > 0 ? series.bookCount : undefined}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoadingMore}
|
||||
onLoadMore={loadMore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -134,6 +134,7 @@ export function BookDateStep({
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="gemini">Google Gemini</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +153,7 @@ export function BookDateStep({
|
||||
onUpdate('bookdateConfigured', false);
|
||||
onUpdate('bookdateModels', []);
|
||||
}}
|
||||
placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'}
|
||||
placeholder={bookdateProvider === 'openai' ? 'sk-...' : bookdateProvider === 'gemini' ? 'AIza...' : 'sk-ant-...'}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Component: API Docs Endpoint Card
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Expandable card for a single API endpoint with "Try it out" functionality.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { ResponseViewer } from './ResponseViewer';
|
||||
import type { EndpointDoc } from '@/lib/constants/api-tokens';
|
||||
|
||||
interface EndpointCardProps {
|
||||
endpoint: EndpointDoc;
|
||||
token: string;
|
||||
useSession: boolean;
|
||||
}
|
||||
|
||||
const METHOD_STYLES: Record<string, string> = {
|
||||
GET: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300',
|
||||
POST: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
|
||||
PUT: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
|
||||
DELETE: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300',
|
||||
};
|
||||
|
||||
export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<number | null>(null);
|
||||
const [data, setData] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleTryIt = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setData(null);
|
||||
setStatus(null);
|
||||
setExpanded(true);
|
||||
|
||||
try {
|
||||
let response: Response;
|
||||
|
||||
if (useSession) {
|
||||
// Use session JWT via fetchWithAuth
|
||||
response = await fetchWithAuth(endpoint.path, { method: endpoint.method });
|
||||
} else {
|
||||
// Use custom API token
|
||||
if (!token.trim()) {
|
||||
setError('Please enter an API token');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
response = await fetch(endpoint.path, {
|
||||
method: endpoint.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.trim()}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setStatus(response.status);
|
||||
const text = await response.text();
|
||||
setData(text);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Request failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoint, token, useSession]);
|
||||
|
||||
const methodStyle = METHOD_STYLES[endpoint.method] || METHOD_STYLES.GET;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 shadow-sm overflow-hidden transition-shadow hover:shadow-md">
|
||||
{/* Card header */}
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold tracking-wide ${methodStyle}`}>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<code className="text-sm font-mono font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{endpoint.path}
|
||||
</code>
|
||||
{endpoint.requiresAdmin && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wider bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{endpoint.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{endpoint.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleTryIt}
|
||||
disabled={loading}
|
||||
className="flex-shrink-0 inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:opacity-50 transition-all active:scale-[0.97]"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 dark:border-gray-900/30 border-t-white dark:border-t-gray-900" />
|
||||
Running
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
</svg>
|
||||
Try it
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expandable response area */}
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out overflow-hidden ${
|
||||
expanded ? 'max-h-[600px] opacity-100 mt-1' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<ResponseViewer
|
||||
status={status}
|
||||
data={data}
|
||||
error={error}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{(data || error) && !loading && (
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
onClick={() => { setExpanded(false); setData(null); setStatus(null); setError(null); }}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Clear response
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Curl example (shown in collapsed footer) */}
|
||||
<div className="px-5 py-3 bg-gray-50 dark:bg-gray-900/30 border-t border-gray-100 dark:border-gray-700/50">
|
||||
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
curl -H "Authorization: Bearer {'<token>'}" {typeof window !== 'undefined' ? window.location.origin : ''}{endpoint.path}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Component: API Docs Response Viewer
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Displays API response with syntax highlighting, status badge, and copy functionality.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
interface ResponseViewerProps {
|
||||
status: number | null;
|
||||
data: string | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function statusColor(status: number): string {
|
||||
if (status >= 200 && status < 300) return 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300';
|
||||
if (status >= 400 && status < 500) return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300';
|
||||
return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300';
|
||||
}
|
||||
|
||||
/** Tokenize JSON string into typed segments for React rendering */
|
||||
type JsonToken = { type: 'string' | 'number' | 'boolean' | 'null' | 'plain'; value: string };
|
||||
|
||||
function tokenizeJson(json: string): JsonToken[] {
|
||||
const tokens: JsonToken[] = [];
|
||||
const regex = /("(?:[^"\\]|\\.)*")|(\b\d+\.?\d*\b)|(\btrue\b|\bfalse\b)|(\bnull\b)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(json)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push({ type: 'plain', value: json.slice(lastIndex, match.index) });
|
||||
}
|
||||
if (match[1] !== undefined) tokens.push({ type: 'string', value: match[1] });
|
||||
else if (match[2] !== undefined) tokens.push({ type: 'number', value: match[2] });
|
||||
else if (match[3] !== undefined) tokens.push({ type: 'boolean', value: match[3] });
|
||||
else if (match[4] !== undefined) tokens.push({ type: 'null', value: match[4] });
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
if (lastIndex < json.length) {
|
||||
tokens.push({ type: 'plain', value: json.slice(lastIndex) });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const TOKEN_COLORS: Record<JsonToken['type'], string> = {
|
||||
string: 'text-emerald-400',
|
||||
number: 'text-blue-400',
|
||||
boolean: 'text-purple-400',
|
||||
null: 'text-purple-400',
|
||||
plain: 'text-gray-300',
|
||||
};
|
||||
|
||||
export function ResponseViewer({ status, data, error, loading }: ResponseViewerProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const tokens = useMemo(() => {
|
||||
if (!data) return [];
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(data), null, 2);
|
||||
return tokenizeJson(formatted);
|
||||
} catch {
|
||||
return [{ type: 'plain' as const, value: data }];
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(data), null, 2);
|
||||
await navigator.clipboard.writeText(formatted);
|
||||
} catch {
|
||||
await navigator.clipboard.writeText(data);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Sending request...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-red-200 dark:border-red-800/50 bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm text-red-700 dark:text-red-300">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || status === null) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
Response
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold ${statusColor(status)}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* JSON body */}
|
||||
<pre className="p-4 bg-[#0d1117] text-sm font-mono leading-relaxed overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||
<code>{tokens.map((t, i) => (
|
||||
<span key={i} className={TOKEN_COLORS[t.type]}>{t.value}</span>
|
||||
))}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Component: API Docs Token Input
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Token input field with toggle between custom API token and current session auth.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface TokenInputProps {
|
||||
token: string;
|
||||
onTokenChange: (token: string) => void;
|
||||
useSession: boolean;
|
||||
onUseSessionChange: (useSession: boolean) => void;
|
||||
}
|
||||
|
||||
export function TokenInput({
|
||||
token,
|
||||
onTokenChange,
|
||||
useSession,
|
||||
onUseSessionChange,
|
||||
}: TokenInputProps) {
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Authentication
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Choose how to authenticate your test requests
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Session toggle */}
|
||||
<button
|
||||
onClick={() => onUseSessionChange(!useSession)}
|
||||
className={`
|
||||
relative inline-flex h-7 w-[140px] items-center rounded-full transition-colors duration-200
|
||||
${useSession
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
absolute inset-y-0.5 w-[68px] rounded-full bg-white dark:bg-gray-100 shadow-sm
|
||||
transition-transform duration-200 ease-in-out
|
||||
${useSession ? 'translate-x-[70px]' : 'translate-x-0.5'}
|
||||
`}
|
||||
/>
|
||||
<span
|
||||
className={`
|
||||
relative z-10 flex-1 text-center text-xs font-medium transition-colors duration-200
|
||||
${!useSession ? 'text-gray-900 dark:text-gray-900' : 'text-white/70'}
|
||||
`}
|
||||
>
|
||||
API Token
|
||||
</span>
|
||||
<span
|
||||
className={`
|
||||
relative z-10 flex-1 text-center text-xs font-medium transition-colors duration-200
|
||||
${useSession ? 'text-gray-900 dark:text-gray-900' : 'text-gray-500 dark:text-gray-400'}
|
||||
`}
|
||||
>
|
||||
Session
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useSession ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
|
||||
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Using your current browser session for authentication
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={(e) => onTokenChange(e.target.value)}
|
||||
placeholder="rmab_your_api_token_here"
|
||||
className="w-full rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900/50 px-4 py-2.5 pr-20 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 focus:outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-2.5 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{showToken ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,9 @@ export function ManualImportBrowser({
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
|
||||
// Cleanup source toggle
|
||||
const [cleanupSource, setCleanupSource] = useState(false);
|
||||
|
||||
// Hover state for folder icon swap
|
||||
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
|
||||
|
||||
@@ -188,6 +191,7 @@ export function ManualImportBrowser({
|
||||
body: JSON.stringify({
|
||||
asin: audiobook.asin,
|
||||
folderPath: selectedPath,
|
||||
cleanupSource,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -288,6 +292,8 @@ export function ManualImportBrowser({
|
||||
isImporting={isImporting}
|
||||
importError={importError}
|
||||
slideClass={slideClass}
|
||||
cleanupSource={cleanupSource}
|
||||
onCleanupSourceChange={setCleanupSource}
|
||||
onBack={handleBackToBrowse}
|
||||
onStartImport={handleStartImport}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,8 @@ interface ConfirmPhaseProps {
|
||||
isImporting: boolean;
|
||||
importError: string | null;
|
||||
slideClass: string;
|
||||
cleanupSource: boolean;
|
||||
onCleanupSourceChange: (value: boolean) => void;
|
||||
onBack: () => void;
|
||||
onStartImport: () => void;
|
||||
}
|
||||
@@ -35,6 +37,8 @@ export function ConfirmPhase({
|
||||
isImporting,
|
||||
importError,
|
||||
slideClass,
|
||||
cleanupSource,
|
||||
onCleanupSourceChange,
|
||||
onBack,
|
||||
onStartImport,
|
||||
}: ConfirmPhaseProps) {
|
||||
@@ -99,6 +103,30 @@ export function ConfirmPhase({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cleanup source toggle */}
|
||||
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Cleanup source files
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Delete original files after successful import
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cleanupSource}
|
||||
onChange={(e) => onCleanupSourceChange(e.target.checked)}
|
||||
disabled={isImporting}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { AuthorDetail } from '@/lib/hooks/useAuthors';
|
||||
import { WatchAuthorButton } from '@/components/ui/WatchButton';
|
||||
|
||||
interface AuthorDetailCardProps {
|
||||
author: AuthorDetail;
|
||||
@@ -64,20 +65,27 @@ export function AuthorDetailCard({ author }: AuthorDetailCardProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audible Link */}
|
||||
{author.audibleUrl && (
|
||||
<a
|
||||
href={author.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{/* Actions row: Audible link + Watch button */}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
|
||||
{author.audibleUrl && (
|
||||
<a
|
||||
href={author.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
<WatchAuthorButton
|
||||
authorAsin={author.asin}
|
||||
authorName={author.name}
|
||||
coverArtUrl={author.image}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{author.description && (
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Component: API Tokens Section (Profile Page)
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { useApiTokens } from '@/lib/hooks/useApiTokens';
|
||||
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||
import Link from 'next/link';
|
||||
import type { ApiToken } from '@/lib/types/api-tokens';
|
||||
|
||||
export function ApiTokensSection() {
|
||||
const api = useApiTokens<ApiToken>({ basePath: '/api/user/api-tokens' });
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
API Tokens
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Create personal API tokens for programmatic access to the API.{' '}
|
||||
<Link href="/api-docs" className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View API documentation
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Error display */}
|
||||
{api.error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
|
||||
{api.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newly created token banner */}
|
||||
{api.createdToken && (
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
Token created successfully! Copy it now — it won't be shown again.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{api.createdToken}
|
||||
</code>
|
||||
<button
|
||||
onClick={api.handleCopy}
|
||||
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||
>
|
||||
{api.copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss token banner"
|
||||
onClick={api.dismissCreatedToken}
|
||||
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create token form */}
|
||||
{api.showCreateForm ? (
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
|
||||
<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-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={api.newTokenName}
|
||||
onChange={(e) => api.setNewTokenName(e.target.value)}
|
||||
placeholder="e.g., Home Assistant, Webhook"
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => e.key === 'Enter' && api.handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Expiration
|
||||
</label>
|
||||
<select
|
||||
value={api.newTokenExpiry}
|
||||
onChange={(e) => api.setNewTokenExpiry(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="never">Never</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="1y">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => api.handleCreate()}
|
||||
disabled={api.creating || !api.newTokenName.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
|
||||
>
|
||||
{api.creating ? 'Creating...' : 'Create Token'}
|
||||
</button>
|
||||
<button
|
||||
onClick={api.resetForm}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => api.setShowCreateForm(true)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
||||
>
|
||||
Create New Token
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{api.loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : api.tokens.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm">No API tokens yet</p>
|
||||
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
|
||||
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{api.tokens.map((token) => (
|
||||
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
|
||||
<td className="py-3 px-2">
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
|
||||
{token.tokenPrefix}...
|
||||
</code>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{api.formatDate(token.lastUsedAt)}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
|
||||
{token.expiresAt ? (
|
||||
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
|
||||
{api.formatDate(token.expiresAt)}
|
||||
{new Date(token.expiresAt) < new Date() && ' (expired)'}
|
||||
</span>
|
||||
) : (
|
||||
'Never'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-2 text-right">
|
||||
<button
|
||||
onClick={() => api.setConfirmRevokeId(token.id)}
|
||||
disabled={api.deletingId === token.id}
|
||||
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{api.deletingId === token.id ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage instructions */}
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
|
||||
</p>
|
||||
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
|
||||
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||
${getInstanceUrl()}/api/requests`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revoke confirmation dialog */}
|
||||
<ConfirmModal
|
||||
isOpen={api.confirmRevokeId !== null}
|
||||
title="Revoke API token"
|
||||
message={
|
||||
<>
|
||||
Are you sure you want to revoke{' '}
|
||||
<span className="font-medium text-gray-800 dark:text-gray-100">
|
||||
“{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}”
|
||||
</span>
|
||||
? Any integrations using this token will immediately lose access. This cannot be undone.
|
||||
</>
|
||||
}
|
||||
confirmText="Revoke token"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
onConfirm={api.handleDeleteConfirmed}
|
||||
onClose={() => api.setConfirmRevokeId(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Component: Watched Lists Section (Profile Page)
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*
|
||||
* Shows the user's watched series and watched authors on their profile page
|
||||
* with the ability to remove items.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { useWatchedSeries, useDeleteWatchedSeries, WatchedSeriesItem } from '@/lib/hooks/useWatchedSeries';
|
||||
import { useWatchedAuthors, useDeleteWatchedAuthor, WatchedAuthorItem } from '@/lib/hooks/useWatchedAuthors';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
function formatRelativeTime(dateStr: string | null): string {
|
||||
if (!dateStr) return 'Never';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watched Series Section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function WatchedSeriesSection() {
|
||||
const router = useRouter();
|
||||
const { series, isLoading } = useWatchedSeries();
|
||||
const { deleteSeries, isLoading: isDeleting } = useDeleteWatchedSeries();
|
||||
const { squareCovers } = usePreferences();
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteSeries(id);
|
||||
setConfirmDeleteId(null);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Watched Series" icon="series" count={null} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[1, 2].map((i) => <CardSkeleton key={i} squareCovers={squareCovers} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (series.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Watched Series" icon="series" count={series.length} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{series.map((item) => (
|
||||
<WatchedSeriesCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
squareCovers={squareCovers}
|
||||
isDeleting={isDeleting && confirmDeleteId === item.id}
|
||||
confirmingDelete={confirmDeleteId === item.id}
|
||||
onNavigate={() => router.push(`/series/${item.seriesAsin}`)}
|
||||
onConfirmDelete={() => setConfirmDeleteId(item.id)}
|
||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WatchedSeriesCard({
|
||||
item, squareCovers, isDeleting, confirmingDelete, onNavigate, onConfirmDelete, onCancelDelete, onDelete,
|
||||
}: {
|
||||
item: WatchedSeriesItem;
|
||||
squareCovers: boolean;
|
||||
isDeleting: boolean;
|
||||
confirmingDelete: boolean;
|
||||
onNavigate: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 hover:shadow-sm transition-shadow">
|
||||
{/* Cover */}
|
||||
<button onClick={onNavigate} className="flex-shrink-0">
|
||||
<div className={`relative w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg overflow-hidden bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900`}>
|
||||
{item.coverArtUrl ? (
|
||||
<Image src={item.coverArtUrl} alt={item.seriesTitle} fill className="object-cover" sizes="56px" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<button onClick={onNavigate} className="text-left">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors">
|
||||
{item.seriesTitle}
|
||||
</h3>
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Last checked: {formatRelativeTime(item.lastCheckedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
{confirmingDelete ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
{isDeleting ? '...' : 'Remove'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelDelete}
|
||||
className="px-2 py-1 text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||||
title="Remove from watched"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watched Authors Section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function WatchedAuthorsSection() {
|
||||
const router = useRouter();
|
||||
const { authors, isLoading } = useWatchedAuthors();
|
||||
const { deleteAuthor, isLoading: isDeleting } = useDeleteWatchedAuthor();
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteAuthor(id);
|
||||
setConfirmDeleteId(null);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Watched Authors" icon="author" count={null} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[1, 2].map((i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (authors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Watched Authors" icon="author" count={authors.length} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{authors.map((item) => (
|
||||
<WatchedAuthorCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isDeleting={isDeleting && confirmDeleteId === item.id}
|
||||
confirmingDelete={confirmDeleteId === item.id}
|
||||
onNavigate={() => router.push(`/authors/${item.authorAsin}`)}
|
||||
onConfirmDelete={() => setConfirmDeleteId(item.id)}
|
||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WatchedAuthorCard({
|
||||
item, isDeleting, confirmingDelete, onNavigate, onConfirmDelete, onCancelDelete, onDelete,
|
||||
}: {
|
||||
item: WatchedAuthorItem;
|
||||
isDeleting: boolean;
|
||||
confirmingDelete: boolean;
|
||||
onNavigate: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 hover:shadow-sm transition-shadow">
|
||||
{/* Avatar */}
|
||||
<button onClick={onNavigate} className="flex-shrink-0">
|
||||
<div className="relative w-14 h-14 rounded-full overflow-hidden bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900">
|
||||
{item.coverArtUrl ? (
|
||||
<Image src={item.coverArtUrl} alt={item.authorName} fill className="object-cover" sizes="56px" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0 flex items-center">
|
||||
<div>
|
||||
<button onClick={onNavigate} className="text-left">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{item.authorName}
|
||||
</h3>
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
Last checked: {formatRelativeTime(item.lastCheckedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
{confirmingDelete ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
{isDeleting ? '...' : 'Remove'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelDelete}
|
||||
className="px-2 py-1 text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||||
title="Remove from watched"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionHeader({ title, icon, count }: { title: string; icon: 'series' | 'author'; count: number | null }) {
|
||||
const gradientColors = icon === 'series'
|
||||
? 'from-emerald-500 to-teal-500'
|
||||
: 'from-blue-500 to-indigo-500';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className={`w-1 h-6 bg-gradient-to-b ${gradientColors} rounded-full`} />
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
{count !== null && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">({count})</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardSkeleton({ squareCovers }: { squareCovers?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 animate-pulse">
|
||||
<div className={`w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg bg-gray-200 dark:bg-gray-700`} />
|
||||
<div className="flex-1 space-y-2 py-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ interface InteractiveTorrentSearchModalProps {
|
||||
title: string;
|
||||
author: string;
|
||||
};
|
||||
customSearchTerms?: string | null; // Optional - admin-set custom search terms override
|
||||
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
|
||||
onSuccess?: () => void;
|
||||
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
|
||||
@@ -87,6 +88,7 @@ export function InteractiveTorrentSearchModal({
|
||||
requestId,
|
||||
asin,
|
||||
audiobook,
|
||||
customSearchTerms,
|
||||
fullAudiobook,
|
||||
onSuccess,
|
||||
searchMode = 'audiobook',
|
||||
@@ -114,7 +116,7 @@ export function InteractiveTorrentSearchModal({
|
||||
|
||||
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]);
|
||||
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
|
||||
const [searchTitle, setSearchTitle] = useState(audiobook.title);
|
||||
const [searchTitle, setSearchTitle] = useState(customSearchTerms || audiobook.title);
|
||||
const [isCustomConfirming, setIsCustomConfirming] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -153,9 +155,9 @@ export function InteractiveTorrentSearchModal({
|
||||
|
||||
// Reset search title when modal opens/closes or audiobook changes
|
||||
useEffect(() => {
|
||||
setSearchTitle(audiobook.title);
|
||||
setSearchTitle(customSearchTerms || audiobook.title);
|
||||
setResults([]);
|
||||
}, [isOpen, audiobook.title]);
|
||||
}, [isOpen, audiobook.title, customSearchTerms]);
|
||||
|
||||
// Perform search when modal opens
|
||||
useEffect(() => {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { SeriesDetail } from '@/lib/hooks/useSeries';
|
||||
import { WatchSeriesButton } from '@/components/ui/WatchButton';
|
||||
|
||||
interface SeriesDetailCardProps {
|
||||
series: SeriesDetail;
|
||||
@@ -91,20 +92,27 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audible Link */}
|
||||
{series.audibleUrl && (
|
||||
<a
|
||||
href={series.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{/* Actions row: Audible link + Watch button */}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
|
||||
{series.audibleUrl && (
|
||||
<a
|
||||
href={series.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
<WatchSeriesButton
|
||||
seriesAsin={series.asin}
|
||||
seriesTitle={series.title}
|
||||
coverArtUrl={series.books[0]?.coverArtUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{series.description && (
|
||||
|
||||
@@ -14,7 +14,7 @@ interface ConfirmModalProps {
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
message: string | React.ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isLoading?: boolean;
|
||||
@@ -35,7 +35,9 @@ export function ConfirmModal({
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm" showCloseButton={false}>
|
||||
<div className="space-y-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">{message}</p>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
{typeof message === 'string' ? <p>{message}</p> : message}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button onClick={onClose} variant="outline" disabled={isLoading}>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Component: Hide Available Toggle
|
||||
* Documentation: UI toggle for hiding titles already in the user's library
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface HideAvailableToggleProps {
|
||||
enabled: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function HideAvailableToggle({ enabled, onToggle }: HideAvailableToggleProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onToggle(!enabled)}
|
||||
aria-label={enabled ? 'Show available titles' : 'Hide available titles'}
|
||||
aria-pressed={enabled}
|
||||
title={enabled ? 'Hide available (on)' : 'Hide available (off)'}
|
||||
className={`
|
||||
p-1.5 rounded-md transition-all duration-200
|
||||
${enabled
|
||||
? 'bg-blue-500/20 dark:bg-blue-400/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/30 dark:ring-blue-400/30 shadow-inner'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-white/20 dark:hover:bg-gray-700/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{enabled ? (
|
||||
<>
|
||||
{/* Eye with slash — hidden state */}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 3l18 18"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.5 10.677a2 2 0 002.823 2.823"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7.362 7.561C5.68 8.74 4.279 10.42 3 12c1.889 2.991 5.282 6 9 6 1.55 0 3.043-.523 4.395-1.35M12 6c3.718 0 7.111 3.009 9 6-.947 1.498-2.057 2.876-3.362 3.939"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Open eye — visible state */}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6c3.718 0 7.111 3.009 9 6-1.889 2.991-5.282 6-9 6s-7.111-3.009-9-6c1.889-2.991 5.282-6 9-6z"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="2"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Component: LoadMoreBar
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface LoadMoreBarProps {
|
||||
loadedCount: number;
|
||||
totalCount?: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
onLoadMore: () => void;
|
||||
itemLabel?: string;
|
||||
}
|
||||
|
||||
export function LoadMoreBar({
|
||||
loadedCount,
|
||||
totalCount,
|
||||
hasMore,
|
||||
isLoading,
|
||||
onLoadMore,
|
||||
itemLabel = 'books',
|
||||
}: LoadMoreBarProps) {
|
||||
if (loadedCount === 0) return null;
|
||||
|
||||
const allLoaded = !hasMore && !isLoading;
|
||||
|
||||
// Count text
|
||||
let countText: string;
|
||||
if (allLoaded) {
|
||||
countText = `All ${loadedCount.toLocaleString()} ${itemLabel} loaded`;
|
||||
} else if (totalCount && totalCount > loadedCount) {
|
||||
countText = `Showing ${loadedCount.toLocaleString()} of ${totalCount.toLocaleString()} ${itemLabel}`;
|
||||
} else {
|
||||
countText = `${loadedCount.toLocaleString()} ${itemLabel} loaded`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left: Count */}
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{countText}
|
||||
</span>
|
||||
|
||||
{/* Right: Action */}
|
||||
{allLoaded ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircleIcon className="w-4 h-4" />
|
||||
Complete
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center gap-2 px-4 py-1.5 text-sm font-medium
|
||||
text-gray-700 dark:text-gray-300
|
||||
border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load more'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Component: Section Toolbar
|
||||
* Documentation: Responsive toolbar that shows inline controls on sm+ and collapses to popover on mobile
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||
import { HideAvailableToggle } from '@/components/ui/HideAvailableToggle';
|
||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
|
||||
interface SectionToolbarProps {
|
||||
hideAvailable: boolean;
|
||||
onToggleHideAvailable: (v: boolean) => void;
|
||||
squareCovers: boolean;
|
||||
onToggleSquareCovers: (v: boolean) => void;
|
||||
cardSize: number;
|
||||
onCardSizeChange: (v: number) => void;
|
||||
}
|
||||
|
||||
export function SectionToolbar({
|
||||
hideAvailable,
|
||||
onToggleHideAvailable,
|
||||
squareCovers,
|
||||
onToggleSquareCovers,
|
||||
cardSize,
|
||||
onCardSizeChange,
|
||||
}: SectionToolbarProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { containerRef, dropdownRef, style } = useSmartDropdownPosition(isOpen);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
containerRef.current && !containerRef.current.contains(target) &&
|
||||
dropdownRef.current && !dropdownRef.current.contains(target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||
}, [isOpen, containerRef, dropdownRef]);
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{/* Inline controls — visible at sm and above */}
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
<HideAvailableToggle enabled={hideAvailable} onToggle={onToggleHideAvailable} />
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={onToggleSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={onCardSizeChange} />
|
||||
</div>
|
||||
|
||||
{/* Collapsed ellipsis trigger — visible below sm */}
|
||||
<div className="sm:hidden" ref={containerRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label="View options"
|
||||
aria-expanded={isOpen}
|
||||
className={`
|
||||
p-1.5 rounded-md transition-all duration-200
|
||||
${isOpen
|
||||
? 'bg-blue-500/20 dark:bg-blue-400/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/30 dark:ring-blue-400/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-white/20 dark:hover:bg-gray-700/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="5" cy="12" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<circle cx="19" cy="12" r="2" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Portal dropdown */}
|
||||
{isOpen && typeof document !== 'undefined' && style && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={style}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg ring-1 ring-black/5 dark:ring-white/10 z-50 py-1 min-w-[220px] animate-in fade-in duration-150"
|
||||
>
|
||||
{/* Hide Available */}
|
||||
<button
|
||||
onClick={() => onToggleHideAvailable(!hideAvailable)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span className={`
|
||||
p-1 rounded-md transition-all duration-200
|
||||
${hideAvailable
|
||||
? 'bg-blue-500/20 dark:bg-blue-400/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/30 dark:ring-blue-400/30 shadow-inner'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}
|
||||
`}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{hideAvailable ? (
|
||||
<>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3l18 18" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.5 10.677a2 2 0 002.823 2.823" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7.362 7.561C5.68 8.74 4.279 10.42 3 12c1.889 2.991 5.282 6 9 6 1.55 0 3.043-.523 4.395-1.35M12 6c3.718 0 7.111 3.009 9 6-.947 1.498-2.057 2.876-3.362 3.939" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6c3.718 0 7.111 3.009 9 6-1.889 2.991-5.282 6-9 6s-7.111-3.009-9-6c1.889-2.991 5.282-6 9-6z" />
|
||||
<circle cx="12" cy="12" r="2" strokeWidth={2} />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">Hide Available</span>
|
||||
{hideAvailable && (
|
||||
<span className="ml-auto text-xs text-blue-600 dark:text-blue-400 font-medium">On</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Square Covers */}
|
||||
<button
|
||||
onClick={() => onToggleSquareCovers(!squareCovers)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span className={`
|
||||
p-1 rounded-md transition-all duration-200
|
||||
${squareCovers
|
||||
? 'bg-blue-500/20 dark:bg-blue-400/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/30 dark:ring-blue-400/30 shadow-inner'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}
|
||||
`}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" strokeWidth={2} />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9h4M3 15h4M21 9h-4M21 15h-4" opacity={squareCovers ? 1 : 0.4} />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">Square Covers</span>
|
||||
{squareCovers && (
|
||||
<span className="ml-auto text-xs text-blue-600 dark:text-blue-400 font-medium">On</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
|
||||
{/* Card Size */}
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 text-sm">
|
||||
<span className="p-1 text-gray-500 dark:text-gray-400">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">Card Size</span>
|
||||
<div className="ml-auto">
|
||||
<CardSizeControls size={cardSize} onSizeChange={onCardSizeChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* Component: Sticky Pagination with Progress Bar
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface StickyPaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
sectionRef: React.RefObject<HTMLElement | null>;
|
||||
label: string; // e.g., "Popular Audiobooks"
|
||||
footerRef?: React.RefObject<HTMLElement | null>; // Optional footer ref to avoid overlap
|
||||
}
|
||||
|
||||
export function StickyPagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
sectionRef,
|
||||
label,
|
||||
footerRef,
|
||||
}: StickyPaginationProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isFooterVisible, setIsFooterVisible] = useState(false);
|
||||
const [jumpPage, setJumpPage] = useState(currentPage.toString());
|
||||
|
||||
// Update jump page input when current page changes externally
|
||||
useEffect(() => {
|
||||
setJumpPage(currentPage.toString());
|
||||
}, [currentPage]);
|
||||
|
||||
// Intersection Observer to show/hide pagination based on section visibility
|
||||
useEffect(() => {
|
||||
if (!sectionRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
// Show pagination when section is in viewport
|
||||
setIsVisible(entry.isIntersecting && entry.intersectionRatio > 0.1);
|
||||
},
|
||||
{
|
||||
threshold: [0, 0.1, 0.5, 1],
|
||||
rootMargin: '-60px 0px -60px 0px', // Account for header/footer
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(sectionRef.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [sectionRef]);
|
||||
|
||||
// Footer observer to hide pagination when footer is visible
|
||||
useEffect(() => {
|
||||
if (!footerRef?.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
// Hide pagination when footer is in viewport
|
||||
setIsFooterVisible(entry.isIntersecting);
|
||||
},
|
||||
{
|
||||
threshold: [0, 0.1],
|
||||
rootMargin: '0px',
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(footerRef.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [footerRef]);
|
||||
|
||||
if (totalPages <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentPage > 1) {
|
||||
onPageChange(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentPage < totalPages) {
|
||||
onPageChange(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJumpSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const page = parseInt(jumpPage, 10);
|
||||
if (!isNaN(page) && page >= 1 && page <= totalPages) {
|
||||
onPageChange(page);
|
||||
} else {
|
||||
// Reset to current page if invalid
|
||||
setJumpPage(currentPage.toString());
|
||||
}
|
||||
};
|
||||
|
||||
// Final visibility: show when section is visible AND footer is not visible
|
||||
const shouldShow = isVisible && !isFooterVisible;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-40 transition-all duration-300 ${
|
||||
shouldShow ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="bg-white/95 dark:bg-gray-900/95 backdrop-blur-lg rounded-full shadow-lg border border-gray-200 dark:border-gray-700 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Section Label - Hidden on small screens */}
|
||||
<div className="hidden md:block text-xs font-medium text-gray-600 dark:text-gray-400 pr-2 border-r border-gray-300 dark:border-gray-600">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
{/* Previous Button */}
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentPage === 1}
|
||||
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
|
||||
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Page Info & Jump */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
Page
|
||||
</span>
|
||||
<form onSubmit={handleJumpSubmit} className="inline-flex">
|
||||
<input
|
||||
type="text"
|
||||
value={jumpPage}
|
||||
onChange={(e) => setJumpPage(e.target.value)}
|
||||
onBlur={handleJumpSubmit}
|
||||
className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded
|
||||
bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100
|
||||
border border-gray-300 dark:border-gray-600
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
||||
aria-label="Current page"
|
||||
/>
|
||||
</form>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
of {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
|
||||
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Component: Unified Pagination — context-aware floating paginator
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Replaces two overlapping StickyPagination instances with a single pill
|
||||
* that automatically tracks which section dominates the viewport and shows
|
||||
* controls for that section. Transitions smoothly when the dominant section
|
||||
* changes. Includes a two-dot section indicator for manual switching.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export interface PaginationSection {
|
||||
/** Display label, e.g. "Popular Audiobooks" */
|
||||
label: string;
|
||||
/** Tailwind color class applied to the active accent dot, e.g. "bg-blue-500" */
|
||||
accentColor: string;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
/** Ref to the section element — used for intersection tracking */
|
||||
sectionRef: React.RefObject<HTMLElement | null>;
|
||||
/** Called when user clicks this section's dot while it's inactive — should scroll to section */
|
||||
onScrollToSection: () => void;
|
||||
}
|
||||
|
||||
interface UnifiedPaginationProps {
|
||||
sections: [PaginationSection, PaginationSection];
|
||||
footerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small page-jump form — isolated to prevent key re-mounts on section switch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PageJumpProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
function PageJump({ currentPage, totalPages, onPageChange }: PageJumpProps) {
|
||||
const [value, setValue] = useState(currentPage.toString());
|
||||
|
||||
// Sync when page changes externally (e.g. after scrollIntoView + setState)
|
||||
useEffect(() => {
|
||||
setValue(currentPage.toString());
|
||||
}, [currentPage]);
|
||||
|
||||
const commit = useCallback(
|
||||
(e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const parsed = parseInt(value, 10);
|
||||
if (!isNaN(parsed) && parsed >= 1 && parsed <= totalPages) {
|
||||
onPageChange(parsed);
|
||||
} else {
|
||||
setValue(currentPage.toString());
|
||||
}
|
||||
},
|
||||
[value, currentPage, totalPages, onPageChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 select-none whitespace-nowrap">
|
||||
Page
|
||||
</span>
|
||||
<form onSubmit={commit} className="inline-flex">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={commit}
|
||||
className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded-md
|
||||
bg-black/[0.04] dark:bg-white/[0.08]
|
||||
text-gray-900 dark:text-gray-100
|
||||
border border-gray-300/60 dark:border-white/10
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent
|
||||
transition-all duration-150"
|
||||
aria-label="Jump to page"
|
||||
/>
|
||||
</form>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 select-none whitespace-nowrap">
|
||||
of {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProps) {
|
||||
// Index of the currently dominant section (0 or 1)
|
||||
const [activeIndex, setActiveIndex] = useState<0 | 1>(0);
|
||||
// Whether the label+controls area is mid-transition (drives opacity fade)
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
const [footerVisible, setFooterVisible] = useState(false);
|
||||
// Per-section raw intersection ratios [0,1]
|
||||
const ratiosRef = useRef<[number, number]>([0, 0]);
|
||||
// Whether each section has any meaningful intersection
|
||||
const [sectionVisible, setSectionVisible] = useState<[boolean, boolean]>([false, false]);
|
||||
|
||||
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Determine if the pill should be shown at all:
|
||||
// - at least one section is meaningfully visible
|
||||
// - footer is not visible
|
||||
// - the active section has >1 page
|
||||
const activeSectionHasPages = sections[activeIndex].totalPages > 1;
|
||||
const eitherSectionVisible = sectionVisible[0] || sectionVisible[1];
|
||||
const shouldShow = eitherSectionVisible && !footerVisible && activeSectionHasPages;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Track which section each instance belongs to via intersection ratio
|
||||
// ------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const observers: IntersectionObserver[] = [];
|
||||
|
||||
sections.forEach((section, idx) => {
|
||||
if (!section.sectionRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
ratiosRef.current[idx as 0 | 1] = entry.intersectionRatio;
|
||||
const isVisible = entry.isIntersecting && entry.intersectionRatio > 0.05;
|
||||
|
||||
setSectionVisible((prev) => {
|
||||
const next: [boolean, boolean] = [...prev] as [boolean, boolean];
|
||||
next[idx as 0 | 1] = isVisible;
|
||||
return next;
|
||||
});
|
||||
|
||||
// Determine dominant section (whichever has more viewport coverage)
|
||||
const [r0, r1] = ratiosRef.current;
|
||||
const dominant: 0 | 1 = r0 >= r1 ? 0 : 1;
|
||||
|
||||
setActiveIndex((current) => {
|
||||
if (current !== dominant) {
|
||||
// Trigger cross-fade transition
|
||||
setIsTransitioning(true);
|
||||
|
||||
if (transitionTimerRef.current) {
|
||||
clearTimeout(transitionTimerRef.current);
|
||||
}
|
||||
transitionTimerRef.current = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
}, 320);
|
||||
|
||||
return dominant;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
},
|
||||
{
|
||||
// Dense threshold array gives us smooth ratio tracking
|
||||
threshold: Array.from({ length: 21 }, (_, i) => i / 20),
|
||||
rootMargin: '-60px 0px -80px 0px',
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(section.sectionRef.current);
|
||||
observers.push(observer);
|
||||
});
|
||||
|
||||
return () => {
|
||||
observers.forEach((o) => o.disconnect());
|
||||
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sections[0].sectionRef, sections[1].sectionRef]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Footer observer
|
||||
// ------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (!footerRef?.current) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setFooterVisible(entry.isIntersecting),
|
||||
{ threshold: [0, 0.01] }
|
||||
);
|
||||
observer.observe(footerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [footerRef]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Derived values for the currently active section
|
||||
// ------------------------------------------------------------------
|
||||
const active = sections[activeIndex];
|
||||
|
||||
const handlePrev = () => {
|
||||
if (active.currentPage > 1) active.onPageChange(active.currentPage - 1);
|
||||
};
|
||||
const handleNext = () => {
|
||||
if (active.currentPage < active.totalPages) active.onPageChange(active.currentPage + 1);
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Render
|
||||
// ------------------------------------------------------------------
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
fixed bottom-6 left-1/2 -translate-x-1/2 z-40
|
||||
transition-all duration-300 ease-out
|
||||
${shouldShow
|
||||
? 'translate-y-0 opacity-100 pointer-events-auto'
|
||||
: 'translate-y-4 opacity-0 pointer-events-none'
|
||||
}
|
||||
`}
|
||||
aria-hidden={!shouldShow}
|
||||
>
|
||||
{/* Pill surface */}
|
||||
<div
|
||||
className="
|
||||
flex items-center gap-0
|
||||
bg-white/90 dark:bg-gray-900/90
|
||||
backdrop-blur-xl
|
||||
rounded-full
|
||||
shadow-[0_8px_32px_rgba(0,0,0,0.12),0_2px_8px_rgba(0,0,0,0.08)]
|
||||
dark:shadow-[0_8px_32px_rgba(0,0,0,0.4),0_2px_8px_rgba(0,0,0,0.3)]
|
||||
border border-gray-200/60 dark:border-white/[0.08]
|
||||
px-1.5 py-1.5
|
||||
overflow-hidden
|
||||
"
|
||||
>
|
||||
{/* Section selector dots — left side */}
|
||||
<div className="flex flex-col gap-1 pl-2 pr-3">
|
||||
{sections.map((section, idx) => {
|
||||
const isActive = idx === activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={section.label}
|
||||
onClick={() => {
|
||||
if (!isActive) section.onScrollToSection();
|
||||
}}
|
||||
disabled={isActive}
|
||||
title={section.label}
|
||||
aria-label={`Switch to ${section.label}`}
|
||||
className={`
|
||||
w-1.5 rounded-full transition-all duration-300 ease-out
|
||||
${isActive
|
||||
? `${section.accentColor} h-4 opacity-100`
|
||||
: 'bg-gray-300 dark:bg-gray-600 h-1.5 opacity-60 hover:opacity-90 hover:scale-110 cursor-pointer'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-6 bg-gray-200 dark:bg-white/10 mr-3 flex-shrink-0" />
|
||||
|
||||
{/* Label + controls — cross-fades on section switch */}
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-3
|
||||
transition-opacity duration-200 ease-in-out
|
||||
${isTransitioning ? 'opacity-0' : 'opacity-100'}
|
||||
`}
|
||||
// key forces full remount on switch so input state resets cleanly
|
||||
key={activeIndex}
|
||||
>
|
||||
{/* Section label — hidden on small screens */}
|
||||
<span className="hidden sm:block text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap pr-1 select-none">
|
||||
{active.label}
|
||||
</span>
|
||||
|
||||
{/* Previous */}
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={active.currentPage === 1}
|
||||
aria-label="Previous page"
|
||||
className="
|
||||
p-1.5 rounded-full
|
||||
text-gray-600 dark:text-gray-300
|
||||
hover:bg-black/[0.06] dark:hover:bg-white/[0.08]
|
||||
active:bg-black/[0.1] dark:active:bg-white/[0.12]
|
||||
active:scale-95
|
||||
disabled:opacity-25 disabled:cursor-not-allowed
|
||||
transition-all duration-150
|
||||
"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
{/* Page jump */}
|
||||
<PageJump
|
||||
currentPage={active.currentPage}
|
||||
totalPages={active.totalPages}
|
||||
onPageChange={active.onPageChange}
|
||||
/>
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={active.currentPage === active.totalPages}
|
||||
aria-label="Next page"
|
||||
className="
|
||||
p-1.5 rounded-full
|
||||
text-gray-600 dark:text-gray-300
|
||||
hover:bg-black/[0.06] dark:hover:bg-white/[0.08]
|
||||
active:bg-black/[0.1] dark:active:bg-white/[0.12]
|
||||
active:scale-95
|
||||
disabled:opacity-25 disabled:cursor-not-allowed
|
||||
transition-all duration-150
|
||||
"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right padding balance */}
|
||||
<div className="w-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Component: Watch Button (Series / Author)
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*
|
||||
* Reusable toggle button for watching/unwatching a series or author.
|
||||
* Shows a confirmation modal before watching. Unwatching is instant.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useWatchedSeries, useAddWatchedSeries, useDeleteWatchedSeries } from '@/lib/hooks/useWatchedSeries';
|
||||
import { useWatchedAuthors, useAddWatchedAuthor, useDeleteWatchedAuthor } from '@/lib/hooks/useWatchedAuthors';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
|
||||
interface WatchSeriesButtonProps {
|
||||
seriesAsin: string;
|
||||
seriesTitle: string;
|
||||
coverArtUrl?: string;
|
||||
}
|
||||
|
||||
export function WatchSeriesButton({ seriesAsin, seriesTitle, coverArtUrl }: WatchSeriesButtonProps) {
|
||||
const { series } = useWatchedSeries();
|
||||
const { addSeries, isLoading: isAdding } = useAddWatchedSeries();
|
||||
const { deleteSeries, isLoading: isDeleting } = useDeleteWatchedSeries();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const watchedEntry = series.find((s) => s.seriesAsin === seriesAsin);
|
||||
const isWatching = !!watchedEntry;
|
||||
const isLoading = isAdding || isDeleting;
|
||||
|
||||
const handleClick = async () => {
|
||||
setError(null);
|
||||
if (isWatching && watchedEntry) {
|
||||
// Unwatch immediately (no confirmation needed)
|
||||
try {
|
||||
await deleteSeries(watchedEntry.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
}
|
||||
} else {
|
||||
// Show confirmation before watching
|
||||
setShowConfirm(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmWatch = async () => {
|
||||
setShowConfirm(false);
|
||||
setError(null);
|
||||
try {
|
||||
await addSeries(seriesAsin, seriesTitle, coverArtUrl);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex flex-col items-start">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ${
|
||||
isWatching
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/50 border border-emerald-200 dark:border-emerald-700/50'
|
||||
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 hover:text-emerald-700 dark:hover:text-emerald-300 border border-gray-200 dark:border-gray-600/50 hover:border-emerald-200 dark:hover:border-emerald-700/50'
|
||||
} ${isLoading ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : isWatching ? (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
{isWatching ? 'Watching' : 'Watch Series'}
|
||||
</button>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 mt-1">{error}</span>
|
||||
)}
|
||||
<ConfirmModal
|
||||
isOpen={showConfirm}
|
||||
onClose={() => setShowConfirm(false)}
|
||||
onConfirm={handleConfirmWatch}
|
||||
title={`Watch "${seriesTitle}"?`}
|
||||
message={`This will request all books in "${seriesTitle}" that aren't already in your library, and automatically request new releases as they're added to the series. Continue?`}
|
||||
confirmText="Watch"
|
||||
isLoading={isAdding}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WatchAuthorButtonProps {
|
||||
authorAsin: string;
|
||||
authorName: string;
|
||||
coverArtUrl?: string;
|
||||
}
|
||||
|
||||
export function WatchAuthorButton({ authorAsin, authorName, coverArtUrl }: WatchAuthorButtonProps) {
|
||||
const { authors } = useWatchedAuthors();
|
||||
const { addAuthor, isLoading: isAdding } = useAddWatchedAuthor();
|
||||
const { deleteAuthor, isLoading: isDeleting } = useDeleteWatchedAuthor();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const watchedEntry = authors.find((a) => a.authorAsin === authorAsin);
|
||||
const isWatching = !!watchedEntry;
|
||||
const isLoading = isAdding || isDeleting;
|
||||
|
||||
const handleClick = async () => {
|
||||
setError(null);
|
||||
if (isWatching && watchedEntry) {
|
||||
// Unwatch immediately (no confirmation needed)
|
||||
try {
|
||||
await deleteAuthor(watchedEntry.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
}
|
||||
} else {
|
||||
// Show confirmation before watching
|
||||
setShowConfirm(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmWatch = async () => {
|
||||
setShowConfirm(false);
|
||||
setError(null);
|
||||
try {
|
||||
await addAuthor(authorAsin, authorName, coverArtUrl);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex flex-col items-start">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ${
|
||||
isWatching
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 border border-blue-200 dark:border-blue-700/50'
|
||||
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-700 dark:hover:text-blue-300 border border-gray-200 dark:border-gray-600/50 hover:border-blue-200 dark:hover:border-blue-700/50'
|
||||
} ${isLoading ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : isWatching ? (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
{isWatching ? 'Watching' : 'Watch Author'}
|
||||
</button>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 mt-1">{error}</span>
|
||||
)}
|
||||
<ConfirmModal
|
||||
isOpen={showConfirm}
|
||||
onClose={() => setShowConfirm(false)}
|
||||
onConfirm={handleConfirmWatch}
|
||||
title={`Watch "${authorName}"?`}
|
||||
message={`This will request all books by "${authorName}" that aren't already in your library, and automatically request new releases. Continue?`}
|
||||
confirmText="Watch"
|
||||
isLoading={isAdding}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
|
||||
interface Preferences {
|
||||
cardSize: number; // 1-9, default 5
|
||||
squareCovers: boolean; // true = square (1:1), false = rectangle (2:3)
|
||||
hideAvailable: boolean; // true = hide "In Your Library" titles
|
||||
}
|
||||
|
||||
interface PreferencesContextType {
|
||||
@@ -17,6 +18,8 @@ interface PreferencesContextType {
|
||||
setCardSize: (size: number) => void;
|
||||
squareCovers: boolean;
|
||||
setSquareCovers: (enabled: boolean) => void;
|
||||
hideAvailable: boolean;
|
||||
setHideAvailable: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
|
||||
@@ -24,6 +27,7 @@ const PreferencesContext = createContext<PreferencesContextType | undefined>(und
|
||||
const DEFAULT_PREFERENCES: Preferences = {
|
||||
cardSize: 5,
|
||||
squareCovers: true,
|
||||
hideAvailable: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'preferences';
|
||||
@@ -31,6 +35,7 @@ const STORAGE_KEY = 'preferences';
|
||||
export function PreferencesProvider({ children }: { children: ReactNode }) {
|
||||
const [cardSize, setCardSizeState] = useState<number>(DEFAULT_PREFERENCES.cardSize);
|
||||
const [squareCovers, setSquareCoversState] = useState<boolean>(DEFAULT_PREFERENCES.squareCovers);
|
||||
const [hideAvailable, setHideAvailableState] = useState<boolean>(DEFAULT_PREFERENCES.hideAvailable);
|
||||
|
||||
// Load preferences from localStorage on mount
|
||||
useEffect(() => {
|
||||
@@ -49,11 +54,14 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
// Load squareCovers preference (defaults to false if not set)
|
||||
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
|
||||
// Load hideAvailable preference
|
||||
setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences from localStorage:', error);
|
||||
setCardSizeState(DEFAULT_PREFERENCES.cardSize);
|
||||
setSquareCoversState(DEFAULT_PREFERENCES.squareCovers);
|
||||
setHideAvailableState(DEFAULT_PREFERENCES.hideAvailable);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -92,6 +100,22 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Update hideAvailable preference in state and localStorage
|
||||
const setHideAvailable = (enabled: boolean) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
setHideAvailableState(enabled);
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const preferences: Preferences = stored ? JSON.parse(stored) : { ...DEFAULT_PREFERENCES };
|
||||
preferences.hideAvailable = enabled;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
|
||||
} catch (error) {
|
||||
console.error('Failed to save preferences to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for storage changes in other tabs (cross-tab sync)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -106,6 +130,8 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
// Sync squareCovers preference
|
||||
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
|
||||
// Sync hideAvailable preference
|
||||
setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse preferences from storage event:', error);
|
||||
}
|
||||
@@ -119,7 +145,7 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PreferencesContext.Provider value={{ cardSize, setCardSize, squareCovers, setSquareCovers }}>
|
||||
<PreferencesContext.Provider value={{ cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable }}>
|
||||
{children}
|
||||
</PreferencesContext.Provider>
|
||||
);
|
||||
|
||||
@@ -547,7 +547,7 @@ export async function buildAIPrompt(
|
||||
|
||||
/**
|
||||
* Call AI API to get recommendations
|
||||
* @param provider - 'openai' | 'claude'
|
||||
* @param provider - 'openai' | 'claude' | 'gemini' | 'custom'
|
||||
* @param model - Model ID
|
||||
* @param encryptedApiKey - Encrypted API key
|
||||
* @param prompt - JSON prompt string
|
||||
@@ -691,6 +691,74 @@ export async function callAI(
|
||||
logger.debug('Claude cleaned response:', { cleanedContent });
|
||||
return JSON.parse(cleanedContent);
|
||||
|
||||
} else if (provider === 'gemini') {
|
||||
const requestBody = {
|
||||
systemInstruction: {
|
||||
parts: [{ text: systemMessage }],
|
||||
},
|
||||
contents: [
|
||||
{
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: {
|
||||
type: "OBJECT",
|
||||
properties: {
|
||||
recommendations: {
|
||||
type: "ARRAY",
|
||||
items: {
|
||||
type: "OBJECT",
|
||||
properties: {
|
||||
title: { type: "STRING" },
|
||||
author: { type: "STRING" },
|
||||
reason: { type: "STRING" },
|
||||
},
|
||||
required: ["title", "author", "reason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["recommendations"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug('Gemini request body:', { requestBody });
|
||||
|
||||
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Gemini API error', { status: response.status, error: errorText });
|
||||
throw new Error(`Gemini API error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Invalid response format from Gemini API');
|
||||
}
|
||||
|
||||
logger.debug('Gemini raw response:', { content });
|
||||
|
||||
// Clean potential markdown wrapping
|
||||
const cleanedContent = content
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/\s*```$/i, '')
|
||||
.trim();
|
||||
|
||||
logger.debug('Gemini cleaned response:', { cleanedContent });
|
||||
return JSON.parse(cleanedContent);
|
||||
|
||||
} else if (provider === 'custom') {
|
||||
if (!baseUrl) {
|
||||
throw new Error('Base URL is required for custom provider');
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Component: API Token Constants
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Centralized API token constants used across authentication middleware and token routes.
|
||||
*/
|
||||
|
||||
/** Prefix prepended to all generated API tokens for identification */
|
||||
export const API_TOKEN_PREFIX = 'rmab_';
|
||||
|
||||
/** Number of random bytes used to generate the token's random portion */
|
||||
export const TOKEN_RANDOM_BYTES = 32;
|
||||
|
||||
/** Length of the token prefix stored in the database for display (first 12 chars: "rmab_" + 7 hex chars) */
|
||||
export const TOKEN_PREFIX_LENGTH = 12;
|
||||
|
||||
/** Maximum number of active (non-expired) API tokens a single user may hold */
|
||||
export const MAX_TOKENS_PER_USER = 25;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endpoint allowlist — restricts which routes API tokens may access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Shape of an allowed endpoint entry */
|
||||
export interface AllowedEndpoint {
|
||||
method: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Extended metadata used by the interactive API docs page */
|
||||
export interface EndpointDoc {
|
||||
method: string;
|
||||
path: string;
|
||||
title: string;
|
||||
description: string;
|
||||
requiresAdmin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoints that API tokens are permitted to call.
|
||||
* JWT-authenticated sessions are NOT restricted by this list.
|
||||
*/
|
||||
export const API_TOKEN_ALLOWED_ENDPOINTS: readonly AllowedEndpoint[] = [
|
||||
{ method: 'GET', path: '/api/auth/me' },
|
||||
{ method: 'GET', path: '/api/requests' },
|
||||
{ method: 'GET', path: '/api/admin/metrics' },
|
||||
{ method: 'GET', path: '/api/admin/downloads/active' },
|
||||
{ method: 'GET', path: '/api/admin/requests/recent' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Full documentation metadata for each allowed endpoint.
|
||||
* Consumed by the /api-docs interactive page.
|
||||
*/
|
||||
export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/auth/me',
|
||||
title: 'Get current user',
|
||||
description:
|
||||
'Returns the authenticated user\'s profile information including username, role, and account details.',
|
||||
requiresAdmin: false,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/requests',
|
||||
title: 'List requests',
|
||||
description:
|
||||
'Returns all audiobook requests visible to the authenticated user. Admins see all requests, users see their own.',
|
||||
requiresAdmin: false,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/metrics',
|
||||
title: 'System metrics',
|
||||
description:
|
||||
'Returns system health metrics including request counts, download statistics, and library size.',
|
||||
requiresAdmin: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/downloads/active',
|
||||
title: 'Active downloads',
|
||||
description:
|
||||
'Returns currently active downloads including progress, speed, and ETA.',
|
||||
requiresAdmin: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/requests/recent',
|
||||
title: 'Recent requests',
|
||||
description:
|
||||
'Returns the most recent audiobook requests across all users.',
|
||||
requiresAdmin: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Check whether a given method + path is on the API token allowlist.
|
||||
* Method comparison is case-insensitive.
|
||||
*/
|
||||
export function isEndpointAllowed(method: string, path: string): boolean {
|
||||
const upperMethod = method.toUpperCase();
|
||||
return API_TOKEN_ALLOWED_ENDPOINTS.some(
|
||||
(ep) => ep.method === upperMethod && ep.path === path
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Component: Shared API Token Management Hook
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { ApiToken } from '@/lib/types/api-tokens';
|
||||
|
||||
/** Typed request body for creating an API token */
|
||||
export interface CreateTokenBody {
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
userId?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface UseApiTokensConfig {
|
||||
/** Base API path, e.g. '/api/admin/api-tokens' or '/api/user/api-tokens' */
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export interface UseApiTokensReturn<T extends ApiToken = ApiToken> {
|
||||
tokens: T[];
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
error: string | null;
|
||||
newTokenName: string;
|
||||
setNewTokenName: (name: string) => void;
|
||||
newTokenExpiry: string;
|
||||
setNewTokenExpiry: (expiry: string) => void;
|
||||
showCreateForm: boolean;
|
||||
setShowCreateForm: (show: boolean) => void;
|
||||
createdToken: string | null;
|
||||
copied: boolean;
|
||||
deletingId: string | null;
|
||||
confirmRevokeId: string | null;
|
||||
setConfirmRevokeId: (id: string | null) => void;
|
||||
fetchTokens: () => Promise<void>;
|
||||
handleCreate: (extraBody?: Partial<CreateTokenBody>) => Promise<boolean>;
|
||||
handleDeleteConfirmed: () => Promise<void>;
|
||||
handleCopy: () => Promise<void>;
|
||||
dismissCreatedToken: () => void;
|
||||
resetForm: () => void;
|
||||
formatDate: (dateStr: string | null) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared hook for API token CRUD operations.
|
||||
* Used by both the admin ApiTab and the user ApiTokensSection.
|
||||
*/
|
||||
export function useApiTokens<T extends ApiToken = ApiToken>(
|
||||
config: UseApiTokensConfig
|
||||
): UseApiTokensReturn<T> {
|
||||
const [tokens, setTokens] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newTokenName, setNewTokenName] = useState('');
|
||||
const [newTokenExpiry, setNewTokenExpiry] = useState('never');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
|
||||
|
||||
const fetchTokens = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth(config.basePath);
|
||||
if (!response.ok) {
|
||||
let message = 'Failed to load API tokens';
|
||||
try {
|
||||
const data = await response.json();
|
||||
message = data.error || message;
|
||||
} catch {
|
||||
// Keep default message when response body is not JSON
|
||||
}
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTokens(data.tokens);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError('Failed to load API tokens');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [config.basePath]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTokens();
|
||||
}, [fetchTokens]);
|
||||
|
||||
const computeExpiresAt = (): string | null => {
|
||||
if (newTokenExpiry === 'never') return null;
|
||||
const date = new Date();
|
||||
switch (newTokenExpiry) {
|
||||
case '30d': date.setDate(date.getDate() + 30); break;
|
||||
case '90d': date.setDate(date.getDate() + 90); break;
|
||||
case '1y': date.setFullYear(date.getFullYear() + 1); break;
|
||||
}
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const handleCreate = async (extraBody?: Partial<CreateTokenBody>) => {
|
||||
if (!newTokenName.trim()) {
|
||||
setError('Token name is required');
|
||||
return false;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const body: CreateTokenBody = {
|
||||
name: newTokenName.trim(),
|
||||
expiresAt: computeExpiresAt(),
|
||||
...extraBody,
|
||||
};
|
||||
|
||||
const response = await fetchWithAuth(config.basePath, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCreatedToken(data.fullToken);
|
||||
setNewTokenName('');
|
||||
setNewTokenExpiry('never');
|
||||
setShowCreateForm(false);
|
||||
await fetchTokens();
|
||||
return true;
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Failed to create token');
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to create token');
|
||||
return false;
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirmed = async () => {
|
||||
const id = confirmRevokeId;
|
||||
if (!id) return;
|
||||
|
||||
setConfirmRevokeId(null);
|
||||
setDeletingId(id);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`${config.basePath}/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTokens(tokens.filter((t) => t.id !== id));
|
||||
} else {
|
||||
setError('Failed to revoke token');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to revoke token');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (createdToken) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdToken);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setError('Failed to copy to clipboard. Please select and copy the token manually.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dismissCreatedToken = () => setCreatedToken(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setShowCreateForm(false);
|
||||
setNewTokenName('');
|
||||
setNewTokenExpiry('never');
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null): string => {
|
||||
if (!dateStr) return 'Never';
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
tokens,
|
||||
loading,
|
||||
creating,
|
||||
error,
|
||||
newTokenName,
|
||||
setNewTokenName,
|
||||
newTokenExpiry,
|
||||
setNewTokenExpiry,
|
||||
showCreateForm,
|
||||
setShowCreateForm,
|
||||
createdToken,
|
||||
copied,
|
||||
deletingId,
|
||||
confirmRevokeId,
|
||||
setConfirmRevokeId,
|
||||
fetchTokens,
|
||||
handleCreate,
|
||||
handleDeleteConfirmed,
|
||||
handleCopy,
|
||||
dismissCreatedToken,
|
||||
resetForm,
|
||||
formatDate,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
|
||||
export interface Audiobook {
|
||||
@@ -33,11 +35,12 @@ export interface Audiobook {
|
||||
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
||||
}
|
||||
|
||||
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) {
|
||||
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
|
||||
const hideParam = hideAvailable ? '&hideAvailable=true' : '';
|
||||
const endpoint =
|
||||
type === 'popular'
|
||||
? `/api/audiobooks/popular?page=${page}&limit=${limit}`
|
||||
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}`;
|
||||
? `/api/audiobooks/popular?page=${page}&limit=${limit}${hideParam}`
|
||||
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}${hideParam}`;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
@@ -57,20 +60,58 @@ export function useAudiobooks(type: 'popular' | 'new-releases', limit: number =
|
||||
};
|
||||
}
|
||||
|
||||
export function useSearch(query: string, page: number = 1) {
|
||||
const shouldFetch = query && query.length > 0;
|
||||
const endpoint = shouldFetch ? `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${page}` : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30000, // Cache for 30 seconds
|
||||
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.asin)) return false;
|
||||
seen.add(item.asin);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearch(query: string) {
|
||||
const prevQueryRef = useRef(query);
|
||||
|
||||
const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite(
|
||||
(pageIndex, prevPageData) => {
|
||||
if (!query || query.length === 0) return null;
|
||||
if (pageIndex === 0) return `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=1`;
|
||||
if (!prevPageData?.hasMore) return null;
|
||||
return `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${pageIndex + 1}`;
|
||||
},
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30000,
|
||||
revalidateFirstPage: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Reset to page 1 when query changes
|
||||
useEffect(() => {
|
||||
if (query !== prevQueryRef.current) {
|
||||
prevQueryRef.current = query;
|
||||
setSize(1);
|
||||
}
|
||||
}, [query, setSize]);
|
||||
|
||||
const results = data ? dedupeByAsin(data.flatMap(page => page?.results || [])) : [];
|
||||
const totalResults = data?.[0]?.totalResults || 0;
|
||||
const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore);
|
||||
const isLoadingInitial = !data && !error && !!query;
|
||||
const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setSize(prev => prev + 1);
|
||||
}, [setSize]);
|
||||
|
||||
return {
|
||||
results: data?.results || [],
|
||||
totalResults: data?.totalResults || 0,
|
||||
hasMore: data?.hasMore || false,
|
||||
isLoading: shouldFetch && isLoading,
|
||||
results,
|
||||
totalResults,
|
||||
hasMore,
|
||||
isLoading: isLoadingInitial,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
+52
-12
@@ -5,7 +5,9 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
import { Audiobook } from './useAudiobooks';
|
||||
|
||||
@@ -68,21 +70,59 @@ export function useAuthorDetail(asin: string | null) {
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuthorBooks(asin: string | null, authorName: string | null) {
|
||||
const shouldFetch = asin && authorName;
|
||||
const endpoint = shouldFetch
|
||||
? `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}`
|
||||
: null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 60000, // Cache for 1 minute
|
||||
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.asin)) return false;
|
||||
seen.add(item.asin);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuthorBooks(asin: string | null, authorName: string | null) {
|
||||
const prevIdentityRef = useRef<string | null>(null);
|
||||
const identity = asin && authorName ? `${asin}:${authorName}` : null;
|
||||
|
||||
const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite(
|
||||
(pageIndex, prevPageData) => {
|
||||
if (!asin || !authorName) return null;
|
||||
if (pageIndex === 0) return `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}&page=1`;
|
||||
if (!prevPageData?.hasMore) return null;
|
||||
return `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}&page=${pageIndex + 1}`;
|
||||
},
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 60000,
|
||||
revalidateFirstPage: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Reset when author changes
|
||||
useEffect(() => {
|
||||
if (identity !== prevIdentityRef.current) {
|
||||
prevIdentityRef.current = identity;
|
||||
setSize(1);
|
||||
}
|
||||
}, [identity, setSize]);
|
||||
|
||||
const books = (data ? dedupeByAsin(data.flatMap(page => page?.books || [])) : []) as Audiobook[];
|
||||
const totalBooks = data?.[0]?.totalBooks || 0;
|
||||
const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore);
|
||||
const isLoadingInitial = !data && !error && !!identity;
|
||||
const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setSize(prev => prev + 1);
|
||||
}, [setSize]);
|
||||
|
||||
return {
|
||||
books: (data?.books || []) as Audiobook[],
|
||||
totalBooks: data?.totalBooks || 0,
|
||||
isLoading: !!shouldFetch && isLoading,
|
||||
books,
|
||||
totalBooks,
|
||||
hasMore,
|
||||
isLoading: isLoadingInitial || (!!identity && isLoading),
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
import { Audiobook } from './useAudiobooks';
|
||||
|
||||
@@ -59,17 +61,63 @@ export function useSeriesSearch(query: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export function useSeriesDetail(asin: string | null) {
|
||||
const endpoint = asin ? `/api/series/${asin}` : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 300000, // Cache for 5 minutes
|
||||
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.asin)) return false;
|
||||
seen.add(item.asin);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function useSeriesDetail(asin: string | null) {
|
||||
const prevAsinRef = useRef<string | null>(null);
|
||||
|
||||
const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite(
|
||||
(pageIndex, prevPageData) => {
|
||||
if (!asin) return null;
|
||||
if (pageIndex === 0) return `/api/series/${asin}?page=1`;
|
||||
if (!prevPageData?.hasMore) return null;
|
||||
return `/api/series/${asin}?page=${pageIndex + 1}`;
|
||||
},
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 300000,
|
||||
revalidateFirstPage: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Reset when series changes
|
||||
useEffect(() => {
|
||||
if (asin !== prevAsinRef.current) {
|
||||
prevAsinRef.current = asin;
|
||||
setSize(1);
|
||||
}
|
||||
}, [asin, setSize]);
|
||||
|
||||
// Merge pages: use first page's metadata, accumulate all books
|
||||
const firstPageSeries = data?.[0]?.series as SeriesDetail | undefined;
|
||||
const allBooks = (data ? dedupeByAsin(data.flatMap(page => page?.series?.books || [])) : []) as Audiobook[];
|
||||
|
||||
const series: SeriesDetail | null = firstPageSeries
|
||||
? { ...firstPageSeries, books: allBooks }
|
||||
: null;
|
||||
|
||||
const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore);
|
||||
const isLoadingInitial = !data && !error && !!asin;
|
||||
const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setSize(prev => prev + 1);
|
||||
}, [setSize]);
|
||||
|
||||
return {
|
||||
series: (data?.series || null) as SeriesDetail | null,
|
||||
isLoading,
|
||||
series,
|
||||
hasMore,
|
||||
isLoading: isLoadingInitial || (!!asin && isLoading),
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Component: Watched Authors Hook
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
export interface WatchedAuthorItem {
|
||||
id: string;
|
||||
authorAsin: string;
|
||||
authorName: string;
|
||||
coverArtUrl: string | null;
|
||||
lastCheckedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
fetchWithAuth(url).then((res) => res.json());
|
||||
|
||||
export function useWatchedAuthors() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken ? '/api/user/watched-authors' : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(
|
||||
endpoint,
|
||||
fetcher,
|
||||
{ refreshInterval: 60000 }
|
||||
);
|
||||
|
||||
return {
|
||||
authors: (data?.authors || []) as WatchedAuthorItem[],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAddWatchedAuthor() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const addAuthor = async (authorAsin: string, authorName: string, coverArtUrl?: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/user/watched-authors', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ authorAsin, authorName, coverArtUrl }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to watch author');
|
||||
}
|
||||
|
||||
// Revalidate watched authors list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-authors'));
|
||||
|
||||
return data.author as WatchedAuthorItem;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { addAuthor, isLoading, error };
|
||||
}
|
||||
|
||||
export function useDeleteWatchedAuthor() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const deleteAuthor = async (id: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/user/watched-authors/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to unwatch author');
|
||||
}
|
||||
|
||||
// Revalidate watched authors list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-authors'));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { deleteAuthor, isLoading, error };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Component: Watched Series Hook
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
export interface WatchedSeriesItem {
|
||||
id: string;
|
||||
seriesAsin: string;
|
||||
seriesTitle: string;
|
||||
coverArtUrl: string | null;
|
||||
lastCheckedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
fetchWithAuth(url).then((res) => res.json());
|
||||
|
||||
export function useWatchedSeries() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken ? '/api/user/watched-series' : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(
|
||||
endpoint,
|
||||
fetcher,
|
||||
{ refreshInterval: 60000 }
|
||||
);
|
||||
|
||||
return {
|
||||
series: (data?.series || []) as WatchedSeriesItem[],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAddWatchedSeries() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const addSeries = async (seriesAsin: string, seriesTitle: string, coverArtUrl?: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/user/watched-series', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seriesAsin, seriesTitle, coverArtUrl }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to watch series');
|
||||
}
|
||||
|
||||
// Revalidate watched series list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-series'));
|
||||
|
||||
return data.series as WatchedSeriesItem;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { addSeries, isLoading, error };
|
||||
}
|
||||
|
||||
export function useDeleteWatchedSeries() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const deleteSeries = async (id: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/user/watched-series/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to unwatch series');
|
||||
}
|
||||
|
||||
// Revalidate watched series list
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-series'));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { deleteSeries, isLoading, error };
|
||||
}
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
getLanguageForRegion,
|
||||
buildContainsSelector,
|
||||
stripPrefixes,
|
||||
type LanguageConfig,
|
||||
} from '../constants/language-config';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { parseRuntime } from '../utils/parse-runtime';
|
||||
import { randomDelay } from '../utils/scrape-resilience';
|
||||
|
||||
const logger = RMABLogger.create('Audible.Series');
|
||||
@@ -288,17 +290,17 @@ function parseSeriesPageSummary(
|
||||
* Scrape a series page for full detail data including books and similar series.
|
||||
* Used by the detail API endpoint.
|
||||
*/
|
||||
export async function scrapeSeriesPage(asin: string): Promise<SeriesDetail | null> {
|
||||
export async function scrapeSeriesPage(asin: string, page: number = 1): Promise<(SeriesDetail & { hasMore: boolean; page: number }) | null> {
|
||||
const service = getAudibleService();
|
||||
const region = service.getRegion();
|
||||
const baseUrl = service.getBaseUrl();
|
||||
const langConfig = getLanguageForRegion(region);
|
||||
|
||||
logger.info(`Scraping series detail page: ${asin}`);
|
||||
logger.info(`Scraping series detail page: ${asin}, page ${page}`);
|
||||
|
||||
try {
|
||||
const { data: response } = await service.fetch(`/series/${asin}`, {
|
||||
params: { ipRedirectOverride: 'true', pageSize: AUDIBLE_PAGE_SIZE },
|
||||
params: { ipRedirectOverride: 'true', pageSize: AUDIBLE_PAGE_SIZE, page },
|
||||
});
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
@@ -311,15 +313,20 @@ export async function scrapeSeriesPage(asin: string): Promise<SeriesDetail | nul
|
||||
undefined;
|
||||
|
||||
// Parse all books from the series page
|
||||
const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes);
|
||||
const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes, langConfig);
|
||||
|
||||
// Use actual book count if we got more from scraping
|
||||
const bookCount = Math.max(summary.bookCount, books.length);
|
||||
|
||||
// Calculate hasMore: use header bookCount if available, otherwise check if full page
|
||||
const hasMore = bookCount > 0
|
||||
? page * AUDIBLE_PAGE_SIZE < bookCount
|
||||
: books.length >= AUDIBLE_PAGE_SIZE;
|
||||
|
||||
// Parse similar series ("Listeners also enjoyed" or similar section)
|
||||
const similarSeries = parseSimilarSeries($);
|
||||
|
||||
logger.info(`Series detail complete: "${summary.title}" (${books.length} books, ${similarSeries.length} similar)`);
|
||||
logger.info(`Series detail complete: "${summary.title}" (${books.length} books, page ${page}, hasMore: ${hasMore})`);
|
||||
|
||||
return {
|
||||
asin,
|
||||
@@ -332,6 +339,8 @@ export async function scrapeSeriesPage(asin: string): Promise<SeriesDetail | nul
|
||||
books,
|
||||
similarSeries,
|
||||
audibleUrl: `${baseUrl}/series/${asin}`,
|
||||
hasMore,
|
||||
page,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to scrape series detail ${asin}`, {
|
||||
@@ -396,7 +405,8 @@ function parseSeriesRating($: cheerio.CheerioAPI): { rating?: number; ratingCoun
|
||||
function parseSeriesBooks(
|
||||
$: cheerio.CheerioAPI,
|
||||
authorPrefixes: string[],
|
||||
narratorPrefixes: string[]
|
||||
narratorPrefixes: string[],
|
||||
langConfig: LanguageConfig
|
||||
): AudibleAudiobook[] {
|
||||
const books: AudibleAudiobook[] = [];
|
||||
const seenAsins = new Set<string>();
|
||||
@@ -446,6 +456,11 @@ function parseSeriesBooks(
|
||||
const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null;
|
||||
const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined;
|
||||
|
||||
// Duration
|
||||
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
||||
const durationMinutes = parseRuntime(runtimeText, langConfig);
|
||||
|
||||
books.push({
|
||||
asin: bookAsin,
|
||||
title,
|
||||
@@ -454,6 +469,7 @@ function parseSeriesBooks(
|
||||
narrator: stripPrefixes(narratorText, narratorPrefixes),
|
||||
coverArtUrl,
|
||||
rating,
|
||||
durationMinutes,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
AdaptivePacer,
|
||||
FetchResultMeta,
|
||||
} from '../utils/scrape-resilience';
|
||||
import { parseRuntime as parseRuntimeUtil } from '../utils/parse-runtime';
|
||||
|
||||
// Module-level logger
|
||||
const logger = RMABLogger.create('Audible');
|
||||
@@ -59,6 +60,13 @@ export interface AudibleSearchResult {
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface AuthorBooksResult {
|
||||
books: AudibleAudiobook[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
totalResults: number;
|
||||
}
|
||||
|
||||
export class AudibleService {
|
||||
private client!: AxiosInstance;
|
||||
private baseUrl: string = 'https://www.audible.com';
|
||||
@@ -564,7 +572,9 @@ export class AudibleService {
|
||||
results: audiobooks,
|
||||
totalResults,
|
||||
page,
|
||||
hasMore: audiobooks.length > 0 && totalResults > page * AUDIBLE_PAGE_SIZE,
|
||||
hasMore: audiobooks.length > 0 && (totalResults > 0
|
||||
? totalResults > page * AUDIBLE_PAGE_SIZE
|
||||
: audiobooks.length >= AUDIBLE_PAGE_SIZE),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Search failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
@@ -583,123 +593,111 @@ export class AudibleService {
|
||||
* Uses Audible's searchAuthor parameter and paginates through all results.
|
||||
* Filters: (1) author link must contain the target ASIN, (2) language must be English.
|
||||
*/
|
||||
async searchByAuthorAsin(authorName: string, authorAsin: string): Promise<AudibleAudiobook[]> {
|
||||
async searchByAuthorAsin(authorName: string, authorAsin: string, page: number = 1): Promise<AuthorBooksResult> {
|
||||
await this.initialize();
|
||||
|
||||
const MAX_PAGES = 10;
|
||||
const allBooks: AudibleAudiobook[] = [];
|
||||
const books: AudibleAudiobook[] = [];
|
||||
const seenAsins = new Set<string>();
|
||||
|
||||
try {
|
||||
logger.info(`Searching books by author "${authorName}" (ASIN: ${authorAsin})...`);
|
||||
logger.info(`Searching books by author "${authorName}" (ASIN: ${authorAsin}), page ${page}...`);
|
||||
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const { data: response, meta } = await this.fetchWithRetry('/search', {
|
||||
params: {
|
||||
ipRedirectOverride: 'true',
|
||||
searchAuthor: authorName,
|
||||
pageSize: AUDIBLE_PAGE_SIZE,
|
||||
page,
|
||||
},
|
||||
const { data: response } = await this.fetchWithRetry('/search', {
|
||||
params: {
|
||||
ipRedirectOverride: 'true',
|
||||
searchAuthor: authorName,
|
||||
pageSize: AUDIBLE_PAGE_SIZE,
|
||||
page,
|
||||
},
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// Count raw items on page before filtering (for hasMore fallback)
|
||||
const pageItemCount = $('.s-result-item, .productListItem').length;
|
||||
|
||||
$('.s-result-item, .productListItem').each((_index, element) => {
|
||||
const $el = $(element);
|
||||
|
||||
// --- Language filter: require matching language for region ---
|
||||
const langConfig = this.getLangConfig();
|
||||
const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() ||
|
||||
$el.find('.languageLabel').text().trim();
|
||||
const langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i');
|
||||
const langMatch = langText.match(langLabelPattern);
|
||||
const language = langMatch?.[1]?.trim();
|
||||
if (!language || !isAcceptedLanguage(language, langConfig)) return;
|
||||
|
||||
// --- Author ASIN filter: verify target ASIN in author links ---
|
||||
const authorLinks = $el.find('a[href*="/author/"]');
|
||||
let hasMatchingAuthor = false;
|
||||
authorLinks.each((_i, link) => {
|
||||
const href = $(link).attr('href') || '';
|
||||
const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
||||
if (asinMatch && asinMatch[1] === authorAsin) {
|
||||
hasMatchingAuthor = true;
|
||||
return false; // break .each()
|
||||
}
|
||||
});
|
||||
if (!hasMatchingAuthor) return;
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
let pageResults = 0;
|
||||
// --- Extract book ASIN ---
|
||||
const bookAsin = $el.find('li').attr('data-asin') ||
|
||||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||
if (!bookAsin || seenAsins.has(bookAsin)) return;
|
||||
seenAsins.add(bookAsin);
|
||||
|
||||
$('.s-result-item, .productListItem').each((_index, element) => {
|
||||
const $el = $(element);
|
||||
// --- Parse book details ---
|
||||
const title = $el.find('h2').first().text().trim() ||
|
||||
$el.find('h3 a').text().trim() ||
|
||||
$el.find('.bc-heading a').text().trim();
|
||||
|
||||
// --- Language filter: require matching language for region ---
|
||||
const langConfig = this.getLangConfig();
|
||||
const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() ||
|
||||
$el.find('.languageLabel').text().trim();
|
||||
// Extract language value (e.g. "Language: English" -> "English", "Sprache: Deutsch" -> "Deutsch")
|
||||
const langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i');
|
||||
const langMatch = langText.match(langLabelPattern);
|
||||
const language = langMatch?.[1]?.trim();
|
||||
if (!language || !isAcceptedLanguage(language, langConfig)) return;
|
||||
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
|
||||
$el.find('.authorLabel').text().trim() ||
|
||||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
||||
|
||||
// --- Author ASIN filter: verify target ASIN in author links ---
|
||||
const authorLinks = $el.find('a[href*="/author/"]');
|
||||
let hasMatchingAuthor = false;
|
||||
authorLinks.each((_i, link) => {
|
||||
const href = $(link).attr('href') || '';
|
||||
const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
||||
if (asinMatch && asinMatch[1] === authorAsin) {
|
||||
hasMatchingAuthor = true;
|
||||
return false; // break .each()
|
||||
}
|
||||
});
|
||||
if (!hasMatchingAuthor) return;
|
||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||
$el.find('.narratorLabel').text().trim();
|
||||
|
||||
// --- Extract book ASIN ---
|
||||
const bookAsin = $el.find('li').attr('data-asin') ||
|
||||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||
if (!bookAsin || seenAsins.has(bookAsin)) return;
|
||||
seenAsins.add(bookAsin);
|
||||
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||
|
||||
// --- Parse book details ---
|
||||
const title = $el.find('h2').first().text().trim() ||
|
||||
$el.find('h3 a').text().trim() ||
|
||||
$el.find('.bc-heading a').text().trim();
|
||||
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
||||
const durationMinutes = this.parseRuntime(runtimeText);
|
||||
|
||||
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
|
||||
$el.find('.authorLabel').text().trim() ||
|
||||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
||||
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
||||
$el.find('.a-icon-star span').first().text().trim();
|
||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||
|
||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||
$el.find('.narratorLabel').text().trim();
|
||||
|
||||
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||
|
||||
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
||||
const durationMinutes = this.parseRuntime(runtimeText);
|
||||
|
||||
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
||||
$el.find('.a-icon-star span').first().text().trim();
|
||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||
|
||||
allBooks.push({
|
||||
asin: bookAsin,
|
||||
title,
|
||||
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
|
||||
authorAsin,
|
||||
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
durationMinutes,
|
||||
rating,
|
||||
});
|
||||
|
||||
pageResults++;
|
||||
books.push({
|
||||
asin: bookAsin,
|
||||
title,
|
||||
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
|
||||
authorAsin,
|
||||
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
durationMinutes,
|
||||
rating,
|
||||
});
|
||||
});
|
||||
|
||||
// Check if there are more pages
|
||||
const resultsText = $('.resultsInfo').text().trim();
|
||||
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
|
||||
const hasMore = totalResults > page * AUDIBLE_PAGE_SIZE;
|
||||
// Check total results for pagination
|
||||
const resultsText = $('.resultsInfo').text().trim();
|
||||
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
|
||||
// Use totalResults if available; otherwise fall back to whether Audible returned a full page
|
||||
const hasMore = books.length > 0 && (totalResults > 0
|
||||
? totalResults > page * AUDIBLE_PAGE_SIZE
|
||||
: pageItemCount >= AUDIBLE_PAGE_SIZE);
|
||||
|
||||
logger.info(`Author books page ${page}: ${pageResults} valid results (${allBooks.length} total, ${totalResults} Audible total)`);
|
||||
|
||||
if (!hasMore || pageResults === 0) break;
|
||||
|
||||
// Pace between pages
|
||||
if (page < MAX_PAGES) {
|
||||
await this.delay(this.pacer.reportPageResult(meta));
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Author books search complete: "${authorName}" → ${allBooks.length} books`);
|
||||
return allBooks;
|
||||
logger.info(`Author books page ${page}: ${books.length} valid results (${totalResults} Audible total)`);
|
||||
return { books, hasMore, page, totalResults };
|
||||
} catch (error) {
|
||||
logger.error(`Author books search failed for "${authorName}"`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
collectedSoFar: allBooks.length,
|
||||
});
|
||||
// Return what we collected before the error
|
||||
return allBooks;
|
||||
return { books, hasMore: false, page, totalResults: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,33 +1135,11 @@ export class AudibleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse runtime text to minutes using language-specific patterns
|
||||
* Parse runtime text to minutes using language-specific patterns.
|
||||
* Delegates to shared utility in src/lib/utils/parse-runtime.ts.
|
||||
*/
|
||||
private parseRuntime(runtimeText: string): number | undefined {
|
||||
if (!runtimeText) return undefined;
|
||||
|
||||
const langConfig = this.getLangConfig();
|
||||
let totalMinutes = 0;
|
||||
|
||||
// Try each hour pattern until one matches
|
||||
for (const pattern of langConfig.scraping.runtimeHourPatterns) {
|
||||
const match = runtimeText.match(pattern);
|
||||
if (match) {
|
||||
totalMinutes += parseInt(match[1]) * 60;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try each minute pattern until one matches
|
||||
for (const pattern of langConfig.scraping.runtimeMinutePatterns) {
|
||||
const match = runtimeText.match(pattern);
|
||||
if (match) {
|
||||
totalMinutes += parseInt(match[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return totalMinutes > 0 ? totalMinutes : undefined;
|
||||
return parseRuntimeUtil(runtimeText, this.getLangConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+107
-3
@@ -4,9 +4,11 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import crypto from 'crypto';
|
||||
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { API_TOKEN_PREFIX, isEndpointAllowed } from '../constants/api-tokens';
|
||||
|
||||
const logger = RMABLogger.create('Auth');
|
||||
|
||||
@@ -32,9 +34,70 @@ function extractToken(request: NextRequest): string | null {
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate via static API token (rmab_ prefix).
|
||||
* Returns a synthetic TokenPayload if valid, null otherwise.
|
||||
* Updates lastUsedAt asynchronously.
|
||||
*/
|
||||
async function authenticateApiToken(token: string): Promise<TokenPayload | null> {
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
const apiToken = await prisma.apiToken.findUnique({
|
||||
where: { tokenHash },
|
||||
include: {
|
||||
tokenUser: {
|
||||
select: {
|
||||
id: true,
|
||||
plexId: true,
|
||||
plexUsername: true,
|
||||
role: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiToken) return null;
|
||||
|
||||
// Check expiration
|
||||
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
|
||||
logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reject tokens for soft-deleted users
|
||||
const user = apiToken.tokenUser;
|
||||
if (!user || user.deletedAt) {
|
||||
logger.warn('API token used by deleted or missing user', {
|
||||
tokenPrefix: apiToken.tokenPrefix,
|
||||
userId: user?.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update lastUsedAt (fire-and-forget)
|
||||
prisma.apiToken.update({
|
||||
where: { id: apiToken.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
}).catch((err) => {
|
||||
logger.debug('Failed to update API token lastUsedAt', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
tokenId: apiToken.id,
|
||||
});
|
||||
});
|
||||
|
||||
// Use the token's target user (userId), not the creator (createdById)
|
||||
return {
|
||||
sub: user.id,
|
||||
plexId: user.plexId,
|
||||
username: user.plexUsername,
|
||||
role: apiToken.role,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require authentication
|
||||
* Verifies JWT token and adds user to request
|
||||
* Verifies JWT token or static API token and adds user to request
|
||||
*/
|
||||
export async function requireAuth(
|
||||
request: NextRequest,
|
||||
@@ -53,6 +116,43 @@ export async function requireAuth(
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a static API token
|
||||
if (token.startsWith(API_TOKEN_PREFIX)) {
|
||||
const apiUser = await authenticateApiToken(token);
|
||||
if (!apiUser) {
|
||||
logger.error('API token authentication failed');
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired API token',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce endpoint allowlist for API token auth
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const method = request.method;
|
||||
if (!isEndpointAllowed(method, pathname)) {
|
||||
logger.warn('API token used on restricted endpoint', {
|
||||
method,
|
||||
path: pathname,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Forbidden',
|
||||
message: 'This endpoint is not available via API token authentication',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const authenticatedRequest = request as AuthenticatedRequest;
|
||||
authenticatedRequest.user = { ...apiUser, id: apiUser.sub };
|
||||
return handler(authenticatedRequest);
|
||||
}
|
||||
|
||||
// Fall back to JWT verification
|
||||
const payload = verifyAccessToken(token);
|
||||
|
||||
if (!payload) {
|
||||
@@ -69,9 +169,13 @@ export async function requireAuth(
|
||||
// Verify user still exists in database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!user || user.deletedAt) {
|
||||
logger.error('User not found in database', { userId: payload.sub });
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -86,7 +190,7 @@ export async function requireAuth(
|
||||
const authenticatedRequest = request as AuthenticatedRequest;
|
||||
authenticatedRequest.user = {
|
||||
...payload,
|
||||
id: user.id,
|
||||
id: payload.sub,
|
||||
};
|
||||
|
||||
return handler(authenticatedRequest);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Component: Check Watched Lists Processor
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*
|
||||
* Dedicated processor for checking watched series and watched authors
|
||||
* for new releases and auto-creating requests.
|
||||
* Supports targeted processing of a single series/author for immediate sync.
|
||||
*/
|
||||
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface CheckWatchedListsPayload {
|
||||
jobId?: string;
|
||||
scheduledJobId?: string;
|
||||
/** If set, only process watched items for this user */
|
||||
userId?: string;
|
||||
/** If set, only process this specific series */
|
||||
seriesAsin?: string;
|
||||
/** If set, only process this specific author */
|
||||
authorAsin?: string;
|
||||
}
|
||||
|
||||
export async function processCheckWatchedLists(payload: CheckWatchedListsPayload): Promise<any> {
|
||||
const { jobId, userId, seriesAsin, authorAsin } = payload;
|
||||
const logger = RMABLogger.forJob(jobId, 'CheckWatchedLists');
|
||||
|
||||
const isTargeted = !!(userId && (seriesAsin || authorAsin));
|
||||
logger.info(isTargeted
|
||||
? `Starting targeted watched lists check (user: ${userId}, series: ${seriesAsin || 'n/a'}, author: ${authorAsin || 'n/a'})...`
|
||||
: 'Starting watched lists check...'
|
||||
);
|
||||
|
||||
const { processWatchedLists } = await import('../services/watched-lists.service');
|
||||
const stats = await processWatchedLists(logger, { userId, seriesAsin, authorAsin });
|
||||
|
||||
logger.info('Watched lists check complete', { stats });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: isTargeted ? 'Targeted watched item checked' : 'Watched lists checked',
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { getConfigService } from '../services/config.service';
|
||||
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||
import { ProwlarrService } from '../integrations/prowlarr.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { isTransientConnectionError } from '../utils/connection-errors';
|
||||
|
||||
/**
|
||||
* Process download job
|
||||
@@ -121,15 +122,22 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
} catch (error) {
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
// Update request status to failed
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: error instanceof Error ? error.message : 'Failed to add download to client',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (isTransientConnectionError(error)) {
|
||||
// Connection error — don't mark request as failed yet.
|
||||
// Bull will retry this job (3 attempts with exponential backoff).
|
||||
// If all retries are exhausted, the global failed handler marks it failed.
|
||||
logger.warn(`Download client unreachable for request ${requestId}, allowing Bull to retry`);
|
||||
} else {
|
||||
// Permanent error — mark request as failed immediately
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: error instanceof Error ? error.message : 'Failed to add download to client',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
|
||||
import { isTransientConnectionError } from '../utils/connection-errors';
|
||||
|
||||
/**
|
||||
* Process monitor download job
|
||||
@@ -20,6 +21,12 @@ import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-
|
||||
const BASE_POLL_INTERVAL = 10;
|
||||
/** Maximum polling interval in seconds (5 minutes) */
|
||||
const MAX_POLL_INTERVAL = 300;
|
||||
/**
|
||||
* Maximum consecutive connection failures before permanently failing the download.
|
||||
* With exponential backoff (10s base, 300s cap), 30 failures spans roughly 30-45 minutes —
|
||||
* enough to survive a Docker restart, service update, or transient network outage.
|
||||
*/
|
||||
const MAX_CONNECTION_FAILURES = 30;
|
||||
|
||||
/**
|
||||
* Compute next poll delay with exponential backoff for stalled downloads.
|
||||
@@ -32,7 +39,8 @@ function getBackoffDelay(stallCount: number): number {
|
||||
|
||||
export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> {
|
||||
const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId,
|
||||
lastProgress: prevProgress, stallCount: prevStallCount, pathWaitCount: prevPathWaitCount } = payload;
|
||||
lastProgress: prevProgress, stallCount: prevStallCount, pathWaitCount: prevPathWaitCount,
|
||||
connectionFailureCount: prevConnectionFailures } = payload;
|
||||
|
||||
const logger = RMABLogger.forJob(jobId, 'MonitorDownload');
|
||||
|
||||
@@ -288,51 +296,99 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
} catch (error) {
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
// Check if this is a transient "not found" error
|
||||
const errorMessage = error instanceof Error ? error.message : '';
|
||||
const isNotFound = errorMessage.includes('not found');
|
||||
const isConnectionError = isTransientConnectionError(error);
|
||||
|
||||
if (isNotFound) {
|
||||
// Transient error - don't mark request as failed, let Bull retry
|
||||
// The request stays in 'downloading' status until Bull exhausts all retries
|
||||
// PATH 1: "Not found" — transient race condition.
|
||||
// Don't mark request as failed; let Bull retry the same job.
|
||||
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
|
||||
} else {
|
||||
// Permanent error - mark request as failed immediately
|
||||
const failureMessage = errorMessage || 'Monitor download failed';
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: failureMessage,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Send notification for request failure
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
if (isConnectionError) {
|
||||
// PATH 2: Connection failure — download client is temporarily unreachable.
|
||||
// Instead of failing the download, self-schedule the next poll with backoff.
|
||||
// This reuses the same adaptive backoff as stalled downloads, giving the
|
||||
// client time to recover (restart, network blip, update, etc.).
|
||||
const failureCount = (prevConnectionFailures ?? 0) + 1;
|
||||
|
||||
if (failureCount >= MAX_CONNECTION_FAILURES) {
|
||||
// Exhausted patience — treat as permanent failure
|
||||
logger.error(
|
||||
`Download client unreachable for ${failureCount} consecutive checks, giving up on request ${requestId}`
|
||||
);
|
||||
// Fall through to permanent failure handling below
|
||||
} else {
|
||||
const delay = getBackoffDelay(failureCount);
|
||||
logger.warn(
|
||||
`Download client unreachable (${failureCount}/${MAX_CONNECTION_FAILURES}), ` +
|
||||
`retrying in ${delay}s for request ${requestId}`,
|
||||
{ error: errorMessage }
|
||||
);
|
||||
|
||||
if (request) {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_error',
|
||||
request.id,
|
||||
request.audiobook.title,
|
||||
request.audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
failureMessage
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
await jobQueue.addMonitorJob(
|
||||
requestId,
|
||||
downloadHistoryId,
|
||||
downloadClientId,
|
||||
downloadClient,
|
||||
delay,
|
||||
prevProgress,
|
||||
prevStallCount ?? 0,
|
||||
prevPathWaitCount,
|
||||
failureCount
|
||||
);
|
||||
|
||||
// Return success — the monitoring loop continues via the new job.
|
||||
// Do NOT throw: that would trigger Bull's retry on this job as well.
|
||||
return {
|
||||
success: true,
|
||||
completed: false,
|
||||
message: `Download client unreachable, will retry in ${delay}s`,
|
||||
requestId,
|
||||
connectionFailureCount: failureCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Rethrow to trigger Bull's retry mechanism
|
||||
// PATH 3: Permanent error (or connection failures exhausted).
|
||||
// Mark request as failed immediately.
|
||||
const failureMessage = errorMessage || 'Monitor download failed';
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: failureMessage,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification for request failure
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (request) {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_error',
|
||||
request.id,
|
||||
request.audiobook.title,
|
||||
request.audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
failureMessage
|
||||
).catch((notifError) => {
|
||||
logger.error('Failed to queue notification', { error: notifError instanceof Error ? notifError.message : String(notifError) });
|
||||
});
|
||||
}
|
||||
|
||||
// Rethrow to trigger Bull's retry mechanism as a safety net
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
import { generateFilesHash } from '../utils/files-hash';
|
||||
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
|
||||
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
|
||||
import { getAudibleService } from '../integrations/audible.service';
|
||||
|
||||
/**
|
||||
* Process organize files job
|
||||
@@ -22,7 +23,7 @@ import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
|
||||
* Handles both audiobook and ebook request types with appropriate branching
|
||||
*/
|
||||
export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> {
|
||||
const { requestId, audiobookId, downloadPath, jobId } = payload;
|
||||
const { requestId, audiobookId, downloadPath, jobId, cleanupSource } = payload;
|
||||
|
||||
const logger = RMABLogger.forJob(jobId, 'OrganizeFiles');
|
||||
|
||||
@@ -118,7 +119,62 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}`)
|
||||
// Enrich missing series data from Audnexus (safety net for records created without series)
|
||||
let series = audiobook.series || undefined;
|
||||
let seriesPart = audiobook.seriesPart || undefined;
|
||||
|
||||
if (audiobook.audibleAsin && !series) {
|
||||
try {
|
||||
logger.info(`Missing series data, fetching from Audnexus for ASIN: ${audiobook.audibleAsin}`);
|
||||
const audibleService = getAudibleService();
|
||||
const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin);
|
||||
|
||||
if (audnexusData) {
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
if (audnexusData.series) {
|
||||
series = audnexusData.series;
|
||||
updates.series = series;
|
||||
logger.info(`Got series "${series}" from Audnexus`);
|
||||
}
|
||||
if (audnexusData.seriesPart) {
|
||||
seriesPart = audnexusData.seriesPart;
|
||||
updates.seriesPart = seriesPart;
|
||||
logger.info(`Got seriesPart "${seriesPart}" from Audnexus`);
|
||||
}
|
||||
if (audnexusData.seriesAsin) {
|
||||
updates.seriesAsin = audnexusData.seriesAsin;
|
||||
}
|
||||
// Also backfill year/narrator if still missing
|
||||
if (!year && audnexusData.releaseDate) {
|
||||
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
||||
if (!isNaN(releaseYear)) {
|
||||
year = releaseYear;
|
||||
updates.year = year;
|
||||
logger.info(`Got year ${year} from Audnexus`);
|
||||
}
|
||||
}
|
||||
if (!narrator && audnexusData.narrator) {
|
||||
narrator = audnexusData.narrator;
|
||||
updates.narrator = narrator;
|
||||
logger.info(`Got narrator "${narrator}" from Audnexus`);
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobookId },
|
||||
data: updates,
|
||||
});
|
||||
logger.info(`Updated audiobook record with Audnexus metadata`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal: missing series won't block organization, just degrades path quality
|
||||
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`);
|
||||
|
||||
// Get file organizer (reads media_dir from database config)
|
||||
const organizer = await getFileOrganizer();
|
||||
@@ -151,8 +207,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
coverArtUrl: audiobook.coverArtUrl || undefined,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
year,
|
||||
series: audiobook.series || undefined,
|
||||
seriesPart: audiobook.seriesPart || undefined,
|
||||
series,
|
||||
seriesPart,
|
||||
},
|
||||
template,
|
||||
jobId ? { jobId, context: 'FileOrganizer' } : undefined,
|
||||
@@ -264,6 +320,11 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
|
||||
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
|
||||
|
||||
// Cleanup source files if requested (manual import feature)
|
||||
if (cleanupSource) {
|
||||
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Files organized successfully',
|
||||
@@ -467,7 +528,7 @@ async function processEbookOrganization(
|
||||
request: { id: string; userId: string; type: string; user: { plexUsername: string | null } },
|
||||
logger: RMABLogger
|
||||
): Promise<any> {
|
||||
const { requestId, audiobookId, downloadPath, jobId } = payload;
|
||||
const { requestId, audiobookId, downloadPath, jobId, cleanupSource } = payload;
|
||||
|
||||
logger.info(`Processing ebook organization for request ${requestId}`);
|
||||
|
||||
@@ -540,6 +601,56 @@ async function processEbookOrganization(
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich missing series data from Audnexus (safety net for records created without series)
|
||||
if (book.audibleAsin && !series) {
|
||||
try {
|
||||
logger.info(`Missing series data for ebook, fetching from Audnexus for ASIN: ${book.audibleAsin}`);
|
||||
const audibleService = getAudibleService();
|
||||
const audnexusData = await audibleService.getAudiobookDetails(book.audibleAsin);
|
||||
|
||||
if (audnexusData) {
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
if (audnexusData.series) {
|
||||
series = audnexusData.series;
|
||||
updates.series = series;
|
||||
logger.info(`Got series "${series}" from Audnexus`);
|
||||
}
|
||||
if (audnexusData.seriesPart) {
|
||||
seriesPart = audnexusData.seriesPart;
|
||||
updates.seriesPart = seriesPart;
|
||||
logger.info(`Got seriesPart "${seriesPart}" from Audnexus`);
|
||||
}
|
||||
if (audnexusData.seriesAsin) {
|
||||
updates.seriesAsin = audnexusData.seriesAsin;
|
||||
}
|
||||
if (!year && audnexusData.releaseDate) {
|
||||
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
||||
if (!isNaN(releaseYear)) {
|
||||
year = releaseYear;
|
||||
updates.year = year;
|
||||
logger.info(`Got year ${year} from Audnexus`);
|
||||
}
|
||||
}
|
||||
if (!narrator && audnexusData.narrator) {
|
||||
narrator = audnexusData.narrator;
|
||||
updates.narrator = narrator;
|
||||
logger.info(`Got narrator "${narrator}" from Audnexus`);
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobookId },
|
||||
data: updates,
|
||||
});
|
||||
logger.info(`Updated book record with Audnexus metadata`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch Audnexus data for ASIN ${book.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`);
|
||||
|
||||
// Check if this is an indexer download (needs to keep source for seeding)
|
||||
@@ -726,6 +837,11 @@ async function processEbookOrganization(
|
||||
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
|
||||
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
|
||||
|
||||
// Cleanup source files if requested (manual import feature)
|
||||
if (cleanupSource) {
|
||||
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Ebook organized successfully',
|
||||
@@ -1003,6 +1119,68 @@ async function cleanupDownloadAfterOrganize(
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SOURCE FILE CLEANUP (MANUAL IMPORT)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Delete source files after successful manual import.
|
||||
* Non-fatal: logs a warning on failure but does not fail the job.
|
||||
* Files are already safely copied to the media library at this point.
|
||||
*/
|
||||
async function cleanupSourceAfterOrganize(
|
||||
downloadPath: string,
|
||||
configService: any,
|
||||
jobId: string | undefined,
|
||||
logger: RMABLogger
|
||||
): Promise<void> {
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
logger.info(`Cleaning up source files: ${downloadPath}`);
|
||||
|
||||
const stats = await fs.stat(downloadPath);
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
logger.info(`Removed source directory: ${downloadPath}`);
|
||||
} else {
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed source file: ${downloadPath}`);
|
||||
}
|
||||
|
||||
// Determine boundary path based on download path prefix
|
||||
const BOOKDROP_PATH = '/bookdrop';
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
const mediaDir = await configService.get('media_dir') || '/media';
|
||||
|
||||
let boundaryPath = downloadDir;
|
||||
if (downloadPath.startsWith(BOOKDROP_PATH)) {
|
||||
boundaryPath = BOOKDROP_PATH;
|
||||
} else if (downloadPath.startsWith(mediaDir)) {
|
||||
boundaryPath = mediaDir;
|
||||
}
|
||||
|
||||
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
|
||||
boundaryPath,
|
||||
logContext: jobId ? { jobId, context: 'CleanupSourceParents' } : undefined,
|
||||
});
|
||||
|
||||
if (cleanupResult.removedDirectories.length > 0) {
|
||||
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal - files are already safely in the media library
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info(`Source path already deleted: ${downloadPath}`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Failed to cleanup source files: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
{ error: error instanceof Error ? error.stack : undefined }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =========================================================================
|
||||
|
||||
@@ -34,6 +34,13 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
},
|
||||
});
|
||||
|
||||
// Check for custom search terms override
|
||||
const requestRecord = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { customSearchTerms: true },
|
||||
});
|
||||
const effectiveSearchTitle = requestRecord?.customSearchTerms || audiobook.title;
|
||||
|
||||
// Get enabled indexers from configuration
|
||||
const { getConfigService } = await import('../services/config.service');
|
||||
const configService = getConfigService();
|
||||
@@ -77,7 +84,11 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
// Get Prowlarr service
|
||||
const prowlarr = await getProwlarrService();
|
||||
|
||||
logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`);
|
||||
if (requestRecord?.customSearchTerms) {
|
||||
logger.info(`Searching with custom terms: "${effectiveSearchTitle}" (original: "${audiobook.title}") by "${audiobook.author}"`);
|
||||
} else {
|
||||
logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`);
|
||||
}
|
||||
|
||||
// Search Prowlarr for each group and combine results
|
||||
const allResults = [];
|
||||
@@ -87,7 +98,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
||||
|
||||
try {
|
||||
const groupResults = await prowlarr.searchWithVariations(audiobook.title, audiobook.author, {
|
||||
const groupResults = await prowlarr.searchWithVariations(effectiveSearchTitle, audiobook.author, {
|
||||
categories: group.categories,
|
||||
indexerIds: group.indexerIds,
|
||||
minSeeders: 1, // Only torrents with at least 1 seeder
|
||||
|
||||
@@ -54,10 +54,12 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
return { success: false, error: 'Username and password required' };
|
||||
}
|
||||
|
||||
const normalizedUsername = username.trim().toLowerCase();
|
||||
|
||||
// Find user (exclude soft-deleted users)
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
plexUsername: username,
|
||||
plexUsername: normalizedUsername,
|
||||
authProvider: 'local',
|
||||
deletedAt: null, // Exclude soft-deleted users
|
||||
},
|
||||
@@ -144,9 +146,10 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
async register(params: RegisterParams): Promise<AuthResult> {
|
||||
try {
|
||||
const { username, password } = params;
|
||||
const normalizedUsername = username?.trim().toLowerCase();
|
||||
|
||||
// Validate
|
||||
if (!username || username.length < 3) {
|
||||
if (!normalizedUsername || normalizedUsername.length < 3) {
|
||||
return { success: false, error: 'Username must be at least 3 characters' };
|
||||
}
|
||||
|
||||
@@ -167,7 +170,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
// Check username uniqueness (only among non-deleted users)
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
plexUsername: username,
|
||||
plexUsername: normalizedUsername,
|
||||
authProvider: 'local',
|
||||
deletedAt: null, // Allow reuse of usernames from deleted accounts
|
||||
},
|
||||
@@ -194,8 +197,8 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
plexId: `local-${username}`,
|
||||
plexUsername: username,
|
||||
plexId: `local-${normalizedUsername}`,
|
||||
plexUsername: normalizedUsername,
|
||||
authToken: encryptedHash,
|
||||
authProvider: 'local',
|
||||
role: isFirstUser ? 'admin' : 'user',
|
||||
|
||||
@@ -27,6 +27,7 @@ export type JobType =
|
||||
| 'cleanup_seeded_torrents'
|
||||
| 'monitor_rss_feeds'
|
||||
| 'sync_goodreads_shelves'
|
||||
| 'check_watched_lists'
|
||||
| 'send_notification'
|
||||
// Ebook-specific job types
|
||||
| 'search_ebook'
|
||||
@@ -66,6 +67,7 @@ export interface MonitorDownloadPayload extends JobPayload {
|
||||
lastProgress?: number; // Previous poll's progress (0-100) for stall detection
|
||||
stallCount?: number; // Consecutive polls with no progress change (drives backoff)
|
||||
pathWaitCount?: number; // Consecutive polls waiting for content_path to relocate to save_path
|
||||
connectionFailureCount?: number; // Consecutive polls where the download client was unreachable
|
||||
}
|
||||
|
||||
export interface OrganizeFilesPayload extends JobPayload {
|
||||
@@ -73,6 +75,7 @@ export interface OrganizeFilesPayload extends JobPayload {
|
||||
audiobookId: string;
|
||||
downloadPath: string;
|
||||
targetPath?: string; // Optional - not used by processor (reads from database config)
|
||||
cleanupSource?: boolean; // If true, delete source files after successful import
|
||||
}
|
||||
|
||||
export interface ScanPlexPayload extends JobPayload {
|
||||
@@ -111,6 +114,16 @@ export interface SyncGoodreadsShelvesPayload extends JobPayload {
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
export interface CheckWatchedListsPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
/** If set, only process watched items for this user */
|
||||
userId?: string;
|
||||
/** If set, only process this specific series */
|
||||
seriesAsin?: string;
|
||||
/** If set, only process this specific author */
|
||||
authorAsin?: string;
|
||||
}
|
||||
|
||||
// Ebook-specific payload interfaces
|
||||
export interface SearchEbookPayload extends JobPayload {
|
||||
requestId: string;
|
||||
@@ -259,6 +272,29 @@ export class JobQueueService {
|
||||
logger.error('Failed to update request/download status', { error: updateError instanceof Error ? updateError.message : String(updateError) });
|
||||
}
|
||||
}
|
||||
|
||||
// Safety net for download_torrent: if the processor skipped marking the
|
||||
// request as failed (e.g. connection error with Bull retries), ensure the
|
||||
// request is marked failed after all retries are exhausted.
|
||||
if (job.name === 'download_torrent' && job.data) {
|
||||
const payload = job.data as DownloadTorrentPayload;
|
||||
logger.error(`DownloadTorrent job permanently failed for request ${payload.requestId} after ${job.attemptsMade} attempts`);
|
||||
|
||||
try {
|
||||
await prisma.request.update({
|
||||
where: { id: payload.requestId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: error.message || 'Failed to add download after multiple retries',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (updateError) {
|
||||
logger.error('Failed to update request status after download_torrent failure', {
|
||||
error: updateError instanceof Error ? updateError.message : String(updateError),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.queue.on('stalled', async (job: BullJob) => {
|
||||
@@ -359,6 +395,12 @@ export class JobQueueService {
|
||||
return await processSyncGoodreadsShelves(payloadWithJobId);
|
||||
});
|
||||
|
||||
this.queue.process('check_watched_lists', 1, async (job: BullJob<CheckWatchedListsPayload>) => {
|
||||
const { processCheckWatchedLists } = await import('../processors/check-watched-lists.processor');
|
||||
const payloadWithJobId = await this.ensureJobRecord(job, 'check_watched_lists');
|
||||
return await processCheckWatchedLists(payloadWithJobId);
|
||||
});
|
||||
|
||||
// Send notification processor
|
||||
this.queue.process('send_notification', 2, async (job: BullJob<SendNotificationPayload>) => {
|
||||
const { processSendNotification } = await import('../processors/send-notification.processor');
|
||||
@@ -569,7 +611,8 @@ export class JobQueueService {
|
||||
delaySeconds: number = 0,
|
||||
lastProgress?: number,
|
||||
stallCount?: number,
|
||||
pathWaitCount?: number
|
||||
pathWaitCount?: number,
|
||||
connectionFailureCount?: number
|
||||
): Promise<string> {
|
||||
return await this.addJob(
|
||||
'monitor_download',
|
||||
@@ -581,6 +624,7 @@ export class JobQueueService {
|
||||
lastProgress,
|
||||
stallCount,
|
||||
pathWaitCount,
|
||||
connectionFailureCount,
|
||||
} as MonitorDownloadPayload,
|
||||
{
|
||||
priority: 5, // Medium priority
|
||||
@@ -597,7 +641,8 @@ export class JobQueueService {
|
||||
requestId: string,
|
||||
audiobookId: string,
|
||||
downloadPath: string,
|
||||
targetPath?: string
|
||||
targetPath?: string,
|
||||
cleanupSource?: boolean
|
||||
): Promise<string> {
|
||||
return await this.addJob(
|
||||
'organize_files',
|
||||
@@ -606,6 +651,7 @@ export class JobQueueService {
|
||||
audiobookId,
|
||||
downloadPath,
|
||||
targetPath, // Not used by processor
|
||||
cleanupSource,
|
||||
} as OrganizeFilesPayload,
|
||||
{
|
||||
priority: 8,
|
||||
@@ -737,6 +783,39 @@ export class JobQueueService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add check watched lists job (watched series + watched authors)
|
||||
*/
|
||||
async addCheckWatchedListsJob(scheduledJobId?: string): Promise<string> {
|
||||
return await this.addJob(
|
||||
'check_watched_lists',
|
||||
{
|
||||
scheduledJobId,
|
||||
} as CheckWatchedListsPayload,
|
||||
{
|
||||
priority: 7,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a targeted check for a specific watched series or author for a specific user.
|
||||
* Used for immediate processing when a user adds a new watch.
|
||||
*/
|
||||
async addCheckWatchedItemJob(userId: string, seriesAsin?: string, authorAsin?: string): Promise<string> {
|
||||
return await this.addJob(
|
||||
'check_watched_lists',
|
||||
{
|
||||
userId,
|
||||
seriesAsin,
|
||||
authorAsin,
|
||||
} as CheckWatchedListsPayload,
|
||||
{
|
||||
priority: 8, // Higher than scheduled (7) since user-initiated
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// EBOOK-SPECIFIC JOB METHODS
|
||||
// =========================================================================
|
||||
|
||||
@@ -45,13 +45,31 @@ export class AppriseProvider implements INotificationProvider {
|
||||
const meta = getEventMeta(payload.event);
|
||||
const { title, body } = this.formatMessage(payload);
|
||||
|
||||
const serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
|
||||
const notificationType = SEVERITY_TYPES[meta.severity];
|
||||
|
||||
// Parse URL to extract embedded HTTP Basic Auth credentials (e.g. https://user:pass@host/)
|
||||
let serverUrl: string;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
try {
|
||||
const parsed = new URL(appriseConfig.serverUrl);
|
||||
if (parsed.username) {
|
||||
const username = decodeURIComponent(parsed.username);
|
||||
const password = decodeURIComponent(parsed.password);
|
||||
headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
serverUrl = parsed.toString().replace(/\/+$/, '');
|
||||
} else {
|
||||
serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
|
||||
}
|
||||
} catch {
|
||||
serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const notificationType = SEVERITY_TYPES[meta.severity];
|
||||
|
||||
// Explicit authToken (Bearer) takes precedence over URL-embedded credentials
|
||||
if (appriseConfig.authToken) {
|
||||
headers['Authorization'] = `Bearer ${appriseConfig.authToken}`;
|
||||
}
|
||||
|
||||
@@ -12,6 +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';
|
||||
|
||||
const logger = RMABLogger.create('RequestCreator');
|
||||
|
||||
@@ -147,6 +148,15 @@ export async function createRequestForUser(
|
||||
}
|
||||
}
|
||||
|
||||
// Seed works table for cross-ASIN matching (Layer 2: request-time seeding)
|
||||
seedAsin(
|
||||
audiobook.asin,
|
||||
audiobookRecord.title,
|
||||
audiobookRecord.author,
|
||||
audiobookRecord.narrator || undefined,
|
||||
undefined // duration not available at request time
|
||||
).catch(() => {});
|
||||
|
||||
// Check if user already has an active request for this audiobook
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('Scheduler');
|
||||
|
||||
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves';
|
||||
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves' | 'check_watched_lists';
|
||||
|
||||
export interface ScheduledJob {
|
||||
id: string;
|
||||
@@ -133,6 +133,13 @@ export class SchedulerService {
|
||||
enabled: true, // Enable by default
|
||||
payload: {},
|
||||
},
|
||||
{
|
||||
name: 'Check Watched Lists',
|
||||
type: 'check_watched_lists' as ScheduledJobType,
|
||||
schedule: '0 0 * * *', // Daily at midnight (every 24 hours)
|
||||
enabled: true, // Enable by default
|
||||
payload: {},
|
||||
},
|
||||
];
|
||||
|
||||
let created = 0;
|
||||
@@ -353,6 +360,9 @@ export class SchedulerService {
|
||||
case 'sync_goodreads_shelves':
|
||||
bullJobId = await this.triggerSyncGoodreadsShelves(job);
|
||||
break;
|
||||
case 'check_watched_lists':
|
||||
bullJobId = await this.triggerCheckWatchedLists(job);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown job type: ${job.type}`);
|
||||
}
|
||||
@@ -627,6 +637,13 @@ export class SchedulerService {
|
||||
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
|
||||
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger watched lists check (watched series + watched authors)
|
||||
*/
|
||||
private async triggerCheckWatchedLists(job: any): Promise<string> {
|
||||
return await this.jobQueue.addCheckWatchedListsJob(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Component: Watched Lists Service
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*
|
||||
* Checks watched series and watched authors for new releases.
|
||||
* Deduplicates results using the works table, checks against user's library,
|
||||
* and auto-creates requests via the shared request-creator service.
|
||||
* Follows the same pattern as goodreads-sync.service.ts.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getAudibleService, AudibleAudiobook } from '@/lib/integrations/audible.service';
|
||||
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { getSiblingAsins } from '@/lib/services/works.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('WatchedLists');
|
||||
|
||||
/** Max books to process per series (avoid excessively long runs) */
|
||||
const MAX_BOOKS_PER_SERIES = 200;
|
||||
|
||||
/** Max author book pages to scrape */
|
||||
const MAX_AUTHOR_PAGES = 4;
|
||||
|
||||
/** Delay between scrapes to avoid rate limiting (ms) */
|
||||
const SCRAPE_DELAY_MS = 2000;
|
||||
|
||||
export interface WatchedListsSyncStats {
|
||||
seriesChecked: number;
|
||||
authorsChecked: number;
|
||||
booksFound: number;
|
||||
requestsCreated: number;
|
||||
skippedOwned: number;
|
||||
skippedExisting: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface WatchedListsSyncOptions {
|
||||
/** Process only this specific user (for targeted sync) */
|
||||
userId?: string;
|
||||
/** Process only this specific series (for immediate sync on watch) */
|
||||
seriesAsin?: string;
|
||||
/** Process only this specific author (for immediate sync on watch) */
|
||||
authorAsin?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all watched series and authors: scrape for new releases,
|
||||
* deduplicate, check library ownership, and create requests.
|
||||
* Called from the check_watched_lists processor.
|
||||
*/
|
||||
export async function processWatchedLists(
|
||||
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
|
||||
options: WatchedListsSyncOptions = {}
|
||||
): Promise<WatchedListsSyncStats> {
|
||||
const log = jobLogger || logger;
|
||||
const stats: WatchedListsSyncStats = {
|
||||
seriesChecked: 0,
|
||||
authorsChecked: 0,
|
||||
booksFound: 0,
|
||||
requestsCreated: 0,
|
||||
skippedOwned: 0,
|
||||
skippedExisting: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
// ---- Watched Series ----
|
||||
await processAllWatchedSeries(log, stats, options);
|
||||
|
||||
// ---- Watched Authors ----
|
||||
await processAllWatchedAuthors(log, stats, options);
|
||||
|
||||
log.info('Watched lists sync complete', {
|
||||
seriesChecked: stats.seriesChecked,
|
||||
authorsChecked: stats.authorsChecked,
|
||||
booksFound: stats.booksFound,
|
||||
requestsCreated: stats.requestsCreated,
|
||||
skippedOwned: stats.skippedOwned,
|
||||
skippedExisting: stats.skippedExisting,
|
||||
errors: stats.errors,
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watched Series
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processAllWatchedSeries(
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats,
|
||||
options: WatchedListsSyncOptions
|
||||
): Promise<void> {
|
||||
const whereClause: any = {};
|
||||
if (options.userId) whereClause.userId = options.userId;
|
||||
if (options.seriesAsin) whereClause.seriesAsin = options.seriesAsin;
|
||||
const watchedSeries = await prisma.watchedSeries.findMany({
|
||||
where: whereClause,
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
});
|
||||
|
||||
if (watchedSeries.length === 0) {
|
||||
log.info('No watched series to process');
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by seriesAsin to avoid re-scraping the same series for multiple users
|
||||
const seriesByAsin = new Map<string, typeof watchedSeries>();
|
||||
for (const ws of watchedSeries) {
|
||||
const list = seriesByAsin.get(ws.seriesAsin) || [];
|
||||
list.push(ws);
|
||||
seriesByAsin.set(ws.seriesAsin, list);
|
||||
}
|
||||
|
||||
log.info(`Processing ${seriesByAsin.size} unique watched series (${watchedSeries.length} total subscriptions)`);
|
||||
|
||||
for (const [seriesAsin, subscriptions] of seriesByAsin) {
|
||||
try {
|
||||
await processSeriesForUsers(seriesAsin, subscriptions, log, stats);
|
||||
} catch (error) {
|
||||
stats.errors++;
|
||||
log.error(`Failed to process watched series ${seriesAsin}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// Rate limit between series
|
||||
await delay(SCRAPE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function processSeriesForUsers(
|
||||
seriesAsin: string,
|
||||
subscriptions: Array<{ id: string; seriesTitle: string; user: { id: string; plexUsername: string } }>,
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats
|
||||
): Promise<void> {
|
||||
const title = subscriptions[0].seriesTitle;
|
||||
log.info(`Scraping watched series: "${title}" (${seriesAsin})`);
|
||||
|
||||
// Scrape all pages of the series (up to MAX_BOOKS_PER_SERIES)
|
||||
const allBooks: AudibleAudiobook[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore && allBooks.length < MAX_BOOKS_PER_SERIES) {
|
||||
const result = await scrapeSeriesPage(seriesAsin, page);
|
||||
if (!result || result.books.length === 0) break;
|
||||
|
||||
allBooks.push(...result.books);
|
||||
hasMore = result.hasMore;
|
||||
page++;
|
||||
|
||||
if (hasMore) await delay(1000);
|
||||
}
|
||||
|
||||
if (allBooks.length === 0) {
|
||||
log.info(`No books found for series "${title}"`);
|
||||
stats.seriesChecked++;
|
||||
return;
|
||||
}
|
||||
|
||||
stats.booksFound += allBooks.length;
|
||||
|
||||
// Deduplicate
|
||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(allBooks);
|
||||
|
||||
// Persist dedup groups (fire-and-forget)
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
// For each user watching this series, create requests for new books
|
||||
for (const subscription of subscriptions) {
|
||||
await createRequestsForUser(
|
||||
subscription.user.id,
|
||||
subscription.user.plexUsername,
|
||||
dedupedBooks,
|
||||
log,
|
||||
stats
|
||||
);
|
||||
|
||||
// Update lastCheckedAt
|
||||
await prisma.watchedSeries.update({
|
||||
where: { id: subscription.id },
|
||||
data: { lastCheckedAt: new Date() },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
stats.seriesChecked++;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watched Authors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processAllWatchedAuthors(
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats,
|
||||
options: WatchedListsSyncOptions
|
||||
): Promise<void> {
|
||||
const whereClause: any = {};
|
||||
if (options.userId) whereClause.userId = options.userId;
|
||||
if (options.authorAsin) whereClause.authorAsin = options.authorAsin;
|
||||
const watchedAuthors = await prisma.watchedAuthor.findMany({
|
||||
where: whereClause,
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
});
|
||||
|
||||
if (watchedAuthors.length === 0) {
|
||||
log.info('No watched authors to process');
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by authorAsin to avoid re-scraping the same author for multiple users
|
||||
const authorsByAsin = new Map<string, typeof watchedAuthors>();
|
||||
for (const wa of watchedAuthors) {
|
||||
const list = authorsByAsin.get(wa.authorAsin) || [];
|
||||
list.push(wa);
|
||||
authorsByAsin.set(wa.authorAsin, list);
|
||||
}
|
||||
|
||||
log.info(`Processing ${authorsByAsin.size} unique watched authors (${watchedAuthors.length} total subscriptions)`);
|
||||
|
||||
for (const [authorAsin, subscriptions] of authorsByAsin) {
|
||||
try {
|
||||
await processAuthorForUsers(authorAsin, subscriptions, log, stats);
|
||||
} catch (error) {
|
||||
stats.errors++;
|
||||
log.error(`Failed to process watched author ${authorAsin}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// Rate limit between authors
|
||||
await delay(SCRAPE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function processAuthorForUsers(
|
||||
authorAsin: string,
|
||||
subscriptions: Array<{ id: string; authorName: string; user: { id: string; plexUsername: string } }>,
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats
|
||||
): Promise<void> {
|
||||
const authorName = subscriptions[0].authorName;
|
||||
log.info(`Scraping watched author: "${authorName}" (${authorAsin})`);
|
||||
|
||||
const audibleService = getAudibleService();
|
||||
const allBooks: AudibleAudiobook[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore && page <= MAX_AUTHOR_PAGES) {
|
||||
try {
|
||||
const result = await audibleService.searchByAuthorAsin(authorName, authorAsin, page);
|
||||
if (result.books.length === 0) break;
|
||||
|
||||
allBooks.push(...result.books);
|
||||
hasMore = result.hasMore;
|
||||
page++;
|
||||
|
||||
if (hasMore) await delay(1000);
|
||||
} catch (error) {
|
||||
log.error(`Failed to scrape author page ${page} for "${authorName}"`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allBooks.length === 0) {
|
||||
log.info(`No books found for author "${authorName}"`);
|
||||
stats.authorsChecked++;
|
||||
return;
|
||||
}
|
||||
|
||||
stats.booksFound += allBooks.length;
|
||||
|
||||
// Deduplicate
|
||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(allBooks);
|
||||
|
||||
// Persist dedup groups (fire-and-forget)
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
// For each user watching this author, create requests for new books
|
||||
for (const subscription of subscriptions) {
|
||||
await createRequestsForUser(
|
||||
subscription.user.id,
|
||||
subscription.user.plexUsername,
|
||||
dedupedBooks,
|
||||
log,
|
||||
stats
|
||||
);
|
||||
|
||||
// Update lastCheckedAt
|
||||
await prisma.watchedAuthor.update({
|
||||
where: { id: subscription.id },
|
||||
data: { lastCheckedAt: new Date() },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
stats.authorsChecked++;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared: Create requests for a user from a list of books
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createRequestsForUser(
|
||||
userId: string,
|
||||
username: string,
|
||||
books: AudibleAudiobook[],
|
||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats
|
||||
): Promise<void> {
|
||||
// Filter to books that have an ASIN
|
||||
const booksWithAsin = books.filter(b => b.asin);
|
||||
if (booksWithAsin.length === 0) return;
|
||||
|
||||
// Batch check: which ASINs are already in library (direct + sibling expansion)
|
||||
const ownedAsins = await getOwnedAsins(booksWithAsin.map(b => b.asin));
|
||||
|
||||
for (const book of booksWithAsin) {
|
||||
// Skip if user already owns this (direct or via sibling ASIN)
|
||||
if (ownedAsins.has(book.asin)) {
|
||||
stats.skippedOwned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createRequestForUser(userId, {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator,
|
||||
description: book.description,
|
||||
coverArtUrl: book.coverArtUrl,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
stats.requestsCreated++;
|
||||
log.info(`Auto-requested "${book.title}" by ${book.author} for ${username}`);
|
||||
} else {
|
||||
// already_available, being_processed, duplicate — all expected
|
||||
stats.skippedExisting++;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Failed to create request for "${book.title}" for ${username}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of ASINs that are already in the library (direct match + sibling expansion).
|
||||
*/
|
||||
async function getOwnedAsins(asins: string[]): Promise<Set<string>> {
|
||||
const owned = new Set<string>();
|
||||
|
||||
// Direct library lookup
|
||||
const libraryItems = await prisma.plexLibrary.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: { asin: true },
|
||||
});
|
||||
for (const item of libraryItems) {
|
||||
if (item.asin) owned.add(item.asin);
|
||||
}
|
||||
|
||||
// Sibling expansion via works table
|
||||
try {
|
||||
const siblingMap = await getSiblingAsins(asins);
|
||||
if (siblingMap.size > 0) {
|
||||
const allSiblings = new Set<string>();
|
||||
for (const siblings of siblingMap.values()) {
|
||||
for (const s of siblings) allSiblings.add(s);
|
||||
}
|
||||
|
||||
if (allSiblings.size > 0) {
|
||||
const siblingLibrary = await prisma.plexLibrary.findMany({
|
||||
where: { asin: { in: [...allSiblings] } },
|
||||
select: { asin: true },
|
||||
});
|
||||
|
||||
for (const item of siblingLibrary) {
|
||||
if (item.asin) {
|
||||
// Mark the original ASIN as owned (not the sibling)
|
||||
for (const [originalAsin, siblings] of siblingMap) {
|
||||
if (siblings.includes(item.asin)) {
|
||||
owned.add(originalAsin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Works table expansion is best-effort
|
||||
}
|
||||
|
||||
return owned;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Component: Works Service
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Manages the works table — persistent cross-ASIN audiobook identity mapping.
|
||||
* Layer 1: Auto-populated from dedup logic when users browse search/author/series pages.
|
||||
* Layer 2: Seeded at request time to ensure requested ASINs are tracked.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
|
||||
|
||||
const logger = RMABLogger.create('WorksService');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer 1: Persist dedup groups (fire-and-forget from API routes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Persist dedup groups to the works table. For each group of 2+ ASINs that
|
||||
* were identified as the same audiobook, create or update a Work record
|
||||
* linking all ASINs together.
|
||||
*
|
||||
* Safe to call fire-and-forget — never throws.
|
||||
*/
|
||||
export async function persistDedupGroups(groups: DedupGroup[]): Promise<void> {
|
||||
try {
|
||||
for (const group of groups) {
|
||||
await persistSingleGroup(group);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist dedup groups', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
groupCount: groups.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a single dedup group. Handles merging when ASINs span multiple
|
||||
* existing works.
|
||||
*/
|
||||
async function persistSingleGroup(group: DedupGroup): Promise<void> {
|
||||
const { canonicalAsin, allAsins, title, author, narrator, durationMinutes } = group;
|
||||
|
||||
// Find which of these ASINs already exist in work_asins
|
||||
const existingEntries = await prisma.workAsin.findMany({
|
||||
where: { asin: { in: allAsins } },
|
||||
select: { asin: true, workId: true },
|
||||
});
|
||||
|
||||
// Collect unique work IDs that already contain any of our ASINs
|
||||
const existingWorkIds = [...new Set(existingEntries.map(e => e.workId))];
|
||||
const existingAsinSet = new Set(existingEntries.map(e => e.asin));
|
||||
|
||||
if (existingWorkIds.length === 0) {
|
||||
// No existing works — create a new one with all ASINs
|
||||
const work = await prisma.work.create({
|
||||
data: { title, author },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
allAsins.map(asin =>
|
||||
prisma.workAsin.create({
|
||||
data: {
|
||||
workId: work.id,
|
||||
asin,
|
||||
narrator: asin === canonicalAsin ? narrator : undefined,
|
||||
durationMinutes: asin === canonicalAsin ? durationMinutes : undefined,
|
||||
isCanonical: asin === canonicalAsin,
|
||||
source: 'dedup_auto',
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
logger.debug('Created new work', { workId: work.id, asinCount: allAsins.length });
|
||||
} else {
|
||||
// Use the first existing work as the target
|
||||
const targetWorkId = existingWorkIds[0];
|
||||
|
||||
// If multiple existing works, merge them into the target
|
||||
if (existingWorkIds.length > 1) {
|
||||
const mergeWorkIds = existingWorkIds.slice(1);
|
||||
|
||||
// Move all ASINs from other works to the target
|
||||
await prisma.workAsin.updateMany({
|
||||
where: { workId: { in: mergeWorkIds } },
|
||||
data: { workId: targetWorkId },
|
||||
});
|
||||
|
||||
// Delete the now-empty works
|
||||
await prisma.work.deleteMany({
|
||||
where: { id: { in: mergeWorkIds } },
|
||||
});
|
||||
|
||||
logger.debug('Merged works', {
|
||||
targetWorkId,
|
||||
mergedWorkIds: mergeWorkIds,
|
||||
});
|
||||
}
|
||||
|
||||
// Add any new ASINs that don't already exist
|
||||
const newAsins = allAsins.filter(a => !existingAsinSet.has(a));
|
||||
if (newAsins.length > 0) {
|
||||
await Promise.all(
|
||||
newAsins.map(asin =>
|
||||
prisma.workAsin.create({
|
||||
data: {
|
||||
workId: targetWorkId,
|
||||
asin,
|
||||
narrator: asin === canonicalAsin ? narrator : undefined,
|
||||
durationMinutes: asin === canonicalAsin ? durationMinutes : undefined,
|
||||
isCanonical: asin === canonicalAsin,
|
||||
source: 'dedup_auto',
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
logger.debug('Added ASINs to existing work', {
|
||||
workId: targetWorkId,
|
||||
newAsinCount: newAsins.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Update canonical status: ensure the canonical ASIN is marked
|
||||
await prisma.workAsin.updateMany({
|
||||
where: { workId: targetWorkId, asin: canonicalAsin },
|
||||
data: { isCanonical: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer 2: Seed ASIN at request time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ensure an ASIN is tracked in the works table. Creates a single-ASIN work
|
||||
* if the ASIN isn't already present. Called at request creation time.
|
||||
*
|
||||
* Safe to call fire-and-forget — never throws.
|
||||
*/
|
||||
export async function seedAsin(
|
||||
asin: string,
|
||||
title: string,
|
||||
author: string,
|
||||
narrator?: string,
|
||||
durationMinutes?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if ASIN already tracked
|
||||
const existing = await prisma.workAsin.findUnique({
|
||||
where: { asin },
|
||||
});
|
||||
if (existing) return;
|
||||
|
||||
// Create a new single-ASIN work
|
||||
const work = await prisma.work.create({
|
||||
data: { title, author },
|
||||
});
|
||||
|
||||
await prisma.workAsin.create({
|
||||
data: {
|
||||
workId: work.id,
|
||||
asin,
|
||||
narrator,
|
||||
durationMinutes,
|
||||
isCanonical: true,
|
||||
source: 'dedup_auto',
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('Seeded ASIN', { workId: work.id, asin });
|
||||
} catch (error) {
|
||||
logger.error('Failed to seed ASIN', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
asin,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sibling ASIN lookup (for library matching expansion)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Given a list of ASINs, return a map of each input ASIN to its sibling ASINs
|
||||
* (other ASINs in the same work, NOT including the input ASIN itself).
|
||||
*
|
||||
* ASINs not found in the works table are simply omitted from the result.
|
||||
*/
|
||||
export async function getSiblingAsins(
|
||||
asins: string[]
|
||||
): Promise<Map<string, string[]>> {
|
||||
const result = new Map<string, string[]>();
|
||||
if (asins.length === 0) return result;
|
||||
|
||||
// Step 1: Find which input ASINs are in work_asins and their work IDs
|
||||
const inputEntries = await prisma.workAsin.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: { asin: true, workId: true },
|
||||
});
|
||||
|
||||
if (inputEntries.length === 0) return result;
|
||||
|
||||
// Build map of workId -> input ASINs in that work
|
||||
const workIdToInputAsins = new Map<string, string[]>();
|
||||
for (const entry of inputEntries) {
|
||||
const list = workIdToInputAsins.get(entry.workId);
|
||||
if (list) {
|
||||
list.push(entry.asin);
|
||||
} else {
|
||||
workIdToInputAsins.set(entry.workId, [entry.asin]);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Get ALL ASINs in those works
|
||||
const workIds = [...workIdToInputAsins.keys()];
|
||||
const allWorkAsins = await prisma.workAsin.findMany({
|
||||
where: { workId: { in: workIds } },
|
||||
select: { asin: true, workId: true },
|
||||
});
|
||||
|
||||
// Build map of workId -> all ASINs
|
||||
const workIdToAllAsins = new Map<string, string[]>();
|
||||
for (const entry of allWorkAsins) {
|
||||
const list = workIdToAllAsins.get(entry.workId);
|
||||
if (list) {
|
||||
list.push(entry.asin);
|
||||
} else {
|
||||
workIdToAllAsins.set(entry.workId, [entry.asin]);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: For each input ASIN, compute siblings (all ASINs in same work minus self)
|
||||
for (const entry of inputEntries) {
|
||||
const allInWork = workIdToAllAsins.get(entry.workId) || [];
|
||||
const siblings = allInWork.filter(a => a !== entry.asin);
|
||||
if (siblings.length > 0) {
|
||||
result.set(entry.asin, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Component: API Token Type Definitions
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
/** Base API token as returned by user-facing endpoints */
|
||||
export interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
role: string;
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Extended API token with cross-user fields, returned by admin endpoints */
|
||||
export interface AdminApiToken extends ApiToken {
|
||||
createdBy: string;
|
||||
createdById: string;
|
||||
tokenUser: string;
|
||||
tokenUserId: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Component: API Token Generation Utility
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { API_TOKEN_PREFIX, TOKEN_RANDOM_BYTES, TOKEN_PREFIX_LENGTH } from '../constants/api-tokens';
|
||||
|
||||
interface GeneratedToken {
|
||||
/** The full token string to return to the user (shown only once) */
|
||||
fullToken: string;
|
||||
/** SHA-256 hash of the full token (stored in database) */
|
||||
tokenHash: string;
|
||||
/** Display prefix for identification (first 12 chars) */
|
||||
tokenPrefix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API token with its hash and display prefix.
|
||||
* The full token is: API_TOKEN_PREFIX + random hex string.
|
||||
* Only the hash is stored; the full token is returned once at creation.
|
||||
*/
|
||||
export function generateApiToken(): GeneratedToken {
|
||||
const randomPart = crypto.randomBytes(TOKEN_RANDOM_BYTES).toString('hex');
|
||||
const fullToken = `${API_TOKEN_PREFIX}${randomPart}`;
|
||||
const tokenHash = crypto.createHash('sha256').update(fullToken).digest('hex');
|
||||
const tokenPrefix = fullToken.substring(0, TOKEN_PREFIX_LENGTH);
|
||||
|
||||
return { fullToken, tokenHash, tokenPrefix };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Component: API Token Rate Limiting
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* In-memory sliding-window rate limiter with lazy eviction and periodic sweep
|
||||
* to prevent unbounded memory growth.
|
||||
*/
|
||||
|
||||
type Bucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
type RateLimitResult = {
|
||||
allowed: boolean;
|
||||
retryAfterSeconds: number;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
/** Number of checkRateLimit calls since the last full sweep */
|
||||
let checkCount = 0;
|
||||
|
||||
/** How often (in calls) to perform a full sweep of expired buckets */
|
||||
const SWEEP_INTERVAL = 100;
|
||||
|
||||
/**
|
||||
* Sweep the entire bucket map and delete all expired entries.
|
||||
* Called automatically every SWEEP_INTERVAL checks.
|
||||
*/
|
||||
function sweepExpiredBuckets(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (now >= bucket.resetAt) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
|
||||
const now = Date.now();
|
||||
|
||||
// Periodic full sweep every SWEEP_INTERVAL calls
|
||||
checkCount += 1;
|
||||
if (checkCount >= SWEEP_INTERVAL) {
|
||||
checkCount = 0;
|
||||
sweepExpiredBuckets();
|
||||
}
|
||||
|
||||
const current = buckets.get(key);
|
||||
|
||||
// Lazy eviction: if the bucket is expired, delete it and start fresh
|
||||
if (!current || now >= current.resetAt) {
|
||||
if (current) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) };
|
||||
}
|
||||
|
||||
if (current.count >= maxRequests) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
current.count += 1;
|
||||
return {
|
||||
allowed: true,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
|
||||
return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000);
|
||||
}
|
||||
|
||||
export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
|
||||
return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
|
||||
}
|
||||
|
||||
/** Reset all buckets and the sweep counter. For testing only. */
|
||||
export function _resetBuckets(): void {
|
||||
buckets.clear();
|
||||
checkCount = 0;
|
||||
}
|
||||
|
||||
/** Get the current number of tracked buckets. For testing only. */
|
||||
export function _getBucketCount(): number {
|
||||
return buckets.size;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { LibraryItem } from '@/lib/services/library';
|
||||
import { getSiblingAsins } from '@/lib/services/works.service';
|
||||
import { RMABLogger } from './logger';
|
||||
|
||||
// Module-level logger
|
||||
@@ -178,6 +179,61 @@ export async function enrichAudiobooksWithMatches(
|
||||
}
|
||||
}
|
||||
|
||||
// Works-table sibling expansion: check if unmatched ASINs have siblings in the library
|
||||
try {
|
||||
const unmatchedAsins = results.filter(r => !r.isAvailable).map(r => r.asin);
|
||||
if (unmatchedAsins.length > 0) {
|
||||
const siblingMap = await getSiblingAsins(unmatchedAsins);
|
||||
if (siblingMap.size > 0) {
|
||||
// Collect all sibling ASINs for a single batch library query
|
||||
const allSiblingAsins = new Set<string>();
|
||||
for (const siblings of siblingMap.values()) {
|
||||
for (const s of siblings) allSiblingAsins.add(s);
|
||||
}
|
||||
|
||||
if (allSiblingAsins.size > 0) {
|
||||
const siblingLibraryMatches = await prisma.plexLibrary.findMany({
|
||||
where: { asin: { in: [...allSiblingAsins] } },
|
||||
select: { asin: true, plexGuid: true },
|
||||
});
|
||||
const libraryAsinSet = new Set(
|
||||
siblingLibraryMatches.filter(m => m.asin).map(m => m.asin!.toLowerCase())
|
||||
);
|
||||
|
||||
// Update results where a sibling ASIN is found in the library
|
||||
for (const result of results) {
|
||||
if (result.isAvailable) continue;
|
||||
const siblings = siblingMap.get(result.asin);
|
||||
if (!siblings) continue;
|
||||
const matchedSiblingAsin = siblings.find(s => libraryAsinSet.has(s.toLowerCase()));
|
||||
if (matchedSiblingAsin) {
|
||||
const libMatch = siblingLibraryMatches.find(
|
||||
m => m.asin?.toLowerCase() === matchedSiblingAsin.toLowerCase()
|
||||
);
|
||||
(result as any).isAvailable = true;
|
||||
(result as any).plexGuid = libMatch?.plexGuid || null;
|
||||
}
|
||||
}
|
||||
|
||||
const siblingMatchCount = results.filter(r => {
|
||||
if (!r.isAvailable) return false;
|
||||
return siblingMap.has(r.asin);
|
||||
}).length;
|
||||
logger.debug('Sibling expansion', {
|
||||
unmatchedCount: unmatchedAsins.length,
|
||||
siblingGroupsFound: siblingMap.size,
|
||||
siblingMatches: siblingMatchCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Works table expansion is best-effort — direct matches still work
|
||||
logger.error('Sibling ASIN expansion failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// Always enrich with request status (check ANY user's requests)
|
||||
const asins = audiobooks.map(book => book.asin);
|
||||
|
||||
@@ -272,6 +328,57 @@ export async function enrichAudiobooksWithMatches(
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ASINs that are considered "available" — present in library or have completed requests.
|
||||
* Used by paginated API routes to exclude available items at the DB level.
|
||||
*/
|
||||
export async function getAvailableAsins(): Promise<Set<string>> {
|
||||
const [libraryItems, completedRequests] = await Promise.all([
|
||||
// ASINs present in the library (Plex or Audiobookshelf)
|
||||
prisma.plexLibrary.findMany({
|
||||
where: { asin: { not: null } },
|
||||
select: { asin: true },
|
||||
distinct: ['asin'],
|
||||
}),
|
||||
// ASINs with completed audiobook requests
|
||||
prisma.audiobook.findMany({
|
||||
where: {
|
||||
audibleAsin: { not: null },
|
||||
requests: {
|
||||
some: {
|
||||
status: 'completed',
|
||||
type: 'audiobook',
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { audibleAsin: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const asins = new Set<string>();
|
||||
for (const item of libraryItems) {
|
||||
if (item.asin) asins.add(item.asin);
|
||||
}
|
||||
for (const item of completedRequests) {
|
||||
if (item.audibleAsin) asins.add(item.audibleAsin);
|
||||
}
|
||||
|
||||
// Expand with works-table sibling ASINs
|
||||
try {
|
||||
if (asins.size > 0) {
|
||||
const siblingMap = await getSiblingAsins([...asins]);
|
||||
for (const siblings of siblingMap.values()) {
|
||||
for (const s of siblings) asins.add(s);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Works table expansion is best-effort
|
||||
}
|
||||
|
||||
return asins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize ISBN for comparison (remove dashes and spaces)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Component: Client-side URL Utilities
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the current instance origin URL.
|
||||
* Returns window.location.origin on the client, or a placeholder on the server.
|
||||
*/
|
||||
export function getInstanceUrl(): string {
|
||||
return typeof window !== 'undefined' ? window.location.origin : 'https://your-instance';
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Component: Connection Error Classification Utility
|
||||
* Documentation: documentation/phase3/README.md
|
||||
*
|
||||
* Classifies errors as transient connection failures (e.g. download client
|
||||
* restarting, network blip) vs permanent failures. Used by download
|
||||
* processors to decide whether to retry with backoff or fail immediately.
|
||||
*/
|
||||
|
||||
/** Node/Axios error codes that indicate the remote service is temporarily unreachable. */
|
||||
const TRANSIENT_ERROR_CODES = new Set([
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'ECONNABORTED',
|
||||
'ETIMEDOUT',
|
||||
'ENOTFOUND',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EPIPE',
|
||||
'EAI_AGAIN',
|
||||
]);
|
||||
|
||||
/** HTTP status codes that indicate a gateway / upstream service issue. */
|
||||
const TRANSIENT_HTTP_STATUSES = new Set([502, 503, 504]);
|
||||
|
||||
/**
|
||||
* Substrings in error messages that strongly indicate a connection-level
|
||||
* failure. Checked as a fallback when structured error properties are
|
||||
* unavailable (e.g. errors re-thrown as plain Error with a message string).
|
||||
*/
|
||||
const TRANSIENT_MESSAGE_PATTERNS = [
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'ECONNABORTED',
|
||||
'ETIMEDOUT',
|
||||
'ENOTFOUND',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EPIPE',
|
||||
'EAI_AGAIN',
|
||||
'connect ECONNREFUSED',
|
||||
'socket hang up',
|
||||
'network error',
|
||||
'Client network socket disconnected',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Returns `true` when the error looks like a transient connection failure
|
||||
* rather than a permanent / logical error.
|
||||
*
|
||||
* Checks (in order):
|
||||
* 1. `error.code` — Node.js / Axios error codes
|
||||
* 2. `error.response.status` — HTTP gateway errors (502/503/504)
|
||||
* 3. `error.message` — fallback substring matching
|
||||
*/
|
||||
export function isTransientConnectionError(error: unknown): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
// 1. Structured error code (Node.js / Axios)
|
||||
const code = (error as any)?.code;
|
||||
if (typeof code === 'string' && TRANSIENT_ERROR_CODES.has(code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. HTTP gateway status from Axios response
|
||||
const status = (error as any)?.response?.status;
|
||||
if (typeof status === 'number' && TRANSIENT_HTTP_STATUSES.has(status)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Fallback: substring match on the error message
|
||||
const message = (error instanceof Error ? error.message : String(error)).toUpperCase();
|
||||
for (const pattern of TRANSIENT_MESSAGE_PATTERNS) {
|
||||
if (message.includes(pattern.toUpperCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Component: Audiobook Deduplication Utility
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Deduplicates audiobook listings that represent the same recording
|
||||
* under different ASINs (publisher re-listings, rights transfers, etc.).
|
||||
*
|
||||
* Dedup key: normalized title + normalized narrator
|
||||
* Duration tolerance: max(longerDuration * 0.01, 5) minutes
|
||||
* Missing duration treated as compatible (graceful degradation).
|
||||
*/
|
||||
|
||||
import type { AudibleAudiobook } from '../integrations/audible.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Title / narrator normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Patterns in parentheses or brackets to strip (edition markers, format labels) */
|
||||
const EDITION_PAREN_RE = /[([][^)\]]*?(?:unabridged|abridged|edition|remaster(?:ed)?|anniversary|complete|original|version|narrat(?:ed|or)?|audio(?:book)?|full cast|dramatiz(?:ed|ation))[^)\]]*[)\]]/gi;
|
||||
|
||||
/** Trailing subtitle after colon or long dash */
|
||||
const SUBTITLE_RE = /\s*[:]\s+.+$/;
|
||||
const LONG_DASH_SUBTITLE_RE = /\s+[-\u2013\u2014]\s+.+$/;
|
||||
|
||||
/** Trailing descriptors like "A Novel", "A Memoir" */
|
||||
const TRAILING_DESCRIPTOR_RE = /\s*[-:,]?\s+a\s+(novel|memoir|thriller|mystery|romance|story|tale|novella)\s*$/i;
|
||||
|
||||
/**
|
||||
* Normalize a title for dedup comparison.
|
||||
* Strips subtitles, edition markers, and trailing descriptors.
|
||||
*/
|
||||
export function normalizeTitle(title: string): string {
|
||||
let t = title.toLowerCase();
|
||||
// Remove parenthesized/bracketed edition markers
|
||||
t = t.replace(EDITION_PAREN_RE, '');
|
||||
// Remove trailing descriptors before subtitle stripping
|
||||
t = t.replace(TRAILING_DESCRIPTOR_RE, '');
|
||||
// Remove subtitle after colon
|
||||
t = t.replace(SUBTITLE_RE, '');
|
||||
// Remove subtitle after long dash (but not short hyphenated words)
|
||||
t = t.replace(LONG_DASH_SUBTITLE_RE, '');
|
||||
// Collapse whitespace and trim
|
||||
return t.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Normalize narrator for comparison. Sorts individual names so order doesn't matter. */
|
||||
function normalizeNarrator(narrator?: string): string {
|
||||
const raw = (narrator || '').toLowerCase().trim();
|
||||
if (!raw) return raw;
|
||||
return raw.split(',').map(n => n.trim()).filter(Boolean).sort().join(', ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Duration compatibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if two durations are compatible (represent the same recording).
|
||||
* Tolerance: max(longerDuration * 0.01, 5) 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);
|
||||
return Math.abs(a - b) <= tolerance;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata scoring (for picking best representative)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function metadataScore(book: AudibleAudiobook): number {
|
||||
let score = 0;
|
||||
if (book.coverArtUrl) score++;
|
||||
if (book.rating != null) score++;
|
||||
if (book.durationMinutes != null) score++;
|
||||
if (book.description) score++;
|
||||
if (book.narrator) score++;
|
||||
if (book.releaseDate) score++;
|
||||
if (book.genres && book.genres.length > 0) score++;
|
||||
return score;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dedup group types (for works-table persistence)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Metadata about a group of ASINs that were collapsed during dedup. */
|
||||
export interface DedupGroup {
|
||||
canonicalAsin: string; // ASIN of the "winner" (best metadata score)
|
||||
allAsins: string[]; // All ASINs in this group (including canonical)
|
||||
title: string; // Author from the canonical entry
|
||||
author: string; // Author from the canonical entry
|
||||
narrator?: string; // Narrator from the canonical entry
|
||||
durationMinutes?: number; // Duration from the canonical entry
|
||||
}
|
||||
|
||||
/** Result of deduplication with group collection. */
|
||||
export interface DeduplicateResult {
|
||||
books: AudibleAudiobook[]; // The deduped list (same as deduplicateAudiobooks returns)
|
||||
groups: DedupGroup[]; // Groups where 2+ ASINs were collapsed
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main dedup functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deduplicate audiobook listings by normalized title + narrator + duration.
|
||||
*
|
||||
* Same narrator + compatible duration + similar title = same recording -> collapse.
|
||||
* Different narrator = different production -> keep both.
|
||||
* Duration outside tolerance = different content (abridged vs unabridged) -> keep both.
|
||||
*
|
||||
* Preserves original ordering (position of first appearance).
|
||||
*/
|
||||
export function deduplicateAudiobooks(books: AudibleAudiobook[]): AudibleAudiobook[] {
|
||||
return deduplicateAndCollectGroups(books).books;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate audiobooks AND return grouping metadata for works-table persistence.
|
||||
* Returns both the deduped list and the groups where 2+ ASINs were collapsed.
|
||||
*/
|
||||
export function deduplicateAndCollectGroups(books: AudibleAudiobook[]): DeduplicateResult {
|
||||
if (books.length <= 1) return { books: [...books], groups: [] };
|
||||
|
||||
// Group by normalized title + narrator
|
||||
const titleNarratorGroups = new Map<string, AudibleAudiobook[]>();
|
||||
const insertionOrder: string[] = [];
|
||||
|
||||
for (const book of books) {
|
||||
const key = `${normalizeTitle(book.title)}|||${normalizeNarrator(book.narrator)}`;
|
||||
const group = titleNarratorGroups.get(key);
|
||||
if (group) {
|
||||
group.push(book);
|
||||
} else {
|
||||
titleNarratorGroups.set(key, [book]);
|
||||
insertionOrder.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const result: AudibleAudiobook[] = [];
|
||||
const dedupGroups: DedupGroup[] = [];
|
||||
|
||||
for (const key of insertionOrder) {
|
||||
const group = titleNarratorGroups.get(key)!;
|
||||
if (group.length === 1) {
|
||||
result.push(group[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Within a title+narrator group, further split by duration compatibility.
|
||||
// Build sub-groups where all members are duration-compatible with the
|
||||
// representative (first member). A book joins the first compatible sub-group.
|
||||
const subGroups: AudibleAudiobook[][] = [];
|
||||
|
||||
for (const book of group) {
|
||||
let placed = false;
|
||||
for (const sg of subGroups) {
|
||||
// Check compatibility against the representative (first member)
|
||||
if (areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes)) {
|
||||
sg.push(book);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
subGroups.push([book]);
|
||||
}
|
||||
}
|
||||
|
||||
// From each sub-group, pick the best representative and collect group metadata
|
||||
for (const sg of subGroups) {
|
||||
let best = sg[0];
|
||||
let bestScore = metadataScore(best);
|
||||
for (let i = 1; i < sg.length; i++) {
|
||||
const score = metadataScore(sg[i]);
|
||||
if (score > bestScore) {
|
||||
best = sg[i];
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
result.push(best);
|
||||
|
||||
// Collect group metadata for works-table persistence (only multi-ASIN groups)
|
||||
if (sg.length >= 2) {
|
||||
dedupGroups.push({
|
||||
canonicalAsin: best.asin,
|
||||
allAsins: sg.map(b => b.asin),
|
||||
title: best.title,
|
||||
author: best.author,
|
||||
narrator: best.narrator,
|
||||
durationMinutes: best.durationMinutes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { books: result, groups: dedupGroups };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Component: Runtime Parsing Utility
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Shared runtime/duration text parser extracted from AudibleService.
|
||||
* Handles all i18n patterns (English, German, Spanish, French) via
|
||||
* language-specific regex patterns in LanguageConfig.
|
||||
*/
|
||||
|
||||
import type { LanguageConfig } from '../constants/language-config';
|
||||
|
||||
/**
|
||||
* Parse runtime text (e.g. "12 hrs and 30 mins", "5 Std. 20 Min.")
|
||||
* into total minutes using language-specific patterns.
|
||||
*
|
||||
* @param runtimeText - Raw runtime string from Audible HTML
|
||||
* @param langConfig - Language configuration with hour/minute regex patterns
|
||||
* @returns Total minutes, or undefined if no duration could be parsed
|
||||
*/
|
||||
export function parseRuntime(runtimeText: string, langConfig: LanguageConfig): number | undefined {
|
||||
if (!runtimeText) return undefined;
|
||||
|
||||
let totalMinutes = 0;
|
||||
|
||||
// Try each hour pattern until one matches
|
||||
for (const pattern of langConfig.scraping.runtimeHourPatterns) {
|
||||
const match = runtimeText.match(pattern);
|
||||
if (match) {
|
||||
totalMinutes += parseInt(match[1]) * 60;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try each minute pattern until one matches
|
||||
for (const pattern of langConfig.scraping.runtimeMinutePatterns) {
|
||||
const match = runtimeText.match(pattern);
|
||||
if (match) {
|
||||
totalMinutes += parseInt(match[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return totalMinutes > 0 ? totalMinutes : undefined;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Component: Admin API Tokens Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
// Valid UUIDs for testing
|
||||
const ADMIN_ID = '11111111-1111-1111-1111-111111111111';
|
||||
const USER_ID = '22222222-2222-2222-2222-222222222222';
|
||||
const ADMIN2_ID = '33333333-3333-3333-3333-333333333333';
|
||||
const NONEXISTENT_ID = '99999999-9999-9999-9999-999999999999';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const checkApiTokenCreateRateLimitMock = vi.hoisted(() => vi.fn());
|
||||
const generateApiTokenMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/apiTokenRateLimit', () => ({
|
||||
checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/api-token', () => ({
|
||||
generateApiToken: generateApiTokenMock,
|
||||
}));
|
||||
|
||||
describe('Admin API tokens routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = {
|
||||
user: { id: ADMIN_ID, username: 'admin', role: 'admin' },
|
||||
json: vi.fn(),
|
||||
};
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
checkApiTokenCreateRateLimitMock.mockReturnValue({ allowed: true });
|
||||
generateApiTokenMock.mockReturnValue({
|
||||
fullToken: 'rmab_test_full_token',
|
||||
tokenHash: 'hashed_token',
|
||||
tokenPrefix: 'rmab_test',
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/api-tokens', () => {
|
||||
it('creates token for self with own role when no userId specified', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({ name: 'Test Token' });
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: ADMIN_ID,
|
||||
role: 'admin',
|
||||
plexUsername: 'admin',
|
||||
});
|
||||
prismaMock.apiToken.count.mockResolvedValueOnce(0);
|
||||
prismaMock.apiToken.create.mockResolvedValueOnce({
|
||||
id: 'token-1',
|
||||
name: 'Test Token',
|
||||
tokenPrefix: 'rmab_test',
|
||||
role: 'admin',
|
||||
expiresAt: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.token.role).toBe('admin');
|
||||
expect(payload.fullToken).toBe('rmab_test_full_token');
|
||||
});
|
||||
|
||||
it('creates token for another user with their role', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({
|
||||
name: 'Token for User',
|
||||
userId: USER_ID,
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: USER_ID,
|
||||
role: 'user',
|
||||
plexUsername: 'regularuser',
|
||||
});
|
||||
prismaMock.apiToken.count.mockResolvedValueOnce(0);
|
||||
prismaMock.apiToken.create.mockResolvedValueOnce({
|
||||
id: 'token-2',
|
||||
name: 'Token for User',
|
||||
tokenPrefix: 'rmab_test',
|
||||
role: 'user',
|
||||
expiresAt: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.token.role).toBe('user');
|
||||
});
|
||||
|
||||
it('rejects role override when role differs from target user role', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({
|
||||
name: 'Escalation Attempt',
|
||||
userId: USER_ID,
|
||||
role: 'admin', // Trying to give admin role to a regular user
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: USER_ID,
|
||||
role: 'user', // Target user is actually a regular user
|
||||
plexUsername: 'regularuser',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain("must match target user's role");
|
||||
});
|
||||
|
||||
it('rejects role downgrade when role differs from target user role', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({
|
||||
name: 'Downgrade Attempt',
|
||||
userId: ADMIN2_ID,
|
||||
role: 'user', // Trying to give user role to an admin
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: ADMIN2_ID,
|
||||
role: 'admin', // Target user is actually an admin
|
||||
plexUsername: 'otheradmin',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain("must match target user's role");
|
||||
});
|
||||
|
||||
it('accepts role when it matches target user role', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({
|
||||
name: 'Matching Role',
|
||||
userId: USER_ID,
|
||||
role: 'user', // Explicitly specifying role that matches
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: USER_ID,
|
||||
role: 'user',
|
||||
plexUsername: 'regularuser',
|
||||
});
|
||||
prismaMock.apiToken.count.mockResolvedValueOnce(0);
|
||||
prismaMock.apiToken.create.mockResolvedValueOnce({
|
||||
id: 'token-3',
|
||||
name: 'Matching Role',
|
||||
tokenPrefix: 'rmab_test',
|
||||
role: 'user',
|
||||
expiresAt: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
|
||||
it('returns 404 when target user does not exist', async () => {
|
||||
authRequest.json.mockResolvedValueOnce({
|
||||
name: 'Token for Ghost',
|
||||
userId: NONEXISTENT_ID,
|
||||
});
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('Target user not found');
|
||||
});
|
||||
|
||||
it('returns 429 when rate limited', async () => {
|
||||
checkApiTokenCreateRateLimitMock.mockReturnValueOnce({
|
||||
allowed: false,
|
||||
retryAfterSeconds: 60,
|
||||
});
|
||||
|
||||
authRequest.json.mockResolvedValueOnce({ name: 'Rate Limited Token' });
|
||||
|
||||
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||
const response = await POST({} as any);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.headers.get('Retry-After')).toBe('60');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,10 @@ vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
|
||||
}) => (isOpen ? <div>Interactive search for {audiobook.title}</div> : null),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/admin/components/AdjustSearchTermsModal', () => ({
|
||||
AdjustSearchTermsModal: () => null,
|
||||
}));
|
||||
|
||||
describe('RequestActionsDropdown', () => {
|
||||
it('exposes manual search, interactive search, cancel, and delete actions', async () => {
|
||||
const onManualSearch = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -47,17 +47,22 @@ vi.mock('@/components/ui/CardSizeControls', () => ({
|
||||
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/StickyPagination', () => ({
|
||||
StickyPagination: ({
|
||||
label,
|
||||
onPageChange,
|
||||
vi.mock('@/components/ui/UnifiedPagination', () => ({
|
||||
UnifiedPagination: ({
|
||||
sections,
|
||||
}: {
|
||||
label: string;
|
||||
onPageChange: (page: number) => void;
|
||||
sections: Array<{
|
||||
label: string;
|
||||
onPageChange: (page: number) => void;
|
||||
}>;
|
||||
}) => (
|
||||
<button type="button" onClick={() => onPageChange(2)}>
|
||||
{label} next
|
||||
</button>
|
||||
<div>
|
||||
{sections.map((s) => (
|
||||
<button key={s.label} type="button" onClick={() => s.onPageChange(2)}>
|
||||
{s.label} next
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -113,7 +118,7 @@ describe('HomePage', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2);
|
||||
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,14 +11,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetMockAuthState } from '../helpers/mock-auth';
|
||||
import { resetMockRouter } from '../helpers/mock-next-navigation';
|
||||
|
||||
const loadMoreMock = vi.hoisted(() => vi.fn());
|
||||
const useSearchMock = vi.hoisted(() => vi.fn());
|
||||
const usePreferencesMock = vi.hoisted(() => ({
|
||||
cardSize: 5,
|
||||
setCardSize: vi.fn(),
|
||||
squareCovers: false,
|
||||
setSquareCovers: vi.fn(),
|
||||
hideAvailable: false,
|
||||
setHideAvailable: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/useAudiobooks', () => ({
|
||||
useSearch: useSearchMock,
|
||||
Audiobook: {},
|
||||
}));
|
||||
|
||||
vi.mock('@/contexts/PreferencesContext', () => ({
|
||||
@@ -49,8 +55,30 @@ vi.mock('@/components/audiobooks/AudiobookGrid', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/CardSizeControls', () => ({
|
||||
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
|
||||
vi.mock('@/components/ui/SectionToolbar', () => ({
|
||||
SectionToolbar: () => <div data-testid="section-toolbar" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/LoadMoreBar', () => ({
|
||||
LoadMoreBar: ({
|
||||
hasMore,
|
||||
isLoading,
|
||||
onLoadMore,
|
||||
}: {
|
||||
loadedCount: number;
|
||||
totalCount?: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
onLoadMore: () => void;
|
||||
itemLabel?: string;
|
||||
}) =>
|
||||
hasMore ? (
|
||||
<button onClick={onLoadMore} disabled={isLoading}>
|
||||
Load more
|
||||
</button>
|
||||
) : (
|
||||
<div data-testid="all-loaded">All loaded</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('SearchPage', () => {
|
||||
@@ -58,6 +86,7 @@ describe('SearchPage', () => {
|
||||
resetMockAuthState();
|
||||
resetMockRouter();
|
||||
useSearchMock.mockReset();
|
||||
loadMoreMock.mockReset();
|
||||
usePreferencesMock.cardSize = 5;
|
||||
usePreferencesMock.setCardSize.mockReset();
|
||||
vi.useFakeTimers();
|
||||
@@ -74,34 +103,25 @@ describe('SearchPage', () => {
|
||||
totalResults: 0,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
loadMore: loadMoreMock,
|
||||
});
|
||||
|
||||
const { default: SearchPage } = await import('@/app/search/page');
|
||||
render(<SearchPage />);
|
||||
|
||||
expect(screen.getByText('Start typing to search for audiobooks')).toBeInTheDocument();
|
||||
expect(useSearchMock).toHaveBeenCalledWith('', 1);
|
||||
expect(useSearchMock).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('debounces search input and loads more results', async () => {
|
||||
useSearchMock.mockImplementation((query: string, page: number) => {
|
||||
if (!query) {
|
||||
return { results: [], totalResults: 0, hasMore: false, isLoading: false };
|
||||
}
|
||||
if (page === 1) {
|
||||
return {
|
||||
results: [{ asin: 'a1', title: 'Book One', author: 'Author' }],
|
||||
totalResults: 2,
|
||||
hasMore: true,
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
results: [{ asin: 'a2', title: 'Book Two', author: 'Author' }],
|
||||
totalResults: 2,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
};
|
||||
useSearchMock.mockReturnValue({
|
||||
results: [{ asin: 'a1', title: 'Book One', author: 'Author' }],
|
||||
totalResults: 2,
|
||||
hasMore: true,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
loadMore: loadMoreMock,
|
||||
});
|
||||
|
||||
const { default: SearchPage } = await import('@/app/search/page');
|
||||
@@ -115,11 +135,11 @@ describe('SearchPage', () => {
|
||||
});
|
||||
|
||||
expect(screen.getByText('Search Results')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Load More Results' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Load more' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('grid')).toHaveAttribute('data-count', '1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Load More Results' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Load more' }));
|
||||
|
||||
expect(useSearchMock).toHaveBeenCalledWith('Dune', 2);
|
||||
expect(loadMoreMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Component: Sticky Pagination Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { StickyPagination } from '@/components/ui/StickyPagination';
|
||||
|
||||
type ObserverEntry = {
|
||||
isIntersecting: boolean;
|
||||
intersectionRatio: number;
|
||||
target: Element;
|
||||
};
|
||||
|
||||
describe('StickyPagination', () => {
|
||||
const observers: { callback: IntersectionObserverCallback }[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
observers.length = 0;
|
||||
class MockIntersectionObserver {
|
||||
callback: IntersectionObserverCallback;
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
takeRecords = vi.fn();
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback;
|
||||
observers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
(global as any).IntersectionObserver = MockIntersectionObserver;
|
||||
});
|
||||
|
||||
it('returns null when there is only one page', () => {
|
||||
const sectionRef = { current: document.createElement('div') };
|
||||
const { container } = render(
|
||||
<StickyPagination
|
||||
currentPage={1}
|
||||
totalPages={1}
|
||||
onPageChange={vi.fn()}
|
||||
sectionRef={sectionRef}
|
||||
label="Popular"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('shows and hides based on section and footer visibility', () => {
|
||||
const sectionRef = { current: document.createElement('div') };
|
||||
const footerRef = { current: document.createElement('div') };
|
||||
|
||||
const { container } = render(
|
||||
<StickyPagination
|
||||
currentPage={2}
|
||||
totalPages={5}
|
||||
onPageChange={vi.fn()}
|
||||
sectionRef={sectionRef}
|
||||
footerRef={footerRef}
|
||||
label="Popular"
|
||||
/>
|
||||
);
|
||||
|
||||
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||
expect(root).toHaveClass('opacity-0');
|
||||
|
||||
act(() => {
|
||||
observers[0].callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 0.2,
|
||||
target: sectionRef.current as Element,
|
||||
} as ObserverEntry,
|
||||
],
|
||||
observers[0] as unknown as IntersectionObserver
|
||||
);
|
||||
});
|
||||
|
||||
expect(root).toHaveClass('opacity-100');
|
||||
|
||||
act(() => {
|
||||
observers[1].callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 0.2,
|
||||
target: footerRef.current as Element,
|
||||
} as ObserverEntry,
|
||||
],
|
||||
observers[1] as unknown as IntersectionObserver
|
||||
);
|
||||
});
|
||||
|
||||
expect(root).toHaveClass('opacity-0');
|
||||
});
|
||||
|
||||
it('handles navigation and jump input updates', () => {
|
||||
const sectionRef = { current: document.createElement('div') };
|
||||
const onPageChange = vi.fn();
|
||||
|
||||
render(
|
||||
<StickyPagination
|
||||
currentPage={2}
|
||||
totalPages={4}
|
||||
onPageChange={onPageChange}
|
||||
sectionRef={sectionRef}
|
||||
label="Popular"
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Next page'));
|
||||
expect(onPageChange).toHaveBeenCalledWith(3);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Previous page'));
|
||||
expect(onPageChange).toHaveBeenCalledWith(1);
|
||||
|
||||
const input = screen.getByLabelText('Current page') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: '4' } });
|
||||
fireEvent.blur(input);
|
||||
expect(onPageChange).toHaveBeenCalledWith(4);
|
||||
|
||||
fireEvent.change(input, { target: { value: '99' } });
|
||||
fireEvent.blur(input);
|
||||
expect(input.value).toBe('2');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user