Compare commits

...

25 Commits

Author SHA1 Message Date
kikootwo 53c1e0dad7 Merge pull request #131 from borski/pr-130-review
feature/api_tokens review fixes: role enforcement security + UI bugfixes
2026-03-04 23:03:14 -05:00
Michael Borohovski 45c8b614e3 Remove role override UI since backend enforces user's actual role
The role override dropdown is now misleading since the backend rejects
any attempt to set a role that differs from the target user's actual role.
Removed the dropdown and added helper text explaining that the token
inherits the selected user's role.
2026-03-04 17:15:46 -08:00
Michael Borohovski 24aa6afefc Add tests for admin token creation role enforcement 2026-03-04 16:57:02 -08:00
Michael Borohovski 81813dc625 Fix token UI success handling, fetch error surfacing, and docs key stability 2026-03-04 16:53:11 -08:00
Michael Borohovski a5e7af1a53 Harden admin token creation to enforce target user role 2026-03-04 16:27:52 -08:00
kikootwo 95917715b1 Remove redundant id field from JWT payloads
Drop the duplicated `id` alias from JWT payloads and related token generation across auth providers and endpoints. The TokenPayload interface no longer includes `id`; middleware now derives `user.id` from `sub` when attaching the authenticated user to requests. Update tests accordingly. This reduces redundancy and ensures the canonical user identifier is `sub`.
2026-03-04 15:36:28 -05:00
kikootwo a50fbc721e Add useApiTokens hook and refactor token UI
Introduce a shared useApiTokens hook to centralize API token CRUD and UI state (fetch, create, delete, copy, formatting). Refactor ApiTab and ApiTokensSection to consume the hook and remove duplicated logic. Add getInstanceUrl utility for client origin used in curl examples. Include an id alias in TokenPayload and add id into generated JWTs across auth routes and providers; update tests accordingly. Improve auth middleware typing and add debug logging around lastUsedAt updates. Add admin logging when creating a token with a role that differs from the target user's role.
2026-03-04 15:18:48 -05:00
kikootwo d6eca611fc Add API tokens management, docs & UI
Introduce full API token support: add a Prisma migration to create api_tokens table and indexes; add types, constants and a generateApiToken utility (hashed token + prefix). Update admin and user token routes to use the generator, enforce per-user active token caps, and integrate rate-limit checks. Add an interactive API docs page with TokenInput, EndpointCard and ResponseViewer components, plus a protected page route. Improve confirmation UX with an accessible ConfirmDialog (focus trap, Escape to close, animations) and wire confirm flows into admin/profile token sections; also update ConfirmModal to accept node messages. Add dialog CSS animations and enhance clipboard error handling. Update related middleware, utils and tests to reflect changes.
2026-03-04 14:51:23 -05:00
kikootwo 45e818c181 Merge pull request #127 from borski/feature/per-user-api-tokens
Add per-user API tokens with security hardening
2026-03-04 13:30:52 -05:00
kikootwo 85977d123c Merge branch 'main' into feature/per-user-api-tokens 2026-03-04 13:26:57 -05:00
kikootwo 441724c378 Normalize local usernames to lowercase
Normalize local account usernames by trimming and lowercasing across the stack. Added a Prisma migration to lowercase existing plex_username and rewrite local plex_id values for non-deleted accounts. Updated LocalAuthProvider, admin login route, and setup completion to use normalized usernames when looking up, creating, and storing users (including plexId `local-{username}`). Added/updated tests to assert case-insensitive lookups, storage of lowercased usernames/plexIds, and duplicate username rejection.
2026-03-04 12:47:09 -05:00
kikootwo d0ce485bdc Enrich audiobook metadata from Audnexus
Query Audnexus (Audible) to backfill missing metadata during manual imports and file organization. Adds getAudibleService imports and calls to fetch audiobook details by ASIN, then backfills series, seriesPart, seriesAsin, year (from releaseDate) and narrator when missing and updates the DB. Failures are non-fatal and logged; logs were added to surface enrichment steps. Also uses the resolved series/seriesPart when building organization metadata.
2026-03-04 12:19:37 -05:00
kikootwo cbf02d3e24 Add watched series/authors feature
Introduce watched lists for series and authors end-to-end.

- Add DB migration to create watched_series and watched_authors tables with indexes and foreign keys.
- Implement API routes: GET/POST for listing/adding and DELETE by id for both /api/user/watched-series and /api/user/watched-authors. Validation, ownership checks, and immediate targeted job triggers are included.
- Add client hooks (useWatchedSeries, useWatchedAuthors) with add/delete helpers and SWR revalidation.
- Add UI components: WatchButton (toggle/confirm) and WatchedListsSection for profile display and removal UX.
- Add processor (check-watched-lists.processor) and service (watched-lists.service) to scrape Audible, deduplicate, check library ownership, and auto-create requests; supports targeted checks for newly watched items.
- Include tests for the watched-lists service.

These changes implement the watched-lists feature to let users watch series/authors and have the system automatically detect and request new releases.
2026-03-03 21:57:38 -05:00
Michael Borohovski f0b2476b87 Add tests for security hardening: deleted user auth rejection, rate limiting 2026-03-03 15:47:19 -08:00
Michael Borohovski 04b6a2c135 Harden API token auth for deleted users and add route rate limiting 2026-03-03 15:16:03 -08:00
Michael Borohovski 61b183542c Add per-user API tokens with admin override support
- Add userId field to ApiToken schema (the user identity the token acts as)
- Auth middleware resolves token identity via userId instead of createdById
- New /api/user/api-tokens routes for self-service token management
- Admin /api/admin/api-tokens routes support userId and role overrides
- API Tokens section on profile page for all users
- Admin API tab shows all tokens with user/role selectors
2026-03-03 12:23:57 -08:00
kikootwo 610873af6b Add works table and ASIN deduping
Add persistent cross-ASIN "works" mapping and client-side deduplication to improve library matching. Introduces a Prisma migration and models (Work, WorkAsin) plus src/lib/services/works.service for persisting dedup groups, seeding ASINs at request time, and sibling lookup. Adds a deduplication utility (deduplicate-audiobooks) that normalizes titles/narrators, compares durations, and returns grouping metadata; API routes (search, author, series) now deduplicate results before enrichment and fire-and-forget persist groups. Adds sibling-ASIN expansion into audiobook matcher and expands getAvailableAsins accordingly. Extracts runtime parsing into a shared parse-runtime util and updates audible scrapers/services to use it. Includes unit tests for dedup logic and works service and updates test Prisma mocks.
2026-03-03 13:31:46 -05:00
kikootwo ff80d995c5 Add hideAvailable filter and unified pagination
Add support for hiding audiobooks that are already available by introducing a hideAvailable query flag and excluding matching ASINs at the DB level. Implemented getAvailableAsins() in audiobook-matcher to gather ASINs from the library and completed requests, and wired it into the popular and new-releases API routes to apply a notIn filter. Propagated the hideAvailable flag through useAudiobooks so client requests include the parameter, and adjusted the homepage to reset pagination when the flag changes. Replaced two StickyPagination instances with a new UnifiedPagination component (new file) that provides a single context-aware floating paginator which tracks the dominant section and allows switching between Popular and New Releases. Also removed client-side filtering in favor of server-side exclusion and made small imports/cleanup in page.tsx.
2026-03-03 12:36:03 -05:00
kikootwo bfd624e120 Bump package version to 1.0.16
Update package.json version from 1.0.15 to 1.0.16 to reflect a new patch release.
2026-03-02 17:06:01 -05:00
kikootwo b559835390 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-03-02 17:05:28 -05:00
kikootwo d25a6ebf79 Add custom search terms & retry download (admin)
Add support for per-request custom search terms and an admin retry-download flow.

- DB/schema: add custom_search_terms column via Prisma migration and schema update.
- Admin UI: new AdjustSearchTermsModal component and UI badges to show custom search status; RequestActionsDropdown and RecentRequestsTable updated to surface adjust/retry actions.
- API: new PATCH /api/admin/requests/[id]/search-terms to set/clear custom terms (optionally trigger a new search) and new POST /api/admin/requests/[id]/retry-download to resume monitoring or re-add downloads using DownloadHistory metadata.
- Behavior: interactive search now prefers customSearchTerms when present; manual import exposes cleanupSource option to organize job; admin requests listing returns downloadAttempts and customSearchTerms.
- UX: add SectionToolbar, LoadMoreBar and HideAvailableToggle components and wire hide-available preference across home, search, author and series pages; authors/series endpoints/page handlers gain pagination metadata.
- Misc: add connection-errors util and update related processors/services and tests to cover the new flows.

These changes enable admins to override search terms per request, trigger searches from the admin UI, and retry failed downloads more robustly.
2026-03-02 17:05:21 -05:00
kikootwo b3dad47aba Merge pull request #120 from brombomb/gemini
Add gemini bookdate support
2026-03-02 16:51:43 -05:00
Rob Walsh 7891e31893 Undo formatting noise 2026-03-02 13:58:11 -07:00
Rob Walsh bff74446fe Fix gemini key 2026-03-02 13:48:49 -07:00
Rob Walsh 038c92e49f Add gemini bookdate support 2026-02-28 22:55:59 -07:00
112 changed files with 9882 additions and 820 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "readmeabook",
"version": "1.0.15",
"version": "1.0.16",
"private": true,
"scripts": {
"dev": "next dev",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "requests" ADD COLUMN "custom_search_terms" TEXT;
@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "works" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"author" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "works_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "work_asins" (
"id" TEXT NOT NULL,
"work_id" TEXT NOT NULL,
"asin" TEXT NOT NULL,
"narrator" TEXT,
"duration_minutes" INTEGER,
"is_canonical" BOOLEAN NOT NULL DEFAULT false,
"source" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "work_asins_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "works_title_idx" ON "works"("title");
-- CreateIndex
CREATE INDEX "works_author_idx" ON "works"("author");
-- CreateIndex
CREATE UNIQUE INDEX "work_asins_asin_key" ON "work_asins"("asin");
-- CreateIndex
CREATE INDEX "work_asins_work_id_idx" ON "work_asins"("work_id");
-- CreateIndex
CREATE INDEX "work_asins_asin_idx" ON "work_asins"("asin");
-- AddForeignKey
ALTER TABLE "work_asins" ADD CONSTRAINT "work_asins_work_id_fkey" FOREIGN KEY ("work_id") REFERENCES "works"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,51 @@
-- CreateTable
CREATE TABLE "watched_series" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"series_asin" TEXT NOT NULL,
"series_title" TEXT NOT NULL,
"cover_art_url" TEXT,
"last_checked_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "watched_series_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "watched_authors" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"author_asin" TEXT NOT NULL,
"author_name" TEXT NOT NULL,
"cover_art_url" TEXT,
"last_checked_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "watched_authors_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "watched_series_user_id_idx" ON "watched_series"("user_id");
-- CreateIndex
CREATE INDEX "watched_series_series_asin_idx" ON "watched_series"("series_asin");
-- CreateIndex
CREATE UNIQUE INDEX "watched_series_user_id_series_asin_key" ON "watched_series"("user_id", "series_asin");
-- CreateIndex
CREATE INDEX "watched_authors_user_id_idx" ON "watched_authors"("user_id");
-- CreateIndex
CREATE INDEX "watched_authors_author_asin_idx" ON "watched_authors"("author_asin");
-- CreateIndex
CREATE UNIQUE INDEX "watched_authors_user_id_author_asin_key" ON "watched_authors"("user_id", "author_asin");
-- AddForeignKey
ALTER TABLE "watched_series" ADD CONSTRAINT "watched_series_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "watched_authors" ADD CONSTRAINT "watched_authors_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- Normalize existing local usernames to lowercase
UPDATE users SET plex_username = LOWER(plex_username) WHERE auth_provider = 'local' AND deleted_at IS NULL;
UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7)) WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%';
@@ -0,0 +1,33 @@
-- CreateTable
CREATE TABLE "api_tokens" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"token_prefix" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'user',
"created_by_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"last_used_at" TIMESTAMP(3),
"expires_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "api_tokens_token_hash_key" ON "api_tokens"("token_hash");
-- CreateIndex
CREATE INDEX "api_tokens_token_hash_idx" ON "api_tokens"("token_hash");
-- CreateIndex
CREATE INDEX "api_tokens_created_by_id_idx" ON "api_tokens"("created_by_id");
-- CreateIndex
CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens"("user_id");
-- AddForeignKey
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+118 -1
View File
@@ -68,6 +68,10 @@ model User {
goodreadsShelves GoodreadsShelf[]
reportedIssues ReportedIssue[] @relation("Reporter")
resolvedIssues ReportedIssue[] @relation("Resolver")
createdApiTokens ApiToken[] @relation("CreatedApiTokens")
apiTokens ApiToken[] @relation("UserApiTokens")
watchedSeries WatchedSeries[]
watchedAuthors WatchedAuthor[]
@@index([plexId])
@@index([role])
@@ -232,6 +236,7 @@ model Request {
importAttempts Int @default(0) @map("import_attempts")
maxImportRetries Int @default(5) @map("max_import_retries")
lastSearchAt DateTime? @map("last_search_at")
customSearchTerms String? @map("custom_search_terms") @db.Text
lastImportAt DateTime? @map("last_import_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -391,7 +396,7 @@ model ScheduledJob {
model BookDateConfig {
id String @id @default(uuid())
provider String // 'openai' | 'claude' | 'custom'
provider String // 'openai' | 'claude' | 'gemini' | 'custom'
apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256)
model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929'
baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints)
@@ -495,6 +500,34 @@ model ReportedIssue {
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
// ============================================================================
// ============================================================================
// API TOKEN TABLE
// Static API tokens for programmatic access (alternative to JWT)
// Documentation: documentation/backend/services/api-tokens.md
// ============================================================================
model ApiToken {
id String @id @default(uuid())
name String // User-friendly label (e.g., "Home Assistant", "Webhook")
tokenHash String @unique @map("token_hash") // SHA-256 hash of the token (never store plaintext)
tokenPrefix String @map("token_prefix") // First 8 chars for display (e.g., "rmab_a1b2")
role String @default("user") // Token role: 'admin' or 'user'
createdById String @map("created_by_id") // Who created the token (may differ from userId for admin-created tokens)
userId String @map("user_id") // The user identity this token acts as
lastUsedAt DateTime? @map("last_used_at")
expiresAt DateTime? @map("expires_at") // null = never expires
createdAt DateTime @default(now()) @map("created_at")
// Relations
createdBy User @relation("CreatedApiTokens", fields: [createdById], references: [id], onDelete: Cascade)
tokenUser User @relation("UserApiTokens", fields: [userId], references: [id], onDelete: Cascade)
@@index([tokenHash])
@@index([createdById])
@@index([userId])
@@map("api_tokens")
}
model GoodreadsShelf {
id String @id @default(uuid())
userId String @map("user_id")
@@ -530,3 +563,87 @@ model GoodreadsBookMapping {
@@index([audibleAsin])
@@map("goodreads_book_mappings")
}
// ============================================================================
// WORKS TABLE
// Cross-ASIN audiobook identity mapping — links multiple Audible ASINs
// to a single logical work for library matching across editions.
// Documentation: documentation/integrations/audible.md
// ============================================================================
model Work {
id String @id @default(uuid())
title String
author String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
asins WorkAsin[]
@@index([title])
@@index([author])
@@map("works")
}
model WorkAsin {
id String @id @default(uuid())
workId String @map("work_id")
asin String @unique
narrator String?
durationMinutes Int? @map("duration_minutes")
isCanonical Boolean @default(false) @map("is_canonical")
source String // 'dedup_auto' | 'admin_manual'
createdAt DateTime @default(now()) @map("created_at")
// Relations
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
@@index([workId])
@@index([asin])
@@map("work_asins")
}
// ============================================================================
// WATCHED LISTS TABLES
// Per-user series and author subscriptions for automatic new-release requests.
// Documentation: documentation/features/watched-lists.md
// ============================================================================
model WatchedSeries {
id String @id @default(uuid())
userId String @map("user_id")
seriesAsin String @map("series_asin")
seriesTitle String @map("series_title")
coverArtUrl String? @map("cover_art_url") @db.Text
lastCheckedAt DateTime? @map("last_checked_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, seriesAsin])
@@index([userId])
@@index([seriesAsin])
@@map("watched_series")
}
model WatchedAuthor {
id String @id @default(uuid())
userId String @map("user_id")
authorAsin String @map("author_asin")
authorName String @map("author_name")
coverArtUrl String? @map("cover_art_url") @db.Text
lastCheckedAt DateTime? @map("last_checked_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, authorAsin])
@@index([userId])
@@index([authorAsin])
@@map("watched_authors")
}
@@ -0,0 +1,154 @@
/**
* Component: Adjust Search Terms Modal
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { useState } from 'react';
import { Modal } from '@/components/ui/Modal';
import { fetchWithAuth } from '@/lib/utils/api';
import { useToast } from '@/components/ui/Toast';
interface AdjustSearchTermsModalProps {
isOpen: boolean;
onClose: () => void;
requestId: string;
title: string;
author: string;
currentSearchTerms?: string | null;
onSuccess?: () => void;
}
export function AdjustSearchTermsModal({
isOpen,
onClose,
requestId,
title,
author,
currentSearchTerms,
onSuccess,
}: AdjustSearchTermsModalProps) {
const toast = useToast();
const [searchTerms, setSearchTerms] = useState(currentSearchTerms || title);
const [isSaving, setIsSaving] = useState(false);
const [isSavingAndSearching, setIsSavingAndSearching] = useState(false);
// Reset state when modal opens
const handleClose = () => {
setSearchTerms(currentSearchTerms || title);
onClose();
};
const save = async (triggerSearch: boolean) => {
const setter = triggerSearch ? setIsSavingAndSearching : setIsSaving;
setter(true);
try {
// If terms match the original title, clear the override
const termsToSave = searchTerms.trim() === title ? null : searchTerms.trim() || null;
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/search-terms`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ searchTerms: termsToSave, triggerSearch }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to update search terms');
}
const data = await response.json();
if (data.searchTriggered) {
toast.success('Search terms saved and search triggered');
} else {
toast.success('Search terms saved');
}
onSuccess?.();
onClose();
} catch (error) {
toast.error(`Failed to save: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setter(false);
}
};
const handleReset = () => {
setSearchTerms(title);
};
const isLoading = isSaving || isSavingAndSearching;
const hasChanges = searchTerms.trim() !== (currentSearchTerms || title);
const isCustom = searchTerms.trim() !== title;
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Adjust Search Terms" size="sm">
<div className="space-y-4">
{/* Original info */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 space-y-1">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Original Title
</div>
<div className="text-sm text-gray-900 dark:text-gray-100 font-medium">{title}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">by {author}</div>
</div>
{/* Search terms input */}
<div>
<label
htmlFor="search-terms"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
>
Search Terms
</label>
<input
id="search-terms"
type="text"
value={searchTerms}
onChange={(e) => setSearchTerms(e.target.value)}
disabled={isLoading}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
placeholder="Enter custom search terms..."
/>
{isCustom && (
<button
onClick={handleReset}
disabled={isLoading}
className="mt-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors disabled:opacity-50"
>
Reset to original title
</button>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleClose}
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => save(false)}
disabled={isLoading || !searchTerms.trim()}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{isSaving ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => save(true)}
disabled={isLoading || !searchTerms.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
{isSavingAndSearching ? 'Saving...' : 'Save & Search'}
</button>
</div>
</div>
</Modal>
);
}
+153 -74
View File
@@ -2,12 +2,13 @@
* Component: Confirm Dialog
* Documentation: documentation/frontend/components.md
*
* Reusable confirmation dialog for destructive actions
* Reusable confirmation dialog for destructive actions.
* Features: backdrop blur, smooth enter animation, Escape to close, focus trap, ARIA.
*/
'use client';
import { Fragment } from 'react';
import React, { useEffect, useRef } from 'react';
export interface ConfirmDialogProps {
isOpen: boolean;
@@ -30,99 +31,177 @@ export function ConfirmDialog({
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Focus the cancel button on open (safer default for destructive dialogs)
useEffect(() => {
if (isOpen) {
// Small delay to let animation start before stealing focus
const t = setTimeout(() => cancelRef.current?.focus(), 50);
return () => clearTimeout(t);
}
}, [isOpen]);
// Escape to close + focus trap
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
return;
}
// Focus trap: tab cycles only within dialog
if (e.key === 'Tab') {
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusable || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onCancel]);
// Prevent body scroll while open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}
}, [isOpen]);
if (!isOpen) return null;
const confirmButtonClasses =
confirmVariant === 'danger'
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-blue-600 hover:bg-blue-700 text-white';
const isDestructive = confirmVariant === 'danger';
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-desc"
className="fixed inset-0 z-50 flex items-center justify-center p-4"
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
className="animate-dialog-backdrop fixed inset-0 bg-black/40 backdrop-blur-sm"
onClick={onCancel}
aria-hidden="true"
/>
{/* Dialog */}
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
{/* Icon */}
<div
className={`mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${
confirmVariant === 'danger'
? 'bg-red-100 dark:bg-red-900'
: 'bg-blue-100 dark:bg-blue-900'
} sm:mx-0 sm:h-10 sm:w-10`}
>
{/* Panel */}
<div
ref={dialogRef}
className="animate-dialog-panel relative w-full max-w-sm rounded-2xl overflow-hidden bg-white dark:bg-gray-900 shadow-2xl ring-1 ring-black/10 dark:ring-white/10"
>
{/* Header */}
<div className="px-6 pt-6 pb-4">
<div className="flex items-start gap-4">
{/* Icon well */}
<div className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full ${
isDestructive
? 'bg-red-50 dark:bg-red-500/10'
: 'bg-blue-50 dark:bg-blue-500/10'
}`}>
{isDestructive ? (
<svg
className={`h-6 w-6 ${
confirmVariant === 'danger'
? 'text-red-600 dark:text-red-400'
: 'text-blue-600 dark:text-blue-400'
}`}
className="w-5 h-5 text-red-500 dark:text-red-400"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
strokeWidth="1.75"
stroke="currentColor"
aria-hidden="true"
>
{confirmVariant === 'danger' ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
)}
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
) : (
<svg
className="w-5 h-5 text-blue-500 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.75"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
)}
</div>
{/* Content */}
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left flex-1">
<h3 className="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
{title}
</h3>
<div className="mt-2">
{typeof message === 'string' ? (
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-pre-line">
{message}
</p>
) : (
<div className="text-sm text-gray-500 dark:text-gray-400">
{message}
</div>
)}
</div>
{/* Text */}
<div className="flex-1 min-w-0 pt-0.5">
<h3
id="confirm-dialog-title"
className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-50"
>
{title}
</h3>
<div id="confirm-dialog-desc" className="mt-1.5">
{typeof message === 'string' ? (
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
{message}
</p>
) : (
<div className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
{message}
</div>
)}
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="bg-gray-50 dark:bg-gray-900 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
<button
type="button"
onClick={onConfirm}
className={`inline-flex w-full justify-center rounded-lg px-4 py-2 text-sm font-semibold shadow-sm sm:w-auto transition-colors ${confirmButtonClasses}`}
>
{confirmLabel}
</button>
<button
type="button"
onClick={onCancel}
className="mt-3 inline-flex w-full justify-center rounded-lg bg-white dark:bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:mt-0 sm:w-auto transition-colors"
>
{cancelLabel}
</button>
</div>
{/* Action bar */}
<div className="flex items-center justify-end gap-2 px-6 py-4 bg-gray-50/80 dark:bg-white/[0.03] border-t border-gray-100 dark:border-white/[0.06]">
<button
ref={cancelRef}
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium rounded-xl text-gray-700 dark:text-gray-300 bg-white dark:bg-white/[0.06] hover:bg-gray-100 dark:hover:bg-white/[0.1] border border-gray-200 dark:border-white/[0.1] transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900"
>
{cancelLabel}
</button>
<button
ref={confirmRef}
type="button"
onClick={onConfirm}
className={`px-4 py-2 text-sm font-medium rounded-xl text-white transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900 active:scale-[0.97] ${
isDestructive
? 'bg-red-600 hover:bg-red-700 focus-visible:ring-red-500'
: 'bg-blue-600 hover:bg-blue-700 focus-visible:ring-blue-500'
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
@@ -28,6 +28,8 @@ interface RecentRequest {
completedAt: Date | null;
errorMessage: string | null;
torrentUrl?: string | null;
downloadAttempts?: number;
customSearchTerms?: string | null;
}
interface User {
@@ -444,6 +446,29 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
}
};
const handleRetryDownload = async (requestId: string) => {
try {
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/retry-download`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
const responseData = await response.json();
if (!response.ok) {
throw new Error(responseData.message || 'Failed to retry download');
}
toast.success(responseData.message || 'Download retry initiated');
await mutate(apiUrl);
} catch (error) {
console.error('[Admin] Failed to retry download:', error);
toast.error(`Failed to retry download: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
// Render loading state
if (isLoading && !data) {
return (
@@ -638,6 +663,17 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
Ebook
</span>
)}
{request.customSearchTerms && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200"
title={`Custom search: ${request.customSearchTerms}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Custom Search
</span>
)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{request.author}
@@ -673,12 +709,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
type: request.type,
asin: request.asin,
torrentUrl: request.torrentUrl,
downloadAttempts: request.downloadAttempts,
customSearchTerms: request.customSearchTerms,
}}
onDelete={handleDeleteClick}
onManualSearch={handleManualSearch}
onCancel={handleCancel}
onRetryDownload={handleRetryDownload}
onViewDetails={(asin) => handleViewDetails(asin, request.status)}
onFetchEbook={handleFetchEbook}
onSearchTermsUpdated={() => mutate(apiUrl)}
ebookSidecarEnabled={ebookSidecarEnabled}
annasArchiveBaseUrl={annasArchiveBaseUrl}
isLoading={isDeleting || isFetchingEbook}
@@ -835,7 +875,6 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
}}
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
requestStatus={viewDetailsStatus}
hideRequestActions
/>
)}
</div>
@@ -10,6 +10,7 @@
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
export interface RequestActionsDropdownProps {
@@ -21,12 +22,16 @@ export interface RequestActionsDropdownProps {
type?: 'audiobook' | 'ebook';
asin?: string | null;
torrentUrl?: string | null;
downloadAttempts?: number;
customSearchTerms?: string | null;
};
onDelete: (requestId: string, title: string) => void;
onManualSearch: (requestId: string) => Promise<void>;
onCancel: (requestId: string) => Promise<void>;
onRetryDownload?: (requestId: string) => Promise<void>;
onViewDetails?: (asin: string) => void;
onFetchEbook?: (requestId: string) => Promise<void>;
onSearchTermsUpdated?: () => void;
ebookSidecarEnabled?: boolean;
annasArchiveBaseUrl?: string;
isLoading?: boolean;
@@ -37,8 +42,10 @@ export function RequestActionsDropdown({
onDelete,
onManualSearch,
onCancel,
onRetryDownload,
onViewDetails,
onFetchEbook,
onSearchTermsUpdated,
ebookSidecarEnabled = false,
annasArchiveBaseUrl = 'https://annas-archive.li',
isLoading = false,
@@ -46,6 +53,7 @@ export function RequestActionsDropdown({
const [isOpen, setIsOpen] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
// Determine request type
@@ -57,6 +65,8 @@ export function RequestActionsDropdown({
// Determine available actions based on status and type
// Ebooks don't support manual/interactive search (Anna's Archive only)
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canAdjustSearchTerms = !isEbook && ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
const canDelete = true; // Admins can always delete
@@ -123,11 +133,27 @@ export function RequestActionsDropdown({
setShowInteractiveSearch(true);
};
const handleAdjustSearchTerms = () => {
setIsOpen(false);
setShowAdjustSearchTerms(true);
};
const handleInteractiveSearchEbook = () => {
setIsOpen(false);
setShowInteractiveSearchEbook(true);
};
const handleRetryDownload = async () => {
setIsOpen(false);
if (onRetryDownload) {
try {
await onRetryDownload(request.requestId);
} catch (error) {
console.error('Failed to retry download:', error);
}
}
};
const handleCancel = async () => {
setIsOpen(false);
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
@@ -253,6 +279,35 @@ export function RequestActionsDropdown({
</button>
)}
{/* Adjust Search Terms */}
{canAdjustSearchTerms && (
<button
onClick={handleAdjustSearchTerms}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
role="menuitem"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span className="flex items-center gap-1.5">
Adjust Search Terms
{request.customSearchTerms && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
)}
</span>
</button>
)}
{/* View Source */}
{canViewSource && viewSourceUrl && (
<a
@@ -328,8 +383,32 @@ export function RequestActionsDropdown({
</button>
)}
{/* Divider if we have search/view actions and other actions */}
{(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && (
{/* Retry Download */}
{canRetryDownload && (
<button
onClick={handleRetryDownload}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
role="menuitem"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Retry Download
</button>
)}
{/* Divider if we have search/view/retry actions and other actions */}
{(canSearch || canViewSource || canFetchEbook || canRetryDownload) && (canCancel || canDelete) && (
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
)}
@@ -358,7 +437,7 @@ export function RequestActionsDropdown({
)}
{/* Divider before delete */}
{canDelete && (canSearch || canCancel) && (
{canDelete && (canSearch || canRetryDownload || canCancel) && (
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
)}
@@ -421,6 +500,7 @@ export function RequestActionsDropdown({
title: request.title,
author: request.author,
}}
customSearchTerms={request.customSearchTerms}
/>
{/* Interactive Search Modal (Ebook) */}
@@ -434,6 +514,17 @@ export function RequestActionsDropdown({
}}
searchMode="ebook"
/>
{/* Adjust Search Terms Modal */}
<AdjustSearchTermsModal
isOpen={showAdjustSearchTerms}
onClose={() => setShowAdjustSearchTerms(false)}
requestId={request.requestId}
title={request.title}
author={request.author}
currentSearchTerms={request.customSearchTerms}
onSuccess={onSearchTermsUpdated}
/>
</>
);
}
+2
View File
@@ -210,6 +210,7 @@ export const getTabValidation = (
return validated.paths;
case 'ebook':
case 'bookdate':
case 'api':
return true; // These tabs handle their own saving
default:
return false;
@@ -228,4 +229,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
{ id: 'notifications' as const, label: 'Notifications', icon: '🔔' },
{ id: 'api' as const, label: 'API', icon: '🔑' },
];
+1 -1
View File
@@ -243,4 +243,4 @@ export interface BookDateModel {
/**
* Tab identifier type
*/
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications';
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications' | 'api';
+5 -1
View File
@@ -23,6 +23,7 @@ import { PathsTab } from './tabs/PathsTab/PathsTab';
import { EbookTab } from './tabs/EbookTab/EbookTab';
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
import { NotificationsTab } from './tabs/NotificationsTab';
import { ApiTab } from './tabs/ApiTab/ApiTab';
// Types and Helpers
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
@@ -346,8 +347,11 @@ export default function AdminSettings() {
{/* Notifications Tab */}
{activeTab === 'notifications' && <NotificationsTab />}
{/* API Tab */}
{activeTab === 'api' && <ApiTab />}
{/* Save Button (only for tabs that save through main page) */}
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && (
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && activeTab !== 'api' && (
<div className="mt-8 flex gap-4">
<Button
onClick={saveSettings}
@@ -0,0 +1,307 @@
/**
* Component: API Token Management Tab (Admin)
* Documentation: documentation/backend/services/api-tokens.md
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { fetchWithAuth } from '@/lib/utils/api';
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog';
import { useApiTokens } from '@/lib/hooks/useApiTokens';
import { getInstanceUrl } from '@/lib/utils/client-url';
import Link from 'next/link';
import type { AdminApiToken } from '@/lib/types/api-tokens';
interface UserOption {
id: string;
plexUsername: string;
role: string;
}
export function ApiTab() {
const api = useApiTokens<AdminApiToken>({ basePath: '/api/admin/api-tokens' });
// Admin-specific state
const [users, setUsers] = useState<UserOption[]>([]);
const [newTokenUserId, setNewTokenUserId] = useState('');
const fetchUsers = useCallback(async () => {
try {
const response = await fetchWithAuth('/api/admin/users');
if (response.ok) {
const data = await response.json();
setUsers(data.users.map((u: any) => ({ id: u.id, plexUsername: u.plexUsername, role: u.role })));
}
} catch {
// Non-critical, user selector just won't populate
}
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleCreate = async () => {
const extraBody: Record<string, string> = {};
if (newTokenUserId) extraBody.userId = newTokenUserId;
const created = await api.handleCreate(extraBody);
// Reset admin-specific fields only when create succeeds
if (created) {
setNewTokenUserId('');
}
};
const handleCancel = () => {
api.resetForm();
setNewTokenUserId('');
};
if (api.loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">API Tokens</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage API tokens for all users. Create tokens for any user for programmatic access.{' '}
<Link href="/api-docs" className="text-blue-600 dark:text-blue-400 hover:underline">
View API documentation
</Link>
</p>
</div>
{/* Error display */}
{api.error && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
{api.error}
</div>
)}
{/* Newly created token banner */}
{api.createdToken && (
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-green-800 dark:text-green-200">
Token created successfully! Copy it now it won&apos;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&apos;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">
&ldquo;{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}&rdquo;
</span>
? Any integrations using this token will immediately lose access. This cannot be undone.
</>
}
confirmLabel="Revoke token"
cancelLabel="Cancel"
confirmVariant="danger"
onConfirm={api.handleDeleteConfirmed}
onCancel={() => api.setConfirmRevokeId(null)}
/>
</div>
);
}
@@ -90,6 +90,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
>
<option value="openai">OpenAI</option>
<option value="claude">Claude (Anthropic)</option>
<option value="gemini">Google Gemini</option>
<option value="custom">Custom (OpenAI-compatible)</option>
</select>
</div>
@@ -136,7 +137,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
? 'Leave blank for local models'
: configured
? '••••••••••••••••'
: (provider === 'openai' ? 'sk-...' : 'sk-ant-...')
: (provider === 'openai' ? 'sk-...' : provider === 'gemini' ? 'AIza...' : 'sk-ant-...')
}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
+142
View File
@@ -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 });
}
})
);
}
+190
View File
@@ -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 });
}
})
);
}
+45 -2
View File
@@ -12,6 +12,7 @@ import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { RMABLogger } from '@/lib/utils/logger';
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
import { getAudibleService } from '@/lib/integrations/audible.service';
const logger = RMABLogger.create('API.Admin.ManualImport');
@@ -54,7 +55,7 @@ export async function POST(request: NextRequest) {
const fs = await import('fs/promises');
const body = await request.json();
const { folderPath, asin } = body;
const { folderPath, asin, cleanupSource } = body;
let { audiobookId } = body;
// Validate required fields
@@ -174,6 +175,48 @@ export async function POST(request: NextRequest) {
);
}
// Enrich missing series/year data from Audnexus (mirrors request-creator.service.ts)
if (audiobook.audibleAsin && (!audiobook.series || !audiobook.year)) {
try {
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin);
if (audnexusData) {
const updates: Record<string, any> = {};
if (!audiobook.series && audnexusData.series) {
updates.series = audnexusData.series;
}
if (!audiobook.seriesPart && audnexusData.seriesPart) {
updates.seriesPart = audnexusData.seriesPart;
}
if (!audiobook.seriesAsin && audnexusData.seriesAsin) {
updates.seriesAsin = audnexusData.seriesAsin;
}
if (!audiobook.year && audnexusData.releaseDate) {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
updates.year = releaseYear;
}
}
if (!audiobook.narrator && audnexusData.narrator) {
updates.narrator = audnexusData.narrator;
}
if (Object.keys(updates).length > 0) {
await prisma.audiobook.update({
where: { id: audiobook.id },
data: updates,
});
logger.info(`Enriched audiobook metadata from Audnexus for ASIN ${audiobook.audibleAsin}`, updates);
}
}
} catch (error) {
// Non-fatal: series enrichment failure should never block the import
logger.warn(`Failed to enrich metadata from Audnexus for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Check for existing requests
const existingRequest = await prisma.request.findFirst({
where: {
@@ -242,7 +285,7 @@ export async function POST(request: NextRequest) {
// Queue organize_files job
const jobQueue = getJobQueueService();
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath);
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath, undefined, cleanupSource === true);
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`);
@@ -0,0 +1,271 @@
/**
* Component: Admin Retry Download API
* Documentation: documentation/admin-dashboard.md
*
* Retries a failed download by either resuming monitoring of a still-alive
* download in the client, or re-adding the download using metadata from the
* most recent selected DownloadHistory record.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Requests.RetryDownload');
/** Download statuses considered "alive" — monitoring can be resumed */
const ALIVE_STATUSES = new Set([
'downloading',
'queued',
'paused',
'checking',
'seeding',
'completed',
]);
/**
* POST /api/admin/requests/[id]/retry-download
* Retry a failed download for an admin request.
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
// Fetch the request with audiobook info
const existingRequest = await prisma.request.findFirst({
where: { id, deletedAt: null },
include: {
audiobook: true,
},
});
if (!existingRequest) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
if (existingRequest.status !== 'failed') {
return NextResponse.json(
{
error: 'InvalidStatus',
message: `Request is not in a failed state (current status: ${existingRequest.status})`,
currentStatus: existingRequest.status,
},
{ status: 400 }
);
}
// Find the most recent selected DownloadHistory record
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId: id, selected: true },
orderBy: { createdAt: 'desc' },
});
if (!downloadHistory) {
return NextResponse.json(
{
error: 'NoHistory',
message: 'No previous download attempt found to retry',
},
{ status: 400 }
);
}
// Require a download URL to be able to re-add
if (!downloadHistory.magnetLink) {
return NextResponse.json(
{
error: 'NoDownloadUrl',
message: 'No download URL available in history to retry',
},
{ status: 400 }
);
}
const jobQueue = getJobQueueService();
let retryPath: 'resumed_monitoring' | 're_added';
// Determine if we can attempt to resume monitoring.
// downloadClient is stored as a plain string in the DB (can be 'qbittorrent', 'sabnzbd',
// 'nzbget', 'transmission', 'deluge', 'direct', or null).
const rawClientType: string | null = downloadHistory.downloadClient;
const clientId = downloadHistory.downloadClientId;
const isDirect = rawClientType === 'direct';
// Only attempt to query the download client if we have a known DownloadClientType,
// a clientId, and it is not a direct (HTTP) download.
const canCheckClient = !isDirect && !!rawClientType && !!clientId;
// Safe to cast here: we have already confirmed rawClientType is non-null and non-direct
const clientType = rawClientType as DownloadClientType | null;
if (canCheckClient) {
// Try to look up the download in the client
try {
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const client = await manager.getClientServiceForProtocol(protocol);
if (client) {
const downloadInfo = await client.getDownload(clientId!);
if (downloadInfo && ALIVE_STATUSES.has(downloadInfo.status)) {
// Download is still alive — restart monitoring
logger.info(`Retry download: resuming monitoring for request ${id}`, {
requestId: id,
downloadClientId: clientId,
downloadStatus: downloadInfo.status,
adminId: req.user.sub,
});
await jobQueue.addMonitorJob(
id,
downloadHistory.id,
clientId!, // canCheckClient guard ensures clientId is non-null
clientType as DownloadClientType,
0 // no delay — start immediately
);
retryPath = 'resumed_monitoring';
} else {
// Download not found or is failed — re-add
logger.info(`Retry download: download not alive (status: ${downloadInfo?.status ?? 'not found'}), re-adding for request ${id}`, {
requestId: id,
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
} else {
// No client configured for that protocol — fall through to re-add
logger.warn(`Retry download: no ${protocol} client configured, re-adding for request ${id}`, {
requestId: id,
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
} catch (clientError) {
// Client lookup failed (connection error etc.) — re-add to be safe
logger.warn(`Retry download: client check failed, re-adding for request ${id}`, {
requestId: id,
error: clientError instanceof Error ? clientError.message : String(clientError),
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
} else {
// Direct download (ebook), no clientId, or no clientType — re-add
logger.info(`Retry download: re-adding for request ${id} (direct=${isDirect}, hasClientId=${!!clientId})`, {
requestId: id,
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
// Increment downloadAttempts, clear errorMessage, set status to downloading
await prisma.request.update({
where: { id },
data: {
status: 'downloading',
errorMessage: null,
downloadAttempts: { increment: 1 },
updatedAt: new Date(),
},
});
const message =
retryPath === 'resumed_monitoring'
? 'Download monitoring resumed'
: 'Download re-added to client';
logger.info(`Retry download completed for request ${id} via ${retryPath}`, {
requestId: id,
adminId: req.user.sub,
path: retryPath,
});
return NextResponse.json({
success: true,
message,
path: retryPath,
});
} catch (error) {
logger.error('Failed to retry download', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
error: 'RetryError',
message: 'Failed to retry download',
},
{ status: 500 }
);
}
});
});
}
/**
* Re-add the download to the queue using metadata from DownloadHistory.
* Reconstructs a TorrentResult from the stored history fields.
*/
async function reAddDownload(
jobQueue: ReturnType<typeof getJobQueueService>,
requestId: string,
audiobook: { id: string; title: string; author: string },
history: {
torrentName: string | null;
magnetLink: string | null;
indexerName: string;
indexerId: number | null;
torrentSizeBytes: bigint | null;
seeders: number | null;
leechers: number | null;
torrentHash: string | null;
torrentUrl: string | null;
}
): Promise<void> {
const torrent: TorrentResult = {
title: history.torrentName ?? audiobook.title,
downloadUrl: history.magnetLink!, // Validated non-null before calling this function
indexer: history.indexerName,
indexerId: history.indexerId ?? undefined,
size: history.torrentSizeBytes !== null ? Number(history.torrentSizeBytes) : 0,
seeders: history.seeders ?? undefined,
leechers: history.leechers ?? undefined,
infoHash: history.torrentHash ?? undefined,
infoUrl: history.torrentUrl ?? undefined,
guid: history.torrentUrl ?? history.magnetLink!,
publishDate: new Date(), // Not stored; use current date as a safe default
};
await jobQueue.addDownloadJob(requestId, audiobook, torrent);
}
@@ -0,0 +1,133 @@
/**
* Component: Admin Custom Search Terms API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.SearchTerms');
/**
* PATCH /api/admin/requests/[id]/search-terms
* Update custom search terms for a request (admin only)
* Body: { searchTerms: string | null, triggerSearch?: boolean }
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
// Parse body
let body;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: 'BadRequest', message: 'Invalid JSON body' },
{ status: 400 }
);
}
const { searchTerms, triggerSearch } = body;
// Validate searchTerms is string or null
if (searchTerms !== null && searchTerms !== undefined && typeof searchTerms !== 'string') {
return NextResponse.json(
{ error: 'BadRequest', message: 'searchTerms must be a string or null' },
{ status: 400 }
);
}
// Trim and normalize
const normalizedTerms = typeof searchTerms === 'string' ? searchTerms.trim() || null : null;
// Find the request
const existingRequest = await prisma.request.findUnique({
where: { id },
include: {
audiobook: {
select: { id: true, title: true, author: true, audibleAsin: true },
},
},
});
if (!existingRequest || existingRequest.deletedAt) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Update custom search terms
await prisma.request.update({
where: { id },
data: {
customSearchTerms: normalizedTerms,
updatedAt: new Date(),
},
});
logger.info(`Custom search terms ${normalizedTerms ? 'set' : 'cleared'} for request ${id}`, {
requestId: id,
customSearchTerms: normalizedTerms,
adminId: req.user.id,
});
// Optionally trigger a new search
let searchTriggered = false;
if (triggerSearch && ['pending', 'failed', 'awaiting_search'].includes(existingRequest.status)) {
// Reset status to pending and clear error
await prisma.request.update({
where: { id },
data: {
status: 'pending',
errorMessage: null,
updatedAt: new Date(),
},
});
// Queue search job
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(id, {
id: existingRequest.audiobook.id,
title: existingRequest.audiobook.title,
author: existingRequest.audiobook.author,
asin: existingRequest.audiobook.audibleAsin || undefined,
});
searchTriggered = true;
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
}
return NextResponse.json({
success: true,
customSearchTerms: normalizedTerms,
searchTriggered,
});
} catch (error) {
logger.error('Failed to update search terms', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to update search terms' },
{ status: 500 }
);
}
});
});
}
+2
View File
@@ -139,6 +139,8 @@ export async function GET(request: NextRequest) {
completedAt: request.completedAt,
errorMessage: request.errorMessage,
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
downloadAttempts: request.downloadAttempts,
customSearchTerms: request.customSearchTerms || null,
}));
return NextResponse.json({
+16 -7
View File
@@ -7,7 +7,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '20', 10);
const hideAvailable = searchParams.get('hideAvailable') === 'true';
// Validate pagination parameters
if (page < 1 || limit < 1 || limit > 100) {
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
const skip = (page - 1) * limit;
// When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
let excludedAsins: string[] = [];
if (hideAvailable) {
const availableSet = await getAvailableAsins();
excludedAsins = [...availableSet];
}
const whereClause = {
isNewRelease: true,
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
};
// Query audible_cache for new release audiobooks
const [audiobooks, totalCount] = await Promise.all([
prisma.audibleCache.findMany({
where: {
isNewRelease: true,
},
where: whereClause,
orderBy: {
newReleaseRank: 'asc',
},
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
},
}),
prisma.audibleCache.count({
where: {
isNewRelease: true,
},
where: whereClause,
}),
]);
+16 -7
View File
@@ -7,7 +7,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '20', 10);
const hideAvailable = searchParams.get('hideAvailable') === 'true';
// Validate pagination parameters
if (page < 1 || limit < 1 || limit > 100) {
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
const skip = (page - 1) * limit;
// When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
let excludedAsins: string[] = [];
if (hideAvailable) {
const availableSet = await getAvailableAsins();
excludedAsins = [...availableSet];
}
const whereClause = {
isPopular: true,
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
};
// Query audible_cache for popular audiobooks
const [audiobooks, totalCount] = await Promise.all([
prisma.audibleCache.findMany({
where: {
isPopular: true,
},
where: whereClause,
orderBy: {
popularRank: 'asc',
},
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
},
}),
prisma.audibleCache.count({
where: {
isPopular: true,
},
where: whereClause,
}),
]);
+12 -2
View File
@@ -6,6 +6,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
@@ -38,14 +40,22 @@ export async function GET(request: NextRequest) {
const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined;
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results);
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
if (groups.length > 0) {
persistDedupGroups(groups).catch(() => {});
}
// Enrich search results with availability and request status information
const enrichedResults = await enrichAudiobooksWithMatches(results.results, userId);
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
return NextResponse.json({
success: true,
query: results.query,
results: enrichedResults,
totalResults: results.totalResults,
totalResults: enrichedResults.length,
page: results.page,
hasMore: results.hasMore,
});
+3 -1
View File
@@ -38,9 +38,11 @@ export async function POST(request: NextRequest) {
);
}
const normalizedUsername = username.trim().toLowerCase();
// Find user by local admin identifier
const user = await prisma.user.findUnique({
where: { plexId: `local-${username}` },
where: { plexId: `local-${normalizedUsername}` },
});
if (!user) {
+18 -4
View File
@@ -6,6 +6,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
@@ -46,16 +48,26 @@ export async function GET(
);
}
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin})`);
const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10);
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin}), page ${page}`);
const audibleService = getAudibleService();
const books = await audibleService.searchByAuthorAsin(authorName.trim(), asin);
const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page);
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books);
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
if (groups.length > 0) {
persistDedupGroups(groups).catch(() => {});
}
// Enrich with library availability and request status
const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(books, userId);
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books`);
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
return NextResponse.json({
success: true,
@@ -63,6 +75,8 @@ export async function GET(
authorName: authorName.trim(),
authorAsin: asin,
totalBooks: enrichedBooks.length,
hasMore: result.hasMore,
page: result.page,
});
} catch (error) {
logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) });
+3 -3
View File
@@ -59,9 +59,9 @@ async function saveConfig(req: AuthenticatedRequest) {
);
}
if (!['openai', 'claude', 'custom'].includes(provider)) {
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json(
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
{ status: 400 }
);
}
@@ -107,7 +107,7 @@ async function saveConfig(req: AuthenticatedRequest) {
// No new API key, use existing one
encryptedApiKeyToUse = existingConfig.apiKey;
} else {
// API key required for OpenAI/Claude
// API key required for OpenAI/Claude/Gemini
return NextResponse.json(
{ error: 'API key is required' },
{ status: 400 }
+48 -4
View File
@@ -52,6 +52,30 @@ async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: st
return allModels;
}
// Fetch available Gemini models from the Google API
async function fetchGeminiModels(apiKey: string): Promise<{ id: string; name: string }[]> {
const response = await fetch(
'https://generativelanguage.googleapis.com/v1beta/models',
{ headers: { 'x-goog-api-key': apiKey } }
);
if (!response.ok) {
const errorText = await response.text();
logger.error('Gemini API error', { error: errorText });
throw new Error('Invalid Gemini API key or connection failed');
}
const data = await response.json();
return (data.models || [])
.filter((m: any) => m.name?.startsWith('models/gemini-') && m.supportedGenerationMethods?.includes('generateContent'))
.map((m: any) => ({
id: m.name.replace('models/', ''),
name: m.displayName || m.name.replace('models/', ''),
}))
.sort((a: any, b: any) => a.name.localeCompare(b.name));
}
// Helper functions for custom provider
function isValidBaseUrl(url: string): boolean {
try {
@@ -79,9 +103,9 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
);
}
if (!['openai', 'claude', 'custom'].includes(provider)) {
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json(
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
{ status: 400 }
);
}
@@ -193,6 +217,16 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
{ status: 400 }
);
}
} else if (provider === 'gemini') {
// Gemini: Fetch models dynamically from the Google API
try {
models = await fetchGeminiModels(testApiKey);
} catch {
return NextResponse.json(
{ error: 'Invalid Gemini API key or connection failed' },
{ status: 400 }
);
}
} else if (provider === 'custom') {
// Custom: Fetch models from custom OpenAI-compatible endpoint
const normalizedUrl = normalizeBaseUrl(testBaseUrl);
@@ -291,9 +325,9 @@ async function unauthenticatedHandler(req: NextRequest) {
);
}
if (!['openai', 'claude', 'custom'].includes(provider)) {
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json(
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
{ status: 400 }
);
}
@@ -363,6 +397,16 @@ async function unauthenticatedHandler(req: NextRequest) {
{ status: 400 }
);
}
} else if (provider === 'gemini') {
// Gemini: Fetch models dynamically
try {
models = await fetchGeminiModels(apiKey);
} catch {
return NextResponse.json(
{ error: 'Invalid Gemini API key or connection failed' },
{ status: 400 }
);
}
} else if (provider === 'custom') {
// Custom: Fetch models from custom OpenAI-compatible endpoint
const normalizedUrl = normalizeBaseUrl(baseUrl);
@@ -125,8 +125,8 @@ export async function POST(
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
}
// Use custom title if provided, otherwise use audiobook's title
const searchTitle = customTitle || requestRecord.audiobook.title;
// Use custom title if provided, then custom search terms, then audiobook's title
const searchTitle = customTitle || requestRecord.customSearchTerms || requestRecord.audiobook.title;
const searchAuthor = requestRecord.audiobook.author;
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle });
+18 -4
View File
@@ -8,6 +8,8 @@ import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups } from '@/lib/services/works.service';
const logger = RMABLogger.create('API.Series.Detail');
@@ -37,9 +39,11 @@ export async function GET(
);
}
logger.info(`Fetching series detail: ${asin}`);
const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10);
const detail = await scrapeSeriesPage(asin);
logger.info(`Fetching series detail: ${asin}, page ${page}`);
const detail = await scrapeSeriesPage(asin, page);
if (!detail) {
return NextResponse.json(
{ error: 'NotFound', message: 'Series not found' },
@@ -47,11 +51,19 @@ export async function GET(
);
}
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(detail.books);
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
if (groups.length > 0) {
persistDedupGroups(groups).catch(() => {});
}
// Enrich books with library availability and request status
const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId);
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books)`);
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
return NextResponse.json({
success: true,
@@ -59,6 +71,8 @@ export async function GET(
...detail,
books: enrichedBooks,
},
hasMore: detail.hasMore,
page: detail.page,
});
} catch (error) {
logger.error('Failed to fetch series detail', {
+3 -2
View File
@@ -140,14 +140,15 @@ export async function POST(request: NextRequest) {
);
}
const normalizedAdminUsername = admin.username.trim().toLowerCase();
const hashedPassword = await bcrypt.hash(admin.password, 10);
const encryptionService = getEncryptionService();
const encryptedPassword = encryptionService.encrypt(hashedPassword);
adminUser = await prisma.user.create({
data: {
plexId: `local-${admin.username}`,
plexUsername: admin.username,
plexId: `local-${normalizedAdminUsername}`,
plexUsername: normalizedAdminUsername,
plexEmail: null,
role: 'admin',
isSetupAdmin: true, // Mark as setup admin - role cannot be changed
+59
View File
@@ -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 });
}
});
}
+141
View File
@@ -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 });
}
});
}
+125
View File
@@ -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 });
}
});
}
+125
View File
@@ -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 });
}
});
}
+42 -12
View File
@@ -5,16 +5,17 @@
'use client';
import { use, useCallback } from 'react';
import { use, useCallback, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard';
import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow';
import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors';
import { Audiobook } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { usePreferences } from '@/contexts/PreferencesContext';
export default function AuthorDetailPage({
@@ -27,11 +28,11 @@ export default function AuthorDetailPage({
const searchParams = useSearchParams();
const fromAuthorName = searchParams.get('from');
const { author, isLoading: authorLoading } = useAuthorDetail(asin);
const { books, totalBooks, isLoading: booksLoading } = useAuthorBooks(
const { books, totalBooks, hasMore, isLoading: booksLoading, isLoadingMore, loadMore } = useAuthorBooks(
asin,
author?.name || null
);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
const handleBack = useCallback(() => {
// Use browser back if we came from within the app, otherwise fallback to /authors
@@ -42,6 +43,20 @@ export default function AuthorDetailPage({
}
}, [router]);
// Filter out available titles when hideAvailable is enabled
const filteredBooks = useMemo(
() => hideAvailable ? books.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : books,
[books, hideAvailable]
);
// Header count text: reflects filtered counts
const visibleCount = filteredBooks.length;
const booksCountText = hasMore && totalBooks > books.length
? `${visibleCount.toLocaleString()} of ${totalBooks.toLocaleString()} title${totalBooks !== 1 ? 's' : ''}`
: visibleCount > 0
? `${visibleCount.toLocaleString()} title${visibleCount !== 1 ? 's' : ''}`
: '';
return (
<ProtectedRoute>
<div className="min-h-screen">
@@ -91,27 +106,42 @@ export default function AuthorDetailPage({
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Books
</h2>
{!booksLoading && totalBooks > 0 && (
{!booksLoading && booksCountText && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({totalBooks} title{totalBooks !== 1 ? 's' : ''})
({booksCountText})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div>
</div>
</div>
{/* Books Grid */}
<AudiobookGrid
audiobooks={books}
audiobooks={filteredBooks}
isLoading={booksLoading}
emptyMessage={`No books found for ${author.name}`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
{/* Load More Bar */}
{filteredBooks.length > 0 && (
<LoadMoreBar
loadedCount={filteredBooks.length}
totalCount={totalBooks > 0 ? totalBooks : undefined}
hasMore={hasMore}
isLoading={isLoadingMore}
onLoadMore={loadMore}
/>
)}
</div>
)}
</main>
+25
View File
@@ -197,6 +197,31 @@ body {
animation: toast-slide-in 0.3s ease-out;
}
/* Confirmation Dialog */
@keyframes dialog-backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dialog-panel-in {
from {
opacity: 0;
transform: scale(0.95) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.animate-dialog-backdrop {
animation: dialog-backdrop-in 0.15s ease-out forwards;
}
.animate-dialog-panel {
animation: dialog-panel-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Hide scrollbar while keeping scroll functional */
.scrollbar-hide {
-ms-overflow-style: none;
+52 -30
View File
@@ -5,20 +5,19 @@
'use client';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { StickyPagination } from '@/components/ui/StickyPagination';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { UnifiedPagination } from '@/components/ui/UnifiedPagination';
import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { usePreferences } from '@/contexts/PreferencesContext';
export default function HomePage() {
const [popularPage, setPopularPage] = useState(1);
const [newReleasesPage, setNewReleasesPage] = useState(1);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
// Refs for auto-scrolling to section tops
const popularSectionRef = useRef<HTMLElement>(null);
@@ -30,14 +29,20 @@ export default function HomePage() {
isLoading: loadingPopular,
totalPages: popularTotalPages,
message: popularMessage,
} = useAudiobooks('popular', 20, popularPage);
} = useAudiobooks('popular', 20, popularPage, hideAvailable);
const {
audiobooks: newReleases,
isLoading: loadingNewReleases,
totalPages: newReleasesTotalPages,
message: newReleasesMessage,
} = useAudiobooks('new-releases', 20, newReleasesPage);
} = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable);
// Reset to page 1 when hideAvailable changes (total pages may differ)
useEffect(() => {
setPopularPage(1);
setNewReleasesPage(1);
}, [hideAvailable]);
// Handle page changes with auto-scroll to section top
const handlePopularPageChange = (page: number) => {
@@ -66,10 +71,14 @@ export default function HomePage() {
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
Popular Audiobooks
</h2>
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div>
</div>
</div>
@@ -107,10 +116,14 @@ export default function HomePage() {
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
New Releases
</h2>
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div>
</div>
</div>
@@ -164,22 +177,31 @@ export default function HomePage() {
</div>
</footer>
{/* Sticky Pagination Controls */}
<StickyPagination
currentPage={popularPage}
totalPages={popularTotalPages}
onPageChange={handlePopularPageChange}
sectionRef={popularSectionRef}
{/* Unified Pagination — single context-aware pill for both sections */}
<UnifiedPagination
footerRef={footerRef}
label="Popular Audiobooks"
/>
<StickyPagination
currentPage={newReleasesPage}
totalPages={newReleasesTotalPages}
onPageChange={handleNewReleasesPageChange}
sectionRef={newReleasesSectionRef}
footerRef={footerRef}
label="New Releases"
sections={[
{
label: 'Popular Audiobooks',
accentColor: 'bg-blue-500',
currentPage: popularPage,
totalPages: popularTotalPages,
onPageChange: handlePopularPageChange,
sectionRef: popularSectionRef,
onScrollToSection: () =>
popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
},
{
label: 'New Releases',
accentColor: 'bg-emerald-500',
currentPage: newReleasesPage,
totalPages: newReleasesTotalPages,
onPageChange: handleNewReleasesPageChange,
sectionRef: newReleasesSectionRef,
onScrollToSection: () =>
newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
},
]}
/>
</div>
</ProtectedRoute>
+11
View File
@@ -12,6 +12,8 @@ import { useAuth } from '@/contexts/AuthContext';
import { useRequests } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn';
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
import { ApiTokensSection } from '@/components/profile/ApiTokensSection';
import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection';
const statConfig = [
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
@@ -142,6 +144,12 @@ export default function ProfilePage() {
{/* Goodreads Shelves */}
<GoodreadsShelvesSection />
{/* Watched Series */}
<WatchedSeriesSection />
{/* Watched Authors */}
<WatchedAuthorsSection />
{/* Active Downloads */}
{activeDownloads.length > 0 && (
<section>
@@ -233,6 +241,9 @@ export default function ProfilePage() {
</div>
)}
</section>
{/* API Tokens */}
<ApiTokensSection />
</main>
</div>
);
+41 -37
View File
@@ -5,41 +5,48 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { useSearch } from '@/lib/hooks/useAudiobooks';
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
import { useSearch, Audiobook } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { usePreferences } from '@/contexts/PreferencesContext';
export default function SearchPage() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [page, setPage] = useState(1);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
setPage(1); // Reset to first page on new search
}, 500);
return () => clearTimeout(timer);
}, [query]);
const { results, totalResults, hasMore, isLoading } = useSearch(debouncedQuery, page);
const { results, totalResults, hasMore, isLoading, isLoadingMore, loadMore } = useSearch(debouncedQuery);
// Filter out available titles when hideAvailable is enabled
const filteredResults = useMemo(
() => hideAvailable ? results.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : results,
[results, hideAvailable]
);
const handleSearch = useCallback((e: React.FormEvent) => {
e.preventDefault();
setPage(1);
}, []);
const handleLoadMore = useCallback(() => {
setPage((prev) => prev + 1);
}, []);
// Header count text: reflects filtered counts
const visibleCount = filteredResults.length;
const countText = hasMore && totalResults > 0
? `${visibleCount.toLocaleString()} of ${totalResults.toLocaleString()} result${totalResults !== 1 ? 's' : ''}`
: visibleCount > 0
? `${visibleCount.toLocaleString()} result${visibleCount !== 1 ? 's' : ''}`
: '';
return (
<ProtectedRoute>
@@ -113,45 +120,42 @@ export default function SearchPage() {
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Search Results
</h2>
{!isLoading && totalResults > 0 && (
{!isLoading && countText && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({totalResults.toLocaleString()} result{totalResults !== 1 ? 's' : ''})
({countText})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div>
</div>
</div>
{/* Results Grid */}
<AudiobookGrid
audiobooks={results}
isLoading={!!(isLoading && page === 1)}
audiobooks={filteredResults}
isLoading={isLoading}
emptyMessage={`No results found for "${debouncedQuery}"`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
{/* Load More */}
{hasMore && !isLoading && (
<div className="flex justify-center">
<button
onClick={handleLoadMore}
className="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Load More Results
</button>
</div>
)}
{/* Loading More Indicator */}
{isLoading && page > 1 && (
<div className="flex justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{/* Load More Bar */}
{filteredResults.length > 0 && (
<LoadMoreBar
loadedCount={filteredResults.length}
totalCount={totalResults}
hasMore={hasMore}
isLoading={isLoadingMore}
onLoadMore={loadMore}
itemLabel="results"
/>
)}
</div>
) : (
+46 -12
View File
@@ -5,16 +5,17 @@
'use client';
import { use, useCallback } from 'react';
import { use, useCallback, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
import { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard';
import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow';
import { useSeriesDetail } from '@/lib/hooks/useSeries';
import { Audiobook } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { usePreferences } from '@/contexts/PreferencesContext';
export default function SeriesDetailPage({
@@ -26,8 +27,8 @@ export default function SeriesDetailPage({
const router = useRouter();
const searchParams = useSearchParams();
const fromSeriesTitle = searchParams.get('from');
const { series, isLoading: seriesLoading } = useSeriesDetail(asin);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
const { series, hasMore, isLoading: seriesLoading, isLoadingMore, loadMore } = useSeriesDetail(asin);
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
const handleBack = useCallback(() => {
// Use browser back if we came from within the app, otherwise fallback to /series
@@ -38,6 +39,24 @@ export default function SeriesDetailPage({
}
}, [router]);
// Filter out available titles when hideAvailable is enabled
const filteredBooks = useMemo(
() => series && hideAvailable
? series.books.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed')
: series?.books ?? [],
[series, hideAvailable]
);
// Header count text: reflects filtered counts
const visibleCount = filteredBooks.length;
const booksCountText = series
? hasMore && series.bookCount > series.books.length
? `${visibleCount.toLocaleString()} of ${series.bookCount.toLocaleString()} title${series.bookCount !== 1 ? 's' : ''}`
: visibleCount > 0
? `${visibleCount.toLocaleString()} title${visibleCount !== 1 ? 's' : ''}`
: ''
: '';
return (
<ProtectedRoute>
<div className="min-h-screen">
@@ -87,27 +106,42 @@ export default function SeriesDetailPage({
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Books in Series
</h2>
{series.books.length > 0 && (
{booksCountText && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({series.books.length} title{series.books.length !== 1 ? 's' : ''})
({booksCountText})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div>
</div>
</div>
{/* Books Grid */}
<AudiobookGrid
audiobooks={series.books}
audiobooks={filteredBooks}
isLoading={seriesLoading}
emptyMessage={`No books found for ${series.title}`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
{/* Load More Bar */}
{filteredBooks.length > 0 && (
<LoadMoreBar
loadedCount={filteredBooks.length}
totalCount={series.bookCount > 0 ? series.bookCount : undefined}
hasMore={hasMore}
isLoading={isLoadingMore}
onLoadMore={loadMore}
/>
)}
</div>
)}
</main>
+2 -1
View File
@@ -134,6 +134,7 @@ export function BookDateStep({
>
<option value="openai">OpenAI</option>
<option value="claude">Claude (Anthropic)</option>
<option value="gemini">Google Gemini</option>
</select>
</div>
@@ -152,7 +153,7 @@ export function BookDateStep({
onUpdate('bookdateConfigured', false);
onUpdate('bookdateModels', []);
}}
placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'}
placeholder={bookdateProvider === 'openai' ? 'sk-...' : bookdateProvider === 'gemini' ? 'AIza...' : 'sk-ant-...'}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
+157
View File
@@ -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 &quot;Authorization: Bearer {'<token>'}&quot; {typeof window !== 'undefined' ? window.location.origin : ''}{endpoint.path}
</code>
</div>
</div>
);
}
+151
View File
@@ -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>
);
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Component: API Docs Token Input
* Documentation: documentation/backend/services/api-tokens.md
*
* Token input field with toggle between custom API token and current session auth.
*/
'use client';
import { useState } from 'react';
interface TokenInputProps {
token: string;
onTokenChange: (token: string) => void;
useSession: boolean;
onUseSessionChange: (useSession: boolean) => void;
}
export function TokenInput({
token,
onTokenChange,
useSession,
onUseSessionChange,
}: TokenInputProps) {
const [showToken, setShowToken] = useState(false);
return (
<div className="rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Authentication
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Choose how to authenticate your test requests
</p>
</div>
{/* Session toggle */}
<button
onClick={() => onUseSessionChange(!useSession)}
className={`
relative inline-flex h-7 w-[140px] items-center rounded-full transition-colors duration-200
${useSession
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-700'
}
`}
>
<span
className={`
absolute inset-y-0.5 w-[68px] rounded-full bg-white dark:bg-gray-100 shadow-sm
transition-transform duration-200 ease-in-out
${useSession ? 'translate-x-[70px]' : 'translate-x-0.5'}
`}
/>
<span
className={`
relative z-10 flex-1 text-center text-xs font-medium transition-colors duration-200
${!useSession ? 'text-gray-900 dark:text-gray-900' : 'text-white/70'}
`}
>
API Token
</span>
<span
className={`
relative z-10 flex-1 text-center text-xs font-medium transition-colors duration-200
${useSession ? 'text-gray-900 dark:text-gray-900' : 'text-gray-500 dark:text-gray-400'}
`}
>
Session
</span>
</button>
</div>
{useSession ? (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span className="text-sm text-blue-700 dark:text-blue-300">
Using your current browser session for authentication
</span>
</div>
) : (
<div className="relative">
<input
type={showToken ? 'text' : 'password'}
value={token}
onChange={(e) => onTokenChange(e.target.value)}
placeholder="rmab_your_api_token_here"
className="w-full rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900/50 px-4 py-2.5 pr-20 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 focus:outline-none transition-all"
/>
<button
onClick={() => setShowToken(!showToken)}
className="absolute right-2 top-1/2 -translate-y-1/2 px-2.5 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
{showToken ? 'Hide' : 'Show'}
</button>
</div>
)}
</div>
);
}
@@ -59,6 +59,9 @@ export function ManualImportBrowser({
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
// Cleanup source toggle
const [cleanupSource, setCleanupSource] = useState(false);
// Hover state for folder icon swap
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
@@ -188,6 +191,7 @@ export function ManualImportBrowser({
body: JSON.stringify({
asin: audiobook.asin,
folderPath: selectedPath,
cleanupSource,
}),
});
const data = await res.json();
@@ -288,6 +292,8 @@ export function ManualImportBrowser({
isImporting={isImporting}
importError={importError}
slideClass={slideClass}
cleanupSource={cleanupSource}
onCleanupSourceChange={setCleanupSource}
onBack={handleBackToBrowse}
onStartImport={handleStartImport}
/>
@@ -22,6 +22,8 @@ interface ConfirmPhaseProps {
isImporting: boolean;
importError: string | null;
slideClass: string;
cleanupSource: boolean;
onCleanupSourceChange: (value: boolean) => void;
onBack: () => void;
onStartImport: () => void;
}
@@ -35,6 +37,8 @@ export function ConfirmPhase({
isImporting,
importError,
slideClass,
cleanupSource,
onCleanupSourceChange,
onBack,
onStartImport,
}: ConfirmPhaseProps) {
@@ -99,6 +103,30 @@ export function ConfirmPhase({
))}
</div>
</div>
{/* Cleanup source toggle */}
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
Cleanup source files
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Delete original files after successful import
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={cleanupSource}
onChange={(e) => onCleanupSourceChange(e.target.checked)}
disabled={isImporting}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
</div>
{/* Error display */}
+22 -14
View File
@@ -11,6 +11,7 @@
import React, { useState } from 'react';
import Image from 'next/image';
import { AuthorDetail } from '@/lib/hooks/useAuthors';
import { WatchAuthorButton } from '@/components/ui/WatchButton';
interface AuthorDetailCardProps {
author: AuthorDetail;
@@ -64,20 +65,27 @@ export function AuthorDetailCard({ author }: AuthorDetailCardProps) {
</div>
)}
{/* Audible Link */}
{author.audibleUrl && (
<a
href={author.audibleUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
View on Audible
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{/* Actions row: Audible link + Watch button */}
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
{author.audibleUrl && (
<a
href={author.audibleUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
View on Audible
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
<WatchAuthorButton
authorAsin={author.asin}
authorName={author.name}
coverArtUrl={author.image}
/>
</div>
{/* Description */}
{author.description && (
+234
View File
@@ -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&apos;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">
&ldquo;{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}&rdquo;
</span>
? Any integrations using this token will immediately lose access. This cannot be undone.
</>
}
confirmText="Revoke token"
cancelText="Cancel"
variant="danger"
onConfirm={api.handleDeleteConfirmed}
onClose={() => api.setConfirmRevokeId(null)}
/>
</section>
);
}
@@ -0,0 +1,323 @@
/**
* Component: Watched Lists Section (Profile Page)
* Documentation: documentation/features/watched-lists.md
*
* Shows the user's watched series and watched authors on their profile page
* with the ability to remove items.
*/
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { useWatchedSeries, useDeleteWatchedSeries, WatchedSeriesItem } from '@/lib/hooks/useWatchedSeries';
import { useWatchedAuthors, useDeleteWatchedAuthor, WatchedAuthorItem } from '@/lib/hooks/useWatchedAuthors';
import { usePreferences } from '@/contexts/PreferencesContext';
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
// ---------------------------------------------------------------------------
// Watched Series Section
// ---------------------------------------------------------------------------
export function WatchedSeriesSection() {
const router = useRouter();
const { series, isLoading } = useWatchedSeries();
const { deleteSeries, isLoading: isDeleting } = useDeleteWatchedSeries();
const { squareCovers } = usePreferences();
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const handleDelete = async (id: string) => {
try {
await deleteSeries(id);
setConfirmDeleteId(null);
} catch {
// Error handled by hook
}
};
if (isLoading) {
return (
<section>
<SectionHeader title="Watched Series" icon="series" count={null} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[1, 2].map((i) => <CardSkeleton key={i} squareCovers={squareCovers} />)}
</div>
</section>
);
}
if (series.length === 0) return null;
return (
<section>
<SectionHeader title="Watched Series" icon="series" count={series.length} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{series.map((item) => (
<WatchedSeriesCard
key={item.id}
item={item}
squareCovers={squareCovers}
isDeleting={isDeleting && confirmDeleteId === item.id}
confirmingDelete={confirmDeleteId === item.id}
onNavigate={() => router.push(`/series/${item.seriesAsin}`)}
onConfirmDelete={() => setConfirmDeleteId(item.id)}
onCancelDelete={() => setConfirmDeleteId(null)}
onDelete={() => handleDelete(item.id)}
/>
))}
</div>
</section>
);
}
function WatchedSeriesCard({
item, squareCovers, isDeleting, confirmingDelete, onNavigate, onConfirmDelete, onCancelDelete, onDelete,
}: {
item: WatchedSeriesItem;
squareCovers: boolean;
isDeleting: boolean;
confirmingDelete: boolean;
onNavigate: () => void;
onConfirmDelete: () => void;
onCancelDelete: () => void;
onDelete: () => void;
}) {
return (
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 hover:shadow-sm transition-shadow">
{/* Cover */}
<button onClick={onNavigate} className="flex-shrink-0">
<div className={`relative w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg overflow-hidden bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900`}>
{item.coverArtUrl ? (
<Image src={item.coverArtUrl} alt={item.seriesTitle} fill className="object-cover" sizes="56px" />
) : (
<div className="absolute inset-0 flex items-center justify-center">
<svg className="w-6 h-6 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
)}
</div>
</button>
{/* Info */}
<div className="flex-1 min-w-0">
<button onClick={onNavigate} className="text-left">
<h3 className="font-semibold text-gray-900 dark:text-white truncate hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors">
{item.seriesTitle}
</h3>
</button>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Last checked: {formatRelativeTime(item.lastCheckedAt)}
</p>
</div>
{/* Delete */}
<div className="flex-shrink-0 flex items-center">
{confirmingDelete ? (
<div className="flex items-center gap-1">
<button
onClick={onDelete}
disabled={isDeleting}
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
>
{isDeleting ? '...' : 'Remove'}
</button>
<button
onClick={onCancelDelete}
className="px-2 py-1 text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={onConfirmDelete}
className="p-1.5 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50"
title="Remove from watched"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Watched Authors Section
// ---------------------------------------------------------------------------
export function WatchedAuthorsSection() {
const router = useRouter();
const { authors, isLoading } = useWatchedAuthors();
const { deleteAuthor, isLoading: isDeleting } = useDeleteWatchedAuthor();
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const handleDelete = async (id: string) => {
try {
await deleteAuthor(id);
setConfirmDeleteId(null);
} catch {
// Error handled by hook
}
};
if (isLoading) {
return (
<section>
<SectionHeader title="Watched Authors" icon="author" count={null} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[1, 2].map((i) => <CardSkeleton key={i} />)}
</div>
</section>
);
}
if (authors.length === 0) return null;
return (
<section>
<SectionHeader title="Watched Authors" icon="author" count={authors.length} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{authors.map((item) => (
<WatchedAuthorCard
key={item.id}
item={item}
isDeleting={isDeleting && confirmDeleteId === item.id}
confirmingDelete={confirmDeleteId === item.id}
onNavigate={() => router.push(`/authors/${item.authorAsin}`)}
onConfirmDelete={() => setConfirmDeleteId(item.id)}
onCancelDelete={() => setConfirmDeleteId(null)}
onDelete={() => handleDelete(item.id)}
/>
))}
</div>
</section>
);
}
function WatchedAuthorCard({
item, isDeleting, confirmingDelete, onNavigate, onConfirmDelete, onCancelDelete, onDelete,
}: {
item: WatchedAuthorItem;
isDeleting: boolean;
confirmingDelete: boolean;
onNavigate: () => void;
onConfirmDelete: () => void;
onCancelDelete: () => void;
onDelete: () => void;
}) {
return (
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 hover:shadow-sm transition-shadow">
{/* Avatar */}
<button onClick={onNavigate} className="flex-shrink-0">
<div className="relative w-14 h-14 rounded-full overflow-hidden bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900">
{item.coverArtUrl ? (
<Image src={item.coverArtUrl} alt={item.authorName} fill className="object-cover" sizes="56px" />
) : (
<div className="absolute inset-0 flex items-center justify-center">
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
)}
</div>
</button>
{/* Info */}
<div className="flex-1 min-w-0 flex items-center">
<div>
<button onClick={onNavigate} className="text-left">
<h3 className="font-semibold text-gray-900 dark:text-white truncate hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
{item.authorName}
</h3>
</button>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
Last checked: {formatRelativeTime(item.lastCheckedAt)}
</p>
</div>
</div>
{/* Delete */}
<div className="flex-shrink-0 flex items-center">
{confirmingDelete ? (
<div className="flex items-center gap-1">
<button
onClick={onDelete}
disabled={isDeleting}
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
>
{isDeleting ? '...' : 'Remove'}
</button>
<button
onClick={onCancelDelete}
className="px-2 py-1 text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={onConfirmDelete}
className="p-1.5 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50"
title="Remove from watched"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Shared Components
// ---------------------------------------------------------------------------
function SectionHeader({ title, icon, count }: { title: string; icon: 'series' | 'author'; count: number | null }) {
const gradientColors = icon === 'series'
? 'from-emerald-500 to-teal-500'
: 'from-blue-500 to-indigo-500';
return (
<div className="flex items-center gap-3 mb-5">
<div className={`w-1 h-6 bg-gradient-to-b ${gradientColors} rounded-full`} />
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
{title}
</h2>
{count !== null && (
<span className="text-sm text-gray-500 dark:text-gray-400">({count})</span>
)}
</div>
);
}
function CardSkeleton({ squareCovers }: { squareCovers?: boolean }) {
return (
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 animate-pulse">
<div className={`w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg bg-gray-200 dark:bg-gray-700`} />
<div className="flex-1 space-y-2 py-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
</div>
</div>
);
}
@@ -34,6 +34,7 @@ interface InteractiveTorrentSearchModalProps {
title: string;
author: string;
};
customSearchTerms?: string | null; // Optional - admin-set custom search terms override
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
onSuccess?: () => void;
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
@@ -87,6 +88,7 @@ export function InteractiveTorrentSearchModal({
requestId,
asin,
audiobook,
customSearchTerms,
fullAudiobook,
onSuccess,
searchMode = 'audiobook',
@@ -114,7 +116,7 @@ export function InteractiveTorrentSearchModal({
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]);
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
const [searchTitle, setSearchTitle] = useState(audiobook.title);
const [searchTitle, setSearchTitle] = useState(customSearchTerms || audiobook.title);
const [isCustomConfirming, setIsCustomConfirming] = useState(false);
const [mounted, setMounted] = useState(false);
@@ -153,9 +155,9 @@ export function InteractiveTorrentSearchModal({
// Reset search title when modal opens/closes or audiobook changes
useEffect(() => {
setSearchTitle(audiobook.title);
setSearchTitle(customSearchTerms || audiobook.title);
setResults([]);
}, [isOpen, audiobook.title]);
}, [isOpen, audiobook.title, customSearchTerms]);
// Perform search when modal opens
useEffect(() => {
+22 -14
View File
@@ -11,6 +11,7 @@
import React, { useState } from 'react';
import Image from 'next/image';
import { SeriesDetail } from '@/lib/hooks/useSeries';
import { WatchSeriesButton } from '@/components/ui/WatchButton';
interface SeriesDetailCardProps {
series: SeriesDetail;
@@ -91,20 +92,27 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC
</div>
)}
{/* Audible Link */}
{series.audibleUrl && (
<a
href={series.audibleUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
View on Audible
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{/* Actions row: Audible link + Watch button */}
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
{series.audibleUrl && (
<a
href={series.audibleUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
View on Audible
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
<WatchSeriesButton
seriesAsin={series.asin}
seriesTitle={series.title}
coverArtUrl={series.books[0]?.coverArtUrl}
/>
</div>
{/* Description */}
{series.description && (
+4 -2
View File
@@ -14,7 +14,7 @@ interface ConfirmModalProps {
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
message: string | React.ReactNode;
confirmText?: string;
cancelText?: string;
isLoading?: boolean;
@@ -35,7 +35,9 @@ export function ConfirmModal({
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm" showCloseButton={false}>
<div className="space-y-6">
<p className="text-gray-600 dark:text-gray-400">{message}</p>
<div className="text-gray-600 dark:text-gray-400">
{typeof message === 'string' ? <p>{message}</p> : message}
</div>
<div className="flex gap-3 justify-end">
<Button onClick={onClose} variant="outline" disabled={isLoading}>
+81
View File
@@ -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>
);
}
+82
View File
@@ -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>
);
}
+175
View File
@@ -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>
);
}
-170
View File
@@ -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>
);
}
+325
View File
@@ -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>
);
}
+186
View File
@@ -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>
);
}
+27 -1
View File
@@ -10,6 +10,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
interface Preferences {
cardSize: number; // 1-9, default 5
squareCovers: boolean; // true = square (1:1), false = rectangle (2:3)
hideAvailable: boolean; // true = hide "In Your Library" titles
}
interface PreferencesContextType {
@@ -17,6 +18,8 @@ interface PreferencesContextType {
setCardSize: (size: number) => void;
squareCovers: boolean;
setSquareCovers: (enabled: boolean) => void;
hideAvailable: boolean;
setHideAvailable: (enabled: boolean) => void;
}
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
@@ -24,6 +27,7 @@ const PreferencesContext = createContext<PreferencesContextType | undefined>(und
const DEFAULT_PREFERENCES: Preferences = {
cardSize: 5,
squareCovers: true,
hideAvailable: false,
};
const STORAGE_KEY = 'preferences';
@@ -31,6 +35,7 @@ const STORAGE_KEY = 'preferences';
export function PreferencesProvider({ children }: { children: ReactNode }) {
const [cardSize, setCardSizeState] = useState<number>(DEFAULT_PREFERENCES.cardSize);
const [squareCovers, setSquareCoversState] = useState<boolean>(DEFAULT_PREFERENCES.squareCovers);
const [hideAvailable, setHideAvailableState] = useState<boolean>(DEFAULT_PREFERENCES.hideAvailable);
// Load preferences from localStorage on mount
useEffect(() => {
@@ -49,11 +54,14 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
}
// Load squareCovers preference (defaults to false if not set)
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
// Load hideAvailable preference
setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable);
}
} catch (error) {
console.error('Failed to load preferences from localStorage:', error);
setCardSizeState(DEFAULT_PREFERENCES.cardSize);
setSquareCoversState(DEFAULT_PREFERENCES.squareCovers);
setHideAvailableState(DEFAULT_PREFERENCES.hideAvailable);
}
}, []);
@@ -92,6 +100,22 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
}
};
// Update hideAvailable preference in state and localStorage
const setHideAvailable = (enabled: boolean) => {
if (typeof window === 'undefined') return;
setHideAvailableState(enabled);
try {
const stored = localStorage.getItem(STORAGE_KEY);
const preferences: Preferences = stored ? JSON.parse(stored) : { ...DEFAULT_PREFERENCES };
preferences.hideAvailable = enabled;
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
} catch (error) {
console.error('Failed to save preferences to localStorage:', error);
}
};
// Listen for storage changes in other tabs (cross-tab sync)
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -106,6 +130,8 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
}
// Sync squareCovers preference
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
// Sync hideAvailable preference
setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable);
} catch (error) {
console.error('Failed to parse preferences from storage event:', error);
}
@@ -119,7 +145,7 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
}, []);
return (
<PreferencesContext.Provider value={{ cardSize, setCardSize, squareCovers, setSquareCovers }}>
<PreferencesContext.Provider value={{ cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable }}>
{children}
</PreferencesContext.Provider>
);
+69 -1
View File
@@ -547,7 +547,7 @@ export async function buildAIPrompt(
/**
* Call AI API to get recommendations
* @param provider - 'openai' | 'claude'
* @param provider - 'openai' | 'claude' | 'gemini' | 'custom'
* @param model - Model ID
* @param encryptedApiKey - Encrypted API key
* @param prompt - JSON prompt string
@@ -691,6 +691,74 @@ export async function callAI(
logger.debug('Claude cleaned response:', { cleanedContent });
return JSON.parse(cleanedContent);
} else if (provider === 'gemini') {
const requestBody = {
systemInstruction: {
parts: [{ text: systemMessage }],
},
contents: [
{
parts: [{ text: prompt }],
},
],
generationConfig: {
responseMimeType: "application/json",
responseSchema: {
type: "OBJECT",
properties: {
recommendations: {
type: "ARRAY",
items: {
type: "OBJECT",
properties: {
title: { type: "STRING" },
author: { type: "STRING" },
reason: { type: "STRING" },
},
required: ["title", "author", "reason"],
},
},
},
required: ["recommendations"],
},
},
};
logger.debug('Gemini request body:', { requestBody });
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('Gemini API error', { status: response.status, error: errorText });
throw new Error(`Gemini API error: ${response.status} ${errorText}`);
}
const data = await response.json();
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!content) {
throw new Error('Invalid response format from Gemini API');
}
logger.debug('Gemini raw response:', { content });
// Clean potential markdown wrapping
const cleanedContent = content
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/i, '')
.trim();
logger.debug('Gemini cleaned response:', { cleanedContent });
return JSON.parse(cleanedContent);
} else if (provider === 'custom') {
if (!baseUrl) {
throw new Error('Base URL is required for custom provider');
+107
View File
@@ -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
);
}
+232
View File
@@ -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,
};
}
+55 -14
View File
@@ -5,7 +5,9 @@
'use client';
import { useRef, useEffect, useCallback } from 'react';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import { authenticatedFetcher } from '@/lib/utils/api';
export interface Audiobook {
@@ -33,11 +35,12 @@ export interface Audiobook {
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
}
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) {
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
const hideParam = hideAvailable ? '&hideAvailable=true' : '';
const endpoint =
type === 'popular'
? `/api/audiobooks/popular?page=${page}&limit=${limit}`
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}`;
? `/api/audiobooks/popular?page=${page}&limit=${limit}${hideParam}`
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}${hideParam}`;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
@@ -57,20 +60,58 @@ export function useAudiobooks(type: 'popular' | 'new-releases', limit: number =
};
}
export function useSearch(query: string, page: number = 1) {
const shouldFetch = query && query.length > 0;
const endpoint = shouldFetch ? `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${page}` : null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 30000, // Cache for 30 seconds
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
const seen = new Set<string>();
return items.filter(item => {
if (seen.has(item.asin)) return false;
seen.add(item.asin);
return true;
});
}
export function useSearch(query: string) {
const prevQueryRef = useRef(query);
const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite(
(pageIndex, prevPageData) => {
if (!query || query.length === 0) return null;
if (pageIndex === 0) return `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=1`;
if (!prevPageData?.hasMore) return null;
return `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${pageIndex + 1}`;
},
authenticatedFetcher,
{
revalidateOnFocus: false,
dedupingInterval: 30000,
revalidateFirstPage: false,
}
);
// Reset to page 1 when query changes
useEffect(() => {
if (query !== prevQueryRef.current) {
prevQueryRef.current = query;
setSize(1);
}
}, [query, setSize]);
const results = data ? dedupeByAsin(data.flatMap(page => page?.results || [])) : [];
const totalResults = data?.[0]?.totalResults || 0;
const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore);
const isLoadingInitial = !data && !error && !!query;
const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating);
const loadMore = useCallback(() => {
setSize(prev => prev + 1);
}, [setSize]);
return {
results: data?.results || [],
totalResults: data?.totalResults || 0,
hasMore: data?.hasMore || false,
isLoading: shouldFetch && isLoading,
results,
totalResults,
hasMore,
isLoading: isLoadingInitial,
isLoadingMore,
loadMore,
error,
};
}
+52 -12
View File
@@ -5,7 +5,9 @@
'use client';
import { useRef, useEffect, useCallback } from 'react';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import { authenticatedFetcher } from '@/lib/utils/api';
import { Audiobook } from './useAudiobooks';
@@ -68,21 +70,59 @@ export function useAuthorDetail(asin: string | null) {
};
}
export function useAuthorBooks(asin: string | null, authorName: string | null) {
const shouldFetch = asin && authorName;
const endpoint = shouldFetch
? `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}`
: null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 60000, // Cache for 1 minute
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
const seen = new Set<string>();
return items.filter(item => {
if (seen.has(item.asin)) return false;
seen.add(item.asin);
return true;
});
}
export function useAuthorBooks(asin: string | null, authorName: string | null) {
const prevIdentityRef = useRef<string | null>(null);
const identity = asin && authorName ? `${asin}:${authorName}` : null;
const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite(
(pageIndex, prevPageData) => {
if (!asin || !authorName) return null;
if (pageIndex === 0) return `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}&page=1`;
if (!prevPageData?.hasMore) return null;
return `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}&page=${pageIndex + 1}`;
},
authenticatedFetcher,
{
revalidateOnFocus: false,
dedupingInterval: 60000,
revalidateFirstPage: false,
}
);
// Reset when author changes
useEffect(() => {
if (identity !== prevIdentityRef.current) {
prevIdentityRef.current = identity;
setSize(1);
}
}, [identity, setSize]);
const books = (data ? dedupeByAsin(data.flatMap(page => page?.books || [])) : []) as Audiobook[];
const totalBooks = data?.[0]?.totalBooks || 0;
const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore);
const isLoadingInitial = !data && !error && !!identity;
const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating);
const loadMore = useCallback(() => {
setSize(prev => prev + 1);
}, [setSize]);
return {
books: (data?.books || []) as Audiobook[],
totalBooks: data?.totalBooks || 0,
isLoading: !!shouldFetch && isLoading,
books,
totalBooks,
hasMore,
isLoading: isLoadingInitial || (!!identity && isLoading),
isLoadingMore,
loadMore,
error,
};
}
+56 -8
View File
@@ -5,7 +5,9 @@
'use client';
import { useRef, useEffect, useCallback } from 'react';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import { authenticatedFetcher } from '@/lib/utils/api';
import { Audiobook } from './useAudiobooks';
@@ -59,17 +61,63 @@ export function useSeriesSearch(query: string) {
};
}
export function useSeriesDetail(asin: string | null) {
const endpoint = asin ? `/api/series/${asin}` : null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 300000, // Cache for 5 minutes
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
const seen = new Set<string>();
return items.filter(item => {
if (seen.has(item.asin)) return false;
seen.add(item.asin);
return true;
});
}
export function useSeriesDetail(asin: string | null) {
const prevAsinRef = useRef<string | null>(null);
const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite(
(pageIndex, prevPageData) => {
if (!asin) return null;
if (pageIndex === 0) return `/api/series/${asin}?page=1`;
if (!prevPageData?.hasMore) return null;
return `/api/series/${asin}?page=${pageIndex + 1}`;
},
authenticatedFetcher,
{
revalidateOnFocus: false,
dedupingInterval: 300000,
revalidateFirstPage: false,
}
);
// Reset when series changes
useEffect(() => {
if (asin !== prevAsinRef.current) {
prevAsinRef.current = asin;
setSize(1);
}
}, [asin, setSize]);
// Merge pages: use first page's metadata, accumulate all books
const firstPageSeries = data?.[0]?.series as SeriesDetail | undefined;
const allBooks = (data ? dedupeByAsin(data.flatMap(page => page?.series?.books || [])) : []) as Audiobook[];
const series: SeriesDetail | null = firstPageSeries
? { ...firstPageSeries, books: allBooks }
: null;
const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore);
const isLoadingInitial = !data && !error && !!asin;
const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating);
const loadMore = useCallback(() => {
setSize(prev => prev + 1);
}, [setSize]);
return {
series: (data?.series || null) as SeriesDetail | null,
isLoading,
series,
hasMore,
isLoading: isLoadingInitial || (!!asin && isLoading),
isLoadingMore,
loadMore,
error,
};
}
+119
View File
@@ -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 };
}
+119
View File
@@ -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 };
}
+22 -6
View File
@@ -14,8 +14,10 @@ import {
getLanguageForRegion,
buildContainsSelector,
stripPrefixes,
type LanguageConfig,
} from '../constants/language-config';
import { RMABLogger } from '../utils/logger';
import { parseRuntime } from '../utils/parse-runtime';
import { randomDelay } from '../utils/scrape-resilience';
const logger = RMABLogger.create('Audible.Series');
@@ -288,17 +290,17 @@ function parseSeriesPageSummary(
* Scrape a series page for full detail data including books and similar series.
* Used by the detail API endpoint.
*/
export async function scrapeSeriesPage(asin: string): Promise<SeriesDetail | null> {
export async function scrapeSeriesPage(asin: string, page: number = 1): Promise<(SeriesDetail & { hasMore: boolean; page: number }) | null> {
const service = getAudibleService();
const region = service.getRegion();
const baseUrl = service.getBaseUrl();
const langConfig = getLanguageForRegion(region);
logger.info(`Scraping series detail page: ${asin}`);
logger.info(`Scraping series detail page: ${asin}, page ${page}`);
try {
const { data: response } = await service.fetch(`/series/${asin}`, {
params: { ipRedirectOverride: 'true', pageSize: AUDIBLE_PAGE_SIZE },
params: { ipRedirectOverride: 'true', pageSize: AUDIBLE_PAGE_SIZE, page },
});
const $ = cheerio.load(response.data);
@@ -311,15 +313,20 @@ export async function scrapeSeriesPage(asin: string): Promise<SeriesDetail | nul
undefined;
// Parse all books from the series page
const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes);
const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes, langConfig);
// Use actual book count if we got more from scraping
const bookCount = Math.max(summary.bookCount, books.length);
// Calculate hasMore: use header bookCount if available, otherwise check if full page
const hasMore = bookCount > 0
? page * AUDIBLE_PAGE_SIZE < bookCount
: books.length >= AUDIBLE_PAGE_SIZE;
// Parse similar series ("Listeners also enjoyed" or similar section)
const similarSeries = parseSimilarSeries($);
logger.info(`Series detail complete: "${summary.title}" (${books.length} books, ${similarSeries.length} similar)`);
logger.info(`Series detail complete: "${summary.title}" (${books.length} books, page ${page}, hasMore: ${hasMore})`);
return {
asin,
@@ -332,6 +339,8 @@ export async function scrapeSeriesPage(asin: string): Promise<SeriesDetail | nul
books,
similarSeries,
audibleUrl: `${baseUrl}/series/${asin}`,
hasMore,
page,
};
} catch (error) {
logger.error(`Failed to scrape series detail ${asin}`, {
@@ -396,7 +405,8 @@ function parseSeriesRating($: cheerio.CheerioAPI): { rating?: number; ratingCoun
function parseSeriesBooks(
$: cheerio.CheerioAPI,
authorPrefixes: string[],
narratorPrefixes: string[]
narratorPrefixes: string[],
langConfig: LanguageConfig
): AudibleAudiobook[] {
const books: AudibleAudiobook[] = [];
const seenAsins = new Set<string>();
@@ -446,6 +456,11 @@ function parseSeriesBooks(
const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null;
const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined;
// Duration
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
const durationMinutes = parseRuntime(runtimeText, langConfig);
books.push({
asin: bookAsin,
title,
@@ -454,6 +469,7 @@ function parseSeriesBooks(
narrator: stripPrefixes(narratorText, narratorPrefixes),
coverArtUrl,
rating,
durationMinutes,
});
});
+97 -121
View File
@@ -23,6 +23,7 @@ import {
AdaptivePacer,
FetchResultMeta,
} from '../utils/scrape-resilience';
import { parseRuntime as parseRuntimeUtil } from '../utils/parse-runtime';
// Module-level logger
const logger = RMABLogger.create('Audible');
@@ -59,6 +60,13 @@ export interface AudibleSearchResult {
hasMore: boolean;
}
export interface AuthorBooksResult {
books: AudibleAudiobook[];
hasMore: boolean;
page: number;
totalResults: number;
}
export class AudibleService {
private client!: AxiosInstance;
private baseUrl: string = 'https://www.audible.com';
@@ -564,7 +572,9 @@ export class AudibleService {
results: audiobooks,
totalResults,
page,
hasMore: audiobooks.length > 0 && totalResults > page * AUDIBLE_PAGE_SIZE,
hasMore: audiobooks.length > 0 && (totalResults > 0
? totalResults > page * AUDIBLE_PAGE_SIZE
: audiobooks.length >= AUDIBLE_PAGE_SIZE),
};
} catch (error) {
logger.error('Search failed', { error: error instanceof Error ? error.message : String(error) });
@@ -583,123 +593,111 @@ export class AudibleService {
* Uses Audible's searchAuthor parameter and paginates through all results.
* Filters: (1) author link must contain the target ASIN, (2) language must be English.
*/
async searchByAuthorAsin(authorName: string, authorAsin: string): Promise<AudibleAudiobook[]> {
async searchByAuthorAsin(authorName: string, authorAsin: string, page: number = 1): Promise<AuthorBooksResult> {
await this.initialize();
const MAX_PAGES = 10;
const allBooks: AudibleAudiobook[] = [];
const books: AudibleAudiobook[] = [];
const seenAsins = new Set<string>();
try {
logger.info(`Searching books by author "${authorName}" (ASIN: ${authorAsin})...`);
logger.info(`Searching books by author "${authorName}" (ASIN: ${authorAsin}), page ${page}...`);
for (let page = 1; page <= MAX_PAGES; page++) {
const { data: response, meta } = await this.fetchWithRetry('/search', {
params: {
ipRedirectOverride: 'true',
searchAuthor: authorName,
pageSize: AUDIBLE_PAGE_SIZE,
page,
},
const { data: response } = await this.fetchWithRetry('/search', {
params: {
ipRedirectOverride: 'true',
searchAuthor: authorName,
pageSize: AUDIBLE_PAGE_SIZE,
page,
},
});
const $ = cheerio.load(response.data);
// Count raw items on page before filtering (for hasMore fallback)
const pageItemCount = $('.s-result-item, .productListItem').length;
$('.s-result-item, .productListItem').each((_index, element) => {
const $el = $(element);
// --- Language filter: require matching language for region ---
const langConfig = this.getLangConfig();
const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() ||
$el.find('.languageLabel').text().trim();
const langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i');
const langMatch = langText.match(langLabelPattern);
const language = langMatch?.[1]?.trim();
if (!language || !isAcceptedLanguage(language, langConfig)) return;
// --- Author ASIN filter: verify target ASIN in author links ---
const authorLinks = $el.find('a[href*="/author/"]');
let hasMatchingAuthor = false;
authorLinks.each((_i, link) => {
const href = $(link).attr('href') || '';
const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
if (asinMatch && asinMatch[1] === authorAsin) {
hasMatchingAuthor = true;
return false; // break .each()
}
});
if (!hasMatchingAuthor) return;
const $ = cheerio.load(response.data);
let pageResults = 0;
// --- Extract book ASIN ---
const bookAsin = $el.find('li').attr('data-asin') ||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!bookAsin || seenAsins.has(bookAsin)) return;
seenAsins.add(bookAsin);
$('.s-result-item, .productListItem').each((_index, element) => {
const $el = $(element);
// --- Parse book details ---
const title = $el.find('h2').first().text().trim() ||
$el.find('h3 a').text().trim() ||
$el.find('.bc-heading a').text().trim();
// --- Language filter: require matching language for region ---
const langConfig = this.getLangConfig();
const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() ||
$el.find('.languageLabel').text().trim();
// Extract language value (e.g. "Language: English" -> "English", "Sprache: Deutsch" -> "Deutsch")
const langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i');
const langMatch = langText.match(langLabelPattern);
const language = langMatch?.[1]?.trim();
if (!language || !isAcceptedLanguage(language, langConfig)) return;
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
$el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
// --- Author ASIN filter: verify target ASIN in author links ---
const authorLinks = $el.find('a[href*="/author/"]');
let hasMatchingAuthor = false;
authorLinks.each((_i, link) => {
const href = $(link).attr('href') || '';
const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
if (asinMatch && asinMatch[1] === authorAsin) {
hasMatchingAuthor = true;
return false; // break .each()
}
});
if (!hasMatchingAuthor) return;
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
$el.find('.narratorLabel').text().trim();
// --- Extract book ASIN ---
const bookAsin = $el.find('li').attr('data-asin') ||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!bookAsin || seenAsins.has(bookAsin)) return;
seenAsins.add(bookAsin);
const coverArtUrl = $el.find('img').attr('src') || '';
// --- Parse book details ---
const title = $el.find('h2').first().text().trim() ||
$el.find('h3 a').text().trim() ||
$el.find('.bc-heading a').text().trim();
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
const durationMinutes = this.parseRuntime(runtimeText);
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
$el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
const ratingText = $el.find('.ratingsLabel').text().trim() ||
$el.find('.a-icon-star span').first().text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
$el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
const durationMinutes = this.parseRuntime(runtimeText);
const ratingText = $el.find('.ratingsLabel').text().trim() ||
$el.find('.a-icon-star span').first().text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
allBooks.push({
asin: bookAsin,
title,
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
authorAsin,
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes,
rating,
});
pageResults++;
books.push({
asin: bookAsin,
title,
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
authorAsin,
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes,
rating,
});
});
// Check if there are more pages
const resultsText = $('.resultsInfo').text().trim();
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
const hasMore = totalResults > page * AUDIBLE_PAGE_SIZE;
// Check total results for pagination
const resultsText = $('.resultsInfo').text().trim();
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
// Use totalResults if available; otherwise fall back to whether Audible returned a full page
const hasMore = books.length > 0 && (totalResults > 0
? totalResults > page * AUDIBLE_PAGE_SIZE
: pageItemCount >= AUDIBLE_PAGE_SIZE);
logger.info(`Author books page ${page}: ${pageResults} valid results (${allBooks.length} total, ${totalResults} Audible total)`);
if (!hasMore || pageResults === 0) break;
// Pace between pages
if (page < MAX_PAGES) {
await this.delay(this.pacer.reportPageResult(meta));
}
}
logger.info(`Author books search complete: "${authorName}" → ${allBooks.length} books`);
return allBooks;
logger.info(`Author books page ${page}: ${books.length} valid results (${totalResults} Audible total)`);
return { books, hasMore, page, totalResults };
} catch (error) {
logger.error(`Author books search failed for "${authorName}"`, {
error: error instanceof Error ? error.message : String(error),
collectedSoFar: allBooks.length,
});
// Return what we collected before the error
return allBooks;
return { books, hasMore: false, page, totalResults: 0 };
}
}
@@ -1137,33 +1135,11 @@ export class AudibleService {
}
/**
* Parse runtime text to minutes using language-specific patterns
* Parse runtime text to minutes using language-specific patterns.
* Delegates to shared utility in src/lib/utils/parse-runtime.ts.
*/
private parseRuntime(runtimeText: string): number | undefined {
if (!runtimeText) return undefined;
const langConfig = this.getLangConfig();
let totalMinutes = 0;
// Try each hour pattern until one matches
for (const pattern of langConfig.scraping.runtimeHourPatterns) {
const match = runtimeText.match(pattern);
if (match) {
totalMinutes += parseInt(match[1]) * 60;
break;
}
}
// Try each minute pattern until one matches
for (const pattern of langConfig.scraping.runtimeMinutePatterns) {
const match = runtimeText.match(pattern);
if (match) {
totalMinutes += parseInt(match[1]);
break;
}
}
return totalMinutes > 0 ? totalMinutes : undefined;
return parseRuntimeUtil(runtimeText, this.getLangConfig());
}
/**
+107 -3
View File
@@ -4,9 +4,11 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
import { API_TOKEN_PREFIX, isEndpointAllowed } from '../constants/api-tokens';
const logger = RMABLogger.create('Auth');
@@ -32,9 +34,70 @@ function extractToken(request: NextRequest): string | null {
return parts[1];
}
/**
* Authenticate via static API token (rmab_ prefix).
* Returns a synthetic TokenPayload if valid, null otherwise.
* Updates lastUsedAt asynchronously.
*/
async function authenticateApiToken(token: string): Promise<TokenPayload | null> {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const apiToken = await prisma.apiToken.findUnique({
where: { tokenHash },
include: {
tokenUser: {
select: {
id: true,
plexId: true,
plexUsername: true,
role: true,
deletedAt: true,
},
},
},
});
if (!apiToken) return null;
// Check expiration
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix });
return null;
}
// Reject tokens for soft-deleted users
const user = apiToken.tokenUser;
if (!user || user.deletedAt) {
logger.warn('API token used by deleted or missing user', {
tokenPrefix: apiToken.tokenPrefix,
userId: user?.id,
});
return null;
}
// Update lastUsedAt (fire-and-forget)
prisma.apiToken.update({
where: { id: apiToken.id },
data: { lastUsedAt: new Date() },
}).catch((err) => {
logger.debug('Failed to update API token lastUsedAt', {
error: err instanceof Error ? err.message : String(err),
tokenId: apiToken.id,
});
});
// Use the token's target user (userId), not the creator (createdById)
return {
sub: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: apiToken.role,
};
}
/**
* Middleware: Require authentication
* Verifies JWT token and adds user to request
* Verifies JWT token or static API token and adds user to request
*/
export async function requireAuth(
request: NextRequest,
@@ -53,6 +116,43 @@ export async function requireAuth(
);
}
// Check if this is a static API token
if (token.startsWith(API_TOKEN_PREFIX)) {
const apiUser = await authenticateApiToken(token);
if (!apiUser) {
logger.error('API token authentication failed');
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Invalid or expired API token',
},
{ status: 401 }
);
}
// Enforce endpoint allowlist for API token auth
const pathname = request.nextUrl.pathname;
const method = request.method;
if (!isEndpointAllowed(method, pathname)) {
logger.warn('API token used on restricted endpoint', {
method,
path: pathname,
});
return NextResponse.json(
{
error: 'Forbidden',
message: 'This endpoint is not available via API token authentication',
},
{ status: 403 }
);
}
const authenticatedRequest = request as AuthenticatedRequest;
authenticatedRequest.user = { ...apiUser, id: apiUser.sub };
return handler(authenticatedRequest);
}
// Fall back to JWT verification
const payload = verifyAccessToken(token);
if (!payload) {
@@ -69,9 +169,13 @@ export async function requireAuth(
// Verify user still exists in database
const user = await prisma.user.findUnique({
where: { id: payload.sub },
select: {
id: true,
deletedAt: true,
},
});
if (!user) {
if (!user || user.deletedAt) {
logger.error('User not found in database', { userId: payload.sub });
return NextResponse.json(
{
@@ -86,7 +190,7 @@ export async function requireAuth(
const authenticatedRequest = request as AuthenticatedRequest;
authenticatedRequest.user = {
...payload,
id: user.id,
id: payload.sub,
};
return handler(authenticatedRequest);
@@ -0,0 +1,43 @@
/**
* Component: Check Watched Lists Processor
* Documentation: documentation/features/watched-lists.md
*
* Dedicated processor for checking watched series and watched authors
* for new releases and auto-creating requests.
* Supports targeted processing of a single series/author for immediate sync.
*/
import { RMABLogger } from '../utils/logger';
export interface CheckWatchedListsPayload {
jobId?: string;
scheduledJobId?: string;
/** If set, only process watched items for this user */
userId?: string;
/** If set, only process this specific series */
seriesAsin?: string;
/** If set, only process this specific author */
authorAsin?: string;
}
export async function processCheckWatchedLists(payload: CheckWatchedListsPayload): Promise<any> {
const { jobId, userId, seriesAsin, authorAsin } = payload;
const logger = RMABLogger.forJob(jobId, 'CheckWatchedLists');
const isTargeted = !!(userId && (seriesAsin || authorAsin));
logger.info(isTargeted
? `Starting targeted watched lists check (user: ${userId}, series: ${seriesAsin || 'n/a'}, author: ${authorAsin || 'n/a'})...`
: 'Starting watched lists check...'
);
const { processWatchedLists } = await import('../services/watched-lists.service');
const stats = await processWatchedLists(logger, { userId, seriesAsin, authorAsin });
logger.info('Watched lists check complete', { stats });
return {
success: true,
message: isTargeted ? 'Targeted watched item checked' : 'Watched lists checked',
...stats,
};
}
@@ -9,6 +9,7 @@ import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service';
import { ProwlarrService } from '../integrations/prowlarr.service';
import { RMABLogger } from '../utils/logger';
import { isTransientConnectionError } from '../utils/connection-errors';
/**
* Process download job
@@ -121,15 +122,22 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
} catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Update request status to failed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Failed to add download to client',
updatedAt: new Date(),
},
});
if (isTransientConnectionError(error)) {
// Connection error — don't mark request as failed yet.
// Bull will retry this job (3 attempts with exponential backoff).
// If all retries are exhausted, the global failed handler marks it failed.
logger.warn(`Download client unreachable for request ${requestId}, allowing Bull to retry`);
} else {
// Permanent error — mark request as failed immediately
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Failed to add download to client',
updatedAt: new Date(),
},
});
}
throw error;
}
@@ -10,6 +10,7 @@ import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
import { isTransientConnectionError } from '../utils/connection-errors';
/**
* Process monitor download job
@@ -20,6 +21,12 @@ import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-
const BASE_POLL_INTERVAL = 10;
/** Maximum polling interval in seconds (5 minutes) */
const MAX_POLL_INTERVAL = 300;
/**
* Maximum consecutive connection failures before permanently failing the download.
* With exponential backoff (10s base, 300s cap), 30 failures spans roughly 30-45 minutes
* enough to survive a Docker restart, service update, or transient network outage.
*/
const MAX_CONNECTION_FAILURES = 30;
/**
* Compute next poll delay with exponential backoff for stalled downloads.
@@ -32,7 +39,8 @@ function getBackoffDelay(stallCount: number): number {
export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> {
const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId,
lastProgress: prevProgress, stallCount: prevStallCount, pathWaitCount: prevPathWaitCount } = payload;
lastProgress: prevProgress, stallCount: prevStallCount, pathWaitCount: prevPathWaitCount,
connectionFailureCount: prevConnectionFailures } = payload;
const logger = RMABLogger.forJob(jobId, 'MonitorDownload');
@@ -288,51 +296,99 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
} catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Check if this is a transient "not found" error
const errorMessage = error instanceof Error ? error.message : '';
const isNotFound = errorMessage.includes('not found');
const isConnectionError = isTransientConnectionError(error);
if (isNotFound) {
// Transient error - don't mark request as failed, let Bull retry
// The request stays in 'downloading' status until Bull exhausts all retries
// PATH 1: "Not found" — transient race condition.
// Don't mark request as failed; let Bull retry the same job.
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
} else {
// Permanent error - mark request as failed immediately
const failureMessage = errorMessage || 'Monitor download failed';
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: failureMessage,
updatedAt: new Date(),
},
});
throw error;
}
// Send notification for request failure
const request = await prisma.request.findUnique({
where: { id: requestId },
include: {
audiobook: true,
user: { select: { plexUsername: true } },
},
});
if (isConnectionError) {
// PATH 2: Connection failure — download client is temporarily unreachable.
// Instead of failing the download, self-schedule the next poll with backoff.
// This reuses the same adaptive backoff as stalled downloads, giving the
// client time to recover (restart, network blip, update, etc.).
const failureCount = (prevConnectionFailures ?? 0) + 1;
if (failureCount >= MAX_CONNECTION_FAILURES) {
// Exhausted patience — treat as permanent failure
logger.error(
`Download client unreachable for ${failureCount} consecutive checks, giving up on request ${requestId}`
);
// Fall through to permanent failure handling below
} else {
const delay = getBackoffDelay(failureCount);
logger.warn(
`Download client unreachable (${failureCount}/${MAX_CONNECTION_FAILURES}), ` +
`retrying in ${delay}s for request ${requestId}`,
{ error: errorMessage }
);
if (request) {
const jobQueue = getJobQueueService();
await jobQueue.addNotificationJob(
'request_error',
request.id,
request.audiobook.title,
request.audiobook.author,
request.user.plexUsername || 'Unknown User',
failureMessage
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
await jobQueue.addMonitorJob(
requestId,
downloadHistoryId,
downloadClientId,
downloadClient,
delay,
prevProgress,
prevStallCount ?? 0,
prevPathWaitCount,
failureCount
);
// Return success — the monitoring loop continues via the new job.
// Do NOT throw: that would trigger Bull's retry on this job as well.
return {
success: true,
completed: false,
message: `Download client unreachable, will retry in ${delay}s`,
requestId,
connectionFailureCount: failureCount,
};
}
}
// Rethrow to trigger Bull's retry mechanism
// PATH 3: Permanent error (or connection failures exhausted).
// Mark request as failed immediately.
const failureMessage = errorMessage || 'Monitor download failed';
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: failureMessage,
updatedAt: new Date(),
},
});
// Send notification for request failure
const request = await prisma.request.findUnique({
where: { id: requestId },
include: {
audiobook: true,
user: { select: { plexUsername: true } },
},
});
if (request) {
const jobQueue = getJobQueueService();
await jobQueue.addNotificationJob(
'request_error',
request.id,
request.audiobook.title,
request.audiobook.author,
request.user.plexUsername || 'Unknown User',
failureMessage
).catch((notifError) => {
logger.error('Failed to queue notification', { error: notifError instanceof Error ? notifError.message : String(notifError) });
});
}
// Rethrow to trigger Bull's retry mechanism as a safety net
throw error;
}
}
+183 -5
View File
@@ -15,6 +15,7 @@ import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import { generateFilesHash } from '../utils/files-hash';
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
import { getAudibleService } from '../integrations/audible.service';
/**
* Process organize files job
@@ -22,7 +23,7 @@ import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
* Handles both audiobook and ebook request types with appropriate branching
*/
export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> {
const { requestId, audiobookId, downloadPath, jobId } = payload;
const { requestId, audiobookId, downloadPath, jobId, cleanupSource } = payload;
const logger = RMABLogger.forJob(jobId, 'OrganizeFiles');
@@ -118,7 +119,62 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
}
}
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}`)
// Enrich missing series data from Audnexus (safety net for records created without series)
let series = audiobook.series || undefined;
let seriesPart = audiobook.seriesPart || undefined;
if (audiobook.audibleAsin && !series) {
try {
logger.info(`Missing series data, fetching from Audnexus for ASIN: ${audiobook.audibleAsin}`);
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin);
if (audnexusData) {
const updates: Record<string, any> = {};
if (audnexusData.series) {
series = audnexusData.series;
updates.series = series;
logger.info(`Got series "${series}" from Audnexus`);
}
if (audnexusData.seriesPart) {
seriesPart = audnexusData.seriesPart;
updates.seriesPart = seriesPart;
logger.info(`Got seriesPart "${seriesPart}" from Audnexus`);
}
if (audnexusData.seriesAsin) {
updates.seriesAsin = audnexusData.seriesAsin;
}
// Also backfill year/narrator if still missing
if (!year && audnexusData.releaseDate) {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
updates.year = year;
logger.info(`Got year ${year} from Audnexus`);
}
}
if (!narrator && audnexusData.narrator) {
narrator = audnexusData.narrator;
updates.narrator = narrator;
logger.info(`Got narrator "${narrator}" from Audnexus`);
}
if (Object.keys(updates).length > 0) {
await prisma.audiobook.update({
where: { id: audiobookId },
data: updates,
});
logger.info(`Updated audiobook record with Audnexus metadata`);
}
}
} catch (error) {
// Non-fatal: missing series won't block organization, just degrades path quality
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
}
}
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`);
// Get file organizer (reads media_dir from database config)
const organizer = await getFileOrganizer();
@@ -151,8 +207,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
coverArtUrl: audiobook.coverArtUrl || undefined,
asin: audiobook.audibleAsin || undefined,
year,
series: audiobook.series || undefined,
seriesPart: audiobook.seriesPart || undefined,
series,
seriesPart,
},
template,
jobId ? { jobId, context: 'FileOrganizer' } : undefined,
@@ -264,6 +320,11 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
// Cleanup source files if requested (manual import feature)
if (cleanupSource) {
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger);
}
return {
success: true,
message: 'Files organized successfully',
@@ -467,7 +528,7 @@ async function processEbookOrganization(
request: { id: string; userId: string; type: string; user: { plexUsername: string | null } },
logger: RMABLogger
): Promise<any> {
const { requestId, audiobookId, downloadPath, jobId } = payload;
const { requestId, audiobookId, downloadPath, jobId, cleanupSource } = payload;
logger.info(`Processing ebook organization for request ${requestId}`);
@@ -540,6 +601,56 @@ async function processEbookOrganization(
}
}
// Enrich missing series data from Audnexus (safety net for records created without series)
if (book.audibleAsin && !series) {
try {
logger.info(`Missing series data for ebook, fetching from Audnexus for ASIN: ${book.audibleAsin}`);
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(book.audibleAsin);
if (audnexusData) {
const updates: Record<string, any> = {};
if (audnexusData.series) {
series = audnexusData.series;
updates.series = series;
logger.info(`Got series "${series}" from Audnexus`);
}
if (audnexusData.seriesPart) {
seriesPart = audnexusData.seriesPart;
updates.seriesPart = seriesPart;
logger.info(`Got seriesPart "${seriesPart}" from Audnexus`);
}
if (audnexusData.seriesAsin) {
updates.seriesAsin = audnexusData.seriesAsin;
}
if (!year && audnexusData.releaseDate) {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
updates.year = year;
logger.info(`Got year ${year} from Audnexus`);
}
}
if (!narrator && audnexusData.narrator) {
narrator = audnexusData.narrator;
updates.narrator = narrator;
logger.info(`Got narrator "${narrator}" from Audnexus`);
}
if (Object.keys(updates).length > 0) {
await prisma.audiobook.update({
where: { id: audiobookId },
data: updates,
});
logger.info(`Updated book record with Audnexus metadata`);
}
}
} catch (error) {
logger.warn(`Failed to fetch Audnexus data for ASIN ${book.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
}
}
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`);
// Check if this is an indexer download (needs to keep source for seeding)
@@ -726,6 +837,11 @@ async function processEbookOrganization(
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
// Cleanup source files if requested (manual import feature)
if (cleanupSource) {
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger);
}
return {
success: true,
message: 'Ebook organized successfully',
@@ -1003,6 +1119,68 @@ async function cleanupDownloadAfterOrganize(
}
}
// =========================================================================
// SOURCE FILE CLEANUP (MANUAL IMPORT)
// =========================================================================
/**
* Delete source files after successful manual import.
* Non-fatal: logs a warning on failure but does not fail the job.
* Files are already safely copied to the media library at this point.
*/
async function cleanupSourceAfterOrganize(
downloadPath: string,
configService: any,
jobId: string | undefined,
logger: RMABLogger
): Promise<void> {
try {
const fs = await import('fs/promises');
logger.info(`Cleaning up source files: ${downloadPath}`);
const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed source directory: ${downloadPath}`);
} else {
await fs.unlink(downloadPath);
logger.info(`Removed source file: ${downloadPath}`);
}
// Determine boundary path based on download path prefix
const BOOKDROP_PATH = '/bookdrop';
const downloadDir = await configService.get('download_dir') || '/downloads';
const mediaDir = await configService.get('media_dir') || '/media';
let boundaryPath = downloadDir;
if (downloadPath.startsWith(BOOKDROP_PATH)) {
boundaryPath = BOOKDROP_PATH;
} else if (downloadPath.startsWith(mediaDir)) {
boundaryPath = mediaDir;
}
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
boundaryPath,
logContext: jobId ? { jobId, context: 'CleanupSourceParents' } : undefined,
});
if (cleanupResult.removedDirectories.length > 0) {
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
}
} catch (error) {
// Non-fatal - files are already safely in the media library
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`Source path already deleted: ${downloadPath}`);
} else {
logger.warn(
`Failed to cleanup source files: ${error instanceof Error ? error.message : 'Unknown error'}`,
{ error: error instanceof Error ? error.stack : undefined }
);
}
}
}
// =========================================================================
// HELPER FUNCTIONS
// =========================================================================
@@ -34,6 +34,13 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
},
});
// Check for custom search terms override
const requestRecord = await prisma.request.findUnique({
where: { id: requestId },
select: { customSearchTerms: true },
});
const effectiveSearchTitle = requestRecord?.customSearchTerms || audiobook.title;
// Get enabled indexers from configuration
const { getConfigService } = await import('../services/config.service');
const configService = getConfigService();
@@ -77,7 +84,11 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Get Prowlarr service
const prowlarr = await getProwlarrService();
logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`);
if (requestRecord?.customSearchTerms) {
logger.info(`Searching with custom terms: "${effectiveSearchTitle}" (original: "${audiobook.title}") by "${audiobook.author}"`);
} else {
logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`);
}
// Search Prowlarr for each group and combine results
const allResults = [];
@@ -87,7 +98,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
try {
const groupResults = await prowlarr.searchWithVariations(audiobook.title, audiobook.author, {
const groupResults = await prowlarr.searchWithVariations(effectiveSearchTitle, audiobook.author, {
categories: group.categories,
indexerIds: group.indexerIds,
minSeeders: 1, // Only torrents with at least 1 seeder
+8 -5
View File
@@ -54,10 +54,12 @@ export class LocalAuthProvider implements IAuthProvider {
return { success: false, error: 'Username and password required' };
}
const normalizedUsername = username.trim().toLowerCase();
// Find user (exclude soft-deleted users)
const user = await prisma.user.findFirst({
where: {
plexUsername: username,
plexUsername: normalizedUsername,
authProvider: 'local',
deletedAt: null, // Exclude soft-deleted users
},
@@ -144,9 +146,10 @@ export class LocalAuthProvider implements IAuthProvider {
async register(params: RegisterParams): Promise<AuthResult> {
try {
const { username, password } = params;
const normalizedUsername = username?.trim().toLowerCase();
// Validate
if (!username || username.length < 3) {
if (!normalizedUsername || normalizedUsername.length < 3) {
return { success: false, error: 'Username must be at least 3 characters' };
}
@@ -167,7 +170,7 @@ export class LocalAuthProvider implements IAuthProvider {
// Check username uniqueness (only among non-deleted users)
const existing = await prisma.user.findFirst({
where: {
plexUsername: username,
plexUsername: normalizedUsername,
authProvider: 'local',
deletedAt: null, // Allow reuse of usernames from deleted accounts
},
@@ -194,8 +197,8 @@ export class LocalAuthProvider implements IAuthProvider {
// Create user
const user = await prisma.user.create({
data: {
plexId: `local-${username}`,
plexUsername: username,
plexId: `local-${normalizedUsername}`,
plexUsername: normalizedUsername,
authToken: encryptedHash,
authProvider: 'local',
role: isFirstUser ? 'admin' : 'user',
+81 -2
View File
@@ -27,6 +27,7 @@ export type JobType =
| 'cleanup_seeded_torrents'
| 'monitor_rss_feeds'
| 'sync_goodreads_shelves'
| 'check_watched_lists'
| 'send_notification'
// Ebook-specific job types
| 'search_ebook'
@@ -66,6 +67,7 @@ export interface MonitorDownloadPayload extends JobPayload {
lastProgress?: number; // Previous poll's progress (0-100) for stall detection
stallCount?: number; // Consecutive polls with no progress change (drives backoff)
pathWaitCount?: number; // Consecutive polls waiting for content_path to relocate to save_path
connectionFailureCount?: number; // Consecutive polls where the download client was unreachable
}
export interface OrganizeFilesPayload extends JobPayload {
@@ -73,6 +75,7 @@ export interface OrganizeFilesPayload extends JobPayload {
audiobookId: string;
downloadPath: string;
targetPath?: string; // Optional - not used by processor (reads from database config)
cleanupSource?: boolean; // If true, delete source files after successful import
}
export interface ScanPlexPayload extends JobPayload {
@@ -111,6 +114,16 @@ export interface SyncGoodreadsShelvesPayload extends JobPayload {
maxLookupsPerShelf?: number;
}
export interface CheckWatchedListsPayload extends JobPayload {
scheduledJobId?: string;
/** If set, only process watched items for this user */
userId?: string;
/** If set, only process this specific series */
seriesAsin?: string;
/** If set, only process this specific author */
authorAsin?: string;
}
// Ebook-specific payload interfaces
export interface SearchEbookPayload extends JobPayload {
requestId: string;
@@ -259,6 +272,29 @@ export class JobQueueService {
logger.error('Failed to update request/download status', { error: updateError instanceof Error ? updateError.message : String(updateError) });
}
}
// Safety net for download_torrent: if the processor skipped marking the
// request as failed (e.g. connection error with Bull retries), ensure the
// request is marked failed after all retries are exhausted.
if (job.name === 'download_torrent' && job.data) {
const payload = job.data as DownloadTorrentPayload;
logger.error(`DownloadTorrent job permanently failed for request ${payload.requestId} after ${job.attemptsMade} attempts`);
try {
await prisma.request.update({
where: { id: payload.requestId },
data: {
status: 'failed',
errorMessage: error.message || 'Failed to add download after multiple retries',
updatedAt: new Date(),
},
});
} catch (updateError) {
logger.error('Failed to update request status after download_torrent failure', {
error: updateError instanceof Error ? updateError.message : String(updateError),
});
}
}
});
this.queue.on('stalled', async (job: BullJob) => {
@@ -359,6 +395,12 @@ export class JobQueueService {
return await processSyncGoodreadsShelves(payloadWithJobId);
});
this.queue.process('check_watched_lists', 1, async (job: BullJob<CheckWatchedListsPayload>) => {
const { processCheckWatchedLists } = await import('../processors/check-watched-lists.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'check_watched_lists');
return await processCheckWatchedLists(payloadWithJobId);
});
// Send notification processor
this.queue.process('send_notification', 2, async (job: BullJob<SendNotificationPayload>) => {
const { processSendNotification } = await import('../processors/send-notification.processor');
@@ -569,7 +611,8 @@ export class JobQueueService {
delaySeconds: number = 0,
lastProgress?: number,
stallCount?: number,
pathWaitCount?: number
pathWaitCount?: number,
connectionFailureCount?: number
): Promise<string> {
return await this.addJob(
'monitor_download',
@@ -581,6 +624,7 @@ export class JobQueueService {
lastProgress,
stallCount,
pathWaitCount,
connectionFailureCount,
} as MonitorDownloadPayload,
{
priority: 5, // Medium priority
@@ -597,7 +641,8 @@ export class JobQueueService {
requestId: string,
audiobookId: string,
downloadPath: string,
targetPath?: string
targetPath?: string,
cleanupSource?: boolean
): Promise<string> {
return await this.addJob(
'organize_files',
@@ -606,6 +651,7 @@ export class JobQueueService {
audiobookId,
downloadPath,
targetPath, // Not used by processor
cleanupSource,
} as OrganizeFilesPayload,
{
priority: 8,
@@ -737,6 +783,39 @@ export class JobQueueService {
);
}
/**
* Add check watched lists job (watched series + watched authors)
*/
async addCheckWatchedListsJob(scheduledJobId?: string): Promise<string> {
return await this.addJob(
'check_watched_lists',
{
scheduledJobId,
} as CheckWatchedListsPayload,
{
priority: 7,
}
);
}
/**
* Add a targeted check for a specific watched series or author for a specific user.
* Used for immediate processing when a user adds a new watch.
*/
async addCheckWatchedItemJob(userId: string, seriesAsin?: string, authorAsin?: string): Promise<string> {
return await this.addJob(
'check_watched_lists',
{
userId,
seriesAsin,
authorAsin,
} as CheckWatchedListsPayload,
{
priority: 8, // Higher than scheduled (7) since user-initiated
}
);
}
// =========================================================================
// EBOOK-SPECIFIC JOB METHODS
// =========================================================================
@@ -45,13 +45,31 @@ export class AppriseProvider implements INotificationProvider {
const meta = getEventMeta(payload.event);
const { title, body } = this.formatMessage(payload);
const serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
const notificationType = SEVERITY_TYPES[meta.severity];
// Parse URL to extract embedded HTTP Basic Auth credentials (e.g. https://user:pass@host/)
let serverUrl: string;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
try {
const parsed = new URL(appriseConfig.serverUrl);
if (parsed.username) {
const username = decodeURIComponent(parsed.username);
const password = decodeURIComponent(parsed.password);
headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
parsed.username = '';
parsed.password = '';
serverUrl = parsed.toString().replace(/\/+$/, '');
} else {
serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
}
} catch {
serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
}
const notificationType = SEVERITY_TYPES[meta.severity];
// Explicit authToken (Bearer) takes precedence over URL-embedded credentials
if (appriseConfig.authToken) {
headers['Authorization'] = `Bearer ${appriseConfig.authToken}`;
}
@@ -12,6 +12,7 @@ import { getJobQueueService } from '@/lib/services/job-queue.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
import { seedAsin } from '@/lib/services/works.service';
const logger = RMABLogger.create('RequestCreator');
@@ -147,6 +148,15 @@ export async function createRequestForUser(
}
}
// Seed works table for cross-ASIN matching (Layer 2: request-time seeding)
seedAsin(
audiobook.asin,
audiobookRecord.title,
audiobookRecord.author,
audiobookRecord.narrator || undefined,
undefined // duration not available at request time
).catch(() => {});
// Check if user already has an active request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
+18 -1
View File
@@ -10,7 +10,7 @@ import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('Scheduler');
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves';
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves' | 'check_watched_lists';
export interface ScheduledJob {
id: string;
@@ -133,6 +133,13 @@ export class SchedulerService {
enabled: true, // Enable by default
payload: {},
},
{
name: 'Check Watched Lists',
type: 'check_watched_lists' as ScheduledJobType,
schedule: '0 0 * * *', // Daily at midnight (every 24 hours)
enabled: true, // Enable by default
payload: {},
},
];
let created = 0;
@@ -353,6 +360,9 @@ export class SchedulerService {
case 'sync_goodreads_shelves':
bullJobId = await this.triggerSyncGoodreadsShelves(job);
break;
case 'check_watched_lists':
bullJobId = await this.triggerCheckWatchedLists(job);
break;
default:
throw new Error(`Unknown job type: ${job.type}`);
}
@@ -627,6 +637,13 @@ export class SchedulerService {
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
}
/**
* Trigger watched lists check (watched series + watched authors)
*/
private async triggerCheckWatchedLists(job: any): Promise<string> {
return await this.jobQueue.addCheckWatchedListsJob(job.id);
}
}
// Singleton instance
+414
View File
@@ -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));
}
+248
View File
@@ -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;
}
+23
View File
@@ -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;
}
+30
View File
@@ -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 };
}
+92
View File
@@ -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;
}
+107
View File
@@ -8,6 +8,7 @@
import { prisma } from '@/lib/db';
import { LibraryItem } from '@/lib/services/library';
import { getSiblingAsins } from '@/lib/services/works.service';
import { RMABLogger } from './logger';
// Module-level logger
@@ -178,6 +179,61 @@ export async function enrichAudiobooksWithMatches(
}
}
// Works-table sibling expansion: check if unmatched ASINs have siblings in the library
try {
const unmatchedAsins = results.filter(r => !r.isAvailable).map(r => r.asin);
if (unmatchedAsins.length > 0) {
const siblingMap = await getSiblingAsins(unmatchedAsins);
if (siblingMap.size > 0) {
// Collect all sibling ASINs for a single batch library query
const allSiblingAsins = new Set<string>();
for (const siblings of siblingMap.values()) {
for (const s of siblings) allSiblingAsins.add(s);
}
if (allSiblingAsins.size > 0) {
const siblingLibraryMatches = await prisma.plexLibrary.findMany({
where: { asin: { in: [...allSiblingAsins] } },
select: { asin: true, plexGuid: true },
});
const libraryAsinSet = new Set(
siblingLibraryMatches.filter(m => m.asin).map(m => m.asin!.toLowerCase())
);
// Update results where a sibling ASIN is found in the library
for (const result of results) {
if (result.isAvailable) continue;
const siblings = siblingMap.get(result.asin);
if (!siblings) continue;
const matchedSiblingAsin = siblings.find(s => libraryAsinSet.has(s.toLowerCase()));
if (matchedSiblingAsin) {
const libMatch = siblingLibraryMatches.find(
m => m.asin?.toLowerCase() === matchedSiblingAsin.toLowerCase()
);
(result as any).isAvailable = true;
(result as any).plexGuid = libMatch?.plexGuid || null;
}
}
const siblingMatchCount = results.filter(r => {
if (!r.isAvailable) return false;
return siblingMap.has(r.asin);
}).length;
logger.debug('Sibling expansion', {
unmatchedCount: unmatchedAsins.length,
siblingGroupsFound: siblingMap.size,
siblingMatches: siblingMatchCount,
});
}
}
}
} catch (error) {
// Works table expansion is best-effort — direct matches still work
logger.error('Sibling ASIN expansion failed', {
error: error instanceof Error ? error.message : String(error),
});
}
// Always enrich with request status (check ANY user's requests)
const asins = audiobooks.map(book => book.asin);
@@ -272,6 +328,57 @@ export async function enrichAudiobooksWithMatches(
return results;
}
/**
* Get all ASINs that are considered "available" present in library or have completed requests.
* Used by paginated API routes to exclude available items at the DB level.
*/
export async function getAvailableAsins(): Promise<Set<string>> {
const [libraryItems, completedRequests] = await Promise.all([
// ASINs present in the library (Plex or Audiobookshelf)
prisma.plexLibrary.findMany({
where: { asin: { not: null } },
select: { asin: true },
distinct: ['asin'],
}),
// ASINs with completed audiobook requests
prisma.audiobook.findMany({
where: {
audibleAsin: { not: null },
requests: {
some: {
status: 'completed',
type: 'audiobook',
deletedAt: null,
},
},
},
select: { audibleAsin: true },
}),
]);
const asins = new Set<string>();
for (const item of libraryItems) {
if (item.asin) asins.add(item.asin);
}
for (const item of completedRequests) {
if (item.audibleAsin) asins.add(item.audibleAsin);
}
// Expand with works-table sibling ASINs
try {
if (asins.size > 0) {
const siblingMap = await getSiblingAsins([...asins]);
for (const siblings of siblingMap.values()) {
for (const s of siblings) asins.add(s);
}
}
} catch {
// Works table expansion is best-effort
}
return asins;
}
/**
* Normalize ISBN for comparison (remove dashes and spaces)
*/
+12
View File
@@ -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';
}
+80
View File
@@ -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;
}
+203
View File
@@ -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 };
}
+44
View File
@@ -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;
}
+215
View File
@@ -0,0 +1,215 @@
/**
* Component: Admin API Tokens Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
// Valid UUIDs for testing
const ADMIN_ID = '11111111-1111-1111-1111-111111111111';
const USER_ID = '22222222-2222-2222-2222-222222222222';
const ADMIN2_ID = '33333333-3333-3333-3333-333333333333';
const NONEXISTENT_ID = '99999999-9999-9999-9999-999999999999';
let authRequest: any;
const prismaMock = createPrismaMock();
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const checkApiTokenCreateRateLimitMock = vi.hoisted(() => vi.fn());
const generateApiTokenMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/utils/apiTokenRateLimit', () => ({
checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock,
}));
vi.mock('@/lib/utils/api-token', () => ({
generateApiToken: generateApiTokenMock,
}));
describe('Admin API tokens routes', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = {
user: { id: ADMIN_ID, username: 'admin', role: 'admin' },
json: vi.fn(),
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
checkApiTokenCreateRateLimitMock.mockReturnValue({ allowed: true });
generateApiTokenMock.mockReturnValue({
fullToken: 'rmab_test_full_token',
tokenHash: 'hashed_token',
tokenPrefix: 'rmab_test',
});
});
describe('POST /api/admin/api-tokens', () => {
it('creates token for self with own role when no userId specified', async () => {
authRequest.json.mockResolvedValueOnce({ name: 'Test Token' });
prismaMock.user.findUnique.mockResolvedValueOnce({
id: ADMIN_ID,
role: 'admin',
plexUsername: 'admin',
});
prismaMock.apiToken.count.mockResolvedValueOnce(0);
prismaMock.apiToken.create.mockResolvedValueOnce({
id: 'token-1',
name: 'Test Token',
tokenPrefix: 'rmab_test',
role: 'admin',
expiresAt: null,
createdAt: new Date(),
});
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(201);
expect(payload.token.role).toBe('admin');
expect(payload.fullToken).toBe('rmab_test_full_token');
});
it('creates token for another user with their role', async () => {
authRequest.json.mockResolvedValueOnce({
name: 'Token for User',
userId: USER_ID,
});
prismaMock.user.findUnique.mockResolvedValueOnce({
id: USER_ID,
role: 'user',
plexUsername: 'regularuser',
});
prismaMock.apiToken.count.mockResolvedValueOnce(0);
prismaMock.apiToken.create.mockResolvedValueOnce({
id: 'token-2',
name: 'Token for User',
tokenPrefix: 'rmab_test',
role: 'user',
expiresAt: null,
createdAt: new Date(),
});
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(201);
expect(payload.token.role).toBe('user');
});
it('rejects role override when role differs from target user role', async () => {
authRequest.json.mockResolvedValueOnce({
name: 'Escalation Attempt',
userId: USER_ID,
role: 'admin', // Trying to give admin role to a regular user
});
prismaMock.user.findUnique.mockResolvedValueOnce({
id: USER_ID,
role: 'user', // Target user is actually a regular user
plexUsername: 'regularuser',
});
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toContain("must match target user's role");
});
it('rejects role downgrade when role differs from target user role', async () => {
authRequest.json.mockResolvedValueOnce({
name: 'Downgrade Attempt',
userId: ADMIN2_ID,
role: 'user', // Trying to give user role to an admin
});
prismaMock.user.findUnique.mockResolvedValueOnce({
id: ADMIN2_ID,
role: 'admin', // Target user is actually an admin
plexUsername: 'otheradmin',
});
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toContain("must match target user's role");
});
it('accepts role when it matches target user role', async () => {
authRequest.json.mockResolvedValueOnce({
name: 'Matching Role',
userId: USER_ID,
role: 'user', // Explicitly specifying role that matches
});
prismaMock.user.findUnique.mockResolvedValueOnce({
id: USER_ID,
role: 'user',
plexUsername: 'regularuser',
});
prismaMock.apiToken.count.mockResolvedValueOnce(0);
prismaMock.apiToken.create.mockResolvedValueOnce({
id: 'token-3',
name: 'Matching Role',
tokenPrefix: 'rmab_test',
role: 'user',
expiresAt: null,
createdAt: new Date(),
});
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
expect(response.status).toBe(201);
});
it('returns 404 when target user does not exist', async () => {
authRequest.json.mockResolvedValueOnce({
name: 'Token for Ghost',
userId: NONEXISTENT_ID,
});
prismaMock.user.findUnique.mockResolvedValueOnce(null);
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('Target user not found');
});
it('returns 429 when rate limited', async () => {
checkApiTokenCreateRateLimitMock.mockReturnValueOnce({
allowed: false,
retryAfterSeconds: 60,
});
authRequest.json.mockResolvedValueOnce({ name: 'Rate Limited Token' });
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
expect(response.status).toBe(429);
expect(response.headers.get('Retry-After')).toBe('60');
});
});
});
@@ -29,6 +29,10 @@ vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
}) => (isOpen ? <div>Interactive search for {audiobook.title}</div> : null),
}));
vi.mock('@/app/admin/components/AdjustSearchTermsModal', () => ({
AdjustSearchTermsModal: () => null,
}));
describe('RequestActionsDropdown', () => {
it('exposes manual search, interactive search, cancel, and delete actions', async () => {
const onManualSearch = vi.fn().mockResolvedValue(undefined);
+15 -10
View File
@@ -47,17 +47,22 @@ vi.mock('@/components/ui/CardSizeControls', () => ({
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
}));
vi.mock('@/components/ui/StickyPagination', () => ({
StickyPagination: ({
label,
onPageChange,
vi.mock('@/components/ui/UnifiedPagination', () => ({
UnifiedPagination: ({
sections,
}: {
label: string;
onPageChange: (page: number) => void;
sections: Array<{
label: string;
onPageChange: (page: number) => void;
}>;
}) => (
<button type="button" onClick={() => onPageChange(2)}>
{label} next
</button>
<div>
{sections.map((s) => (
<button key={s.label} type="button" onClick={() => s.onPageChange(2)}>
{s.label} next
</button>
))}
</div>
),
}));
@@ -113,7 +118,7 @@ describe('HomePage', () => {
fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' }));
await waitFor(() => {
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2);
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2, undefined);
});
});
});
+44 -24
View File
@@ -11,14 +11,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetMockAuthState } from '../helpers/mock-auth';
import { resetMockRouter } from '../helpers/mock-next-navigation';
const loadMoreMock = vi.hoisted(() => vi.fn());
const useSearchMock = vi.hoisted(() => vi.fn());
const usePreferencesMock = vi.hoisted(() => ({
cardSize: 5,
setCardSize: vi.fn(),
squareCovers: false,
setSquareCovers: vi.fn(),
hideAvailable: false,
setHideAvailable: vi.fn(),
}));
vi.mock('@/lib/hooks/useAudiobooks', () => ({
useSearch: useSearchMock,
Audiobook: {},
}));
vi.mock('@/contexts/PreferencesContext', () => ({
@@ -49,8 +55,30 @@ vi.mock('@/components/audiobooks/AudiobookGrid', () => ({
),
}));
vi.mock('@/components/ui/CardSizeControls', () => ({
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
vi.mock('@/components/ui/SectionToolbar', () => ({
SectionToolbar: () => <div data-testid="section-toolbar" />,
}));
vi.mock('@/components/ui/LoadMoreBar', () => ({
LoadMoreBar: ({
hasMore,
isLoading,
onLoadMore,
}: {
loadedCount: number;
totalCount?: number;
hasMore: boolean;
isLoading: boolean;
onLoadMore: () => void;
itemLabel?: string;
}) =>
hasMore ? (
<button onClick={onLoadMore} disabled={isLoading}>
Load more
</button>
) : (
<div data-testid="all-loaded">All loaded</div>
),
}));
describe('SearchPage', () => {
@@ -58,6 +86,7 @@ describe('SearchPage', () => {
resetMockAuthState();
resetMockRouter();
useSearchMock.mockReset();
loadMoreMock.mockReset();
usePreferencesMock.cardSize = 5;
usePreferencesMock.setCardSize.mockReset();
vi.useFakeTimers();
@@ -74,34 +103,25 @@ describe('SearchPage', () => {
totalResults: 0,
hasMore: false,
isLoading: false,
isLoadingMore: false,
loadMore: loadMoreMock,
});
const { default: SearchPage } = await import('@/app/search/page');
render(<SearchPage />);
expect(screen.getByText('Start typing to search for audiobooks')).toBeInTheDocument();
expect(useSearchMock).toHaveBeenCalledWith('', 1);
expect(useSearchMock).toHaveBeenCalledWith('');
});
it('debounces search input and loads more results', async () => {
useSearchMock.mockImplementation((query: string, page: number) => {
if (!query) {
return { results: [], totalResults: 0, hasMore: false, isLoading: false };
}
if (page === 1) {
return {
results: [{ asin: 'a1', title: 'Book One', author: 'Author' }],
totalResults: 2,
hasMore: true,
isLoading: false,
};
}
return {
results: [{ asin: 'a2', title: 'Book Two', author: 'Author' }],
totalResults: 2,
hasMore: false,
isLoading: false,
};
useSearchMock.mockReturnValue({
results: [{ asin: 'a1', title: 'Book One', author: 'Author' }],
totalResults: 2,
hasMore: true,
isLoading: false,
isLoadingMore: false,
loadMore: loadMoreMock,
});
const { default: SearchPage } = await import('@/app/search/page');
@@ -115,11 +135,11 @@ describe('SearchPage', () => {
});
expect(screen.getByText('Search Results')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Load More Results' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Load more' })).toBeInTheDocument();
expect(screen.getByTestId('grid')).toHaveAttribute('data-count', '1');
fireEvent.click(screen.getByRole('button', { name: 'Load More Results' }));
fireEvent.click(screen.getByRole('button', { name: 'Load more' }));
expect(useSearchMock).toHaveBeenCalledWith('Dune', 2);
expect(loadMoreMock).toHaveBeenCalled();
});
});
@@ -1,133 +0,0 @@
/**
* Component: Sticky Pagination Tests
* Documentation: documentation/frontend/components.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { StickyPagination } from '@/components/ui/StickyPagination';
type ObserverEntry = {
isIntersecting: boolean;
intersectionRatio: number;
target: Element;
};
describe('StickyPagination', () => {
const observers: { callback: IntersectionObserverCallback }[] = [];
beforeEach(() => {
observers.length = 0;
class MockIntersectionObserver {
callback: IntersectionObserverCallback;
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn();
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
observers.push(this);
}
}
(global as any).IntersectionObserver = MockIntersectionObserver;
});
it('returns null when there is only one page', () => {
const sectionRef = { current: document.createElement('div') };
const { container } = render(
<StickyPagination
currentPage={1}
totalPages={1}
onPageChange={vi.fn()}
sectionRef={sectionRef}
label="Popular"
/>
);
expect(container.firstChild).toBeNull();
});
it('shows and hides based on section and footer visibility', () => {
const sectionRef = { current: document.createElement('div') };
const footerRef = { current: document.createElement('div') };
const { container } = render(
<StickyPagination
currentPage={2}
totalPages={5}
onPageChange={vi.fn()}
sectionRef={sectionRef}
footerRef={footerRef}
label="Popular"
/>
);
const root = container.querySelector('div.fixed') as HTMLElement;
expect(root).toHaveClass('opacity-0');
act(() => {
observers[0].callback(
[
{
isIntersecting: true,
intersectionRatio: 0.2,
target: sectionRef.current as Element,
} as ObserverEntry,
],
observers[0] as unknown as IntersectionObserver
);
});
expect(root).toHaveClass('opacity-100');
act(() => {
observers[1].callback(
[
{
isIntersecting: true,
intersectionRatio: 0.2,
target: footerRef.current as Element,
} as ObserverEntry,
],
observers[1] as unknown as IntersectionObserver
);
});
expect(root).toHaveClass('opacity-0');
});
it('handles navigation and jump input updates', () => {
const sectionRef = { current: document.createElement('div') };
const onPageChange = vi.fn();
render(
<StickyPagination
currentPage={2}
totalPages={4}
onPageChange={onPageChange}
sectionRef={sectionRef}
label="Popular"
/>
);
fireEvent.click(screen.getByLabelText('Next page'));
expect(onPageChange).toHaveBeenCalledWith(3);
fireEvent.click(screen.getByLabelText('Previous page'));
expect(onPageChange).toHaveBeenCalledWith(1);
const input = screen.getByLabelText('Current page') as HTMLInputElement;
fireEvent.change(input, { target: { value: '4' } });
fireEvent.blur(input);
expect(onPageChange).toHaveBeenCalledWith(4);
fireEvent.change(input, { target: { value: '99' } });
fireEvent.blur(input);
expect(input.value).toBe('2');
});
});

Some files were not shown because too many files have changed in this diff Show More