mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-18 04:00: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",
|
"name": "readmeabook",
|
||||||
"version": "1.0.15",
|
"version": "1.0.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"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[]
|
goodreadsShelves GoodreadsShelf[]
|
||||||
reportedIssues ReportedIssue[] @relation("Reporter")
|
reportedIssues ReportedIssue[] @relation("Reporter")
|
||||||
resolvedIssues ReportedIssue[] @relation("Resolver")
|
resolvedIssues ReportedIssue[] @relation("Resolver")
|
||||||
|
createdApiTokens ApiToken[] @relation("CreatedApiTokens")
|
||||||
|
apiTokens ApiToken[] @relation("UserApiTokens")
|
||||||
|
watchedSeries WatchedSeries[]
|
||||||
|
watchedAuthors WatchedAuthor[]
|
||||||
|
|
||||||
@@index([plexId])
|
@@index([plexId])
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@ -232,6 +236,7 @@ model Request {
|
|||||||
importAttempts Int @default(0) @map("import_attempts")
|
importAttempts Int @default(0) @map("import_attempts")
|
||||||
maxImportRetries Int @default(5) @map("max_import_retries")
|
maxImportRetries Int @default(5) @map("max_import_retries")
|
||||||
lastSearchAt DateTime? @map("last_search_at")
|
lastSearchAt DateTime? @map("last_search_at")
|
||||||
|
customSearchTerms String? @map("custom_search_terms") @db.Text
|
||||||
lastImportAt DateTime? @map("last_import_at")
|
lastImportAt DateTime? @map("last_import_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@@ -391,7 +396,7 @@ model ScheduledJob {
|
|||||||
|
|
||||||
model BookDateConfig {
|
model BookDateConfig {
|
||||||
id String @id @default(uuid())
|
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)
|
apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256)
|
||||||
model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929'
|
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)
|
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
|
// 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 {
|
model GoodreadsShelf {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
@@ -530,3 +563,87 @@ model GoodreadsBookMapping {
|
|||||||
@@index([audibleAsin])
|
@@index([audibleAsin])
|
||||||
@@map("goodreads_book_mappings")
|
@@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
|
* Component: Confirm Dialog
|
||||||
* Documentation: documentation/frontend/components.md
|
* 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';
|
'use client';
|
||||||
|
|
||||||
import { Fragment } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
export interface ConfirmDialogProps {
|
export interface ConfirmDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -30,99 +31,177 @@ export function ConfirmDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: ConfirmDialogProps) {
|
}: 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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const confirmButtonClasses =
|
const isDestructive = confirmVariant === 'danger';
|
||||||
confirmVariant === 'danger'
|
|
||||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
|
||||||
: 'bg-blue-600 hover:bg-blue-700 text-white';
|
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Backdrop */}
|
||||||
<div
|
<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}
|
onClick={onCancel}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dialog */}
|
{/* Panel */}
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
<div
|
||||||
<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">
|
ref={dialogRef}
|
||||||
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
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"
|
||||||
<div className="sm:flex sm:items-start">
|
>
|
||||||
{/* Icon */}
|
{/* Header */}
|
||||||
<div
|
<div className="px-6 pt-6 pb-4">
|
||||||
className={`mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${
|
<div className="flex items-start gap-4">
|
||||||
confirmVariant === 'danger'
|
{/* Icon well */}
|
||||||
? 'bg-red-100 dark:bg-red-900'
|
<div className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full ${
|
||||||
: 'bg-blue-100 dark:bg-blue-900'
|
isDestructive
|
||||||
} sm:mx-0 sm:h-10 sm:w-10`}
|
? 'bg-red-50 dark:bg-red-500/10'
|
||||||
>
|
: 'bg-blue-50 dark:bg-blue-500/10'
|
||||||
|
}`}>
|
||||||
|
{isDestructive ? (
|
||||||
<svg
|
<svg
|
||||||
className={`h-6 w-6 ${
|
className="w-5 h-5 text-red-500 dark:text-red-400"
|
||||||
confirmVariant === 'danger'
|
|
||||||
? 'text-red-600 dark:text-red-400'
|
|
||||||
: 'text-blue-600 dark:text-blue-400'
|
|
||||||
}`}
|
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.75"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{confirmVariant === 'danger' ? (
|
<path
|
||||||
<path
|
strokeLinecap="round"
|
||||||
strokeLinecap="round"
|
strokeLinejoin="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"
|
||||||
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"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
</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 */}
|
{/* Text */}
|
||||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left flex-1">
|
<div className="flex-1 min-w-0 pt-0.5">
|
||||||
<h3 className="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
<h3
|
||||||
{title}
|
id="confirm-dialog-title"
|
||||||
</h3>
|
className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-50"
|
||||||
<div className="mt-2">
|
>
|
||||||
{typeof message === 'string' ? (
|
{title}
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-pre-line">
|
</h3>
|
||||||
{message}
|
<div id="confirm-dialog-desc" className="mt-1.5">
|
||||||
</p>
|
{typeof message === 'string' ? (
|
||||||
) : (
|
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
{message}
|
||||||
{message}
|
</p>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||||
</div>
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Action bar */}
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
|
<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
|
<button
|
||||||
type="button"
|
ref={cancelRef}
|
||||||
onClick={onConfirm}
|
type="button"
|
||||||
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}`}
|
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"
|
||||||
{confirmLabel}
|
>
|
||||||
</button>
|
{cancelLabel}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
onClick={onCancel}
|
ref={confirmRef}
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={onConfirm}
|
||||||
{cancelLabel}
|
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] ${
|
||||||
</button>
|
isDestructive
|
||||||
</div>
|
? '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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ interface RecentRequest {
|
|||||||
completedAt: Date | null;
|
completedAt: Date | null;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
torrentUrl?: string | null;
|
torrentUrl?: string | null;
|
||||||
|
downloadAttempts?: number;
|
||||||
|
customSearchTerms?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
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
|
// Render loading state
|
||||||
if (isLoading && !data) {
|
if (isLoading && !data) {
|
||||||
return (
|
return (
|
||||||
@@ -638,6 +663,17 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
|||||||
Ebook
|
Ebook
|
||||||
</span>
|
</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>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{request.author}
|
{request.author}
|
||||||
@@ -673,12 +709,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
|||||||
type: request.type,
|
type: request.type,
|
||||||
asin: request.asin,
|
asin: request.asin,
|
||||||
torrentUrl: request.torrentUrl,
|
torrentUrl: request.torrentUrl,
|
||||||
|
downloadAttempts: request.downloadAttempts,
|
||||||
|
customSearchTerms: request.customSearchTerms,
|
||||||
}}
|
}}
|
||||||
onDelete={handleDeleteClick}
|
onDelete={handleDeleteClick}
|
||||||
onManualSearch={handleManualSearch}
|
onManualSearch={handleManualSearch}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
|
onRetryDownload={handleRetryDownload}
|
||||||
onViewDetails={(asin) => handleViewDetails(asin, request.status)}
|
onViewDetails={(asin) => handleViewDetails(asin, request.status)}
|
||||||
onFetchEbook={handleFetchEbook}
|
onFetchEbook={handleFetchEbook}
|
||||||
|
onSearchTermsUpdated={() => mutate(apiUrl)}
|
||||||
ebookSidecarEnabled={ebookSidecarEnabled}
|
ebookSidecarEnabled={ebookSidecarEnabled}
|
||||||
annasArchiveBaseUrl={annasArchiveBaseUrl}
|
annasArchiveBaseUrl={annasArchiveBaseUrl}
|
||||||
isLoading={isDeleting || isFetchingEbook}
|
isLoading={isDeleting || isFetchingEbook}
|
||||||
@@ -835,7 +875,6 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
|||||||
}}
|
}}
|
||||||
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
|
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
|
||||||
requestStatus={viewDetailsStatus}
|
requestStatus={viewDetailsStatus}
|
||||||
hideRequestActions
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||||
|
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
|
||||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||||
|
|
||||||
export interface RequestActionsDropdownProps {
|
export interface RequestActionsDropdownProps {
|
||||||
@@ -21,12 +22,16 @@ export interface RequestActionsDropdownProps {
|
|||||||
type?: 'audiobook' | 'ebook';
|
type?: 'audiobook' | 'ebook';
|
||||||
asin?: string | null;
|
asin?: string | null;
|
||||||
torrentUrl?: string | null;
|
torrentUrl?: string | null;
|
||||||
|
downloadAttempts?: number;
|
||||||
|
customSearchTerms?: string | null;
|
||||||
};
|
};
|
||||||
onDelete: (requestId: string, title: string) => void;
|
onDelete: (requestId: string, title: string) => void;
|
||||||
onManualSearch: (requestId: string) => Promise<void>;
|
onManualSearch: (requestId: string) => Promise<void>;
|
||||||
onCancel: (requestId: string) => Promise<void>;
|
onCancel: (requestId: string) => Promise<void>;
|
||||||
|
onRetryDownload?: (requestId: string) => Promise<void>;
|
||||||
onViewDetails?: (asin: string) => void;
|
onViewDetails?: (asin: string) => void;
|
||||||
onFetchEbook?: (requestId: string) => Promise<void>;
|
onFetchEbook?: (requestId: string) => Promise<void>;
|
||||||
|
onSearchTermsUpdated?: () => void;
|
||||||
ebookSidecarEnabled?: boolean;
|
ebookSidecarEnabled?: boolean;
|
||||||
annasArchiveBaseUrl?: string;
|
annasArchiveBaseUrl?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
@@ -37,8 +42,10 @@ export function RequestActionsDropdown({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onManualSearch,
|
onManualSearch,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onRetryDownload,
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
onFetchEbook,
|
onFetchEbook,
|
||||||
|
onSearchTermsUpdated,
|
||||||
ebookSidecarEnabled = false,
|
ebookSidecarEnabled = false,
|
||||||
annasArchiveBaseUrl = 'https://annas-archive.li',
|
annasArchiveBaseUrl = 'https://annas-archive.li',
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -46,6 +53,7 @@ export function RequestActionsDropdown({
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||||
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
||||||
|
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
|
||||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
|
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
|
||||||
|
|
||||||
// Determine request type
|
// Determine request type
|
||||||
@@ -57,6 +65,8 @@ export function RequestActionsDropdown({
|
|||||||
// Determine available actions based on status and type
|
// Determine available actions based on status and type
|
||||||
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
||||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
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 canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||||
const canDelete = true; // Admins can always delete
|
const canDelete = true; // Admins can always delete
|
||||||
|
|
||||||
@@ -123,11 +133,27 @@ export function RequestActionsDropdown({
|
|||||||
setShowInteractiveSearch(true);
|
setShowInteractiveSearch(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAdjustSearchTerms = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setShowAdjustSearchTerms(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleInteractiveSearchEbook = () => {
|
const handleInteractiveSearchEbook = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setShowInteractiveSearchEbook(true);
|
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 () => {
|
const handleCancel = async () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
|
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
|
||||||
@@ -253,6 +279,35 @@ export function RequestActionsDropdown({
|
|||||||
</button>
|
</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 */}
|
{/* View Source */}
|
||||||
{canViewSource && viewSourceUrl && (
|
{canViewSource && viewSourceUrl && (
|
||||||
<a
|
<a
|
||||||
@@ -328,8 +383,32 @@ export function RequestActionsDropdown({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Divider if we have search/view actions and other actions */}
|
{/* Retry Download */}
|
||||||
{(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && (
|
{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" />
|
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -358,7 +437,7 @@ export function RequestActionsDropdown({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Divider before delete */}
|
{/* Divider before delete */}
|
||||||
{canDelete && (canSearch || canCancel) && (
|
{canDelete && (canSearch || canRetryDownload || canCancel) && (
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -421,6 +500,7 @@ export function RequestActionsDropdown({
|
|||||||
title: request.title,
|
title: request.title,
|
||||||
author: request.author,
|
author: request.author,
|
||||||
}}
|
}}
|
||||||
|
customSearchTerms={request.customSearchTerms}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Interactive Search Modal (Ebook) */}
|
{/* Interactive Search Modal (Ebook) */}
|
||||||
@@ -434,6 +514,17 @@ export function RequestActionsDropdown({
|
|||||||
}}
|
}}
|
||||||
searchMode="ebook"
|
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;
|
return validated.paths;
|
||||||
case 'ebook':
|
case 'ebook':
|
||||||
case 'bookdate':
|
case 'bookdate':
|
||||||
|
case 'api':
|
||||||
return true; // These tabs handle their own saving
|
return true; // These tabs handle their own saving
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
@@ -228,4 +229,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [
|
|||||||
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
|
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
|
||||||
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
|
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
|
||||||
{ id: 'notifications' as const, label: 'Notifications', 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
|
* 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 { EbookTab } from './tabs/EbookTab/EbookTab';
|
||||||
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
|
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
|
||||||
import { NotificationsTab } from './tabs/NotificationsTab';
|
import { NotificationsTab } from './tabs/NotificationsTab';
|
||||||
|
import { ApiTab } from './tabs/ApiTab/ApiTab';
|
||||||
|
|
||||||
// Types and Helpers
|
// Types and Helpers
|
||||||
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
|
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
|
||||||
@@ -346,8 +347,11 @@ export default function AdminSettings() {
|
|||||||
{/* Notifications Tab */}
|
{/* Notifications Tab */}
|
||||||
{activeTab === 'notifications' && <NotificationsTab />}
|
{activeTab === 'notifications' && <NotificationsTab />}
|
||||||
|
|
||||||
|
{/* API Tab */}
|
||||||
|
{activeTab === 'api' && <ApiTab />}
|
||||||
|
|
||||||
{/* Save Button (only for tabs that save through main page) */}
|
{/* 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">
|
<div className="mt-8 flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={saveSettings}
|
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="openai">OpenAI</option>
|
||||||
<option value="claude">Claude (Anthropic)</option>
|
<option value="claude">Claude (Anthropic)</option>
|
||||||
|
<option value="gemini">Google Gemini</option>
|
||||||
<option value="custom">Custom (OpenAI-compatible)</option>
|
<option value="custom">Custom (OpenAI-compatible)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,7 +137,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
|
|||||||
? 'Leave blank for local models'
|
? 'Leave blank for local models'
|
||||||
: configured
|
: 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">
|
<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 { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||||
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.ManualImport');
|
const logger = RMABLogger.create('API.Admin.ManualImport');
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const fs = await import('fs/promises');
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { folderPath, asin } = body;
|
const { folderPath, asin, cleanupSource } = body;
|
||||||
let { audiobookId } = body;
|
let { audiobookId } = body;
|
||||||
|
|
||||||
// Validate required fields
|
// 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
|
// Check for existing requests
|
||||||
const existingRequest = await prisma.request.findFirst({
|
const existingRequest = await prisma.request.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -242,7 +285,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Queue organize_files job
|
// Queue organize_files job
|
||||||
const jobQueue = getJobQueueService();
|
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}`);
|
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,
|
completedAt: request.completedAt,
|
||||||
errorMessage: request.errorMessage,
|
errorMessage: request.errorMessage,
|
||||||
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
|
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
|
||||||
|
downloadAttempts: request.downloadAttempts,
|
||||||
|
customSearchTerms: request.customSearchTerms || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/db';
|
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 { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||||
|
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||||
|
|
||||||
// Validate pagination parameters
|
// Validate pagination parameters
|
||||||
if (page < 1 || limit < 1 || limit > 100) {
|
if (page < 1 || limit < 1 || limit > 100) {
|
||||||
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const skip = (page - 1) * limit;
|
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
|
// Query audible_cache for new release audiobooks
|
||||||
const [audiobooks, totalCount] = await Promise.all([
|
const [audiobooks, totalCount] = await Promise.all([
|
||||||
prisma.audibleCache.findMany({
|
prisma.audibleCache.findMany({
|
||||||
where: {
|
where: whereClause,
|
||||||
isNewRelease: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
orderBy: {
|
||||||
newReleaseRank: 'asc',
|
newReleaseRank: 'asc',
|
||||||
},
|
},
|
||||||
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.audibleCache.count({
|
prisma.audibleCache.count({
|
||||||
where: {
|
where: whereClause,
|
||||||
isNewRelease: true,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/db';
|
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 { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||||
|
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||||
|
|
||||||
// Validate pagination parameters
|
// Validate pagination parameters
|
||||||
if (page < 1 || limit < 1 || limit > 100) {
|
if (page < 1 || limit < 1 || limit > 100) {
|
||||||
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const skip = (page - 1) * limit;
|
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
|
// Query audible_cache for popular audiobooks
|
||||||
const [audiobooks, totalCount] = await Promise.all([
|
const [audiobooks, totalCount] = await Promise.all([
|
||||||
prisma.audibleCache.findMany({
|
prisma.audibleCache.findMany({
|
||||||
where: {
|
where: whereClause,
|
||||||
isPopular: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
orderBy: {
|
||||||
popularRank: 'asc',
|
popularRank: 'asc',
|
||||||
},
|
},
|
||||||
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.audibleCache.count({
|
prisma.audibleCache.count({
|
||||||
where: {
|
where: whereClause,
|
||||||
isPopular: true,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
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 { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
@@ -38,14 +40,22 @@ export async function GET(request: NextRequest) {
|
|||||||
const currentUser = getCurrentUser(request);
|
const currentUser = getCurrentUser(request);
|
||||||
const userId = currentUser?.sub || undefined;
|
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
|
// 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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
query: results.query,
|
query: results.query,
|
||||||
results: enrichedResults,
|
results: enrichedResults,
|
||||||
totalResults: results.totalResults,
|
totalResults: enrichedResults.length,
|
||||||
page: results.page,
|
page: results.page,
|
||||||
hasMore: results.hasMore,
|
hasMore: results.hasMore,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedUsername = username.trim().toLowerCase();
|
||||||
|
|
||||||
// Find user by local admin identifier
|
// Find user by local admin identifier
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { plexId: `local-${username}` },
|
where: { plexId: `local-${normalizedUsername}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
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 { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
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 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
|
// Enrich with library availability and request status
|
||||||
const userId = currentUser.sub || undefined;
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -63,6 +75,8 @@ export async function GET(
|
|||||||
authorName: authorName.trim(),
|
authorName: authorName.trim(),
|
||||||
authorAsin: asin,
|
authorAsin: asin,
|
||||||
totalBooks: enrichedBooks.length,
|
totalBooks: enrichedBooks.length,
|
||||||
|
hasMore: result.hasMore,
|
||||||
|
page: result.page,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(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(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ async function saveConfig(req: AuthenticatedRequest) {
|
|||||||
// No new API key, use existing one
|
// No new API key, use existing one
|
||||||
encryptedApiKeyToUse = existingConfig.apiKey;
|
encryptedApiKeyToUse = existingConfig.apiKey;
|
||||||
} else {
|
} else {
|
||||||
// API key required for OpenAI/Claude
|
// API key required for OpenAI/Claude/Gemini
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'API key is required' },
|
{ error: 'API key is required' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
|
|||||||
@@ -52,6 +52,30 @@ async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: st
|
|||||||
return allModels;
|
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
|
// Helper functions for custom provider
|
||||||
function isValidBaseUrl(url: string): boolean {
|
function isValidBaseUrl(url: string): boolean {
|
||||||
try {
|
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(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -193,6 +217,16 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
{ status: 400 }
|
{ 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') {
|
} else if (provider === 'custom') {
|
||||||
// Custom: Fetch models from custom OpenAI-compatible endpoint
|
// Custom: Fetch models from custom OpenAI-compatible endpoint
|
||||||
const normalizedUrl = normalizeBaseUrl(testBaseUrl);
|
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(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -363,6 +397,16 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
{ status: 400 }
|
{ 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') {
|
} else if (provider === 'custom') {
|
||||||
// Custom: Fetch models from custom OpenAI-compatible endpoint
|
// Custom: Fetch models from custom OpenAI-compatible endpoint
|
||||||
const normalizedUrl = normalizeBaseUrl(baseUrl);
|
const normalizedUrl = normalizeBaseUrl(baseUrl);
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ export async function POST(
|
|||||||
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
|
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use custom title if provided, otherwise use audiobook's title
|
// Use custom title if provided, then custom search terms, then audiobook's title
|
||||||
const searchTitle = customTitle || requestRecord.audiobook.title;
|
const searchTitle = customTitle || requestRecord.customSearchTerms || requestRecord.audiobook.title;
|
||||||
const searchAuthor = requestRecord.audiobook.author;
|
const searchAuthor = requestRecord.audiobook.author;
|
||||||
|
|
||||||
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle });
|
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 { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
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');
|
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) {
|
if (!detail) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'NotFound', message: 'Series not found' },
|
{ 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
|
// Enrich books with library availability and request status
|
||||||
const userId = currentUser.sub || undefined;
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -59,6 +71,8 @@ export async function GET(
|
|||||||
...detail,
|
...detail,
|
||||||
books: enrichedBooks,
|
books: enrichedBooks,
|
||||||
},
|
},
|
||||||
|
hasMore: detail.hasMore,
|
||||||
|
page: detail.page,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch series detail', {
|
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 hashedPassword = await bcrypt.hash(admin.password, 10);
|
||||||
const encryptionService = getEncryptionService();
|
const encryptionService = getEncryptionService();
|
||||||
const encryptedPassword = encryptionService.encrypt(hashedPassword);
|
const encryptedPassword = encryptionService.encrypt(hashedPassword);
|
||||||
|
|
||||||
adminUser = await prisma.user.create({
|
adminUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
plexId: `local-${admin.username}`,
|
plexId: `local-${normalizedAdminUsername}`,
|
||||||
plexUsername: admin.username,
|
plexUsername: normalizedAdminUsername,
|
||||||
plexEmail: null,
|
plexEmail: null,
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
isSetupAdmin: true, // Mark as setup admin - role cannot be changed
|
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';
|
'use client';
|
||||||
|
|
||||||
import { use, useCallback } from 'react';
|
import { use, useCallback, useMemo } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||||
|
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
|
||||||
import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard';
|
import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard';
|
||||||
import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow';
|
import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow';
|
||||||
import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors';
|
import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors';
|
||||||
|
import { Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
|
|
||||||
export default function AuthorDetailPage({
|
export default function AuthorDetailPage({
|
||||||
@@ -27,11 +28,11 @@ export default function AuthorDetailPage({
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const fromAuthorName = searchParams.get('from');
|
const fromAuthorName = searchParams.get('from');
|
||||||
const { author, isLoading: authorLoading } = useAuthorDetail(asin);
|
const { author, isLoading: authorLoading } = useAuthorDetail(asin);
|
||||||
const { books, totalBooks, isLoading: booksLoading } = useAuthorBooks(
|
const { books, totalBooks, hasMore, isLoading: booksLoading, isLoadingMore, loadMore } = useAuthorBooks(
|
||||||
asin,
|
asin,
|
||||||
author?.name || null
|
author?.name || null
|
||||||
);
|
);
|
||||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
// Use browser back if we came from within the app, otherwise fallback to /authors
|
// Use browser back if we came from within the app, otherwise fallback to /authors
|
||||||
@@ -42,6 +43,20 @@ export default function AuthorDetailPage({
|
|||||||
}
|
}
|
||||||
}, [router]);
|
}, [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 (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<div className="min-h-screen">
|
<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">
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||||
Books
|
Books
|
||||||
</h2>
|
</h2>
|
||||||
{!booksLoading && totalBooks > 0 && (
|
{!booksLoading && booksCountText && (
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||||
({totalBooks} title{totalBooks !== 1 ? 's' : ''})
|
({booksCountText})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<SectionToolbar
|
||||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
hideAvailable={hideAvailable}
|
||||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
onToggleHideAvailable={setHideAvailable}
|
||||||
</div>
|
squareCovers={squareCovers}
|
||||||
|
onToggleSquareCovers={setSquareCovers}
|
||||||
|
cardSize={cardSize}
|
||||||
|
onCardSizeChange={setCardSize}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Books Grid */}
|
{/* Books Grid */}
|
||||||
<AudiobookGrid
|
<AudiobookGrid
|
||||||
audiobooks={books}
|
audiobooks={filteredBooks}
|
||||||
isLoading={booksLoading}
|
isLoading={booksLoading}
|
||||||
emptyMessage={`No books found for ${author.name}`}
|
emptyMessage={`No books found for ${author.name}`}
|
||||||
cardSize={cardSize}
|
cardSize={cardSize}
|
||||||
squareCovers={squareCovers}
|
squareCovers={squareCovers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Load More Bar */}
|
||||||
|
{filteredBooks.length > 0 && (
|
||||||
|
<LoadMoreBar
|
||||||
|
loadedCount={filteredBooks.length}
|
||||||
|
totalCount={totalBooks > 0 ? totalBooks : undefined}
|
||||||
|
hasMore={hasMore}
|
||||||
|
isLoading={isLoadingMore}
|
||||||
|
onLoadMore={loadMore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -197,6 +197,31 @@ body {
|
|||||||
animation: toast-slide-in 0.3s ease-out;
|
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 */
|
/* Hide scrollbar while keeping scroll functional */
|
||||||
.scrollbar-hide {
|
.scrollbar-hide {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
|
|||||||
+52
-30
@@ -5,20 +5,19 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||||
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
|
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
|
||||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||||
import { StickyPagination } from '@/components/ui/StickyPagination';
|
import { UnifiedPagination } from '@/components/ui/UnifiedPagination';
|
||||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [popularPage, setPopularPage] = useState(1);
|
const [popularPage, setPopularPage] = useState(1);
|
||||||
const [newReleasesPage, setNewReleasesPage] = 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
|
// Refs for auto-scrolling to section tops
|
||||||
const popularSectionRef = useRef<HTMLElement>(null);
|
const popularSectionRef = useRef<HTMLElement>(null);
|
||||||
@@ -30,14 +29,20 @@ export default function HomePage() {
|
|||||||
isLoading: loadingPopular,
|
isLoading: loadingPopular,
|
||||||
totalPages: popularTotalPages,
|
totalPages: popularTotalPages,
|
||||||
message: popularMessage,
|
message: popularMessage,
|
||||||
} = useAudiobooks('popular', 20, popularPage);
|
} = useAudiobooks('popular', 20, popularPage, hideAvailable);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
audiobooks: newReleases,
|
audiobooks: newReleases,
|
||||||
isLoading: loadingNewReleases,
|
isLoading: loadingNewReleases,
|
||||||
totalPages: newReleasesTotalPages,
|
totalPages: newReleasesTotalPages,
|
||||||
message: newReleasesMessage,
|
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
|
// Handle page changes with auto-scroll to section top
|
||||||
const handlePopularPageChange = (page: number) => {
|
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">
|
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||||
Popular Audiobooks
|
Popular Audiobooks
|
||||||
</h2>
|
</h2>
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<SectionToolbar
|
||||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
hideAvailable={hideAvailable}
|
||||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
onToggleHideAvailable={setHideAvailable}
|
||||||
</div>
|
squareCovers={squareCovers}
|
||||||
|
onToggleSquareCovers={setSquareCovers}
|
||||||
|
cardSize={cardSize}
|
||||||
|
onCardSizeChange={setCardSize}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||||
New Releases
|
New Releases
|
||||||
</h2>
|
</h2>
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<SectionToolbar
|
||||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
hideAvailable={hideAvailable}
|
||||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
onToggleHideAvailable={setHideAvailable}
|
||||||
</div>
|
squareCovers={squareCovers}
|
||||||
|
onToggleSquareCovers={setSquareCovers}
|
||||||
|
cardSize={cardSize}
|
||||||
|
onCardSizeChange={setCardSize}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,22 +177,31 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{/* Sticky Pagination Controls */}
|
{/* Unified Pagination — single context-aware pill for both sections */}
|
||||||
<StickyPagination
|
<UnifiedPagination
|
||||||
currentPage={popularPage}
|
|
||||||
totalPages={popularTotalPages}
|
|
||||||
onPageChange={handlePopularPageChange}
|
|
||||||
sectionRef={popularSectionRef}
|
|
||||||
footerRef={footerRef}
|
footerRef={footerRef}
|
||||||
label="Popular Audiobooks"
|
sections={[
|
||||||
/>
|
{
|
||||||
<StickyPagination
|
label: 'Popular Audiobooks',
|
||||||
currentPage={newReleasesPage}
|
accentColor: 'bg-blue-500',
|
||||||
totalPages={newReleasesTotalPages}
|
currentPage: popularPage,
|
||||||
onPageChange={handleNewReleasesPageChange}
|
totalPages: popularTotalPages,
|
||||||
sectionRef={newReleasesSectionRef}
|
onPageChange: handlePopularPageChange,
|
||||||
footerRef={footerRef}
|
sectionRef: popularSectionRef,
|
||||||
label="New Releases"
|
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>
|
</div>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { useAuth } from '@/contexts/AuthContext';
|
|||||||
import { useRequests } from '@/lib/hooks/useRequests';
|
import { useRequests } from '@/lib/hooks/useRequests';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
||||||
|
import { ApiTokensSection } from '@/components/profile/ApiTokensSection';
|
||||||
|
import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection';
|
||||||
|
|
||||||
const statConfig = [
|
const statConfig = [
|
||||||
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
||||||
@@ -142,6 +144,12 @@ export default function ProfilePage() {
|
|||||||
{/* Goodreads Shelves */}
|
{/* Goodreads Shelves */}
|
||||||
<GoodreadsShelvesSection />
|
<GoodreadsShelvesSection />
|
||||||
|
|
||||||
|
{/* Watched Series */}
|
||||||
|
<WatchedSeriesSection />
|
||||||
|
|
||||||
|
{/* Watched Authors */}
|
||||||
|
<WatchedAuthorsSection />
|
||||||
|
|
||||||
{/* Active Downloads */}
|
{/* Active Downloads */}
|
||||||
{activeDownloads.length > 0 && (
|
{activeDownloads.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
@@ -233,6 +241,9 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* API Tokens */}
|
||||||
|
<ApiTokensSection />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+41
-37
@@ -5,41 +5,48 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
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 { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
|
||||||
|
|
||||||
// Debounce search query
|
// Debounce search query
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedQuery(query);
|
setDebouncedQuery(query);
|
||||||
setPage(1); // Reset to first page on new search
|
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [query]);
|
}, [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) => {
|
const handleSearch = useCallback((e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPage(1);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLoadMore = useCallback(() => {
|
// Header count text: reflects filtered counts
|
||||||
setPage((prev) => prev + 1);
|
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 (
|
return (
|
||||||
<ProtectedRoute>
|
<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">
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||||
Search Results
|
Search Results
|
||||||
</h2>
|
</h2>
|
||||||
{!isLoading && totalResults > 0 && (
|
{!isLoading && countText && (
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||||
({totalResults.toLocaleString()} result{totalResults !== 1 ? 's' : ''})
|
({countText})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<SectionToolbar
|
||||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
hideAvailable={hideAvailable}
|
||||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
onToggleHideAvailable={setHideAvailable}
|
||||||
</div>
|
squareCovers={squareCovers}
|
||||||
|
onToggleSquareCovers={setSquareCovers}
|
||||||
|
cardSize={cardSize}
|
||||||
|
onCardSizeChange={setCardSize}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results Grid */}
|
{/* Results Grid */}
|
||||||
<AudiobookGrid
|
<AudiobookGrid
|
||||||
audiobooks={results}
|
audiobooks={filteredResults}
|
||||||
isLoading={!!(isLoading && page === 1)}
|
isLoading={isLoading}
|
||||||
emptyMessage={`No results found for "${debouncedQuery}"`}
|
emptyMessage={`No results found for "${debouncedQuery}"`}
|
||||||
cardSize={cardSize}
|
cardSize={cardSize}
|
||||||
squareCovers={squareCovers}
|
squareCovers={squareCovers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Load More */}
|
{/* Load More Bar */}
|
||||||
{hasMore && !isLoading && (
|
{filteredResults.length > 0 && (
|
||||||
<div className="flex justify-center">
|
<LoadMoreBar
|
||||||
<button
|
loadedCount={filteredResults.length}
|
||||||
onClick={handleLoadMore}
|
totalCount={totalResults}
|
||||||
className="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
hasMore={hasMore}
|
||||||
>
|
isLoading={isLoadingMore}
|
||||||
Load More Results
|
onLoadMore={loadMore}
|
||||||
</button>
|
itemLabel="results"
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -5,16 +5,17 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { use, useCallback } from 'react';
|
import { use, useCallback, useMemo } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||||
|
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
|
||||||
import { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard';
|
import { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard';
|
||||||
import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow';
|
import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow';
|
||||||
import { useSeriesDetail } from '@/lib/hooks/useSeries';
|
import { useSeriesDetail } from '@/lib/hooks/useSeries';
|
||||||
|
import { Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
|
|
||||||
export default function SeriesDetailPage({
|
export default function SeriesDetailPage({
|
||||||
@@ -26,8 +27,8 @@ export default function SeriesDetailPage({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const fromSeriesTitle = searchParams.get('from');
|
const fromSeriesTitle = searchParams.get('from');
|
||||||
const { series, isLoading: seriesLoading } = useSeriesDetail(asin);
|
const { series, hasMore, isLoading: seriesLoading, isLoadingMore, loadMore } = useSeriesDetail(asin);
|
||||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
// Use browser back if we came from within the app, otherwise fallback to /series
|
// Use browser back if we came from within the app, otherwise fallback to /series
|
||||||
@@ -38,6 +39,24 @@ export default function SeriesDetailPage({
|
|||||||
}
|
}
|
||||||
}, [router]);
|
}, [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 (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<div className="min-h-screen">
|
<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">
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||||
Books in Series
|
Books in Series
|
||||||
</h2>
|
</h2>
|
||||||
{series.books.length > 0 && (
|
{booksCountText && (
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<SectionToolbar
|
||||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
hideAvailable={hideAvailable}
|
||||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
onToggleHideAvailable={setHideAvailable}
|
||||||
</div>
|
squareCovers={squareCovers}
|
||||||
|
onToggleSquareCovers={setSquareCovers}
|
||||||
|
cardSize={cardSize}
|
||||||
|
onCardSizeChange={setCardSize}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Books Grid */}
|
{/* Books Grid */}
|
||||||
<AudiobookGrid
|
<AudiobookGrid
|
||||||
audiobooks={series.books}
|
audiobooks={filteredBooks}
|
||||||
isLoading={seriesLoading}
|
isLoading={seriesLoading}
|
||||||
emptyMessage={`No books found for ${series.title}`}
|
emptyMessage={`No books found for ${series.title}`}
|
||||||
cardSize={cardSize}
|
cardSize={cardSize}
|
||||||
squareCovers={squareCovers}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export function BookDateStep({
|
|||||||
>
|
>
|
||||||
<option value="openai">OpenAI</option>
|
<option value="openai">OpenAI</option>
|
||||||
<option value="claude">Claude (Anthropic)</option>
|
<option value="claude">Claude (Anthropic)</option>
|
||||||
|
<option value="gemini">Google Gemini</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ export function BookDateStep({
|
|||||||
onUpdate('bookdateConfigured', false);
|
onUpdate('bookdateConfigured', false);
|
||||||
onUpdate('bookdateModels', []);
|
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"
|
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">
|
<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 [isImporting, setIsImporting] = useState(false);
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Cleanup source toggle
|
||||||
|
const [cleanupSource, setCleanupSource] = useState(false);
|
||||||
|
|
||||||
// Hover state for folder icon swap
|
// Hover state for folder icon swap
|
||||||
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
|
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -188,6 +191,7 @@ export function ManualImportBrowser({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
asin: audiobook.asin,
|
asin: audiobook.asin,
|
||||||
folderPath: selectedPath,
|
folderPath: selectedPath,
|
||||||
|
cleanupSource,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -288,6 +292,8 @@ export function ManualImportBrowser({
|
|||||||
isImporting={isImporting}
|
isImporting={isImporting}
|
||||||
importError={importError}
|
importError={importError}
|
||||||
slideClass={slideClass}
|
slideClass={slideClass}
|
||||||
|
cleanupSource={cleanupSource}
|
||||||
|
onCleanupSourceChange={setCleanupSource}
|
||||||
onBack={handleBackToBrowse}
|
onBack={handleBackToBrowse}
|
||||||
onStartImport={handleStartImport}
|
onStartImport={handleStartImport}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface ConfirmPhaseProps {
|
|||||||
isImporting: boolean;
|
isImporting: boolean;
|
||||||
importError: string | null;
|
importError: string | null;
|
||||||
slideClass: string;
|
slideClass: string;
|
||||||
|
cleanupSource: boolean;
|
||||||
|
onCleanupSourceChange: (value: boolean) => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onStartImport: () => void;
|
onStartImport: () => void;
|
||||||
}
|
}
|
||||||
@@ -35,6 +37,8 @@ export function ConfirmPhase({
|
|||||||
isImporting,
|
isImporting,
|
||||||
importError,
|
importError,
|
||||||
slideClass,
|
slideClass,
|
||||||
|
cleanupSource,
|
||||||
|
onCleanupSourceChange,
|
||||||
onBack,
|
onBack,
|
||||||
onStartImport,
|
onStartImport,
|
||||||
}: ConfirmPhaseProps) {
|
}: ConfirmPhaseProps) {
|
||||||
@@ -99,6 +103,30 @@ export function ConfirmPhase({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Error display */}
|
{/* Error display */}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { AuthorDetail } from '@/lib/hooks/useAuthors';
|
import { AuthorDetail } from '@/lib/hooks/useAuthors';
|
||||||
|
import { WatchAuthorButton } from '@/components/ui/WatchButton';
|
||||||
|
|
||||||
interface AuthorDetailCardProps {
|
interface AuthorDetailCardProps {
|
||||||
author: AuthorDetail;
|
author: AuthorDetail;
|
||||||
@@ -64,20 +65,27 @@ export function AuthorDetailCard({ author }: AuthorDetailCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Audible Link */}
|
{/* Actions row: Audible link + Watch button */}
|
||||||
{author.audibleUrl && (
|
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
|
||||||
<a
|
{author.audibleUrl && (
|
||||||
href={author.audibleUrl}
|
<a
|
||||||
target="_blank"
|
href={author.audibleUrl}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
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"
|
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">
|
View on Audible
|
||||||
<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 className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<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" />
|
||||||
</a>
|
</svg>
|
||||||
)}
|
</a>
|
||||||
|
)}
|
||||||
|
<WatchAuthorButton
|
||||||
|
authorAsin={author.asin}
|
||||||
|
authorName={author.name}
|
||||||
|
coverArtUrl={author.image}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{author.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;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
};
|
};
|
||||||
|
customSearchTerms?: string | null; // Optional - admin-set custom search terms override
|
||||||
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
|
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
|
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
|
||||||
@@ -87,6 +88,7 @@ export function InteractiveTorrentSearchModal({
|
|||||||
requestId,
|
requestId,
|
||||||
asin,
|
asin,
|
||||||
audiobook,
|
audiobook,
|
||||||
|
customSearchTerms,
|
||||||
fullAudiobook,
|
fullAudiobook,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
searchMode = 'audiobook',
|
searchMode = 'audiobook',
|
||||||
@@ -114,7 +116,7 @@ export function InteractiveTorrentSearchModal({
|
|||||||
|
|
||||||
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]);
|
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]);
|
||||||
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
|
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 [isCustomConfirming, setIsCustomConfirming] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
@@ -153,9 +155,9 @@ export function InteractiveTorrentSearchModal({
|
|||||||
|
|
||||||
// Reset search title when modal opens/closes or audiobook changes
|
// Reset search title when modal opens/closes or audiobook changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchTitle(audiobook.title);
|
setSearchTitle(customSearchTerms || audiobook.title);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
}, [isOpen, audiobook.title]);
|
}, [isOpen, audiobook.title, customSearchTerms]);
|
||||||
|
|
||||||
// Perform search when modal opens
|
// Perform search when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { SeriesDetail } from '@/lib/hooks/useSeries';
|
import { SeriesDetail } from '@/lib/hooks/useSeries';
|
||||||
|
import { WatchSeriesButton } from '@/components/ui/WatchButton';
|
||||||
|
|
||||||
interface SeriesDetailCardProps {
|
interface SeriesDetailCardProps {
|
||||||
series: SeriesDetail;
|
series: SeriesDetail;
|
||||||
@@ -91,20 +92,27 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Audible Link */}
|
{/* Actions row: Audible link + Watch button */}
|
||||||
{series.audibleUrl && (
|
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
|
||||||
<a
|
{series.audibleUrl && (
|
||||||
href={series.audibleUrl}
|
<a
|
||||||
target="_blank"
|
href={series.audibleUrl}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
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"
|
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">
|
View on Audible
|
||||||
<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 className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<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" />
|
||||||
</a>
|
</svg>
|
||||||
)}
|
</a>
|
||||||
|
)}
|
||||||
|
<WatchSeriesButton
|
||||||
|
seriesAsin={series.asin}
|
||||||
|
seriesTitle={series.title}
|
||||||
|
coverArtUrl={series.books[0]?.coverArtUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{series.description && (
|
{series.description && (
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface ConfirmModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string | React.ReactNode;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
@@ -35,7 +35,9 @@ export function ConfirmModal({
|
|||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm" showCloseButton={false}>
|
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm" showCloseButton={false}>
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="flex gap-3 justify-end">
|
||||||
<Button onClick={onClose} variant="outline" disabled={isLoading}>
|
<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 {
|
interface Preferences {
|
||||||
cardSize: number; // 1-9, default 5
|
cardSize: number; // 1-9, default 5
|
||||||
squareCovers: boolean; // true = square (1:1), false = rectangle (2:3)
|
squareCovers: boolean; // true = square (1:1), false = rectangle (2:3)
|
||||||
|
hideAvailable: boolean; // true = hide "In Your Library" titles
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreferencesContextType {
|
interface PreferencesContextType {
|
||||||
@@ -17,6 +18,8 @@ interface PreferencesContextType {
|
|||||||
setCardSize: (size: number) => void;
|
setCardSize: (size: number) => void;
|
||||||
squareCovers: boolean;
|
squareCovers: boolean;
|
||||||
setSquareCovers: (enabled: boolean) => void;
|
setSquareCovers: (enabled: boolean) => void;
|
||||||
|
hideAvailable: boolean;
|
||||||
|
setHideAvailable: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
|
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
|
||||||
@@ -24,6 +27,7 @@ const PreferencesContext = createContext<PreferencesContextType | undefined>(und
|
|||||||
const DEFAULT_PREFERENCES: Preferences = {
|
const DEFAULT_PREFERENCES: Preferences = {
|
||||||
cardSize: 5,
|
cardSize: 5,
|
||||||
squareCovers: true,
|
squareCovers: true,
|
||||||
|
hideAvailable: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = 'preferences';
|
const STORAGE_KEY = 'preferences';
|
||||||
@@ -31,6 +35,7 @@ const STORAGE_KEY = 'preferences';
|
|||||||
export function PreferencesProvider({ children }: { children: ReactNode }) {
|
export function PreferencesProvider({ children }: { children: ReactNode }) {
|
||||||
const [cardSize, setCardSizeState] = useState<number>(DEFAULT_PREFERENCES.cardSize);
|
const [cardSize, setCardSizeState] = useState<number>(DEFAULT_PREFERENCES.cardSize);
|
||||||
const [squareCovers, setSquareCoversState] = useState<boolean>(DEFAULT_PREFERENCES.squareCovers);
|
const [squareCovers, setSquareCoversState] = useState<boolean>(DEFAULT_PREFERENCES.squareCovers);
|
||||||
|
const [hideAvailable, setHideAvailableState] = useState<boolean>(DEFAULT_PREFERENCES.hideAvailable);
|
||||||
|
|
||||||
// Load preferences from localStorage on mount
|
// Load preferences from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,11 +54,14 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
// Load squareCovers preference (defaults to false if not set)
|
// Load squareCovers preference (defaults to false if not set)
|
||||||
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
|
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
|
||||||
|
// Load hideAvailable preference
|
||||||
|
setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load preferences from localStorage:', error);
|
console.error('Failed to load preferences from localStorage:', error);
|
||||||
setCardSizeState(DEFAULT_PREFERENCES.cardSize);
|
setCardSizeState(DEFAULT_PREFERENCES.cardSize);
|
||||||
setSquareCoversState(DEFAULT_PREFERENCES.squareCovers);
|
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)
|
// Listen for storage changes in other tabs (cross-tab sync)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -106,6 +130,8 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
// Sync squareCovers preference
|
// Sync squareCovers preference
|
||||||
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
|
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
|
||||||
|
// Sync hideAvailable preference
|
||||||
|
setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse preferences from storage event:', error);
|
console.error('Failed to parse preferences from storage event:', error);
|
||||||
}
|
}
|
||||||
@@ -119,7 +145,7 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PreferencesContext.Provider value={{ cardSize, setCardSize, squareCovers, setSquareCovers }}>
|
<PreferencesContext.Provider value={{ cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable }}>
|
||||||
{children}
|
{children}
|
||||||
</PreferencesContext.Provider>
|
</PreferencesContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -547,7 +547,7 @@ export async function buildAIPrompt(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Call AI API to get recommendations
|
* Call AI API to get recommendations
|
||||||
* @param provider - 'openai' | 'claude'
|
* @param provider - 'openai' | 'claude' | 'gemini' | 'custom'
|
||||||
* @param model - Model ID
|
* @param model - Model ID
|
||||||
* @param encryptedApiKey - Encrypted API key
|
* @param encryptedApiKey - Encrypted API key
|
||||||
* @param prompt - JSON prompt string
|
* @param prompt - JSON prompt string
|
||||||
@@ -691,6 +691,74 @@ export async function callAI(
|
|||||||
logger.debug('Claude cleaned response:', { cleanedContent });
|
logger.debug('Claude cleaned response:', { cleanedContent });
|
||||||
return JSON.parse(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') {
|
} else if (provider === 'custom') {
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
throw new Error('Base URL is required for custom provider');
|
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';
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import useSWRInfinite from 'swr/infinite';
|
||||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||||
|
|
||||||
export interface Audiobook {
|
export interface Audiobook {
|
||||||
@@ -33,11 +35,12 @@ export interface Audiobook {
|
|||||||
hasReportedIssue?: boolean; // True if an open issue exists for this 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 =
|
const endpoint =
|
||||||
type === 'popular'
|
type === 'popular'
|
||||||
? `/api/audiobooks/popular?page=${page}&limit=${limit}`
|
? `/api/audiobooks/popular?page=${page}&limit=${limit}${hideParam}`
|
||||||
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}`;
|
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}${hideParam}`;
|
||||||
|
|
||||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
@@ -57,20 +60,58 @@ export function useAudiobooks(type: 'popular' | 'new-releases', limit: number =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSearch(query: string, page: number = 1) {
|
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
|
||||||
const shouldFetch = query && query.length > 0;
|
const seen = new Set<string>();
|
||||||
const endpoint = shouldFetch ? `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${page}` : null;
|
return items.filter(item => {
|
||||||
|
if (seen.has(item.asin)) return false;
|
||||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
seen.add(item.asin);
|
||||||
revalidateOnFocus: false,
|
return true;
|
||||||
dedupingInterval: 30000, // Cache for 30 seconds
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
results: data?.results || [],
|
results,
|
||||||
totalResults: data?.totalResults || 0,
|
totalResults,
|
||||||
hasMore: data?.hasMore || false,
|
hasMore,
|
||||||
isLoading: shouldFetch && isLoading,
|
isLoading: isLoadingInitial,
|
||||||
|
isLoadingMore,
|
||||||
|
loadMore,
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-12
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import useSWRInfinite from 'swr/infinite';
|
||||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||||
import { Audiobook } from './useAudiobooks';
|
import { Audiobook } from './useAudiobooks';
|
||||||
|
|
||||||
@@ -68,21 +70,59 @@ export function useAuthorDetail(asin: string | null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuthorBooks(asin: string | null, authorName: string | null) {
|
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
|
||||||
const shouldFetch = asin && authorName;
|
const seen = new Set<string>();
|
||||||
const endpoint = shouldFetch
|
return items.filter(item => {
|
||||||
? `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}`
|
if (seen.has(item.asin)) return false;
|
||||||
: null;
|
seen.add(item.asin);
|
||||||
|
return true;
|
||||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
dedupingInterval: 60000, // Cache for 1 minute
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
books: (data?.books || []) as Audiobook[],
|
books,
|
||||||
totalBooks: data?.totalBooks || 0,
|
totalBooks,
|
||||||
isLoading: !!shouldFetch && isLoading,
|
hasMore,
|
||||||
|
isLoading: isLoadingInitial || (!!identity && isLoading),
|
||||||
|
isLoadingMore,
|
||||||
|
loadMore,
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import useSWRInfinite from 'swr/infinite';
|
||||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||||
import { Audiobook } from './useAudiobooks';
|
import { Audiobook } from './useAudiobooks';
|
||||||
|
|
||||||
@@ -59,17 +61,63 @@ export function useSeriesSearch(query: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSeriesDetail(asin: string | null) {
|
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
|
||||||
const endpoint = asin ? `/api/series/${asin}` : null;
|
const seen = new Set<string>();
|
||||||
|
return items.filter(item => {
|
||||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
if (seen.has(item.asin)) return false;
|
||||||
revalidateOnFocus: false,
|
seen.add(item.asin);
|
||||||
dedupingInterval: 300000, // Cache for 5 minutes
|
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 {
|
return {
|
||||||
series: (data?.series || null) as SeriesDetail | null,
|
series,
|
||||||
isLoading,
|
hasMore,
|
||||||
|
isLoading: isLoadingInitial || (!!asin && isLoading),
|
||||||
|
isLoadingMore,
|
||||||
|
loadMore,
|
||||||
error,
|
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,
|
getLanguageForRegion,
|
||||||
buildContainsSelector,
|
buildContainsSelector,
|
||||||
stripPrefixes,
|
stripPrefixes,
|
||||||
|
type LanguageConfig,
|
||||||
} from '../constants/language-config';
|
} from '../constants/language-config';
|
||||||
import { RMABLogger } from '../utils/logger';
|
import { RMABLogger } from '../utils/logger';
|
||||||
|
import { parseRuntime } from '../utils/parse-runtime';
|
||||||
import { randomDelay } from '../utils/scrape-resilience';
|
import { randomDelay } from '../utils/scrape-resilience';
|
||||||
|
|
||||||
const logger = RMABLogger.create('Audible.Series');
|
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.
|
* Scrape a series page for full detail data including books and similar series.
|
||||||
* Used by the detail API endpoint.
|
* 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 service = getAudibleService();
|
||||||
const region = service.getRegion();
|
const region = service.getRegion();
|
||||||
const baseUrl = service.getBaseUrl();
|
const baseUrl = service.getBaseUrl();
|
||||||
const langConfig = getLanguageForRegion(region);
|
const langConfig = getLanguageForRegion(region);
|
||||||
|
|
||||||
logger.info(`Scraping series detail page: ${asin}`);
|
logger.info(`Scraping series detail page: ${asin}, page ${page}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: response } = await service.fetch(`/series/${asin}`, {
|
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);
|
const $ = cheerio.load(response.data);
|
||||||
|
|
||||||
@@ -311,15 +313,20 @@ export async function scrapeSeriesPage(asin: string): Promise<SeriesDetail | nul
|
|||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
// Parse all books from the series page
|
// 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
|
// Use actual book count if we got more from scraping
|
||||||
const bookCount = Math.max(summary.bookCount, books.length);
|
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)
|
// Parse similar series ("Listeners also enjoyed" or similar section)
|
||||||
const similarSeries = parseSimilarSeries($);
|
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 {
|
return {
|
||||||
asin,
|
asin,
|
||||||
@@ -332,6 +339,8 @@ export async function scrapeSeriesPage(asin: string): Promise<SeriesDetail | nul
|
|||||||
books,
|
books,
|
||||||
similarSeries,
|
similarSeries,
|
||||||
audibleUrl: `${baseUrl}/series/${asin}`,
|
audibleUrl: `${baseUrl}/series/${asin}`,
|
||||||
|
hasMore,
|
||||||
|
page,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to scrape series detail ${asin}`, {
|
logger.error(`Failed to scrape series detail ${asin}`, {
|
||||||
@@ -396,7 +405,8 @@ function parseSeriesRating($: cheerio.CheerioAPI): { rating?: number; ratingCoun
|
|||||||
function parseSeriesBooks(
|
function parseSeriesBooks(
|
||||||
$: cheerio.CheerioAPI,
|
$: cheerio.CheerioAPI,
|
||||||
authorPrefixes: string[],
|
authorPrefixes: string[],
|
||||||
narratorPrefixes: string[]
|
narratorPrefixes: string[],
|
||||||
|
langConfig: LanguageConfig
|
||||||
): AudibleAudiobook[] {
|
): AudibleAudiobook[] {
|
||||||
const books: AudibleAudiobook[] = [];
|
const books: AudibleAudiobook[] = [];
|
||||||
const seenAsins = new Set<string>();
|
const seenAsins = new Set<string>();
|
||||||
@@ -446,6 +456,11 @@ function parseSeriesBooks(
|
|||||||
const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null;
|
const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null;
|
||||||
const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined;
|
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({
|
books.push({
|
||||||
asin: bookAsin,
|
asin: bookAsin,
|
||||||
title,
|
title,
|
||||||
@@ -454,6 +469,7 @@ function parseSeriesBooks(
|
|||||||
narrator: stripPrefixes(narratorText, narratorPrefixes),
|
narrator: stripPrefixes(narratorText, narratorPrefixes),
|
||||||
coverArtUrl,
|
coverArtUrl,
|
||||||
rating,
|
rating,
|
||||||
|
durationMinutes,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
AdaptivePacer,
|
AdaptivePacer,
|
||||||
FetchResultMeta,
|
FetchResultMeta,
|
||||||
} from '../utils/scrape-resilience';
|
} from '../utils/scrape-resilience';
|
||||||
|
import { parseRuntime as parseRuntimeUtil } from '../utils/parse-runtime';
|
||||||
|
|
||||||
// Module-level logger
|
// Module-level logger
|
||||||
const logger = RMABLogger.create('Audible');
|
const logger = RMABLogger.create('Audible');
|
||||||
@@ -59,6 +60,13 @@ export interface AudibleSearchResult {
|
|||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthorBooksResult {
|
||||||
|
books: AudibleAudiobook[];
|
||||||
|
hasMore: boolean;
|
||||||
|
page: number;
|
||||||
|
totalResults: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class AudibleService {
|
export class AudibleService {
|
||||||
private client!: AxiosInstance;
|
private client!: AxiosInstance;
|
||||||
private baseUrl: string = 'https://www.audible.com';
|
private baseUrl: string = 'https://www.audible.com';
|
||||||
@@ -564,7 +572,9 @@ export class AudibleService {
|
|||||||
results: audiobooks,
|
results: audiobooks,
|
||||||
totalResults,
|
totalResults,
|
||||||
page,
|
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) {
|
} catch (error) {
|
||||||
logger.error('Search failed', { error: error instanceof Error ? error.message : String(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.
|
* Uses Audible's searchAuthor parameter and paginates through all results.
|
||||||
* Filters: (1) author link must contain the target ASIN, (2) language must be English.
|
* 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();
|
await this.initialize();
|
||||||
|
|
||||||
const MAX_PAGES = 10;
|
const books: AudibleAudiobook[] = [];
|
||||||
const allBooks: AudibleAudiobook[] = [];
|
|
||||||
const seenAsins = new Set<string>();
|
const seenAsins = new Set<string>();
|
||||||
|
|
||||||
try {
|
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 } = await this.fetchWithRetry('/search', {
|
||||||
const { data: response, meta } = await this.fetchWithRetry('/search', {
|
params: {
|
||||||
params: {
|
ipRedirectOverride: 'true',
|
||||||
ipRedirectOverride: 'true',
|
searchAuthor: authorName,
|
||||||
searchAuthor: authorName,
|
pageSize: AUDIBLE_PAGE_SIZE,
|
||||||
pageSize: AUDIBLE_PAGE_SIZE,
|
page,
|
||||||
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);
|
// --- Extract book ASIN ---
|
||||||
let pageResults = 0;
|
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) => {
|
// --- Parse book details ---
|
||||||
const $el = $(element);
|
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 authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
|
||||||
const langConfig = this.getLangConfig();
|
$el.find('.authorLabel').text().trim() ||
|
||||||
const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() ||
|
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
||||||
$el.find('.languageLabel').text().trim();
|
|
||||||
// Extract language value (e.g. "Language: English" -> "English", "Sprache: Deutsch" -> "Deutsch")
|
|
||||||
const langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i');
|
|
||||||
const langMatch = langText.match(langLabelPattern);
|
|
||||||
const language = langMatch?.[1]?.trim();
|
|
||||||
if (!language || !isAcceptedLanguage(language, langConfig)) return;
|
|
||||||
|
|
||||||
// --- Author ASIN filter: verify target ASIN in author links ---
|
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||||
const authorLinks = $el.find('a[href*="/author/"]');
|
$el.find('.narratorLabel').text().trim();
|
||||||
let hasMatchingAuthor = false;
|
|
||||||
authorLinks.each((_i, link) => {
|
|
||||||
const href = $(link).attr('href') || '';
|
|
||||||
const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
|
||||||
if (asinMatch && asinMatch[1] === authorAsin) {
|
|
||||||
hasMatchingAuthor = true;
|
|
||||||
return false; // break .each()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!hasMatchingAuthor) return;
|
|
||||||
|
|
||||||
// --- Extract book ASIN ---
|
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||||
const bookAsin = $el.find('li').attr('data-asin') ||
|
|
||||||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
|
||||||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
|
||||||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
|
||||||
if (!bookAsin || seenAsins.has(bookAsin)) return;
|
|
||||||
seenAsins.add(bookAsin);
|
|
||||||
|
|
||||||
// --- Parse book details ---
|
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||||
const title = $el.find('h2').first().text().trim() ||
|
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
||||||
$el.find('h3 a').text().trim() ||
|
const durationMinutes = this.parseRuntime(runtimeText);
|
||||||
$el.find('.bc-heading a').text().trim();
|
|
||||||
|
|
||||||
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
|
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
||||||
$el.find('.authorLabel').text().trim() ||
|
$el.find('.a-icon-star span').first().text().trim();
|
||||||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||||
|
|
||||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
books.push({
|
||||||
$el.find('.narratorLabel').text().trim();
|
asin: bookAsin,
|
||||||
|
title,
|
||||||
const coverArtUrl = $el.find('img').attr('src') || '';
|
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
|
||||||
|
authorAsin,
|
||||||
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
|
||||||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||||
const durationMinutes = this.parseRuntime(runtimeText);
|
durationMinutes,
|
||||||
|
rating,
|
||||||
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
|
||||||
$el.find('.a-icon-star span').first().text().trim();
|
|
||||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
|
||||||
|
|
||||||
allBooks.push({
|
|
||||||
asin: bookAsin,
|
|
||||||
title,
|
|
||||||
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
|
|
||||||
authorAsin,
|
|
||||||
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
|
|
||||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
|
||||||
durationMinutes,
|
|
||||||
rating,
|
|
||||||
});
|
|
||||||
|
|
||||||
pageResults++;
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Check if there are more pages
|
// Check total results for pagination
|
||||||
const resultsText = $('.resultsInfo').text().trim();
|
const resultsText = $('.resultsInfo').text().trim();
|
||||||
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
|
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
|
||||||
const hasMore = totalResults > page * AUDIBLE_PAGE_SIZE;
|
// 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)`);
|
logger.info(`Author books page ${page}: ${books.length} valid results (${totalResults} Audible total)`);
|
||||||
|
return { books, hasMore, page, totalResults };
|
||||||
if (!hasMore || pageResults === 0) break;
|
|
||||||
|
|
||||||
// Pace between pages
|
|
||||||
if (page < MAX_PAGES) {
|
|
||||||
await this.delay(this.pacer.reportPageResult(meta));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Author books search complete: "${authorName}" → ${allBooks.length} books`);
|
|
||||||
return allBooks;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Author books search failed for "${authorName}"`, {
|
logger.error(`Author books search failed for "${authorName}"`, {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
collectedSoFar: allBooks.length,
|
|
||||||
});
|
});
|
||||||
// Return what we collected before the error
|
return { books, hasMore: false, page, totalResults: 0 };
|
||||||
return allBooks;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
private parseRuntime(runtimeText: string): number | undefined {
|
||||||
if (!runtimeText) return undefined;
|
return parseRuntimeUtil(runtimeText, this.getLangConfig());
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+107
-3
@@ -4,9 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
|
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
|
||||||
import { prisma } from '../db';
|
import { prisma } from '../db';
|
||||||
import { RMABLogger } from '../utils/logger';
|
import { RMABLogger } from '../utils/logger';
|
||||||
|
import { API_TOKEN_PREFIX, isEndpointAllowed } from '../constants/api-tokens';
|
||||||
|
|
||||||
const logger = RMABLogger.create('Auth');
|
const logger = RMABLogger.create('Auth');
|
||||||
|
|
||||||
@@ -32,9 +34,70 @@ function extractToken(request: NextRequest): string | null {
|
|||||||
return parts[1];
|
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
|
* 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(
|
export async function requireAuth(
|
||||||
request: NextRequest,
|
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);
|
const payload = verifyAccessToken(token);
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
@@ -69,9 +169,13 @@ export async function requireAuth(
|
|||||||
// Verify user still exists in database
|
// Verify user still exists in database
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.sub },
|
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 });
|
logger.error('User not found in database', { userId: payload.sub });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -86,7 +190,7 @@ export async function requireAuth(
|
|||||||
const authenticatedRequest = request as AuthenticatedRequest;
|
const authenticatedRequest = request as AuthenticatedRequest;
|
||||||
authenticatedRequest.user = {
|
authenticatedRequest.user = {
|
||||||
...payload,
|
...payload,
|
||||||
id: user.id,
|
id: payload.sub,
|
||||||
};
|
};
|
||||||
|
|
||||||
return handler(authenticatedRequest);
|
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 { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||||
import { ProwlarrService } from '../integrations/prowlarr.service';
|
import { ProwlarrService } from '../integrations/prowlarr.service';
|
||||||
import { RMABLogger } from '../utils/logger';
|
import { RMABLogger } from '../utils/logger';
|
||||||
|
import { isTransientConnectionError } from '../utils/connection-errors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process download job
|
* Process download job
|
||||||
@@ -121,15 +122,22 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
|
||||||
// Update request status to failed
|
if (isTransientConnectionError(error)) {
|
||||||
await prisma.request.update({
|
// Connection error — don't mark request as failed yet.
|
||||||
where: { id: requestId },
|
// Bull will retry this job (3 attempts with exponential backoff).
|
||||||
data: {
|
// If all retries are exhausted, the global failed handler marks it failed.
|
||||||
status: 'failed',
|
logger.warn(`Download client unreachable for request ${requestId}, allowing Bull to retry`);
|
||||||
errorMessage: error instanceof Error ? error.message : 'Failed to add download to client',
|
} else {
|
||||||
updatedAt: new Date(),
|
// 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;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
|||||||
import { getConfigService } from '../services/config.service';
|
import { getConfigService } from '../services/config.service';
|
||||||
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
|
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
|
||||||
|
import { isTransientConnectionError } from '../utils/connection-errors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process monitor download job
|
* Process monitor download job
|
||||||
@@ -20,6 +21,12 @@ import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-
|
|||||||
const BASE_POLL_INTERVAL = 10;
|
const BASE_POLL_INTERVAL = 10;
|
||||||
/** Maximum polling interval in seconds (5 minutes) */
|
/** Maximum polling interval in seconds (5 minutes) */
|
||||||
const MAX_POLL_INTERVAL = 300;
|
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.
|
* 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> {
|
export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> {
|
||||||
const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId,
|
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');
|
const logger = RMABLogger.forJob(jobId, 'MonitorDownload');
|
||||||
|
|
||||||
@@ -288,51 +296,99 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown 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 errorMessage = error instanceof Error ? error.message : '';
|
||||||
const isNotFound = errorMessage.includes('not found');
|
const isNotFound = errorMessage.includes('not found');
|
||||||
|
const isConnectionError = isTransientConnectionError(error);
|
||||||
|
|
||||||
if (isNotFound) {
|
if (isNotFound) {
|
||||||
// Transient error - don't mark request as failed, let Bull retry
|
// PATH 1: "Not found" — transient race condition.
|
||||||
// The request stays in 'downloading' status until Bull exhausts all retries
|
// Don't mark request as failed; let Bull retry the same job.
|
||||||
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
|
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
|
||||||
} else {
|
throw error;
|
||||||
// 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(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send notification for request failure
|
if (isConnectionError) {
|
||||||
const request = await prisma.request.findUnique({
|
// PATH 2: Connection failure — download client is temporarily unreachable.
|
||||||
where: { id: requestId },
|
// Instead of failing the download, self-schedule the next poll with backoff.
|
||||||
include: {
|
// This reuses the same adaptive backoff as stalled downloads, giving the
|
||||||
audiobook: true,
|
// client time to recover (restart, network blip, update, etc.).
|
||||||
user: { select: { plexUsername: true } },
|
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();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addNotificationJob(
|
await jobQueue.addMonitorJob(
|
||||||
'request_error',
|
requestId,
|
||||||
request.id,
|
downloadHistoryId,
|
||||||
request.audiobook.title,
|
downloadClientId,
|
||||||
request.audiobook.author,
|
downloadClient,
|
||||||
request.user.plexUsername || 'Unknown User',
|
delay,
|
||||||
failureMessage
|
prevProgress,
|
||||||
).catch((error) => {
|
prevStallCount ?? 0,
|
||||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
|||||||
import { generateFilesHash } from '../utils/files-hash';
|
import { generateFilesHash } from '../utils/files-hash';
|
||||||
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
|
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
|
||||||
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
|
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
|
||||||
|
import { getAudibleService } from '../integrations/audible.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process organize files job
|
* Process organize files job
|
||||||
@@ -22,7 +23,7 @@ import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
|
|||||||
* Handles both audiobook and ebook request types with appropriate branching
|
* Handles both audiobook and ebook request types with appropriate branching
|
||||||
*/
|
*/
|
||||||
export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> {
|
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');
|
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)
|
// Get file organizer (reads media_dir from database config)
|
||||||
const organizer = await getFileOrganizer();
|
const organizer = await getFileOrganizer();
|
||||||
@@ -151,8 +207,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
|||||||
coverArtUrl: audiobook.coverArtUrl || undefined,
|
coverArtUrl: audiobook.coverArtUrl || undefined,
|
||||||
asin: audiobook.audibleAsin || undefined,
|
asin: audiobook.audibleAsin || undefined,
|
||||||
year,
|
year,
|
||||||
series: audiobook.series || undefined,
|
series,
|
||||||
seriesPart: audiobook.seriesPart || undefined,
|
seriesPart,
|
||||||
},
|
},
|
||||||
template,
|
template,
|
||||||
jobId ? { jobId, context: 'FileOrganizer' } : undefined,
|
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)
|
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
|
||||||
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
|
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
|
||||||
|
|
||||||
|
// Cleanup source files if requested (manual import feature)
|
||||||
|
if (cleanupSource) {
|
||||||
|
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Files organized successfully',
|
message: 'Files organized successfully',
|
||||||
@@ -467,7 +528,7 @@ async function processEbookOrganization(
|
|||||||
request: { id: string; userId: string; type: string; user: { plexUsername: string | null } },
|
request: { id: string; userId: string; type: string; user: { plexUsername: string | null } },
|
||||||
logger: RMABLogger
|
logger: RMABLogger
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const { requestId, audiobookId, downloadPath, jobId } = payload;
|
const { requestId, audiobookId, downloadPath, jobId, cleanupSource } = payload;
|
||||||
|
|
||||||
logger.info(`Processing ebook organization for request ${requestId}`);
|
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'}`);
|
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)
|
// 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)
|
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
|
||||||
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
|
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
|
||||||
|
|
||||||
|
// Cleanup source files if requested (manual import feature)
|
||||||
|
if (cleanupSource) {
|
||||||
|
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Ebook organized successfully',
|
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
|
// 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
|
// Get enabled indexers from configuration
|
||||||
const { getConfigService } = await import('../services/config.service');
|
const { getConfigService } = await import('../services/config.service');
|
||||||
const configService = getConfigService();
|
const configService = getConfigService();
|
||||||
@@ -77,7 +84,11 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
|||||||
// Get Prowlarr service
|
// Get Prowlarr service
|
||||||
const prowlarr = await getProwlarrService();
|
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
|
// Search Prowlarr for each group and combine results
|
||||||
const allResults = [];
|
const allResults = [];
|
||||||
@@ -87,7 +98,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
|||||||
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const groupResults = await prowlarr.searchWithVariations(audiobook.title, audiobook.author, {
|
const groupResults = await prowlarr.searchWithVariations(effectiveSearchTitle, audiobook.author, {
|
||||||
categories: group.categories,
|
categories: group.categories,
|
||||||
indexerIds: group.indexerIds,
|
indexerIds: group.indexerIds,
|
||||||
minSeeders: 1, // Only torrents with at least 1 seeder
|
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' };
|
return { success: false, error: 'Username and password required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedUsername = username.trim().toLowerCase();
|
||||||
|
|
||||||
// Find user (exclude soft-deleted users)
|
// Find user (exclude soft-deleted users)
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
plexUsername: username,
|
plexUsername: normalizedUsername,
|
||||||
authProvider: 'local',
|
authProvider: 'local',
|
||||||
deletedAt: null, // Exclude soft-deleted users
|
deletedAt: null, // Exclude soft-deleted users
|
||||||
},
|
},
|
||||||
@@ -144,9 +146,10 @@ export class LocalAuthProvider implements IAuthProvider {
|
|||||||
async register(params: RegisterParams): Promise<AuthResult> {
|
async register(params: RegisterParams): Promise<AuthResult> {
|
||||||
try {
|
try {
|
||||||
const { username, password } = params;
|
const { username, password } = params;
|
||||||
|
const normalizedUsername = username?.trim().toLowerCase();
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
if (!username || username.length < 3) {
|
if (!normalizedUsername || normalizedUsername.length < 3) {
|
||||||
return { success: false, error: 'Username must be at least 3 characters' };
|
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)
|
// Check username uniqueness (only among non-deleted users)
|
||||||
const existing = await prisma.user.findFirst({
|
const existing = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
plexUsername: username,
|
plexUsername: normalizedUsername,
|
||||||
authProvider: 'local',
|
authProvider: 'local',
|
||||||
deletedAt: null, // Allow reuse of usernames from deleted accounts
|
deletedAt: null, // Allow reuse of usernames from deleted accounts
|
||||||
},
|
},
|
||||||
@@ -194,8 +197,8 @@ export class LocalAuthProvider implements IAuthProvider {
|
|||||||
// Create user
|
// Create user
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
plexId: `local-${username}`,
|
plexId: `local-${normalizedUsername}`,
|
||||||
plexUsername: username,
|
plexUsername: normalizedUsername,
|
||||||
authToken: encryptedHash,
|
authToken: encryptedHash,
|
||||||
authProvider: 'local',
|
authProvider: 'local',
|
||||||
role: isFirstUser ? 'admin' : 'user',
|
role: isFirstUser ? 'admin' : 'user',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type JobType =
|
|||||||
| 'cleanup_seeded_torrents'
|
| 'cleanup_seeded_torrents'
|
||||||
| 'monitor_rss_feeds'
|
| 'monitor_rss_feeds'
|
||||||
| 'sync_goodreads_shelves'
|
| 'sync_goodreads_shelves'
|
||||||
|
| 'check_watched_lists'
|
||||||
| 'send_notification'
|
| 'send_notification'
|
||||||
// Ebook-specific job types
|
// Ebook-specific job types
|
||||||
| 'search_ebook'
|
| 'search_ebook'
|
||||||
@@ -66,6 +67,7 @@ export interface MonitorDownloadPayload extends JobPayload {
|
|||||||
lastProgress?: number; // Previous poll's progress (0-100) for stall detection
|
lastProgress?: number; // Previous poll's progress (0-100) for stall detection
|
||||||
stallCount?: number; // Consecutive polls with no progress change (drives backoff)
|
stallCount?: number; // Consecutive polls with no progress change (drives backoff)
|
||||||
pathWaitCount?: number; // Consecutive polls waiting for content_path to relocate to save_path
|
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 {
|
export interface OrganizeFilesPayload extends JobPayload {
|
||||||
@@ -73,6 +75,7 @@ export interface OrganizeFilesPayload extends JobPayload {
|
|||||||
audiobookId: string;
|
audiobookId: string;
|
||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
targetPath?: string; // Optional - not used by processor (reads from database config)
|
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 {
|
export interface ScanPlexPayload extends JobPayload {
|
||||||
@@ -111,6 +114,16 @@ export interface SyncGoodreadsShelvesPayload extends JobPayload {
|
|||||||
maxLookupsPerShelf?: number;
|
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
|
// Ebook-specific payload interfaces
|
||||||
export interface SearchEbookPayload extends JobPayload {
|
export interface SearchEbookPayload extends JobPayload {
|
||||||
requestId: string;
|
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) });
|
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) => {
|
this.queue.on('stalled', async (job: BullJob) => {
|
||||||
@@ -359,6 +395,12 @@ export class JobQueueService {
|
|||||||
return await processSyncGoodreadsShelves(payloadWithJobId);
|
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
|
// Send notification processor
|
||||||
this.queue.process('send_notification', 2, async (job: BullJob<SendNotificationPayload>) => {
|
this.queue.process('send_notification', 2, async (job: BullJob<SendNotificationPayload>) => {
|
||||||
const { processSendNotification } = await import('../processors/send-notification.processor');
|
const { processSendNotification } = await import('../processors/send-notification.processor');
|
||||||
@@ -569,7 +611,8 @@ export class JobQueueService {
|
|||||||
delaySeconds: number = 0,
|
delaySeconds: number = 0,
|
||||||
lastProgress?: number,
|
lastProgress?: number,
|
||||||
stallCount?: number,
|
stallCount?: number,
|
||||||
pathWaitCount?: number
|
pathWaitCount?: number,
|
||||||
|
connectionFailureCount?: number
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'monitor_download',
|
'monitor_download',
|
||||||
@@ -581,6 +624,7 @@ export class JobQueueService {
|
|||||||
lastProgress,
|
lastProgress,
|
||||||
stallCount,
|
stallCount,
|
||||||
pathWaitCount,
|
pathWaitCount,
|
||||||
|
connectionFailureCount,
|
||||||
} as MonitorDownloadPayload,
|
} as MonitorDownloadPayload,
|
||||||
{
|
{
|
||||||
priority: 5, // Medium priority
|
priority: 5, // Medium priority
|
||||||
@@ -597,7 +641,8 @@ export class JobQueueService {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
audiobookId: string,
|
audiobookId: string,
|
||||||
downloadPath: string,
|
downloadPath: string,
|
||||||
targetPath?: string
|
targetPath?: string,
|
||||||
|
cleanupSource?: boolean
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'organize_files',
|
'organize_files',
|
||||||
@@ -606,6 +651,7 @@ export class JobQueueService {
|
|||||||
audiobookId,
|
audiobookId,
|
||||||
downloadPath,
|
downloadPath,
|
||||||
targetPath, // Not used by processor
|
targetPath, // Not used by processor
|
||||||
|
cleanupSource,
|
||||||
} as OrganizeFilesPayload,
|
} as OrganizeFilesPayload,
|
||||||
{
|
{
|
||||||
priority: 8,
|
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
|
// EBOOK-SPECIFIC JOB METHODS
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -45,13 +45,31 @@ export class AppriseProvider implements INotificationProvider {
|
|||||||
const meta = getEventMeta(payload.event);
|
const meta = getEventMeta(payload.event);
|
||||||
const { title, body } = this.formatMessage(payload);
|
const { title, body } = this.formatMessage(payload);
|
||||||
|
|
||||||
const serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
|
// Parse URL to extract embedded HTTP Basic Auth credentials (e.g. https://user:pass@host/)
|
||||||
const notificationType = SEVERITY_TYPES[meta.severity];
|
let serverUrl: string;
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'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) {
|
if (appriseConfig.authToken) {
|
||||||
headers['Authorization'] = `Bearer ${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 { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { seedAsin } from '@/lib/services/works.service';
|
||||||
|
|
||||||
const logger = RMABLogger.create('RequestCreator');
|
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
|
// Check if user already has an active request for this audiobook
|
||||||
const existingRequest = await prisma.request.findFirst({
|
const existingRequest = await prisma.request.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { RMABLogger } from '../utils/logger';
|
|||||||
|
|
||||||
const logger = RMABLogger.create('Scheduler');
|
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 {
|
export interface ScheduledJob {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -133,6 +133,13 @@ export class SchedulerService {
|
|||||||
enabled: true, // Enable by default
|
enabled: true, // Enable by default
|
||||||
payload: {},
|
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;
|
let created = 0;
|
||||||
@@ -353,6 +360,9 @@ export class SchedulerService {
|
|||||||
case 'sync_goodreads_shelves':
|
case 'sync_goodreads_shelves':
|
||||||
bullJobId = await this.triggerSyncGoodreadsShelves(job);
|
bullJobId = await this.triggerSyncGoodreadsShelves(job);
|
||||||
break;
|
break;
|
||||||
|
case 'check_watched_lists':
|
||||||
|
bullJobId = await this.triggerCheckWatchedLists(job);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown job type: ${job.type}`);
|
throw new Error(`Unknown job type: ${job.type}`);
|
||||||
}
|
}
|
||||||
@@ -627,6 +637,13 @@ export class SchedulerService {
|
|||||||
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
|
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
|
||||||
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
|
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
|
// 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 { prisma } from '@/lib/db';
|
||||||
import { LibraryItem } from '@/lib/services/library';
|
import { LibraryItem } from '@/lib/services/library';
|
||||||
|
import { getSiblingAsins } from '@/lib/services/works.service';
|
||||||
import { RMABLogger } from './logger';
|
import { RMABLogger } from './logger';
|
||||||
|
|
||||||
// Module-level 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)
|
// Always enrich with request status (check ANY user's requests)
|
||||||
const asins = audiobooks.map(book => book.asin);
|
const asins = audiobooks.map(book => book.asin);
|
||||||
|
|
||||||
@@ -272,6 +328,57 @@ export async function enrichAudiobooksWithMatches(
|
|||||||
return results;
|
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)
|
* 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),
|
}) => (isOpen ? <div>Interactive search for {audiobook.title}</div> : null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/app/admin/components/AdjustSearchTermsModal', () => ({
|
||||||
|
AdjustSearchTermsModal: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('RequestActionsDropdown', () => {
|
describe('RequestActionsDropdown', () => {
|
||||||
it('exposes manual search, interactive search, cancel, and delete actions', async () => {
|
it('exposes manual search, interactive search, cancel, and delete actions', async () => {
|
||||||
const onManualSearch = vi.fn().mockResolvedValue(undefined);
|
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} />,
|
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/components/ui/StickyPagination', () => ({
|
vi.mock('@/components/ui/UnifiedPagination', () => ({
|
||||||
StickyPagination: ({
|
UnifiedPagination: ({
|
||||||
label,
|
sections,
|
||||||
onPageChange,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
sections: Array<{
|
||||||
onPageChange: (page: number) => void;
|
label: string;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}>;
|
||||||
}) => (
|
}) => (
|
||||||
<button type="button" onClick={() => onPageChange(2)}>
|
<div>
|
||||||
{label} next
|
{sections.map((s) => (
|
||||||
</button>
|
<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' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
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 { resetMockAuthState } from '../helpers/mock-auth';
|
||||||
import { resetMockRouter } from '../helpers/mock-next-navigation';
|
import { resetMockRouter } from '../helpers/mock-next-navigation';
|
||||||
|
|
||||||
|
const loadMoreMock = vi.hoisted(() => vi.fn());
|
||||||
const useSearchMock = vi.hoisted(() => vi.fn());
|
const useSearchMock = vi.hoisted(() => vi.fn());
|
||||||
const usePreferencesMock = vi.hoisted(() => ({
|
const usePreferencesMock = vi.hoisted(() => ({
|
||||||
cardSize: 5,
|
cardSize: 5,
|
||||||
setCardSize: vi.fn(),
|
setCardSize: vi.fn(),
|
||||||
|
squareCovers: false,
|
||||||
|
setSquareCovers: vi.fn(),
|
||||||
|
hideAvailable: false,
|
||||||
|
setHideAvailable: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/hooks/useAudiobooks', () => ({
|
vi.mock('@/lib/hooks/useAudiobooks', () => ({
|
||||||
useSearch: useSearchMock,
|
useSearch: useSearchMock,
|
||||||
|
Audiobook: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/contexts/PreferencesContext', () => ({
|
vi.mock('@/contexts/PreferencesContext', () => ({
|
||||||
@@ -49,8 +55,30 @@ vi.mock('@/components/audiobooks/AudiobookGrid', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/components/ui/CardSizeControls', () => ({
|
vi.mock('@/components/ui/SectionToolbar', () => ({
|
||||||
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
|
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', () => {
|
describe('SearchPage', () => {
|
||||||
@@ -58,6 +86,7 @@ describe('SearchPage', () => {
|
|||||||
resetMockAuthState();
|
resetMockAuthState();
|
||||||
resetMockRouter();
|
resetMockRouter();
|
||||||
useSearchMock.mockReset();
|
useSearchMock.mockReset();
|
||||||
|
loadMoreMock.mockReset();
|
||||||
usePreferencesMock.cardSize = 5;
|
usePreferencesMock.cardSize = 5;
|
||||||
usePreferencesMock.setCardSize.mockReset();
|
usePreferencesMock.setCardSize.mockReset();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -74,34 +103,25 @@ describe('SearchPage', () => {
|
|||||||
totalResults: 0,
|
totalResults: 0,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isLoadingMore: false,
|
||||||
|
loadMore: loadMoreMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { default: SearchPage } = await import('@/app/search/page');
|
const { default: SearchPage } = await import('@/app/search/page');
|
||||||
render(<SearchPage />);
|
render(<SearchPage />);
|
||||||
|
|
||||||
expect(screen.getByText('Start typing to search for audiobooks')).toBeInTheDocument();
|
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 () => {
|
it('debounces search input and loads more results', async () => {
|
||||||
useSearchMock.mockImplementation((query: string, page: number) => {
|
useSearchMock.mockReturnValue({
|
||||||
if (!query) {
|
results: [{ asin: 'a1', title: 'Book One', author: 'Author' }],
|
||||||
return { results: [], totalResults: 0, hasMore: false, isLoading: false };
|
totalResults: 2,
|
||||||
}
|
hasMore: true,
|
||||||
if (page === 1) {
|
isLoading: false,
|
||||||
return {
|
isLoadingMore: false,
|
||||||
results: [{ asin: 'a1', title: 'Book One', author: 'Author' }],
|
loadMore: loadMoreMock,
|
||||||
totalResults: 2,
|
|
||||||
hasMore: true,
|
|
||||||
isLoading: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
results: [{ asin: 'a2', title: 'Book Two', author: 'Author' }],
|
|
||||||
totalResults: 2,
|
|
||||||
hasMore: false,
|
|
||||||
isLoading: false,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { default: SearchPage } = await import('@/app/search/page');
|
const { default: SearchPage } = await import('@/app/search/page');
|
||||||
@@ -115,11 +135,11 @@ describe('SearchPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText('Search Results')).toBeInTheDocument();
|
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');
|
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