mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-16 19:20:09 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53c1e0dad7 | |||
| 45c8b614e3 | |||
| 24aa6afefc | |||
| 81813dc625 | |||
| a5e7af1a53 | |||
| 95917715b1 | |||
| a50fbc721e | |||
| d6eca611fc | |||
| 45e818c181 | |||
| 85977d123c | |||
| 441724c378 | |||
| d0ce485bdc | |||
| cbf02d3e24 | |||
| f0b2476b87 | |||
| 04b6a2c135 | |||
| 61b183542c | |||
| 610873af6b | |||
| ff80d995c5 |
@@ -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;
|
||||||
@@ -68,6 +68,10 @@ model User {
|
|||||||
goodreadsShelves GoodreadsShelf[]
|
goodreadsShelves GoodreadsShelf[]
|
||||||
reportedIssues ReportedIssue[] @relation("Reporter")
|
reportedIssues ReportedIssue[] @relation("Reporter")
|
||||||
resolvedIssues ReportedIssue[] @relation("Resolver")
|
resolvedIssues ReportedIssue[] @relation("Resolver")
|
||||||
|
createdApiTokens ApiToken[] @relation("CreatedApiTokens")
|
||||||
|
apiTokens ApiToken[] @relation("UserApiTokens")
|
||||||
|
watchedSeries WatchedSeries[]
|
||||||
|
watchedAuthors WatchedAuthor[]
|
||||||
|
|
||||||
@@index([plexId])
|
@@index([plexId])
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@ -496,6 +500,34 @@ model ReportedIssue {
|
|||||||
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
|
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API TOKEN TABLE
|
||||||
|
// Static API tokens for programmatic access (alternative to JWT)
|
||||||
|
// Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model ApiToken {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String // User-friendly label (e.g., "Home Assistant", "Webhook")
|
||||||
|
tokenHash String @unique @map("token_hash") // SHA-256 hash of the token (never store plaintext)
|
||||||
|
tokenPrefix String @map("token_prefix") // First 8 chars for display (e.g., "rmab_a1b2")
|
||||||
|
role String @default("user") // Token role: 'admin' or 'user'
|
||||||
|
createdById String @map("created_by_id") // Who created the token (may differ from userId for admin-created tokens)
|
||||||
|
userId String @map("user_id") // The user identity this token acts as
|
||||||
|
lastUsedAt DateTime? @map("last_used_at")
|
||||||
|
expiresAt DateTime? @map("expires_at") // null = never expires
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
createdBy User @relation("CreatedApiTokens", fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
|
tokenUser User @relation("UserApiTokens", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tokenHash])
|
||||||
|
@@index([createdById])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("api_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
model GoodreadsShelf {
|
model GoodreadsShelf {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
@@ -531,3 +563,87 @@ model GoodreadsBookMapping {
|
|||||||
@@index([audibleAsin])
|
@@index([audibleAsin])
|
||||||
@@map("goodreads_book_mappings")
|
@@map("goodreads_book_mappings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WORKS TABLE
|
||||||
|
// Cross-ASIN audiobook identity mapping — links multiple Audible ASINs
|
||||||
|
// to a single logical work for library matching across editions.
|
||||||
|
// Documentation: documentation/integrations/audible.md
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model Work {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
author String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
asins WorkAsin[]
|
||||||
|
|
||||||
|
@@index([title])
|
||||||
|
@@index([author])
|
||||||
|
@@map("works")
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkAsin {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
workId String @map("work_id")
|
||||||
|
asin String @unique
|
||||||
|
narrator String?
|
||||||
|
durationMinutes Int? @map("duration_minutes")
|
||||||
|
isCanonical Boolean @default(false) @map("is_canonical")
|
||||||
|
source String // 'dedup_auto' | 'admin_manual'
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([workId])
|
||||||
|
@@index([asin])
|
||||||
|
@@map("work_asins")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WATCHED LISTS TABLES
|
||||||
|
// Per-user series and author subscriptions for automatic new-release requests.
|
||||||
|
// Documentation: documentation/features/watched-lists.md
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model WatchedSeries {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
seriesAsin String @map("series_asin")
|
||||||
|
seriesTitle String @map("series_title")
|
||||||
|
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||||
|
lastCheckedAt DateTime? @map("last_checked_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, seriesAsin])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([seriesAsin])
|
||||||
|
@@map("watched_series")
|
||||||
|
}
|
||||||
|
|
||||||
|
model WatchedAuthor {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
authorAsin String @map("author_asin")
|
||||||
|
authorName String @map("author_name")
|
||||||
|
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||||
|
lastCheckedAt DateTime? @map("last_checked_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, authorAsin])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([authorAsin])
|
||||||
|
@@map("watched_authors")
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
* Component: Confirm Dialog
|
* Component: Confirm Dialog
|
||||||
* Documentation: documentation/frontend/components.md
|
* Documentation: documentation/frontend/components.md
|
||||||
*
|
*
|
||||||
* Reusable confirmation dialog for destructive actions
|
* Reusable confirmation dialog for destructive actions.
|
||||||
|
* Features: backdrop blur, smooth enter animation, Escape to close, focus trap, ARIA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
export interface ConfirmDialogProps {
|
export interface ConfirmDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -30,99 +31,177 @@ export function ConfirmDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Focus the cancel button on open (safer default for destructive dialogs)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
// Small delay to let animation start before stealing focus
|
||||||
|
const t = setTimeout(() => cancelRef.current?.focus(), 50);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Escape to close + focus trap
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus trap: tab cycles only within dialog
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
if (!focusable || focusable.length === 0) return;
|
||||||
|
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onCancel]);
|
||||||
|
|
||||||
|
// Prevent body scroll while open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = ''; };
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const confirmButtonClasses =
|
const isDestructive = confirmVariant === 'danger';
|
||||||
confirmVariant === 'danger'
|
|
||||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
|
||||||
: 'bg-blue-600 hover:bg-blue-700 text-white';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-dialog-title"
|
||||||
|
aria-describedby="confirm-dialog-desc"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
className="animate-dialog-backdrop fixed inset-0 bg-black/40 backdrop-blur-sm"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dialog */}
|
{/* Panel */}
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
<div
|
||||||
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
ref={dialogRef}
|
||||||
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
className="animate-dialog-panel relative w-full max-w-sm rounded-2xl overflow-hidden bg-white dark:bg-gray-900 shadow-2xl ring-1 ring-black/10 dark:ring-white/10"
|
||||||
<div className="sm:flex sm:items-start">
|
>
|
||||||
{/* Icon */}
|
{/* Header */}
|
||||||
<div
|
<div className="px-6 pt-6 pb-4">
|
||||||
className={`mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${
|
<div className="flex items-start gap-4">
|
||||||
confirmVariant === 'danger'
|
{/* Icon well */}
|
||||||
? 'bg-red-100 dark:bg-red-900'
|
<div className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full ${
|
||||||
: 'bg-blue-100 dark:bg-blue-900'
|
isDestructive
|
||||||
} sm:mx-0 sm:h-10 sm:w-10`}
|
? 'bg-red-50 dark:bg-red-500/10'
|
||||||
>
|
: 'bg-blue-50 dark:bg-blue-500/10'
|
||||||
|
}`}>
|
||||||
|
{isDestructive ? (
|
||||||
<svg
|
<svg
|
||||||
className={`h-6 w-6 ${
|
className="w-5 h-5 text-red-500 dark:text-red-400"
|
||||||
confirmVariant === 'danger'
|
|
||||||
? 'text-red-600 dark:text-red-400'
|
|
||||||
: 'text-blue-600 dark:text-blue-400'
|
|
||||||
}`}
|
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.75"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{confirmVariant === 'danger' ? (
|
<path
|
||||||
<path
|
strokeLinecap="round"
|
||||||
strokeLinecap="round"
|
strokeLinejoin="round"
|
||||||
strokeLinejoin="round"
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
) : (
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-blue-500 dark:text-blue-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Text */}
|
||||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left flex-1">
|
<div className="flex-1 min-w-0 pt-0.5">
|
||||||
<h3 className="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
<h3
|
||||||
{title}
|
id="confirm-dialog-title"
|
||||||
</h3>
|
className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-50"
|
||||||
<div className="mt-2">
|
>
|
||||||
{typeof message === 'string' ? (
|
{title}
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-pre-line">
|
</h3>
|
||||||
{message}
|
<div id="confirm-dialog-desc" className="mt-1.5">
|
||||||
</p>
|
{typeof message === 'string' ? (
|
||||||
) : (
|
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
{message}
|
||||||
{message}
|
</p>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||||
</div>
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Action bar */}
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
|
<div className="flex items-center justify-end gap-2 px-6 py-4 bg-gray-50/80 dark:bg-white/[0.03] border-t border-gray-100 dark:border-white/[0.06]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
ref={cancelRef}
|
||||||
onClick={onConfirm}
|
type="button"
|
||||||
className={`inline-flex w-full justify-center rounded-lg px-4 py-2 text-sm font-semibold shadow-sm sm:w-auto transition-colors ${confirmButtonClasses}`}
|
onClick={onCancel}
|
||||||
>
|
className="px-4 py-2 text-sm font-medium rounded-xl text-gray-700 dark:text-gray-300 bg-white dark:bg-white/[0.06] hover:bg-gray-100 dark:hover:bg-white/[0.1] border border-gray-200 dark:border-white/[0.1] transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900"
|
||||||
{confirmLabel}
|
>
|
||||||
</button>
|
{cancelLabel}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
onClick={onCancel}
|
ref={confirmRef}
|
||||||
className="mt-3 inline-flex w-full justify-center rounded-lg bg-white dark:bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:mt-0 sm:w-auto transition-colors"
|
type="button"
|
||||||
>
|
onClick={onConfirm}
|
||||||
{cancelLabel}
|
className={`px-4 py-2 text-sm font-medium rounded-xl text-white transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900 active:scale-[0.97] ${
|
||||||
</button>
|
isDestructive
|
||||||
</div>
|
? 'bg-red-600 hover:bg-red-700 focus-visible:ring-red-500'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700 focus-visible:ring-blue-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export const getTabValidation = (
|
|||||||
return validated.paths;
|
return validated.paths;
|
||||||
case 'ebook':
|
case 'ebook':
|
||||||
case 'bookdate':
|
case 'bookdate':
|
||||||
|
case 'api':
|
||||||
return true; // These tabs handle their own saving
|
return true; // These tabs handle their own saving
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
@@ -228,4 +229,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [
|
|||||||
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
|
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
|
||||||
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
|
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
|
||||||
{ id: 'notifications' as const, label: 'Notifications', icon: '🔔' },
|
{ id: 'notifications' as const, label: 'Notifications', icon: '🔔' },
|
||||||
|
{ id: 'api' as const, label: 'API', icon: '🔑' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -243,4 +243,4 @@ export interface BookDateModel {
|
|||||||
/**
|
/**
|
||||||
* Tab identifier type
|
* Tab identifier type
|
||||||
*/
|
*/
|
||||||
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications';
|
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications' | 'api';
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { PathsTab } from './tabs/PathsTab/PathsTab';
|
|||||||
import { EbookTab } from './tabs/EbookTab/EbookTab';
|
import { EbookTab } from './tabs/EbookTab/EbookTab';
|
||||||
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
|
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
|
||||||
import { NotificationsTab } from './tabs/NotificationsTab';
|
import { NotificationsTab } from './tabs/NotificationsTab';
|
||||||
|
import { ApiTab } from './tabs/ApiTab/ApiTab';
|
||||||
|
|
||||||
// Types and Helpers
|
// Types and Helpers
|
||||||
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
|
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
|
||||||
@@ -346,8 +347,11 @@ export default function AdminSettings() {
|
|||||||
{/* Notifications Tab */}
|
{/* Notifications Tab */}
|
||||||
{activeTab === 'notifications' && <NotificationsTab />}
|
{activeTab === 'notifications' && <NotificationsTab />}
|
||||||
|
|
||||||
|
{/* API Tab */}
|
||||||
|
{activeTab === 'api' && <ApiTab />}
|
||||||
|
|
||||||
{/* Save Button (only for tabs that save through main page) */}
|
{/* Save Button (only for tabs that save through main page) */}
|
||||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && (
|
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && activeTab !== 'api' && (
|
||||||
<div className="mt-8 flex gap-4">
|
<div className="mt-8 flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={saveSettings}
|
onClick={saveSettings}
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* Component: API Token Management Tab (Admin)
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog';
|
||||||
|
import { useApiTokens } from '@/lib/hooks/useApiTokens';
|
||||||
|
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { AdminApiToken } from '@/lib/types/api-tokens';
|
||||||
|
|
||||||
|
interface UserOption {
|
||||||
|
id: string;
|
||||||
|
plexUsername: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiTab() {
|
||||||
|
const api = useApiTokens<AdminApiToken>({ basePath: '/api/admin/api-tokens' });
|
||||||
|
|
||||||
|
// Admin-specific state
|
||||||
|
const [users, setUsers] = useState<UserOption[]>([]);
|
||||||
|
const [newTokenUserId, setNewTokenUserId] = useState('');
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/admin/users');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setUsers(data.users.map((u: any) => ({ id: u.id, plexUsername: u.plexUsername, role: u.role })));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical, user selector just won't populate
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, [fetchUsers]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const extraBody: Record<string, string> = {};
|
||||||
|
if (newTokenUserId) extraBody.userId = newTokenUserId;
|
||||||
|
const created = await api.handleCreate(extraBody);
|
||||||
|
// Reset admin-specific fields only when create succeeds
|
||||||
|
if (created) {
|
||||||
|
setNewTokenUserId('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
api.resetForm();
|
||||||
|
setNewTokenUserId('');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (api.loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">API Tokens</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Manage API tokens for all users. Create tokens for any user for programmatic access.{' '}
|
||||||
|
<Link href="/api-docs" className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
View API documentation
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{api.error && (
|
||||||
|
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
|
||||||
|
{api.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Newly created token banner */}
|
||||||
|
{api.createdToken && (
|
||||||
|
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
|
Token created successfully! Copy it now — it won't be shown again.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||||
|
{api.createdToken}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={api.handleCopy}
|
||||||
|
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||||
|
>
|
||||||
|
{api.copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Dismiss token banner"
|
||||||
|
onClick={api.dismissCreatedToken}
|
||||||
|
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create token form */}
|
||||||
|
{api.showCreateForm ? (
|
||||||
|
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={api.newTokenName}
|
||||||
|
onChange={(e) => api.setNewTokenName(e.target.value)}
|
||||||
|
placeholder="e.g., Home Assistant, Webhook"
|
||||||
|
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Expiration
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={api.newTokenExpiry}
|
||||||
|
onChange={(e) => api.setNewTokenExpiry(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="never">Never</option>
|
||||||
|
<option value="30d">30 days</option>
|
||||||
|
<option value="90d">90 days</option>
|
||||||
|
<option value="1y">1 year</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
User (acts as)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={newTokenUserId}
|
||||||
|
onChange={(e) => setNewTokenUserId(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Current user (default)</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.plexUsername} ({u.role})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Token will inherit the selected user's role
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={api.creating || !api.newTokenName.trim()}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
|
||||||
|
>
|
||||||
|
{api.creating ? 'Creating...' : 'Create Token'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => api.setShowCreateForm(true)}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
||||||
|
>
|
||||||
|
Create New Token
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Token list */}
|
||||||
|
{api.tokens.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
<p className="mt-2 text-sm">No API tokens yet</p>
|
||||||
|
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Acts As</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Role</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Created By</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
|
||||||
|
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{api.tokens.map((token) => (
|
||||||
|
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
|
||||||
|
<td className="py-3 px-2">
|
||||||
|
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
|
||||||
|
{token.tokenPrefix}...
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.tokenUser}</td>
|
||||||
|
<td className="py-3 px-2">
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
token.role === 'admin'
|
||||||
|
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{token.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.createdBy}</td>
|
||||||
|
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{api.formatDate(token.lastUsedAt)}</td>
|
||||||
|
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
|
||||||
|
{token.expiresAt ? (
|
||||||
|
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
|
||||||
|
{api.formatDate(token.expiresAt)}
|
||||||
|
{new Date(token.expiresAt) < new Date() && ' (expired)'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Never'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => api.setConfirmRevokeId(token.id)}
|
||||||
|
disabled={api.deletingId === token.id}
|
||||||
|
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{api.deletingId === token.id ? 'Revoking...' : 'Revoke'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Usage instructions */}
|
||||||
|
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
|
||||||
|
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||||
|
${getInstanceUrl()}/api/requests`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revoke confirmation dialog */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={api.confirmRevokeId !== null}
|
||||||
|
title="Revoke API token"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
Are you sure you want to revoke{' '}
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
“{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}”
|
||||||
|
</span>
|
||||||
|
? Any integrations using this token will immediately lose access. This cannot be undone.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="Revoke token"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
confirmVariant="danger"
|
||||||
|
onConfirm={api.handleDeleteConfirmed}
|
||||||
|
onCancel={() => api.setConfirmRevokeId(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Component: Interactive API Documentation Page
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*
|
||||||
|
* Lists all API token-accessible endpoints with "Try it out" functionality.
|
||||||
|
* Users can test with a custom API token or their current browser session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||||
|
import { TokenInput } from '@/components/api-docs/TokenInput';
|
||||||
|
import { EndpointCard } from '@/components/api-docs/EndpointCard';
|
||||||
|
import { API_TOKEN_ENDPOINT_DOCS } from '@/lib/constants/api-tokens';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function ApiDocsPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [useSession, setUseSession] = useState(false);
|
||||||
|
const isAdmin = user?.role === 'admin';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 pt-8 pb-16">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</Link>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">API Documentation</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||||
|
API Reference
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-base text-gray-500 dark:text-gray-400 leading-relaxed max-w-2xl">
|
||||||
|
Interact with ReadMeABook programmatically using API tokens. These endpoints are
|
||||||
|
available for external integrations, dashboards, and automation tools.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Quick links */}
|
||||||
|
<div className="flex flex-wrap gap-3 mt-4">
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
Manage your tokens
|
||||||
|
</Link>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<Link
|
||||||
|
href="/admin/settings"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
Admin token management
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Authentication section */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<TokenInput
|
||||||
|
token={token}
|
||||||
|
onTokenChange={setToken}
|
||||||
|
useSession={useSession}
|
||||||
|
onUseSessionChange={setUseSession}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage instructions card */}
|
||||||
|
<div className="mb-8 rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
Quick Start
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Include your API token in the <code className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-900 rounded text-xs font-mono">Authorization</code> header as a Bearer token:
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-4 rounded-xl overflow-x-auto font-mono leading-relaxed">
|
||||||
|
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||||
|
${getInstanceUrl()}/api/requests`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Endpoints section header */}
|
||||||
|
<div className="flex items-center gap-3 mb-5">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Available Endpoints
|
||||||
|
</h2>
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400">
|
||||||
|
{API_TOKEN_ENDPOINT_DOCS.length} endpoints
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Endpoint cards */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{API_TOKEN_ENDPOINT_DOCS.map((endpoint) => (
|
||||||
|
<EndpointCard
|
||||||
|
key={`${endpoint.method}:${endpoint.path}`}
|
||||||
|
endpoint={endpoint}
|
||||||
|
token={token}
|
||||||
|
useSession={useSession}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer note */}
|
||||||
|
<div className="mt-10 text-center">
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
API tokens are restricted to the endpoints listed above.
|
||||||
|
JWT session authentication has access to all endpoints.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Component: API Token Delete Route
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/api-tokens/[id]
|
||||||
|
* Revoke (delete) an API token
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||||
|
requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many API token revoke attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const token = await prisma.apiToken.findUnique({ where: { id } });
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.apiToken.delete({ where: { id } });
|
||||||
|
|
||||||
|
logger.info('API token revoked', { tokenId: id, name: token.name, revokedBy: req.user!.username });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to revoke API token', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin API Token Management Routes
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||||
|
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||||
|
import { generateApiToken } from '@/lib/utils/api-token';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||||
|
|
||||||
|
const CreateTokenSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
expiresAt: z.string().datetime().nullable().optional(),
|
||||||
|
userId: z.string().uuid().optional(), // Admin can specify which user the token acts as
|
||||||
|
role: z.enum(['admin', 'user']).optional(), // Accepted for compatibility, but cannot differ from target user role
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/api-tokens
|
||||||
|
* List ALL API tokens across all users
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||||
|
requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const tokens = await prisma.apiToken.findMany({
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: { id: true, plexUsername: true },
|
||||||
|
},
|
||||||
|
tokenUser: {
|
||||||
|
select: { id: true, plexUsername: true, role: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sanitized = tokens.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
tokenPrefix: t.tokenPrefix,
|
||||||
|
role: t.role,
|
||||||
|
createdBy: t.createdBy.plexUsername,
|
||||||
|
createdById: t.createdBy.id,
|
||||||
|
tokenUser: t.tokenUser.plexUsername,
|
||||||
|
tokenUserId: t.tokenUser.id,
|
||||||
|
lastUsedAt: t.lastUsedAt,
|
||||||
|
expiresAt: t.expiresAt,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ tokens: sanitized });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to list API tokens', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/api-tokens
|
||||||
|
* Create a new API token. Admin can optionally specify userId.
|
||||||
|
* Token role is always derived from the target user's current role.
|
||||||
|
* Returns the full token ONCE.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||||
|
requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const rateLimit = checkApiTokenCreateRateLimit(req.user!.id);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many API token create attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { name, expiresAt, userId, role } = CreateTokenSchema.parse(body);
|
||||||
|
|
||||||
|
// Determine target user (defaults to the admin themselves)
|
||||||
|
const targetUserId = userId || req.user!.id;
|
||||||
|
|
||||||
|
// Verify the target user exists
|
||||||
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id: targetUserId },
|
||||||
|
select: { id: true, role: true, plexUsername: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'Target user not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce per-user token cap (count only active, non-expired tokens)
|
||||||
|
const activeTokenCount = await prisma.apiToken.count({
|
||||||
|
where: {
|
||||||
|
userId: targetUserId,
|
||||||
|
OR: [
|
||||||
|
{ expiresAt: null },
|
||||||
|
{ expiresAt: { gt: new Date() } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeTokenCount >= MAX_TOKENS_PER_USER) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Token limit reached. Users may have at most ${MAX_TOKENS_PER_USER} active API tokens.` },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security guard: token role must always match the target user's persisted role.
|
||||||
|
// This avoids role/identity mismatch (for example: acting as user A with admin role).
|
||||||
|
if (role && role !== targetUser.role) {
|
||||||
|
logger.warn('Admin attempted token role override that differs from target user role', {
|
||||||
|
requestedRole: role,
|
||||||
|
userActualRole: targetUser.role,
|
||||||
|
targetUser: targetUser.plexUsername,
|
||||||
|
createdBy: req.user!.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Token role must match target user's role (${targetUser.role}).`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenRole = targetUser.role;
|
||||||
|
|
||||||
|
// Generate the token
|
||||||
|
const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
|
||||||
|
|
||||||
|
const apiToken = await prisma.apiToken.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
tokenHash,
|
||||||
|
tokenPrefix,
|
||||||
|
role: tokenRole,
|
||||||
|
createdById: req.user!.id,
|
||||||
|
userId: targetUserId,
|
||||||
|
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Admin API token created', {
|
||||||
|
tokenId: apiToken.id,
|
||||||
|
name,
|
||||||
|
createdBy: req.user!.username,
|
||||||
|
targetUser: targetUser.plexUsername,
|
||||||
|
role: tokenRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
token: {
|
||||||
|
id: apiToken.id,
|
||||||
|
name: apiToken.name,
|
||||||
|
tokenPrefix: apiToken.tokenPrefix,
|
||||||
|
role: apiToken.role,
|
||||||
|
expiresAt: apiToken.expiresAt,
|
||||||
|
createdAt: apiToken.createdAt,
|
||||||
|
},
|
||||||
|
// Full token is returned ONLY on creation
|
||||||
|
fullToken,
|
||||||
|
}, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create API token', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Failed to create API token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { prisma } from '@/lib/db';
|
|||||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||||
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.ManualImport');
|
const logger = RMABLogger.create('API.Admin.ManualImport');
|
||||||
|
|
||||||
@@ -174,6 +175,48 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich missing series/year data from Audnexus (mirrors request-creator.service.ts)
|
||||||
|
if (audiobook.audibleAsin && (!audiobook.series || !audiobook.year)) {
|
||||||
|
try {
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin);
|
||||||
|
|
||||||
|
if (audnexusData) {
|
||||||
|
const updates: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (!audiobook.series && audnexusData.series) {
|
||||||
|
updates.series = audnexusData.series;
|
||||||
|
}
|
||||||
|
if (!audiobook.seriesPart && audnexusData.seriesPart) {
|
||||||
|
updates.seriesPart = audnexusData.seriesPart;
|
||||||
|
}
|
||||||
|
if (!audiobook.seriesAsin && audnexusData.seriesAsin) {
|
||||||
|
updates.seriesAsin = audnexusData.seriesAsin;
|
||||||
|
}
|
||||||
|
if (!audiobook.year && audnexusData.releaseDate) {
|
||||||
|
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
||||||
|
if (!isNaN(releaseYear)) {
|
||||||
|
updates.year = releaseYear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!audiobook.narrator && audnexusData.narrator) {
|
||||||
|
updates.narrator = audnexusData.narrator;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await prisma.audiobook.update({
|
||||||
|
where: { id: audiobook.id },
|
||||||
|
data: updates,
|
||||||
|
});
|
||||||
|
logger.info(`Enriched audiobook metadata from Audnexus for ASIN ${audiobook.audibleAsin}`, updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Non-fatal: series enrichment failure should never block the import
|
||||||
|
logger.warn(`Failed to enrich metadata from Audnexus for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for existing requests
|
// Check for existing requests
|
||||||
const existingRequest = await prisma.request.findFirst({
|
const existingRequest = await prisma.request.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||||
|
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||||
|
|
||||||
// Validate pagination parameters
|
// Validate pagination parameters
|
||||||
if (page < 1 || limit < 1 || limit > 100) {
|
if (page < 1 || limit < 1 || limit > 100) {
|
||||||
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
|
||||||
|
let excludedAsins: string[] = [];
|
||||||
|
if (hideAvailable) {
|
||||||
|
const availableSet = await getAvailableAsins();
|
||||||
|
excludedAsins = [...availableSet];
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = {
|
||||||
|
isNewRelease: true,
|
||||||
|
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
// Query audible_cache for new release audiobooks
|
// Query audible_cache for new release audiobooks
|
||||||
const [audiobooks, totalCount] = await Promise.all([
|
const [audiobooks, totalCount] = await Promise.all([
|
||||||
prisma.audibleCache.findMany({
|
prisma.audibleCache.findMany({
|
||||||
where: {
|
where: whereClause,
|
||||||
isNewRelease: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
orderBy: {
|
||||||
newReleaseRank: 'asc',
|
newReleaseRank: 'asc',
|
||||||
},
|
},
|
||||||
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.audibleCache.count({
|
prisma.audibleCache.count({
|
||||||
where: {
|
where: whereClause,
|
||||||
isNewRelease: true,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||||
|
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||||
|
|
||||||
// Validate pagination parameters
|
// Validate pagination parameters
|
||||||
if (page < 1 || limit < 1 || limit > 100) {
|
if (page < 1 || limit < 1 || limit > 100) {
|
||||||
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
|
||||||
|
let excludedAsins: string[] = [];
|
||||||
|
if (hideAvailable) {
|
||||||
|
const availableSet = await getAvailableAsins();
|
||||||
|
excludedAsins = [...availableSet];
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = {
|
||||||
|
isPopular: true,
|
||||||
|
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
// Query audible_cache for popular audiobooks
|
// Query audible_cache for popular audiobooks
|
||||||
const [audiobooks, totalCount] = await Promise.all([
|
const [audiobooks, totalCount] = await Promise.all([
|
||||||
prisma.audibleCache.findMany({
|
prisma.audibleCache.findMany({
|
||||||
where: {
|
where: whereClause,
|
||||||
isPopular: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
orderBy: {
|
||||||
popularRank: 'asc',
|
popularRank: 'asc',
|
||||||
},
|
},
|
||||||
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.audibleCache.count({
|
prisma.audibleCache.count({
|
||||||
where: {
|
where: whereClause,
|
||||||
isPopular: true,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||||
|
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
|
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
@@ -38,14 +40,22 @@ export async function GET(request: NextRequest) {
|
|||||||
const currentUser = getCurrentUser(request);
|
const currentUser = getCurrentUser(request);
|
||||||
const userId = currentUser?.sub || undefined;
|
const userId = currentUser?.sub || undefined;
|
||||||
|
|
||||||
|
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||||
|
const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results);
|
||||||
|
|
||||||
|
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||||
|
if (groups.length > 0) {
|
||||||
|
persistDedupGroups(groups).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Enrich search results with availability and request status information
|
// Enrich search results with availability and request status information
|
||||||
const enrichedResults = await enrichAudiobooksWithMatches(results.results, userId);
|
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
query: results.query,
|
query: results.query,
|
||||||
results: enrichedResults,
|
results: enrichedResults,
|
||||||
totalResults: results.totalResults,
|
totalResults: enrichedResults.length,
|
||||||
page: results.page,
|
page: results.page,
|
||||||
hasMore: results.hasMore,
|
hasMore: results.hasMore,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedUsername = username.trim().toLowerCase();
|
||||||
|
|
||||||
// Find user by local admin identifier
|
// Find user by local admin identifier
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { plexId: `local-${username}` },
|
where: { plexId: `local-${normalizedUsername}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||||
|
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
|
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
@@ -53,9 +55,17 @@ export async function GET(
|
|||||||
const audibleService = getAudibleService();
|
const audibleService = getAudibleService();
|
||||||
const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page);
|
const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page);
|
||||||
|
|
||||||
|
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||||
|
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books);
|
||||||
|
|
||||||
|
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||||
|
if (groups.length > 0) {
|
||||||
|
persistDedupGroups(groups).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Enrich with library availability and request status
|
// Enrich with library availability and request status
|
||||||
const userId = currentUser.sub || undefined;
|
const userId = currentUser.sub || undefined;
|
||||||
const enrichedBooks = await enrichAudiobooksWithMatches(result.books, userId);
|
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||||
|
|
||||||
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
|
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
|
||||||
|
|
||||||
@@ -64,7 +74,7 @@ export async function GET(
|
|||||||
books: enrichedBooks,
|
books: enrichedBooks,
|
||||||
authorName: authorName.trim(),
|
authorName: authorName.trim(),
|
||||||
authorAsin: asin,
|
authorAsin: asin,
|
||||||
totalBooks: result.totalResults || enrichedBooks.length,
|
totalBooks: enrichedBooks.length,
|
||||||
hasMore: result.hasMore,
|
hasMore: result.hasMore,
|
||||||
page: result.page,
|
page: result.page,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { getCurrentUser } from '@/lib/middleware/auth';
|
|||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||||
|
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
|
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Series.Detail');
|
const logger = RMABLogger.create('API.Series.Detail');
|
||||||
|
|
||||||
@@ -49,9 +51,17 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||||
|
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(detail.books);
|
||||||
|
|
||||||
|
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||||
|
if (groups.length > 0) {
|
||||||
|
persistDedupGroups(groups).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Enrich books with library availability and request status
|
// Enrich books with library availability and request status
|
||||||
const userId = currentUser.sub || undefined;
|
const userId = currentUser.sub || undefined;
|
||||||
const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId);
|
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||||
|
|
||||||
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
|
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
|
||||||
|
|
||||||
|
|||||||
@@ -140,14 +140,15 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedAdminUsername = admin.username.trim().toLowerCase();
|
||||||
const hashedPassword = await bcrypt.hash(admin.password, 10);
|
const hashedPassword = await bcrypt.hash(admin.password, 10);
|
||||||
const encryptionService = getEncryptionService();
|
const encryptionService = getEncryptionService();
|
||||||
const encryptedPassword = encryptionService.encrypt(hashedPassword);
|
const encryptedPassword = encryptionService.encrypt(hashedPassword);
|
||||||
|
|
||||||
adminUser = await prisma.user.create({
|
adminUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
plexId: `local-${admin.username}`,
|
plexId: `local-${normalizedAdminUsername}`,
|
||||||
plexUsername: admin.username,
|
plexUsername: normalizedAdminUsername,
|
||||||
plexEmail: null,
|
plexEmail: null,
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
isSetupAdmin: true, // Mark as setup admin - role cannot be changed
|
isSetupAdmin: true, // Mark as setup admin - role cannot be changed
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Component: User API Token Delete Route (self-service)
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/user/api-tokens/[id]
|
||||||
|
* Revoke (delete) one of the current user's own API tokens
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many API token revoke attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const token = await prisma.apiToken.findUnique({ where: { id } });
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow deleting own tokens
|
||||||
|
if (token.userId !== req.user!.id) {
|
||||||
|
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.apiToken.delete({ where: { id } });
|
||||||
|
|
||||||
|
logger.info('User API token revoked', { tokenId: id, name: token.name, userId: req.user!.id });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to revoke user API token', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Component: User API Token Routes (self-service)
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||||
|
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||||
|
import { generateApiToken } from '@/lib/utils/api-token';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||||
|
|
||||||
|
const CreateTokenSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
expiresAt: z.string().datetime().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user/api-tokens
|
||||||
|
* List the current user's own API tokens
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
const tokens = await prisma.apiToken.findMany({
|
||||||
|
where: { userId: req.user!.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sanitized = tokens.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
tokenPrefix: t.tokenPrefix,
|
||||||
|
role: t.role,
|
||||||
|
lastUsedAt: t.lastUsedAt,
|
||||||
|
expiresAt: t.expiresAt,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ tokens: sanitized });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to list user API tokens', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/user/api-tokens
|
||||||
|
* Create a token for the current user with their own role. Returns full token ONCE.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
const rateLimit = checkApiTokenCreateRateLimit(req.user!.id);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many API token create attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { name, expiresAt } = CreateTokenSchema.parse(body);
|
||||||
|
|
||||||
|
// Look up the user's actual role from the database
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.id },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce per-user token cap (count only active, non-expired tokens)
|
||||||
|
const activeTokenCount = await prisma.apiToken.count({
|
||||||
|
where: {
|
||||||
|
userId: req.user!.id,
|
||||||
|
OR: [
|
||||||
|
{ expiresAt: null },
|
||||||
|
{ expiresAt: { gt: new Date() } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeTokenCount >= MAX_TOKENS_PER_USER) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Token limit reached. Users may have at most ${MAX_TOKENS_PER_USER} active API tokens.` },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the token
|
||||||
|
const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
|
||||||
|
|
||||||
|
const apiToken = await prisma.apiToken.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
tokenHash,
|
||||||
|
tokenPrefix,
|
||||||
|
role: user.role, // Always the user's own role
|
||||||
|
createdById: req.user!.id,
|
||||||
|
userId: req.user!.id, // Token acts as the current user
|
||||||
|
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('User API token created', { tokenId: apiToken.id, name, userId: req.user!.id });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
token: {
|
||||||
|
id: apiToken.id,
|
||||||
|
name: apiToken.name,
|
||||||
|
tokenPrefix: apiToken.tokenPrefix,
|
||||||
|
role: apiToken.role,
|
||||||
|
expiresAt: apiToken.expiresAt,
|
||||||
|
createdAt: apiToken.createdAt,
|
||||||
|
},
|
||||||
|
fullToken,
|
||||||
|
}, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create user API token', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Failed to create API token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Component: Watched Author Delete Route
|
||||||
|
* Documentation: documentation/features/watched-lists.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.WatchedAuthors');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/user/watched-authors/[id]
|
||||||
|
* Remove an author from the user's watch list (ownership check)
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const watched = await prisma.watchedAuthor.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!watched) {
|
||||||
|
return NextResponse.json({ error: 'Watched author not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership check
|
||||||
|
if (watched.userId !== req.user.id) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.watchedAuthor.delete({ where: { id } });
|
||||||
|
|
||||||
|
logger.info(`User ${req.user.id} stopped watching author "${watched.authorName}" (${watched.authorAsin})`);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete watched author', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Failed to delete watched author' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Component: Watched Authors API Routes
|
||||||
|
* Documentation: documentation/features/watched-lists.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.WatchedAuthors');
|
||||||
|
|
||||||
|
const AddWatchedAuthorSchema = z.object({
|
||||||
|
authorAsin: z.string().regex(/^[A-Z0-9]{10}$/, 'Invalid author ASIN'),
|
||||||
|
authorName: z.string().min(1).max(500),
|
||||||
|
coverArtUrl: z.string().url().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user/watched-authors
|
||||||
|
* List the current user's watched authors
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authors = await prisma.watchedAuthor.findMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
authors: authors.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
authorAsin: a.authorAsin,
|
||||||
|
authorName: a.authorName,
|
||||||
|
coverArtUrl: a.coverArtUrl,
|
||||||
|
lastCheckedAt: a.lastCheckedAt,
|
||||||
|
createdAt: a.createdAt,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to list watched authors', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Failed to list watched authors' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/user/watched-authors
|
||||||
|
* Add an author to the user's watch list
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { authorAsin, authorName, coverArtUrl } = AddWatchedAuthorSchema.parse(body);
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
const existing = await prisma.watchedAuthor.findUnique({
|
||||||
|
where: { userId_authorAsin: { userId: req.user.id, authorAsin } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'AlreadyWatching', message: 'You are already watching this author' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const watched = await prisma.watchedAuthor.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user.id,
|
||||||
|
authorAsin,
|
||||||
|
authorName,
|
||||||
|
coverArtUrl: coverArtUrl || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`User ${req.user.id} started watching author "${authorName}" (${authorAsin})`);
|
||||||
|
|
||||||
|
// Trigger immediate targeted check for this author (fire-and-forget)
|
||||||
|
try {
|
||||||
|
const jobQueue = getJobQueueService();
|
||||||
|
await jobQueue.addCheckWatchedItemJob(req.user.id, undefined, authorAsin);
|
||||||
|
logger.info(`Triggered immediate check for watched author "${authorName}" (${authorAsin})`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to trigger immediate watched author check', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
author: {
|
||||||
|
id: watched.id,
|
||||||
|
authorAsin: watched.authorAsin,
|
||||||
|
authorName: watched.authorName,
|
||||||
|
coverArtUrl: watched.coverArtUrl,
|
||||||
|
lastCheckedAt: watched.lastCheckedAt,
|
||||||
|
createdAt: watched.createdAt,
|
||||||
|
},
|
||||||
|
}, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to add watched author', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Failed to add watched author' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Component: Watched Series Delete Route
|
||||||
|
* Documentation: documentation/features/watched-lists.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.WatchedSeries');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/user/watched-series/[id]
|
||||||
|
* Remove a series from the user's watch list (ownership check)
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const watched = await prisma.watchedSeries.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!watched) {
|
||||||
|
return NextResponse.json({ error: 'Watched series not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership check
|
||||||
|
if (watched.userId !== req.user.id) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.watchedSeries.delete({ where: { id } });
|
||||||
|
|
||||||
|
logger.info(`User ${req.user.id} stopped watching series "${watched.seriesTitle}" (${watched.seriesAsin})`);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete watched series', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Failed to delete watched series' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Component: Watched Series API Routes
|
||||||
|
* Documentation: documentation/features/watched-lists.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.WatchedSeries');
|
||||||
|
|
||||||
|
const AddWatchedSeriesSchema = z.object({
|
||||||
|
seriesAsin: z.string().regex(/^[A-Z0-9]{10}$/, 'Invalid series ASIN'),
|
||||||
|
seriesTitle: z.string().min(1).max(500),
|
||||||
|
coverArtUrl: z.string().url().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user/watched-series
|
||||||
|
* List the current user's watched series
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = await prisma.watchedSeries.findMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
series: series.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
seriesAsin: s.seriesAsin,
|
||||||
|
seriesTitle: s.seriesTitle,
|
||||||
|
coverArtUrl: s.coverArtUrl,
|
||||||
|
lastCheckedAt: s.lastCheckedAt,
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to list watched series', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Failed to list watched series' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/user/watched-series
|
||||||
|
* Add a series to the user's watch list
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { seriesAsin, seriesTitle, coverArtUrl } = AddWatchedSeriesSchema.parse(body);
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
const existing = await prisma.watchedSeries.findUnique({
|
||||||
|
where: { userId_seriesAsin: { userId: req.user.id, seriesAsin } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'AlreadyWatching', message: 'You are already watching this series' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const watched = await prisma.watchedSeries.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user.id,
|
||||||
|
seriesAsin,
|
||||||
|
seriesTitle,
|
||||||
|
coverArtUrl: coverArtUrl || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`User ${req.user.id} started watching series "${seriesTitle}" (${seriesAsin})`);
|
||||||
|
|
||||||
|
// Trigger immediate targeted check for this series (fire-and-forget)
|
||||||
|
try {
|
||||||
|
const jobQueue = getJobQueueService();
|
||||||
|
await jobQueue.addCheckWatchedItemJob(req.user.id, seriesAsin);
|
||||||
|
logger.info(`Triggered immediate check for watched series "${seriesTitle}" (${seriesAsin})`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to trigger immediate watched series check', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
series: {
|
||||||
|
id: watched.id,
|
||||||
|
seriesAsin: watched.seriesAsin,
|
||||||
|
seriesTitle: watched.seriesTitle,
|
||||||
|
coverArtUrl: watched.coverArtUrl,
|
||||||
|
lastCheckedAt: watched.lastCheckedAt,
|
||||||
|
createdAt: watched.createdAt,
|
||||||
|
},
|
||||||
|
}, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to add watched series', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Failed to add watched series' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -197,6 +197,31 @@ body {
|
|||||||
animation: toast-slide-in 0.3s ease-out;
|
animation: toast-slide-in 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Confirmation Dialog */
|
||||||
|
@keyframes dialog-backdrop-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-panel-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-dialog-backdrop {
|
||||||
|
animation: dialog-backdrop-in 0.15s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-dialog-panel {
|
||||||
|
animation: dialog-panel-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide scrollbar while keeping scroll functional */
|
/* Hide scrollbar while keeping scroll functional */
|
||||||
.scrollbar-hide {
|
.scrollbar-hide {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
|
|||||||
+36
-31
@@ -5,12 +5,12 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useMemo } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||||
import { useAudiobooks, Audiobook } from '@/lib/hooks/useAudiobooks';
|
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
|
||||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||||
import { StickyPagination } from '@/components/ui/StickyPagination';
|
import { UnifiedPagination } from '@/components/ui/UnifiedPagination';
|
||||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
|
|
||||||
@@ -29,24 +29,20 @@ export default function HomePage() {
|
|||||||
isLoading: loadingPopular,
|
isLoading: loadingPopular,
|
||||||
totalPages: popularTotalPages,
|
totalPages: popularTotalPages,
|
||||||
message: popularMessage,
|
message: popularMessage,
|
||||||
} = useAudiobooks('popular', 20, popularPage);
|
} = useAudiobooks('popular', 20, popularPage, hideAvailable);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
audiobooks: newReleases,
|
audiobooks: newReleases,
|
||||||
isLoading: loadingNewReleases,
|
isLoading: loadingNewReleases,
|
||||||
totalPages: newReleasesTotalPages,
|
totalPages: newReleasesTotalPages,
|
||||||
message: newReleasesMessage,
|
message: newReleasesMessage,
|
||||||
} = useAudiobooks('new-releases', 20, newReleasesPage);
|
} = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable);
|
||||||
|
|
||||||
// Filter out available titles when hideAvailable is enabled
|
// Reset to page 1 when hideAvailable changes (total pages may differ)
|
||||||
const filteredPopular = useMemo(
|
useEffect(() => {
|
||||||
() => hideAvailable ? popular.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : popular,
|
setPopularPage(1);
|
||||||
[popular, hideAvailable]
|
setNewReleasesPage(1);
|
||||||
);
|
}, [hideAvailable]);
|
||||||
const filteredNewReleases = useMemo(
|
|
||||||
() => hideAvailable ? newReleases.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : newReleases,
|
|
||||||
[newReleases, hideAvailable]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle page changes with auto-scroll to section top
|
// Handle page changes with auto-scroll to section top
|
||||||
const handlePopularPageChange = (page: number) => {
|
const handlePopularPageChange = (page: number) => {
|
||||||
@@ -100,7 +96,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AudiobookGrid
|
<AudiobookGrid
|
||||||
audiobooks={filteredPopular}
|
audiobooks={popular}
|
||||||
isLoading={loadingPopular}
|
isLoading={loadingPopular}
|
||||||
emptyMessage="No popular audiobooks available"
|
emptyMessage="No popular audiobooks available"
|
||||||
cardSize={cardSize}
|
cardSize={cardSize}
|
||||||
@@ -145,7 +141,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AudiobookGrid
|
<AudiobookGrid
|
||||||
audiobooks={filteredNewReleases}
|
audiobooks={newReleases}
|
||||||
isLoading={loadingNewReleases}
|
isLoading={loadingNewReleases}
|
||||||
emptyMessage="No new releases available"
|
emptyMessage="No new releases available"
|
||||||
cardSize={cardSize}
|
cardSize={cardSize}
|
||||||
@@ -181,22 +177,31 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{/* Sticky Pagination Controls */}
|
{/* Unified Pagination — single context-aware pill for both sections */}
|
||||||
<StickyPagination
|
<UnifiedPagination
|
||||||
currentPage={popularPage}
|
|
||||||
totalPages={popularTotalPages}
|
|
||||||
onPageChange={handlePopularPageChange}
|
|
||||||
sectionRef={popularSectionRef}
|
|
||||||
footerRef={footerRef}
|
footerRef={footerRef}
|
||||||
label="Popular Audiobooks"
|
sections={[
|
||||||
/>
|
{
|
||||||
<StickyPagination
|
label: 'Popular Audiobooks',
|
||||||
currentPage={newReleasesPage}
|
accentColor: 'bg-blue-500',
|
||||||
totalPages={newReleasesTotalPages}
|
currentPage: popularPage,
|
||||||
onPageChange={handleNewReleasesPageChange}
|
totalPages: popularTotalPages,
|
||||||
sectionRef={newReleasesSectionRef}
|
onPageChange: handlePopularPageChange,
|
||||||
footerRef={footerRef}
|
sectionRef: popularSectionRef,
|
||||||
label="New Releases"
|
onScrollToSection: () =>
|
||||||
|
popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'New Releases',
|
||||||
|
accentColor: 'bg-emerald-500',
|
||||||
|
currentPage: newReleasesPage,
|
||||||
|
totalPages: newReleasesTotalPages,
|
||||||
|
onPageChange: handleNewReleasesPageChange,
|
||||||
|
sectionRef: newReleasesSectionRef,
|
||||||
|
onScrollToSection: () =>
|
||||||
|
newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { useAuth } from '@/contexts/AuthContext';
|
|||||||
import { useRequests } from '@/lib/hooks/useRequests';
|
import { useRequests } from '@/lib/hooks/useRequests';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
||||||
|
import { ApiTokensSection } from '@/components/profile/ApiTokensSection';
|
||||||
|
import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection';
|
||||||
|
|
||||||
const statConfig = [
|
const statConfig = [
|
||||||
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
||||||
@@ -142,6 +144,12 @@ export default function ProfilePage() {
|
|||||||
{/* Goodreads Shelves */}
|
{/* Goodreads Shelves */}
|
||||||
<GoodreadsShelvesSection />
|
<GoodreadsShelvesSection />
|
||||||
|
|
||||||
|
{/* Watched Series */}
|
||||||
|
<WatchedSeriesSection />
|
||||||
|
|
||||||
|
{/* Watched Authors */}
|
||||||
|
<WatchedAuthorsSection />
|
||||||
|
|
||||||
{/* Active Downloads */}
|
{/* Active Downloads */}
|
||||||
{activeDownloads.length > 0 && (
|
{activeDownloads.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
@@ -233,6 +241,9 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* API Tokens */}
|
||||||
|
<ApiTokensSection />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Component: API Docs Endpoint Card
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*
|
||||||
|
* Expandable card for a single API endpoint with "Try it out" functionality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
import { ResponseViewer } from './ResponseViewer';
|
||||||
|
import type { EndpointDoc } from '@/lib/constants/api-tokens';
|
||||||
|
|
||||||
|
interface EndpointCardProps {
|
||||||
|
endpoint: EndpointDoc;
|
||||||
|
token: string;
|
||||||
|
useSession: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_STYLES: Record<string, string> = {
|
||||||
|
GET: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300',
|
||||||
|
POST: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
|
||||||
|
PUT: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
|
||||||
|
DELETE: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [status, setStatus] = useState<number | null>(null);
|
||||||
|
const [data, setData] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleTryIt = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setData(null);
|
||||||
|
setStatus(null);
|
||||||
|
setExpanded(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response: Response;
|
||||||
|
|
||||||
|
if (useSession) {
|
||||||
|
// Use session JWT via fetchWithAuth
|
||||||
|
response = await fetchWithAuth(endpoint.path, { method: endpoint.method });
|
||||||
|
} else {
|
||||||
|
// Use custom API token
|
||||||
|
if (!token.trim()) {
|
||||||
|
setError('Please enter an API token');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response = await fetch(endpoint.path, {
|
||||||
|
method: endpoint.method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.trim()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(response.status);
|
||||||
|
const text = await response.text();
|
||||||
|
setData(text);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Request failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [endpoint, token, useSession]);
|
||||||
|
|
||||||
|
const methodStyle = METHOD_STYLES[endpoint.method] || METHOD_STYLES.GET;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 shadow-sm overflow-hidden transition-shadow hover:shadow-md">
|
||||||
|
{/* Card header */}
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2.5 mb-2">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold tracking-wide ${methodStyle}`}>
|
||||||
|
{endpoint.method}
|
||||||
|
</span>
|
||||||
|
<code className="text-sm font-mono font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{endpoint.path}
|
||||||
|
</code>
|
||||||
|
{endpoint.requiresAdmin && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wider bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
|
{endpoint.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||||
|
{endpoint.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleTryIt}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-shrink-0 inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:opacity-50 transition-all active:scale-[0.97]"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 dark:border-gray-900/30 border-t-white dark:border-t-gray-900" />
|
||||||
|
Running
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
</svg>
|
||||||
|
Try it
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable response area */}
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-300 ease-in-out overflow-hidden ${
|
||||||
|
expanded ? 'max-h-[600px] opacity-100 mt-1' : 'max-h-0 opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ResponseViewer
|
||||||
|
status={status}
|
||||||
|
data={data}
|
||||||
|
error={error}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(data || error) && !loading && (
|
||||||
|
<div className="flex justify-end mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setExpanded(false); setData(null); setStatus(null); setError(null); }}
|
||||||
|
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Clear response
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Curl example (shown in collapsed footer) */}
|
||||||
|
<div className="px-5 py-3 bg-gray-50 dark:bg-gray-900/30 border-t border-gray-100 dark:border-gray-700/50">
|
||||||
|
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||||
|
curl -H "Authorization: Bearer {'<token>'}" {typeof window !== 'undefined' ? window.location.origin : ''}{endpoint.path}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Component: API Docs Response Viewer
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*
|
||||||
|
* Displays API response with syntax highlighting, status badge, and copy functionality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
|
||||||
|
interface ResponseViewerProps {
|
||||||
|
status: number | null;
|
||||||
|
data: string | null;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: number): string {
|
||||||
|
if (status >= 200 && status < 300) return 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300';
|
||||||
|
if (status >= 400 && status < 500) return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300';
|
||||||
|
return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tokenize JSON string into typed segments for React rendering */
|
||||||
|
type JsonToken = { type: 'string' | 'number' | 'boolean' | 'null' | 'plain'; value: string };
|
||||||
|
|
||||||
|
function tokenizeJson(json: string): JsonToken[] {
|
||||||
|
const tokens: JsonToken[] = [];
|
||||||
|
const regex = /("(?:[^"\\]|\\.)*")|(\b\d+\.?\d*\b)|(\btrue\b|\bfalse\b)|(\bnull\b)/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = regex.exec(json)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
tokens.push({ type: 'plain', value: json.slice(lastIndex, match.index) });
|
||||||
|
}
|
||||||
|
if (match[1] !== undefined) tokens.push({ type: 'string', value: match[1] });
|
||||||
|
else if (match[2] !== undefined) tokens.push({ type: 'number', value: match[2] });
|
||||||
|
else if (match[3] !== undefined) tokens.push({ type: 'boolean', value: match[3] });
|
||||||
|
else if (match[4] !== undefined) tokens.push({ type: 'null', value: match[4] });
|
||||||
|
lastIndex = regex.lastIndex;
|
||||||
|
}
|
||||||
|
if (lastIndex < json.length) {
|
||||||
|
tokens.push({ type: 'plain', value: json.slice(lastIndex) });
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_COLORS: Record<JsonToken['type'], string> = {
|
||||||
|
string: 'text-emerald-400',
|
||||||
|
number: 'text-blue-400',
|
||||||
|
boolean: 'text-purple-400',
|
||||||
|
null: 'text-purple-400',
|
||||||
|
plain: 'text-gray-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResponseViewer({ status, data, error, loading }: ResponseViewerProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const tokens = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
try {
|
||||||
|
const formatted = JSON.stringify(JSON.parse(data), null, 2);
|
||||||
|
return tokenizeJson(formatted);
|
||||||
|
} catch {
|
||||||
|
return [{ type: 'plain' as const, value: data }];
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!data) return;
|
||||||
|
try {
|
||||||
|
const formatted = JSON.stringify(JSON.parse(data), null, 2);
|
||||||
|
await navigator.clipboard.writeText(formatted);
|
||||||
|
} catch {
|
||||||
|
await navigator.clipboard.writeText(data);
|
||||||
|
}
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">Sending request...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-xl border border-red-200 dark:border-red-800/50 bg-red-50 dark:bg-red-900/20 p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm text-red-700 dark:text-red-300">{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || status === null) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
{/* Header bar */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||||
|
Response
|
||||||
|
</span>
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold ${statusColor(status)}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JSON body */}
|
||||||
|
<pre className="p-4 bg-[#0d1117] text-sm font-mono leading-relaxed overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||||
|
<code>{tokens.map((t, i) => (
|
||||||
|
<span key={i} className={TOKEN_COLORS[t.type]}>{t.value}</span>
|
||||||
|
))}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Component: API Docs Token Input
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*
|
||||||
|
* Token input field with toggle between custom API token and current session auth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface TokenInputProps {
|
||||||
|
token: string;
|
||||||
|
onTokenChange: (token: string) => void;
|
||||||
|
useSession: boolean;
|
||||||
|
onUseSessionChange: (useSession: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TokenInput({
|
||||||
|
token,
|
||||||
|
onTokenChange,
|
||||||
|
useSession,
|
||||||
|
onUseSessionChange,
|
||||||
|
}: TokenInputProps) {
|
||||||
|
const [showToken, setShowToken] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Authentication
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
Choose how to authenticate your test requests
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => onUseSessionChange(!useSession)}
|
||||||
|
className={`
|
||||||
|
relative inline-flex h-7 w-[140px] items-center rounded-full transition-colors duration-200
|
||||||
|
${useSession
|
||||||
|
? 'bg-blue-600'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
absolute inset-y-0.5 w-[68px] rounded-full bg-white dark:bg-gray-100 shadow-sm
|
||||||
|
transition-transform duration-200 ease-in-out
|
||||||
|
${useSession ? 'translate-x-[70px]' : 'translate-x-0.5'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
relative z-10 flex-1 text-center text-xs font-medium transition-colors duration-200
|
||||||
|
${!useSession ? 'text-gray-900 dark:text-gray-900' : 'text-white/70'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
API Token
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
relative z-10 flex-1 text-center text-xs font-medium transition-colors duration-200
|
||||||
|
${useSession ? 'text-gray-900 dark:text-gray-900' : 'text-gray-500 dark:text-gray-400'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
Session
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{useSession ? (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
|
||||||
|
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
Using your current browser session for authentication
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showToken ? 'text' : 'password'}
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => onTokenChange(e.target.value)}
|
||||||
|
placeholder="rmab_your_api_token_here"
|
||||||
|
className="w-full rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900/50 px-4 py-2.5 pr-20 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 focus:outline-none transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowToken(!showToken)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 px-2.5 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
{showToken ? 'Hide' : 'Show'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { AuthorDetail } from '@/lib/hooks/useAuthors';
|
import { AuthorDetail } from '@/lib/hooks/useAuthors';
|
||||||
|
import { WatchAuthorButton } from '@/components/ui/WatchButton';
|
||||||
|
|
||||||
interface AuthorDetailCardProps {
|
interface AuthorDetailCardProps {
|
||||||
author: AuthorDetail;
|
author: AuthorDetail;
|
||||||
@@ -64,20 +65,27 @@ export function AuthorDetailCard({ author }: AuthorDetailCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Audible Link */}
|
{/* Actions row: Audible link + Watch button */}
|
||||||
{author.audibleUrl && (
|
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
|
||||||
<a
|
{author.audibleUrl && (
|
||||||
href={author.audibleUrl}
|
<a
|
||||||
target="_blank"
|
href={author.audibleUrl}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
rel="noopener noreferrer"
|
||||||
>
|
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
View on Audible
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
View on Audible
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
</a>
|
</svg>
|
||||||
)}
|
</a>
|
||||||
|
)}
|
||||||
|
<WatchAuthorButton
|
||||||
|
authorAsin={author.asin}
|
||||||
|
authorName={author.name}
|
||||||
|
coverArtUrl={author.image}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{author.description && (
|
{author.description && (
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* Component: API Tokens Section (Profile Page)
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||||
|
import { useApiTokens } from '@/lib/hooks/useApiTokens';
|
||||||
|
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { ApiToken } from '@/lib/types/api-tokens';
|
||||||
|
|
||||||
|
export function ApiTokensSection() {
|
||||||
|
const api = useApiTokens<ApiToken>({ basePath: '/api/user/api-tokens' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
API Tokens
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Create personal API tokens for programmatic access to the API.{' '}
|
||||||
|
<Link href="/api-docs" className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
View API documentation
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 shadow-sm">
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Error display */}
|
||||||
|
{api.error && (
|
||||||
|
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
|
||||||
|
{api.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Newly created token banner */}
|
||||||
|
{api.createdToken && (
|
||||||
|
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
|
Token created successfully! Copy it now — it won't be shown again.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||||
|
{api.createdToken}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={api.handleCopy}
|
||||||
|
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||||
|
>
|
||||||
|
{api.copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Dismiss token banner"
|
||||||
|
onClick={api.dismissCreatedToken}
|
||||||
|
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create token form */}
|
||||||
|
{api.showCreateForm ? (
|
||||||
|
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={api.newTokenName}
|
||||||
|
onChange={(e) => api.setNewTokenName(e.target.value)}
|
||||||
|
placeholder="e.g., Home Assistant, Webhook"
|
||||||
|
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && api.handleCreate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Expiration
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={api.newTokenExpiry}
|
||||||
|
onChange={(e) => api.setNewTokenExpiry(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="never">Never</option>
|
||||||
|
<option value="30d">30 days</option>
|
||||||
|
<option value="90d">90 days</option>
|
||||||
|
<option value="1y">1 year</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => api.handleCreate()}
|
||||||
|
disabled={api.creating || !api.newTokenName.trim()}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
|
||||||
|
>
|
||||||
|
{api.creating ? 'Creating...' : 'Create Token'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={api.resetForm}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => api.setShowCreateForm(true)}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
||||||
|
>
|
||||||
|
Create New Token
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Token list */}
|
||||||
|
{api.loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
) : api.tokens.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
<p className="mt-2 text-sm">No API tokens yet</p>
|
||||||
|
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
|
||||||
|
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{api.tokens.map((token) => (
|
||||||
|
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
|
||||||
|
<td className="py-3 px-2">
|
||||||
|
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
|
||||||
|
{token.tokenPrefix}...
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{api.formatDate(token.lastUsedAt)}</td>
|
||||||
|
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
|
||||||
|
{token.expiresAt ? (
|
||||||
|
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
|
||||||
|
{api.formatDate(token.expiresAt)}
|
||||||
|
{new Date(token.expiresAt) < new Date() && ' (expired)'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Never'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => api.setConfirmRevokeId(token.id)}
|
||||||
|
disabled={api.deletingId === token.id}
|
||||||
|
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{api.deletingId === token.id ? 'Revoking...' : 'Revoke'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Usage instructions */}
|
||||||
|
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
|
||||||
|
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||||
|
${getInstanceUrl()}/api/requests`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revoke confirmation dialog */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={api.confirmRevokeId !== null}
|
||||||
|
title="Revoke API token"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
Are you sure you want to revoke{' '}
|
||||||
|
<span className="font-medium text-gray-800 dark:text-gray-100">
|
||||||
|
“{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}”
|
||||||
|
</span>
|
||||||
|
? Any integrations using this token will immediately lose access. This cannot be undone.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmText="Revoke token"
|
||||||
|
cancelText="Cancel"
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={api.handleDeleteConfirmed}
|
||||||
|
onClose={() => api.setConfirmRevokeId(null)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* Component: Watched Lists Section (Profile Page)
|
||||||
|
* Documentation: documentation/features/watched-lists.md
|
||||||
|
*
|
||||||
|
* Shows the user's watched series and watched authors on their profile page
|
||||||
|
* with the ability to remove items.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useWatchedSeries, useDeleteWatchedSeries, WatchedSeriesItem } from '@/lib/hooks/useWatchedSeries';
|
||||||
|
import { useWatchedAuthors, useDeleteWatchedAuthor, WatchedAuthorItem } from '@/lib/hooks/useWatchedAuthors';
|
||||||
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return 'Never';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
if (diffMins < 1) return 'just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
return `${diffDays}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Watched Series Section
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function WatchedSeriesSection() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { series, isLoading } = useWatchedSeries();
|
||||||
|
const { deleteSeries, isLoading: isDeleting } = useDeleteWatchedSeries();
|
||||||
|
const { squareCovers } = usePreferences();
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteSeries(id);
|
||||||
|
setConfirmDeleteId(null);
|
||||||
|
} catch {
|
||||||
|
// Error handled by hook
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Watched Series" icon="series" count={null} />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{[1, 2].map((i) => <CardSkeleton key={i} squareCovers={squareCovers} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (series.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Watched Series" icon="series" count={series.length} />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{series.map((item) => (
|
||||||
|
<WatchedSeriesCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
squareCovers={squareCovers}
|
||||||
|
isDeleting={isDeleting && confirmDeleteId === item.id}
|
||||||
|
confirmingDelete={confirmDeleteId === item.id}
|
||||||
|
onNavigate={() => router.push(`/series/${item.seriesAsin}`)}
|
||||||
|
onConfirmDelete={() => setConfirmDeleteId(item.id)}
|
||||||
|
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||||
|
onDelete={() => handleDelete(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WatchedSeriesCard({
|
||||||
|
item, squareCovers, isDeleting, confirmingDelete, onNavigate, onConfirmDelete, onCancelDelete, onDelete,
|
||||||
|
}: {
|
||||||
|
item: WatchedSeriesItem;
|
||||||
|
squareCovers: boolean;
|
||||||
|
isDeleting: boolean;
|
||||||
|
confirmingDelete: boolean;
|
||||||
|
onNavigate: () => void;
|
||||||
|
onConfirmDelete: () => void;
|
||||||
|
onCancelDelete: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 hover:shadow-sm transition-shadow">
|
||||||
|
{/* Cover */}
|
||||||
|
<button onClick={onNavigate} className="flex-shrink-0">
|
||||||
|
<div className={`relative w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg overflow-hidden bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900`}>
|
||||||
|
{item.coverArtUrl ? (
|
||||||
|
<Image src={item.coverArtUrl} alt={item.seriesTitle} fill className="object-cover" sizes="56px" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<button onClick={onNavigate} className="text-left">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white truncate hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors">
|
||||||
|
{item.seriesTitle}
|
||||||
|
</h3>
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
Last checked: {formatRelativeTime(item.lastCheckedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<div className="flex-shrink-0 flex items-center">
|
||||||
|
{confirmingDelete ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
|
||||||
|
>
|
||||||
|
{isDeleting ? '...' : 'Remove'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCancelDelete}
|
||||||
|
className="px-2 py-1 text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onConfirmDelete}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||||||
|
title="Remove from watched"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Watched Authors Section
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function WatchedAuthorsSection() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { authors, isLoading } = useWatchedAuthors();
|
||||||
|
const { deleteAuthor, isLoading: isDeleting } = useDeleteWatchedAuthor();
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteAuthor(id);
|
||||||
|
setConfirmDeleteId(null);
|
||||||
|
} catch {
|
||||||
|
// Error handled by hook
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Watched Authors" icon="author" count={null} />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{[1, 2].map((i) => <CardSkeleton key={i} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authors.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Watched Authors" icon="author" count={authors.length} />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{authors.map((item) => (
|
||||||
|
<WatchedAuthorCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isDeleting={isDeleting && confirmDeleteId === item.id}
|
||||||
|
confirmingDelete={confirmDeleteId === item.id}
|
||||||
|
onNavigate={() => router.push(`/authors/${item.authorAsin}`)}
|
||||||
|
onConfirmDelete={() => setConfirmDeleteId(item.id)}
|
||||||
|
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||||
|
onDelete={() => handleDelete(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WatchedAuthorCard({
|
||||||
|
item, isDeleting, confirmingDelete, onNavigate, onConfirmDelete, onCancelDelete, onDelete,
|
||||||
|
}: {
|
||||||
|
item: WatchedAuthorItem;
|
||||||
|
isDeleting: boolean;
|
||||||
|
confirmingDelete: boolean;
|
||||||
|
onNavigate: () => void;
|
||||||
|
onConfirmDelete: () => void;
|
||||||
|
onCancelDelete: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 hover:shadow-sm transition-shadow">
|
||||||
|
{/* Avatar */}
|
||||||
|
<button onClick={onNavigate} className="flex-shrink-0">
|
||||||
|
<div className="relative w-14 h-14 rounded-full overflow-hidden bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900">
|
||||||
|
{item.coverArtUrl ? (
|
||||||
|
<Image src={item.coverArtUrl} alt={item.authorName} fill className="object-cover" sizes="56px" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0 flex items-center">
|
||||||
|
<div>
|
||||||
|
<button onClick={onNavigate} className="text-left">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white truncate hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||||
|
{item.authorName}
|
||||||
|
</h3>
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
Last checked: {formatRelativeTime(item.lastCheckedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<div className="flex-shrink-0 flex items-center">
|
||||||
|
{confirmingDelete ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
|
||||||
|
>
|
||||||
|
{isDeleting ? '...' : 'Remove'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCancelDelete}
|
||||||
|
className="px-2 py-1 text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onConfirmDelete}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||||||
|
title="Remove from watched"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared Components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SectionHeader({ title, icon, count }: { title: string; icon: 'series' | 'author'; count: number | null }) {
|
||||||
|
const gradientColors = icon === 'series'
|
||||||
|
? 'from-emerald-500 to-teal-500'
|
||||||
|
: 'from-blue-500 to-indigo-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 mb-5">
|
||||||
|
<div className={`w-1 h-6 bg-gradient-to-b ${gradientColors} rounded-full`} />
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{count !== null && (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">({count})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardSkeleton({ squareCovers }: { squareCovers?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 animate-pulse">
|
||||||
|
<div className={`w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg bg-gray-200 dark:bg-gray-700`} />
|
||||||
|
<div className="flex-1 space-y-2 py-2">
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
|
||||||
|
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { SeriesDetail } from '@/lib/hooks/useSeries';
|
import { SeriesDetail } from '@/lib/hooks/useSeries';
|
||||||
|
import { WatchSeriesButton } from '@/components/ui/WatchButton';
|
||||||
|
|
||||||
interface SeriesDetailCardProps {
|
interface SeriesDetailCardProps {
|
||||||
series: SeriesDetail;
|
series: SeriesDetail;
|
||||||
@@ -91,20 +92,27 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Audible Link */}
|
{/* Actions row: Audible link + Watch button */}
|
||||||
{series.audibleUrl && (
|
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
|
||||||
<a
|
{series.audibleUrl && (
|
||||||
href={series.audibleUrl}
|
<a
|
||||||
target="_blank"
|
href={series.audibleUrl}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
rel="noopener noreferrer"
|
||||||
>
|
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
View on Audible
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
View on Audible
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
</a>
|
</svg>
|
||||||
)}
|
</a>
|
||||||
|
)}
|
||||||
|
<WatchSeriesButton
|
||||||
|
seriesAsin={series.asin}
|
||||||
|
seriesTitle={series.title}
|
||||||
|
coverArtUrl={series.books[0]?.coverArtUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{series.description && (
|
{series.description && (
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface ConfirmModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string | React.ReactNode;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
@@ -35,7 +35,9 @@ export function ConfirmModal({
|
|||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm" showCloseButton={false}>
|
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm" showCloseButton={false}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<p className="text-gray-600 dark:text-gray-400">{message}</p>
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
{typeof message === 'string' ? <p>{message}</p> : message}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<Button onClick={onClose} variant="outline" disabled={isLoading}>
|
<Button onClick={onClose} variant="outline" disabled={isLoading}>
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
/**
|
|
||||||
* Component: Sticky Pagination with Progress Bar
|
|
||||||
* Documentation: documentation/frontend/components.md
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
interface StickyPaginationProps {
|
|
||||||
currentPage: number;
|
|
||||||
totalPages: number;
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
sectionRef: React.RefObject<HTMLElement | null>;
|
|
||||||
label: string; // e.g., "Popular Audiobooks"
|
|
||||||
footerRef?: React.RefObject<HTMLElement | null>; // Optional footer ref to avoid overlap
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StickyPagination({
|
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
onPageChange,
|
|
||||||
sectionRef,
|
|
||||||
label,
|
|
||||||
footerRef,
|
|
||||||
}: StickyPaginationProps) {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const [isFooterVisible, setIsFooterVisible] = useState(false);
|
|
||||||
const [jumpPage, setJumpPage] = useState(currentPage.toString());
|
|
||||||
|
|
||||||
// Update jump page input when current page changes externally
|
|
||||||
useEffect(() => {
|
|
||||||
setJumpPage(currentPage.toString());
|
|
||||||
}, [currentPage]);
|
|
||||||
|
|
||||||
// Intersection Observer to show/hide pagination based on section visibility
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sectionRef.current) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
// Show pagination when section is in viewport
|
|
||||||
setIsVisible(entry.isIntersecting && entry.intersectionRatio > 0.1);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
threshold: [0, 0.1, 0.5, 1],
|
|
||||||
rootMargin: '-60px 0px -60px 0px', // Account for header/footer
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(sectionRef.current);
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [sectionRef]);
|
|
||||||
|
|
||||||
// Footer observer to hide pagination when footer is visible
|
|
||||||
useEffect(() => {
|
|
||||||
if (!footerRef?.current) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
// Hide pagination when footer is in viewport
|
|
||||||
setIsFooterVisible(entry.isIntersecting);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
threshold: [0, 0.1],
|
|
||||||
rootMargin: '0px',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(footerRef.current);
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [footerRef]);
|
|
||||||
|
|
||||||
if (totalPages <= 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePrevious = () => {
|
|
||||||
if (currentPage > 1) {
|
|
||||||
onPageChange(currentPage - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNext = () => {
|
|
||||||
if (currentPage < totalPages) {
|
|
||||||
onPageChange(currentPage + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJumpSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const page = parseInt(jumpPage, 10);
|
|
||||||
if (!isNaN(page) && page >= 1 && page <= totalPages) {
|
|
||||||
onPageChange(page);
|
|
||||||
} else {
|
|
||||||
// Reset to current page if invalid
|
|
||||||
setJumpPage(currentPage.toString());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Final visibility: show when section is visible AND footer is not visible
|
|
||||||
const shouldShow = isVisible && !isFooterVisible;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-40 transition-all duration-300 ${
|
|
||||||
shouldShow ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="bg-white/95 dark:bg-gray-900/95 backdrop-blur-lg rounded-full shadow-lg border border-gray-200 dark:border-gray-700 px-4 py-2.5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Section Label - Hidden on small screens */}
|
|
||||||
<div className="hidden md:block text-xs font-medium text-gray-600 dark:text-gray-400 pr-2 border-r border-gray-300 dark:border-gray-600">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Previous Button */}
|
|
||||||
<button
|
|
||||||
onClick={handlePrevious}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
|
|
||||||
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
|
|
||||||
transition-colors"
|
|
||||||
aria-label="Previous page"
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Page Info & Jump */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
|
||||||
Page
|
|
||||||
</span>
|
|
||||||
<form onSubmit={handleJumpSubmit} className="inline-flex">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={jumpPage}
|
|
||||||
onChange={(e) => setJumpPage(e.target.value)}
|
|
||||||
onBlur={handleJumpSubmit}
|
|
||||||
className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded
|
|
||||||
bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100
|
|
||||||
border border-gray-300 dark:border-gray-600
|
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
aria-label="Current page"
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
|
||||||
of {totalPages}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Next Button */}
|
|
||||||
<button
|
|
||||||
onClick={handleNext}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
|
|
||||||
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
|
|
||||||
transition-colors"
|
|
||||||
aria-label="Next page"
|
|
||||||
>
|
|
||||||
<ChevronRightIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
/**
|
||||||
|
* Component: Unified Pagination — context-aware floating paginator
|
||||||
|
* Documentation: documentation/frontend/components.md
|
||||||
|
*
|
||||||
|
* Replaces two overlapping StickyPagination instances with a single pill
|
||||||
|
* that automatically tracks which section dominates the viewport and shows
|
||||||
|
* controls for that section. Transitions smoothly when the dominant section
|
||||||
|
* changes. Includes a two-dot section indicator for manual switching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export interface PaginationSection {
|
||||||
|
/** Display label, e.g. "Popular Audiobooks" */
|
||||||
|
label: string;
|
||||||
|
/** Tailwind color class applied to the active accent dot, e.g. "bg-blue-500" */
|
||||||
|
accentColor: string;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
/** Ref to the section element — used for intersection tracking */
|
||||||
|
sectionRef: React.RefObject<HTMLElement | null>;
|
||||||
|
/** Called when user clicks this section's dot while it's inactive — should scroll to section */
|
||||||
|
onScrollToSection: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnifiedPaginationProps {
|
||||||
|
sections: [PaginationSection, PaginationSection];
|
||||||
|
footerRef?: React.RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Small page-jump form — isolated to prevent key re-mounts on section switch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface PageJumpProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageJump({ currentPage, totalPages, onPageChange }: PageJumpProps) {
|
||||||
|
const [value, setValue] = useState(currentPage.toString());
|
||||||
|
|
||||||
|
// Sync when page changes externally (e.g. after scrollIntoView + setState)
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(currentPage.toString());
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
|
const commit = useCallback(
|
||||||
|
(e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (!isNaN(parsed) && parsed >= 1 && parsed <= totalPages) {
|
||||||
|
onPageChange(parsed);
|
||||||
|
} else {
|
||||||
|
setValue(currentPage.toString());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[value, currentPage, totalPages, onPageChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 select-none whitespace-nowrap">
|
||||||
|
Page
|
||||||
|
</span>
|
||||||
|
<form onSubmit={commit} className="inline-flex">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded-md
|
||||||
|
bg-black/[0.04] dark:bg-white/[0.08]
|
||||||
|
text-gray-900 dark:text-gray-100
|
||||||
|
border border-gray-300/60 dark:border-white/10
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent
|
||||||
|
transition-all duration-150"
|
||||||
|
aria-label="Jump to page"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 select-none whitespace-nowrap">
|
||||||
|
of {totalPages}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProps) {
|
||||||
|
// Index of the currently dominant section (0 or 1)
|
||||||
|
const [activeIndex, setActiveIndex] = useState<0 | 1>(0);
|
||||||
|
// Whether the label+controls area is mid-transition (drives opacity fade)
|
||||||
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
|
|
||||||
|
const [footerVisible, setFooterVisible] = useState(false);
|
||||||
|
// Per-section raw intersection ratios [0,1]
|
||||||
|
const ratiosRef = useRef<[number, number]>([0, 0]);
|
||||||
|
// Whether each section has any meaningful intersection
|
||||||
|
const [sectionVisible, setSectionVisible] = useState<[boolean, boolean]>([false, false]);
|
||||||
|
|
||||||
|
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Determine if the pill should be shown at all:
|
||||||
|
// - at least one section is meaningfully visible
|
||||||
|
// - footer is not visible
|
||||||
|
// - the active section has >1 page
|
||||||
|
const activeSectionHasPages = sections[activeIndex].totalPages > 1;
|
||||||
|
const eitherSectionVisible = sectionVisible[0] || sectionVisible[1];
|
||||||
|
const shouldShow = eitherSectionVisible && !footerVisible && activeSectionHasPages;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Track which section each instance belongs to via intersection ratio
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
const observers: IntersectionObserver[] = [];
|
||||||
|
|
||||||
|
sections.forEach((section, idx) => {
|
||||||
|
if (!section.sectionRef.current) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
ratiosRef.current[idx as 0 | 1] = entry.intersectionRatio;
|
||||||
|
const isVisible = entry.isIntersecting && entry.intersectionRatio > 0.05;
|
||||||
|
|
||||||
|
setSectionVisible((prev) => {
|
||||||
|
const next: [boolean, boolean] = [...prev] as [boolean, boolean];
|
||||||
|
next[idx as 0 | 1] = isVisible;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine dominant section (whichever has more viewport coverage)
|
||||||
|
const [r0, r1] = ratiosRef.current;
|
||||||
|
const dominant: 0 | 1 = r0 >= r1 ? 0 : 1;
|
||||||
|
|
||||||
|
setActiveIndex((current) => {
|
||||||
|
if (current !== dominant) {
|
||||||
|
// Trigger cross-fade transition
|
||||||
|
setIsTransitioning(true);
|
||||||
|
|
||||||
|
if (transitionTimerRef.current) {
|
||||||
|
clearTimeout(transitionTimerRef.current);
|
||||||
|
}
|
||||||
|
transitionTimerRef.current = setTimeout(() => {
|
||||||
|
setIsTransitioning(false);
|
||||||
|
}, 320);
|
||||||
|
|
||||||
|
return dominant;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Dense threshold array gives us smooth ratio tracking
|
||||||
|
threshold: Array.from({ length: 21 }, (_, i) => i / 20),
|
||||||
|
rootMargin: '-60px 0px -80px 0px',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(section.sectionRef.current);
|
||||||
|
observers.push(observer);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observers.forEach((o) => o.disconnect());
|
||||||
|
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sections[0].sectionRef, sections[1].sectionRef]);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Footer observer
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
if (!footerRef?.current) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => setFooterVisible(entry.isIntersecting),
|
||||||
|
{ threshold: [0, 0.01] }
|
||||||
|
);
|
||||||
|
observer.observe(footerRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [footerRef]);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Derived values for the currently active section
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const active = sections[activeIndex];
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (active.currentPage > 1) active.onPageChange(active.currentPage - 1);
|
||||||
|
};
|
||||||
|
const handleNext = () => {
|
||||||
|
if (active.currentPage < active.totalPages) active.onPageChange(active.currentPage + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
fixed bottom-6 left-1/2 -translate-x-1/2 z-40
|
||||||
|
transition-all duration-300 ease-out
|
||||||
|
${shouldShow
|
||||||
|
? 'translate-y-0 opacity-100 pointer-events-auto'
|
||||||
|
: 'translate-y-4 opacity-0 pointer-events-none'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
aria-hidden={!shouldShow}
|
||||||
|
>
|
||||||
|
{/* Pill surface */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
flex items-center gap-0
|
||||||
|
bg-white/90 dark:bg-gray-900/90
|
||||||
|
backdrop-blur-xl
|
||||||
|
rounded-full
|
||||||
|
shadow-[0_8px_32px_rgba(0,0,0,0.12),0_2px_8px_rgba(0,0,0,0.08)]
|
||||||
|
dark:shadow-[0_8px_32px_rgba(0,0,0,0.4),0_2px_8px_rgba(0,0,0,0.3)]
|
||||||
|
border border-gray-200/60 dark:border-white/[0.08]
|
||||||
|
px-1.5 py-1.5
|
||||||
|
overflow-hidden
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* Section selector dots — left side */}
|
||||||
|
<div className="flex flex-col gap-1 pl-2 pr-3">
|
||||||
|
{sections.map((section, idx) => {
|
||||||
|
const isActive = idx === activeIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={section.label}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isActive) section.onScrollToSection();
|
||||||
|
}}
|
||||||
|
disabled={isActive}
|
||||||
|
title={section.label}
|
||||||
|
aria-label={`Switch to ${section.label}`}
|
||||||
|
className={`
|
||||||
|
w-1.5 rounded-full transition-all duration-300 ease-out
|
||||||
|
${isActive
|
||||||
|
? `${section.accentColor} h-4 opacity-100`
|
||||||
|
: 'bg-gray-300 dark:bg-gray-600 h-1.5 opacity-60 hover:opacity-90 hover:scale-110 cursor-pointer'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="w-px h-6 bg-gray-200 dark:bg-white/10 mr-3 flex-shrink-0" />
|
||||||
|
|
||||||
|
{/* Label + controls — cross-fades on section switch */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3
|
||||||
|
transition-opacity duration-200 ease-in-out
|
||||||
|
${isTransitioning ? 'opacity-0' : 'opacity-100'}
|
||||||
|
`}
|
||||||
|
// key forces full remount on switch so input state resets cleanly
|
||||||
|
key={activeIndex}
|
||||||
|
>
|
||||||
|
{/* Section label — hidden on small screens */}
|
||||||
|
<span className="hidden sm:block text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap pr-1 select-none">
|
||||||
|
{active.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Previous */}
|
||||||
|
<button
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={active.currentPage === 1}
|
||||||
|
aria-label="Previous page"
|
||||||
|
className="
|
||||||
|
p-1.5 rounded-full
|
||||||
|
text-gray-600 dark:text-gray-300
|
||||||
|
hover:bg-black/[0.06] dark:hover:bg-white/[0.08]
|
||||||
|
active:bg-black/[0.1] dark:active:bg-white/[0.12]
|
||||||
|
active:scale-95
|
||||||
|
disabled:opacity-25 disabled:cursor-not-allowed
|
||||||
|
transition-all duration-150
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="w-4 h-4" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page jump */}
|
||||||
|
<PageJump
|
||||||
|
currentPage={active.currentPage}
|
||||||
|
totalPages={active.totalPages}
|
||||||
|
onPageChange={active.onPageChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={active.currentPage === active.totalPages}
|
||||||
|
aria-label="Next page"
|
||||||
|
className="
|
||||||
|
p-1.5 rounded-full
|
||||||
|
text-gray-600 dark:text-gray-300
|
||||||
|
hover:bg-black/[0.06] dark:hover:bg-white/[0.08]
|
||||||
|
active:bg-black/[0.1] dark:active:bg-white/[0.12]
|
||||||
|
active:scale-95
|
||||||
|
disabled:opacity-25 disabled:cursor-not-allowed
|
||||||
|
transition-all duration-150
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="w-4 h-4" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right padding balance */}
|
||||||
|
<div className="w-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Component: Watch Button (Series / Author)
|
||||||
|
* Documentation: documentation/features/watched-lists.md
|
||||||
|
*
|
||||||
|
* Reusable toggle button for watching/unwatching a series or author.
|
||||||
|
* Shows a confirmation modal before watching. Unwatching is instant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useWatchedSeries, useAddWatchedSeries, useDeleteWatchedSeries } from '@/lib/hooks/useWatchedSeries';
|
||||||
|
import { useWatchedAuthors, useAddWatchedAuthor, useDeleteWatchedAuthor } from '@/lib/hooks/useWatchedAuthors';
|
||||||
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
|
|
||||||
|
interface WatchSeriesButtonProps {
|
||||||
|
seriesAsin: string;
|
||||||
|
seriesTitle: string;
|
||||||
|
coverArtUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatchSeriesButton({ seriesAsin, seriesTitle, coverArtUrl }: WatchSeriesButtonProps) {
|
||||||
|
const { series } = useWatchedSeries();
|
||||||
|
const { addSeries, isLoading: isAdding } = useAddWatchedSeries();
|
||||||
|
const { deleteSeries, isLoading: isDeleting } = useDeleteWatchedSeries();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
const watchedEntry = series.find((s) => s.seriesAsin === seriesAsin);
|
||||||
|
const isWatching = !!watchedEntry;
|
||||||
|
const isLoading = isAdding || isDeleting;
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (isWatching && watchedEntry) {
|
||||||
|
// Unwatch immediately (no confirmation needed)
|
||||||
|
try {
|
||||||
|
await deleteSeries(watchedEntry.id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show confirmation before watching
|
||||||
|
setShowConfirm(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmWatch = async () => {
|
||||||
|
setShowConfirm(false);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await addSeries(seriesAsin, seriesTitle, coverArtUrl);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex flex-col items-start">
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ${
|
||||||
|
isWatching
|
||||||
|
? 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/50 border border-emerald-200 dark:border-emerald-700/50'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 hover:text-emerald-700 dark:hover:text-emerald-300 border border-gray-200 dark:border-gray-600/50 hover:border-emerald-200 dark:hover:border-emerald-700/50'
|
||||||
|
} ${isLoading ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
) : isWatching ? (
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isWatching ? 'Watching' : 'Watch Series'}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<span className="text-xs text-red-500 mt-1">{error}</span>
|
||||||
|
)}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showConfirm}
|
||||||
|
onClose={() => setShowConfirm(false)}
|
||||||
|
onConfirm={handleConfirmWatch}
|
||||||
|
title={`Watch "${seriesTitle}"?`}
|
||||||
|
message={`This will request all books in "${seriesTitle}" that aren't already in your library, and automatically request new releases as they're added to the series. Continue?`}
|
||||||
|
confirmText="Watch"
|
||||||
|
isLoading={isAdding}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WatchAuthorButtonProps {
|
||||||
|
authorAsin: string;
|
||||||
|
authorName: string;
|
||||||
|
coverArtUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatchAuthorButton({ authorAsin, authorName, coverArtUrl }: WatchAuthorButtonProps) {
|
||||||
|
const { authors } = useWatchedAuthors();
|
||||||
|
const { addAuthor, isLoading: isAdding } = useAddWatchedAuthor();
|
||||||
|
const { deleteAuthor, isLoading: isDeleting } = useDeleteWatchedAuthor();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
const watchedEntry = authors.find((a) => a.authorAsin === authorAsin);
|
||||||
|
const isWatching = !!watchedEntry;
|
||||||
|
const isLoading = isAdding || isDeleting;
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (isWatching && watchedEntry) {
|
||||||
|
// Unwatch immediately (no confirmation needed)
|
||||||
|
try {
|
||||||
|
await deleteAuthor(watchedEntry.id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show confirmation before watching
|
||||||
|
setShowConfirm(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmWatch = async () => {
|
||||||
|
setShowConfirm(false);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await addAuthor(authorAsin, authorName, coverArtUrl);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex flex-col items-start">
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ${
|
||||||
|
isWatching
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 border border-blue-200 dark:border-blue-700/50'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-700 dark:hover:text-blue-300 border border-gray-200 dark:border-gray-600/50 hover:border-blue-200 dark:hover:border-blue-700/50'
|
||||||
|
} ${isLoading ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
) : isWatching ? (
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isWatching ? 'Watching' : 'Watch Author'}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<span className="text-xs text-red-500 mt-1">{error}</span>
|
||||||
|
)}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showConfirm}
|
||||||
|
onClose={() => setShowConfirm(false)}
|
||||||
|
onConfirm={handleConfirmWatch}
|
||||||
|
title={`Watch "${authorName}"?`}
|
||||||
|
message={`This will request all books by "${authorName}" that aren't already in your library, and automatically request new releases. Continue?`}
|
||||||
|
confirmText="Watch"
|
||||||
|
isLoading={isAdding}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Component: API Token Constants
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*
|
||||||
|
* Centralized API token constants used across authentication middleware and token routes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Prefix prepended to all generated API tokens for identification */
|
||||||
|
export const API_TOKEN_PREFIX = 'rmab_';
|
||||||
|
|
||||||
|
/** Number of random bytes used to generate the token's random portion */
|
||||||
|
export const TOKEN_RANDOM_BYTES = 32;
|
||||||
|
|
||||||
|
/** Length of the token prefix stored in the database for display (first 12 chars: "rmab_" + 7 hex chars) */
|
||||||
|
export const TOKEN_PREFIX_LENGTH = 12;
|
||||||
|
|
||||||
|
/** Maximum number of active (non-expired) API tokens a single user may hold */
|
||||||
|
export const MAX_TOKENS_PER_USER = 25;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Endpoint allowlist — restricts which routes API tokens may access
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Shape of an allowed endpoint entry */
|
||||||
|
export interface AllowedEndpoint {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extended metadata used by the interactive API docs page */
|
||||||
|
export interface EndpointDoc {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
requiresAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoints that API tokens are permitted to call.
|
||||||
|
* JWT-authenticated sessions are NOT restricted by this list.
|
||||||
|
*/
|
||||||
|
export const API_TOKEN_ALLOWED_ENDPOINTS: readonly AllowedEndpoint[] = [
|
||||||
|
{ method: 'GET', path: '/api/auth/me' },
|
||||||
|
{ method: 'GET', path: '/api/requests' },
|
||||||
|
{ method: 'GET', path: '/api/admin/metrics' },
|
||||||
|
{ method: 'GET', path: '/api/admin/downloads/active' },
|
||||||
|
{ method: 'GET', path: '/api/admin/requests/recent' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full documentation metadata for each allowed endpoint.
|
||||||
|
* Consumed by the /api-docs interactive page.
|
||||||
|
*/
|
||||||
|
export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/auth/me',
|
||||||
|
title: 'Get current user',
|
||||||
|
description:
|
||||||
|
'Returns the authenticated user\'s profile information including username, role, and account details.',
|
||||||
|
requiresAdmin: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/requests',
|
||||||
|
title: 'List requests',
|
||||||
|
description:
|
||||||
|
'Returns all audiobook requests visible to the authenticated user. Admins see all requests, users see their own.',
|
||||||
|
requiresAdmin: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/admin/metrics',
|
||||||
|
title: 'System metrics',
|
||||||
|
description:
|
||||||
|
'Returns system health metrics including request counts, download statistics, and library size.',
|
||||||
|
requiresAdmin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/admin/downloads/active',
|
||||||
|
title: 'Active downloads',
|
||||||
|
description:
|
||||||
|
'Returns currently active downloads including progress, speed, and ETA.',
|
||||||
|
requiresAdmin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/admin/requests/recent',
|
||||||
|
title: 'Recent requests',
|
||||||
|
description:
|
||||||
|
'Returns the most recent audiobook requests across all users.',
|
||||||
|
requiresAdmin: true,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a given method + path is on the API token allowlist.
|
||||||
|
* Method comparison is case-insensitive.
|
||||||
|
*/
|
||||||
|
export function isEndpointAllowed(method: string, path: string): boolean {
|
||||||
|
const upperMethod = method.toUpperCase();
|
||||||
|
return API_TOKEN_ALLOWED_ENDPOINTS.some(
|
||||||
|
(ep) => ep.method === upperMethod && ep.path === path
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Component: Shared API Token Management Hook
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
import type { ApiToken } from '@/lib/types/api-tokens';
|
||||||
|
|
||||||
|
/** Typed request body for creating an API token */
|
||||||
|
export interface CreateTokenBody {
|
||||||
|
name: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
userId?: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseApiTokensConfig {
|
||||||
|
/** Base API path, e.g. '/api/admin/api-tokens' or '/api/user/api-tokens' */
|
||||||
|
basePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseApiTokensReturn<T extends ApiToken = ApiToken> {
|
||||||
|
tokens: T[];
|
||||||
|
loading: boolean;
|
||||||
|
creating: boolean;
|
||||||
|
error: string | null;
|
||||||
|
newTokenName: string;
|
||||||
|
setNewTokenName: (name: string) => void;
|
||||||
|
newTokenExpiry: string;
|
||||||
|
setNewTokenExpiry: (expiry: string) => void;
|
||||||
|
showCreateForm: boolean;
|
||||||
|
setShowCreateForm: (show: boolean) => void;
|
||||||
|
createdToken: string | null;
|
||||||
|
copied: boolean;
|
||||||
|
deletingId: string | null;
|
||||||
|
confirmRevokeId: string | null;
|
||||||
|
setConfirmRevokeId: (id: string | null) => void;
|
||||||
|
fetchTokens: () => Promise<void>;
|
||||||
|
handleCreate: (extraBody?: Partial<CreateTokenBody>) => Promise<boolean>;
|
||||||
|
handleDeleteConfirmed: () => Promise<void>;
|
||||||
|
handleCopy: () => Promise<void>;
|
||||||
|
dismissCreatedToken: () => void;
|
||||||
|
resetForm: () => void;
|
||||||
|
formatDate: (dateStr: string | null) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared hook for API token CRUD operations.
|
||||||
|
* Used by both the admin ApiTab and the user ApiTokensSection.
|
||||||
|
*/
|
||||||
|
export function useApiTokens<T extends ApiToken = ApiToken>(
|
||||||
|
config: UseApiTokensConfig
|
||||||
|
): UseApiTokensReturn<T> {
|
||||||
|
const [tokens, setTokens] = useState<T[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newTokenName, setNewTokenName] = useState('');
|
||||||
|
const [newTokenExpiry, setNewTokenExpiry] = useState('never');
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTokens = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth(config.basePath);
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Failed to load API tokens';
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
message = data.error || message;
|
||||||
|
} catch {
|
||||||
|
// Keep default message when response body is not JSON
|
||||||
|
}
|
||||||
|
setError(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setTokens(data.tokens);
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load API tokens');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [config.basePath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTokens();
|
||||||
|
}, [fetchTokens]);
|
||||||
|
|
||||||
|
const computeExpiresAt = (): string | null => {
|
||||||
|
if (newTokenExpiry === 'never') return null;
|
||||||
|
const date = new Date();
|
||||||
|
switch (newTokenExpiry) {
|
||||||
|
case '30d': date.setDate(date.getDate() + 30); break;
|
||||||
|
case '90d': date.setDate(date.getDate() + 90); break;
|
||||||
|
case '1y': date.setFullYear(date.getFullYear() + 1); break;
|
||||||
|
}
|
||||||
|
return date.toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (extraBody?: Partial<CreateTokenBody>) => {
|
||||||
|
if (!newTokenName.trim()) {
|
||||||
|
setError('Token name is required');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: CreateTokenBody = {
|
||||||
|
name: newTokenName.trim(),
|
||||||
|
expiresAt: computeExpiresAt(),
|
||||||
|
...extraBody,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(config.basePath, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCreatedToken(data.fullToken);
|
||||||
|
setNewTokenName('');
|
||||||
|
setNewTokenExpiry('never');
|
||||||
|
setShowCreateForm(false);
|
||||||
|
await fetchTokens();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || 'Failed to create token');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to create token');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirmed = async () => {
|
||||||
|
const id = confirmRevokeId;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
setConfirmRevokeId(null);
|
||||||
|
setDeletingId(id);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth(`${config.basePath}/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setTokens(tokens.filter((t) => t.id !== id));
|
||||||
|
} else {
|
||||||
|
setError('Failed to revoke token');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to revoke token');
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (createdToken) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(createdToken);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to copy to clipboard. Please select and copy the token manually.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissCreatedToken = () => setCreatedToken(null);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setNewTokenName('');
|
||||||
|
setNewTokenExpiry('never');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string | null): string => {
|
||||||
|
if (!dateStr) return 'Never';
|
||||||
|
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokens,
|
||||||
|
loading,
|
||||||
|
creating,
|
||||||
|
error,
|
||||||
|
newTokenName,
|
||||||
|
setNewTokenName,
|
||||||
|
newTokenExpiry,
|
||||||
|
setNewTokenExpiry,
|
||||||
|
showCreateForm,
|
||||||
|
setShowCreateForm,
|
||||||
|
createdToken,
|
||||||
|
copied,
|
||||||
|
deletingId,
|
||||||
|
confirmRevokeId,
|
||||||
|
setConfirmRevokeId,
|
||||||
|
fetchTokens,
|
||||||
|
handleCreate,
|
||||||
|
handleDeleteConfirmed,
|
||||||
|
handleCopy,
|
||||||
|
dismissCreatedToken,
|
||||||
|
resetForm,
|
||||||
|
formatDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -35,11 +35,12 @@ export interface Audiobook {
|
|||||||
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) {
|
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
|
||||||
|
const hideParam = hideAvailable ? '&hideAvailable=true' : '';
|
||||||
const endpoint =
|
const endpoint =
|
||||||
type === 'popular'
|
type === 'popular'
|
||||||
? `/api/audiobooks/popular?page=${page}&limit=${limit}`
|
? `/api/audiobooks/popular?page=${page}&limit=${limit}${hideParam}`
|
||||||
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}`;
|
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}${hideParam}`;
|
||||||
|
|
||||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Component: Watched Authors Hook
|
||||||
|
* Documentation: documentation/features/watched-lists.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import useSWR, { mutate } from 'swr';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
|
||||||
|
export interface WatchedAuthorItem {
|
||||||
|
id: string;
|
||||||
|
authorAsin: string;
|
||||||
|
authorName: string;
|
||||||
|
coverArtUrl: string | null;
|
||||||
|
lastCheckedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) =>
|
||||||
|
fetchWithAuth(url).then((res) => res.json());
|
||||||
|
|
||||||
|
export function useWatchedAuthors() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
|
||||||
|
const endpoint = accessToken ? '/api/user/watched-authors' : null;
|
||||||
|
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
endpoint,
|
||||||
|
fetcher,
|
||||||
|
{ refreshInterval: 60000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
authors: (data?.authors || []) as WatchedAuthorItem[],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddWatchedAuthor() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const addAuthor = async (authorAsin: string, authorName: string, coverArtUrl?: string) => {
|
||||||
|
if (!accessToken) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/user/watched-authors', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ authorAsin, authorName, coverArtUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || data.error || 'Failed to watch author');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revalidate watched authors list
|
||||||
|
mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-authors'));
|
||||||
|
|
||||||
|
return data.author as WatchedAuthorItem;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { addAuthor, isLoading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteWatchedAuthor() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const deleteAuthor = async (id: string) => {
|
||||||
|
if (!accessToken) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth(`/api/user/watched-authors/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || data.error || 'Failed to unwatch author');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revalidate watched authors list
|
||||||
|
mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-authors'));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { deleteAuthor, isLoading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Component: Watched Series Hook
|
||||||
|
* Documentation: documentation/features/watched-lists.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import useSWR, { mutate } from 'swr';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
|
||||||
|
export interface WatchedSeriesItem {
|
||||||
|
id: string;
|
||||||
|
seriesAsin: string;
|
||||||
|
seriesTitle: string;
|
||||||
|
coverArtUrl: string | null;
|
||||||
|
lastCheckedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) =>
|
||||||
|
fetchWithAuth(url).then((res) => res.json());
|
||||||
|
|
||||||
|
export function useWatchedSeries() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
|
||||||
|
const endpoint = accessToken ? '/api/user/watched-series' : null;
|
||||||
|
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
endpoint,
|
||||||
|
fetcher,
|
||||||
|
{ refreshInterval: 60000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
series: (data?.series || []) as WatchedSeriesItem[],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddWatchedSeries() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const addSeries = async (seriesAsin: string, seriesTitle: string, coverArtUrl?: string) => {
|
||||||
|
if (!accessToken) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/user/watched-series', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ seriesAsin, seriesTitle, coverArtUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || data.error || 'Failed to watch series');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revalidate watched series list
|
||||||
|
mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-series'));
|
||||||
|
|
||||||
|
return data.series as WatchedSeriesItem;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { addSeries, isLoading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteWatchedSeries() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const deleteSeries = async (id: string) => {
|
||||||
|
if (!accessToken) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth(`/api/user/watched-series/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || data.error || 'Failed to unwatch series');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revalidate watched series list
|
||||||
|
mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-series'));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { deleteSeries, isLoading, error };
|
||||||
|
}
|
||||||
@@ -14,8 +14,10 @@ import {
|
|||||||
getLanguageForRegion,
|
getLanguageForRegion,
|
||||||
buildContainsSelector,
|
buildContainsSelector,
|
||||||
stripPrefixes,
|
stripPrefixes,
|
||||||
|
type LanguageConfig,
|
||||||
} from '../constants/language-config';
|
} from '../constants/language-config';
|
||||||
import { RMABLogger } from '../utils/logger';
|
import { RMABLogger } from '../utils/logger';
|
||||||
|
import { parseRuntime } from '../utils/parse-runtime';
|
||||||
import { randomDelay } from '../utils/scrape-resilience';
|
import { randomDelay } from '../utils/scrape-resilience';
|
||||||
|
|
||||||
const logger = RMABLogger.create('Audible.Series');
|
const logger = RMABLogger.create('Audible.Series');
|
||||||
@@ -311,7 +313,7 @@ export async function scrapeSeriesPage(asin: string, page: number = 1): Promise<
|
|||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
// Parse all books from the series page
|
// Parse all books from the series page
|
||||||
const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes);
|
const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes, langConfig);
|
||||||
|
|
||||||
// Use actual book count if we got more from scraping
|
// Use actual book count if we got more from scraping
|
||||||
const bookCount = Math.max(summary.bookCount, books.length);
|
const bookCount = Math.max(summary.bookCount, books.length);
|
||||||
@@ -403,7 +405,8 @@ function parseSeriesRating($: cheerio.CheerioAPI): { rating?: number; ratingCoun
|
|||||||
function parseSeriesBooks(
|
function parseSeriesBooks(
|
||||||
$: cheerio.CheerioAPI,
|
$: cheerio.CheerioAPI,
|
||||||
authorPrefixes: string[],
|
authorPrefixes: string[],
|
||||||
narratorPrefixes: string[]
|
narratorPrefixes: string[],
|
||||||
|
langConfig: LanguageConfig
|
||||||
): AudibleAudiobook[] {
|
): AudibleAudiobook[] {
|
||||||
const books: AudibleAudiobook[] = [];
|
const books: AudibleAudiobook[] = [];
|
||||||
const seenAsins = new Set<string>();
|
const seenAsins = new Set<string>();
|
||||||
@@ -453,6 +456,11 @@ function parseSeriesBooks(
|
|||||||
const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null;
|
const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null;
|
||||||
const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined;
|
const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined;
|
||||||
|
|
||||||
|
// Duration
|
||||||
|
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||||
|
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
||||||
|
const durationMinutes = parseRuntime(runtimeText, langConfig);
|
||||||
|
|
||||||
books.push({
|
books.push({
|
||||||
asin: bookAsin,
|
asin: bookAsin,
|
||||||
title,
|
title,
|
||||||
@@ -461,6 +469,7 @@ function parseSeriesBooks(
|
|||||||
narrator: stripPrefixes(narratorText, narratorPrefixes),
|
narrator: stripPrefixes(narratorText, narratorPrefixes),
|
||||||
coverArtUrl,
|
coverArtUrl,
|
||||||
rating,
|
rating,
|
||||||
|
durationMinutes,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
AdaptivePacer,
|
AdaptivePacer,
|
||||||
FetchResultMeta,
|
FetchResultMeta,
|
||||||
} from '../utils/scrape-resilience';
|
} from '../utils/scrape-resilience';
|
||||||
|
import { parseRuntime as parseRuntimeUtil } from '../utils/parse-runtime';
|
||||||
|
|
||||||
// Module-level logger
|
// Module-level logger
|
||||||
const logger = RMABLogger.create('Audible');
|
const logger = RMABLogger.create('Audible');
|
||||||
@@ -1134,33 +1135,11 @@ export class AudibleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse runtime text to minutes using language-specific patterns
|
* Parse runtime text to minutes using language-specific patterns.
|
||||||
|
* Delegates to shared utility in src/lib/utils/parse-runtime.ts.
|
||||||
*/
|
*/
|
||||||
private parseRuntime(runtimeText: string): number | undefined {
|
private parseRuntime(runtimeText: string): number | undefined {
|
||||||
if (!runtimeText) return undefined;
|
return parseRuntimeUtil(runtimeText, this.getLangConfig());
|
||||||
|
|
||||||
const langConfig = this.getLangConfig();
|
|
||||||
let totalMinutes = 0;
|
|
||||||
|
|
||||||
// Try each hour pattern until one matches
|
|
||||||
for (const pattern of langConfig.scraping.runtimeHourPatterns) {
|
|
||||||
const match = runtimeText.match(pattern);
|
|
||||||
if (match) {
|
|
||||||
totalMinutes += parseInt(match[1]) * 60;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try each minute pattern until one matches
|
|
||||||
for (const pattern of langConfig.scraping.runtimeMinutePatterns) {
|
|
||||||
const match = runtimeText.match(pattern);
|
|
||||||
if (match) {
|
|
||||||
totalMinutes += parseInt(match[1]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return totalMinutes > 0 ? totalMinutes : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+107
-3
@@ -4,9 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
|
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
|
||||||
import { prisma } from '../db';
|
import { prisma } from '../db';
|
||||||
import { RMABLogger } from '../utils/logger';
|
import { RMABLogger } from '../utils/logger';
|
||||||
|
import { API_TOKEN_PREFIX, isEndpointAllowed } from '../constants/api-tokens';
|
||||||
|
|
||||||
const logger = RMABLogger.create('Auth');
|
const logger = RMABLogger.create('Auth');
|
||||||
|
|
||||||
@@ -32,9 +34,70 @@ function extractToken(request: NextRequest): string | null {
|
|||||||
return parts[1];
|
return parts[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate via static API token (rmab_ prefix).
|
||||||
|
* Returns a synthetic TokenPayload if valid, null otherwise.
|
||||||
|
* Updates lastUsedAt asynchronously.
|
||||||
|
*/
|
||||||
|
async function authenticateApiToken(token: string): Promise<TokenPayload | null> {
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
|
||||||
|
const apiToken = await prisma.apiToken.findUnique({
|
||||||
|
where: { tokenHash },
|
||||||
|
include: {
|
||||||
|
tokenUser: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
plexId: true,
|
||||||
|
plexUsername: true,
|
||||||
|
role: true,
|
||||||
|
deletedAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiToken) return null;
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
|
||||||
|
logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject tokens for soft-deleted users
|
||||||
|
const user = apiToken.tokenUser;
|
||||||
|
if (!user || user.deletedAt) {
|
||||||
|
logger.warn('API token used by deleted or missing user', {
|
||||||
|
tokenPrefix: apiToken.tokenPrefix,
|
||||||
|
userId: user?.id,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lastUsedAt (fire-and-forget)
|
||||||
|
prisma.apiToken.update({
|
||||||
|
where: { id: apiToken.id },
|
||||||
|
data: { lastUsedAt: new Date() },
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.debug('Failed to update API token lastUsedAt', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
tokenId: apiToken.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the token's target user (userId), not the creator (createdById)
|
||||||
|
return {
|
||||||
|
sub: user.id,
|
||||||
|
plexId: user.plexId,
|
||||||
|
username: user.plexUsername,
|
||||||
|
role: apiToken.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware: Require authentication
|
* Middleware: Require authentication
|
||||||
* Verifies JWT token and adds user to request
|
* Verifies JWT token or static API token and adds user to request
|
||||||
*/
|
*/
|
||||||
export async function requireAuth(
|
export async function requireAuth(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -53,6 +116,43 @@ export async function requireAuth(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a static API token
|
||||||
|
if (token.startsWith(API_TOKEN_PREFIX)) {
|
||||||
|
const apiUser = await authenticateApiToken(token);
|
||||||
|
if (!apiUser) {
|
||||||
|
logger.error('API token authentication failed');
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Invalid or expired API token',
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce endpoint allowlist for API token auth
|
||||||
|
const pathname = request.nextUrl.pathname;
|
||||||
|
const method = request.method;
|
||||||
|
if (!isEndpointAllowed(method, pathname)) {
|
||||||
|
logger.warn('API token used on restricted endpoint', {
|
||||||
|
method,
|
||||||
|
path: pathname,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'This endpoint is not available via API token authentication',
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticatedRequest = request as AuthenticatedRequest;
|
||||||
|
authenticatedRequest.user = { ...apiUser, id: apiUser.sub };
|
||||||
|
return handler(authenticatedRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to JWT verification
|
||||||
const payload = verifyAccessToken(token);
|
const payload = verifyAccessToken(token);
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
@@ -69,9 +169,13 @@ export async function requireAuth(
|
|||||||
// Verify user still exists in database
|
// Verify user still exists in database
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
deletedAt: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user || user.deletedAt) {
|
||||||
logger.error('User not found in database', { userId: payload.sub });
|
logger.error('User not found in database', { userId: payload.sub });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -86,7 +190,7 @@ export async function requireAuth(
|
|||||||
const authenticatedRequest = request as AuthenticatedRequest;
|
const authenticatedRequest = request as AuthenticatedRequest;
|
||||||
authenticatedRequest.user = {
|
authenticatedRequest.user = {
|
||||||
...payload,
|
...payload,
|
||||||
id: user.id,
|
id: payload.sub,
|
||||||
};
|
};
|
||||||
|
|
||||||
return handler(authenticatedRequest);
|
return handler(authenticatedRequest);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Component: Check Watched Lists Processor
|
||||||
|
* Documentation: documentation/features/watched-lists.md
|
||||||
|
*
|
||||||
|
* Dedicated processor for checking watched series and watched authors
|
||||||
|
* for new releases and auto-creating requests.
|
||||||
|
* Supports targeted processing of a single series/author for immediate sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RMABLogger } from '../utils/logger';
|
||||||
|
|
||||||
|
export interface CheckWatchedListsPayload {
|
||||||
|
jobId?: string;
|
||||||
|
scheduledJobId?: string;
|
||||||
|
/** If set, only process watched items for this user */
|
||||||
|
userId?: string;
|
||||||
|
/** If set, only process this specific series */
|
||||||
|
seriesAsin?: string;
|
||||||
|
/** If set, only process this specific author */
|
||||||
|
authorAsin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processCheckWatchedLists(payload: CheckWatchedListsPayload): Promise<any> {
|
||||||
|
const { jobId, userId, seriesAsin, authorAsin } = payload;
|
||||||
|
const logger = RMABLogger.forJob(jobId, 'CheckWatchedLists');
|
||||||
|
|
||||||
|
const isTargeted = !!(userId && (seriesAsin || authorAsin));
|
||||||
|
logger.info(isTargeted
|
||||||
|
? `Starting targeted watched lists check (user: ${userId}, series: ${seriesAsin || 'n/a'}, author: ${authorAsin || 'n/a'})...`
|
||||||
|
: 'Starting watched lists check...'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { processWatchedLists } = await import('../services/watched-lists.service');
|
||||||
|
const stats = await processWatchedLists(logger, { userId, seriesAsin, authorAsin });
|
||||||
|
|
||||||
|
logger.info('Watched lists check complete', { stats });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: isTargeted ? 'Targeted watched item checked' : 'Watched lists checked',
|
||||||
|
...stats,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
|||||||
import { generateFilesHash } from '../utils/files-hash';
|
import { generateFilesHash } from '../utils/files-hash';
|
||||||
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
|
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
|
||||||
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
|
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
|
||||||
|
import { getAudibleService } from '../integrations/audible.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process organize files job
|
* Process organize files job
|
||||||
@@ -118,7 +119,62 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}`)
|
// Enrich missing series data from Audnexus (safety net for records created without series)
|
||||||
|
let series = audiobook.series || undefined;
|
||||||
|
let seriesPart = audiobook.seriesPart || undefined;
|
||||||
|
|
||||||
|
if (audiobook.audibleAsin && !series) {
|
||||||
|
try {
|
||||||
|
logger.info(`Missing series data, fetching from Audnexus for ASIN: ${audiobook.audibleAsin}`);
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin);
|
||||||
|
|
||||||
|
if (audnexusData) {
|
||||||
|
const updates: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (audnexusData.series) {
|
||||||
|
series = audnexusData.series;
|
||||||
|
updates.series = series;
|
||||||
|
logger.info(`Got series "${series}" from Audnexus`);
|
||||||
|
}
|
||||||
|
if (audnexusData.seriesPart) {
|
||||||
|
seriesPart = audnexusData.seriesPart;
|
||||||
|
updates.seriesPart = seriesPart;
|
||||||
|
logger.info(`Got seriesPart "${seriesPart}" from Audnexus`);
|
||||||
|
}
|
||||||
|
if (audnexusData.seriesAsin) {
|
||||||
|
updates.seriesAsin = audnexusData.seriesAsin;
|
||||||
|
}
|
||||||
|
// Also backfill year/narrator if still missing
|
||||||
|
if (!year && audnexusData.releaseDate) {
|
||||||
|
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
||||||
|
if (!isNaN(releaseYear)) {
|
||||||
|
year = releaseYear;
|
||||||
|
updates.year = year;
|
||||||
|
logger.info(`Got year ${year} from Audnexus`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!narrator && audnexusData.narrator) {
|
||||||
|
narrator = audnexusData.narrator;
|
||||||
|
updates.narrator = narrator;
|
||||||
|
logger.info(`Got narrator "${narrator}" from Audnexus`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await prisma.audiobook.update({
|
||||||
|
where: { id: audiobookId },
|
||||||
|
data: updates,
|
||||||
|
});
|
||||||
|
logger.info(`Updated audiobook record with Audnexus metadata`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Non-fatal: missing series won't block organization, just degrades path quality
|
||||||
|
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`);
|
||||||
|
|
||||||
// Get file organizer (reads media_dir from database config)
|
// Get file organizer (reads media_dir from database config)
|
||||||
const organizer = await getFileOrganizer();
|
const organizer = await getFileOrganizer();
|
||||||
@@ -151,8 +207,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
|||||||
coverArtUrl: audiobook.coverArtUrl || undefined,
|
coverArtUrl: audiobook.coverArtUrl || undefined,
|
||||||
asin: audiobook.audibleAsin || undefined,
|
asin: audiobook.audibleAsin || undefined,
|
||||||
year,
|
year,
|
||||||
series: audiobook.series || undefined,
|
series,
|
||||||
seriesPart: audiobook.seriesPart || undefined,
|
seriesPart,
|
||||||
},
|
},
|
||||||
template,
|
template,
|
||||||
jobId ? { jobId, context: 'FileOrganizer' } : undefined,
|
jobId ? { jobId, context: 'FileOrganizer' } : undefined,
|
||||||
@@ -545,6 +601,56 @@ async function processEbookOrganization(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich missing series data from Audnexus (safety net for records created without series)
|
||||||
|
if (book.audibleAsin && !series) {
|
||||||
|
try {
|
||||||
|
logger.info(`Missing series data for ebook, fetching from Audnexus for ASIN: ${book.audibleAsin}`);
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const audnexusData = await audibleService.getAudiobookDetails(book.audibleAsin);
|
||||||
|
|
||||||
|
if (audnexusData) {
|
||||||
|
const updates: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (audnexusData.series) {
|
||||||
|
series = audnexusData.series;
|
||||||
|
updates.series = series;
|
||||||
|
logger.info(`Got series "${series}" from Audnexus`);
|
||||||
|
}
|
||||||
|
if (audnexusData.seriesPart) {
|
||||||
|
seriesPart = audnexusData.seriesPart;
|
||||||
|
updates.seriesPart = seriesPart;
|
||||||
|
logger.info(`Got seriesPart "${seriesPart}" from Audnexus`);
|
||||||
|
}
|
||||||
|
if (audnexusData.seriesAsin) {
|
||||||
|
updates.seriesAsin = audnexusData.seriesAsin;
|
||||||
|
}
|
||||||
|
if (!year && audnexusData.releaseDate) {
|
||||||
|
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
||||||
|
if (!isNaN(releaseYear)) {
|
||||||
|
year = releaseYear;
|
||||||
|
updates.year = year;
|
||||||
|
logger.info(`Got year ${year} from Audnexus`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!narrator && audnexusData.narrator) {
|
||||||
|
narrator = audnexusData.narrator;
|
||||||
|
updates.narrator = narrator;
|
||||||
|
logger.info(`Got narrator "${narrator}" from Audnexus`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await prisma.audiobook.update({
|
||||||
|
where: { id: audiobookId },
|
||||||
|
data: updates,
|
||||||
|
});
|
||||||
|
logger.info(`Updated book record with Audnexus metadata`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to fetch Audnexus data for ASIN ${book.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`);
|
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`);
|
||||||
|
|
||||||
// Check if this is an indexer download (needs to keep source for seeding)
|
// Check if this is an indexer download (needs to keep source for seeding)
|
||||||
|
|||||||
@@ -54,10 +54,12 @@ export class LocalAuthProvider implements IAuthProvider {
|
|||||||
return { success: false, error: 'Username and password required' };
|
return { success: false, error: 'Username and password required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedUsername = username.trim().toLowerCase();
|
||||||
|
|
||||||
// Find user (exclude soft-deleted users)
|
// Find user (exclude soft-deleted users)
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
plexUsername: username,
|
plexUsername: normalizedUsername,
|
||||||
authProvider: 'local',
|
authProvider: 'local',
|
||||||
deletedAt: null, // Exclude soft-deleted users
|
deletedAt: null, // Exclude soft-deleted users
|
||||||
},
|
},
|
||||||
@@ -144,9 +146,10 @@ export class LocalAuthProvider implements IAuthProvider {
|
|||||||
async register(params: RegisterParams): Promise<AuthResult> {
|
async register(params: RegisterParams): Promise<AuthResult> {
|
||||||
try {
|
try {
|
||||||
const { username, password } = params;
|
const { username, password } = params;
|
||||||
|
const normalizedUsername = username?.trim().toLowerCase();
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
if (!username || username.length < 3) {
|
if (!normalizedUsername || normalizedUsername.length < 3) {
|
||||||
return { success: false, error: 'Username must be at least 3 characters' };
|
return { success: false, error: 'Username must be at least 3 characters' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +170,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
|||||||
// Check username uniqueness (only among non-deleted users)
|
// Check username uniqueness (only among non-deleted users)
|
||||||
const existing = await prisma.user.findFirst({
|
const existing = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
plexUsername: username,
|
plexUsername: normalizedUsername,
|
||||||
authProvider: 'local',
|
authProvider: 'local',
|
||||||
deletedAt: null, // Allow reuse of usernames from deleted accounts
|
deletedAt: null, // Allow reuse of usernames from deleted accounts
|
||||||
},
|
},
|
||||||
@@ -194,8 +197,8 @@ export class LocalAuthProvider implements IAuthProvider {
|
|||||||
// Create user
|
// Create user
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
plexId: `local-${username}`,
|
plexId: `local-${normalizedUsername}`,
|
||||||
plexUsername: username,
|
plexUsername: normalizedUsername,
|
||||||
authToken: encryptedHash,
|
authToken: encryptedHash,
|
||||||
authProvider: 'local',
|
authProvider: 'local',
|
||||||
role: isFirstUser ? 'admin' : 'user',
|
role: isFirstUser ? 'admin' : 'user',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type JobType =
|
|||||||
| 'cleanup_seeded_torrents'
|
| 'cleanup_seeded_torrents'
|
||||||
| 'monitor_rss_feeds'
|
| 'monitor_rss_feeds'
|
||||||
| 'sync_goodreads_shelves'
|
| 'sync_goodreads_shelves'
|
||||||
|
| 'check_watched_lists'
|
||||||
| 'send_notification'
|
| 'send_notification'
|
||||||
// Ebook-specific job types
|
// Ebook-specific job types
|
||||||
| 'search_ebook'
|
| 'search_ebook'
|
||||||
@@ -113,6 +114,16 @@ export interface SyncGoodreadsShelvesPayload extends JobPayload {
|
|||||||
maxLookupsPerShelf?: number;
|
maxLookupsPerShelf?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CheckWatchedListsPayload extends JobPayload {
|
||||||
|
scheduledJobId?: string;
|
||||||
|
/** If set, only process watched items for this user */
|
||||||
|
userId?: string;
|
||||||
|
/** If set, only process this specific series */
|
||||||
|
seriesAsin?: string;
|
||||||
|
/** If set, only process this specific author */
|
||||||
|
authorAsin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Ebook-specific payload interfaces
|
// Ebook-specific payload interfaces
|
||||||
export interface SearchEbookPayload extends JobPayload {
|
export interface SearchEbookPayload extends JobPayload {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -384,6 +395,12 @@ export class JobQueueService {
|
|||||||
return await processSyncGoodreadsShelves(payloadWithJobId);
|
return await processSyncGoodreadsShelves(payloadWithJobId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.queue.process('check_watched_lists', 1, async (job: BullJob<CheckWatchedListsPayload>) => {
|
||||||
|
const { processCheckWatchedLists } = await import('../processors/check-watched-lists.processor');
|
||||||
|
const payloadWithJobId = await this.ensureJobRecord(job, 'check_watched_lists');
|
||||||
|
return await processCheckWatchedLists(payloadWithJobId);
|
||||||
|
});
|
||||||
|
|
||||||
// Send notification processor
|
// Send notification processor
|
||||||
this.queue.process('send_notification', 2, async (job: BullJob<SendNotificationPayload>) => {
|
this.queue.process('send_notification', 2, async (job: BullJob<SendNotificationPayload>) => {
|
||||||
const { processSendNotification } = await import('../processors/send-notification.processor');
|
const { processSendNotification } = await import('../processors/send-notification.processor');
|
||||||
@@ -766,6 +783,39 @@ export class JobQueueService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add check watched lists job (watched series + watched authors)
|
||||||
|
*/
|
||||||
|
async addCheckWatchedListsJob(scheduledJobId?: string): Promise<string> {
|
||||||
|
return await this.addJob(
|
||||||
|
'check_watched_lists',
|
||||||
|
{
|
||||||
|
scheduledJobId,
|
||||||
|
} as CheckWatchedListsPayload,
|
||||||
|
{
|
||||||
|
priority: 7,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a targeted check for a specific watched series or author for a specific user.
|
||||||
|
* Used for immediate processing when a user adds a new watch.
|
||||||
|
*/
|
||||||
|
async addCheckWatchedItemJob(userId: string, seriesAsin?: string, authorAsin?: string): Promise<string> {
|
||||||
|
return await this.addJob(
|
||||||
|
'check_watched_lists',
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
seriesAsin,
|
||||||
|
authorAsin,
|
||||||
|
} as CheckWatchedListsPayload,
|
||||||
|
{
|
||||||
|
priority: 8, // Higher than scheduled (7) since user-initiated
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// EBOOK-SPECIFIC JOB METHODS
|
// EBOOK-SPECIFIC JOB METHODS
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { getJobQueueService } from '@/lib/services/job-queue.service';
|
|||||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { seedAsin } from '@/lib/services/works.service';
|
||||||
|
|
||||||
const logger = RMABLogger.create('RequestCreator');
|
const logger = RMABLogger.create('RequestCreator');
|
||||||
|
|
||||||
@@ -147,6 +148,15 @@ export async function createRequestForUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed works table for cross-ASIN matching (Layer 2: request-time seeding)
|
||||||
|
seedAsin(
|
||||||
|
audiobook.asin,
|
||||||
|
audiobookRecord.title,
|
||||||
|
audiobookRecord.author,
|
||||||
|
audiobookRecord.narrator || undefined,
|
||||||
|
undefined // duration not available at request time
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
// Check if user already has an active request for this audiobook
|
// Check if user already has an active request for this audiobook
|
||||||
const existingRequest = await prisma.request.findFirst({
|
const existingRequest = await prisma.request.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { RMABLogger } from '../utils/logger';
|
|||||||
|
|
||||||
const logger = RMABLogger.create('Scheduler');
|
const logger = RMABLogger.create('Scheduler');
|
||||||
|
|
||||||
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves';
|
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves' | 'check_watched_lists';
|
||||||
|
|
||||||
export interface ScheduledJob {
|
export interface ScheduledJob {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -133,6 +133,13 @@ export class SchedulerService {
|
|||||||
enabled: true, // Enable by default
|
enabled: true, // Enable by default
|
||||||
payload: {},
|
payload: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Check Watched Lists',
|
||||||
|
type: 'check_watched_lists' as ScheduledJobType,
|
||||||
|
schedule: '0 0 * * *', // Daily at midnight (every 24 hours)
|
||||||
|
enabled: true, // Enable by default
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let created = 0;
|
let created = 0;
|
||||||
@@ -353,6 +360,9 @@ export class SchedulerService {
|
|||||||
case 'sync_goodreads_shelves':
|
case 'sync_goodreads_shelves':
|
||||||
bullJobId = await this.triggerSyncGoodreadsShelves(job);
|
bullJobId = await this.triggerSyncGoodreadsShelves(job);
|
||||||
break;
|
break;
|
||||||
|
case 'check_watched_lists':
|
||||||
|
bullJobId = await this.triggerCheckWatchedLists(job);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown job type: ${job.type}`);
|
throw new Error(`Unknown job type: ${job.type}`);
|
||||||
}
|
}
|
||||||
@@ -627,6 +637,13 @@ export class SchedulerService {
|
|||||||
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
|
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
|
||||||
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
|
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger watched lists check (watched series + watched authors)
|
||||||
|
*/
|
||||||
|
private async triggerCheckWatchedLists(job: any): Promise<string> {
|
||||||
|
return await this.jobQueue.addCheckWatchedListsJob(job.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
/**
|
||||||
|
* Component: Watched Lists Service
|
||||||
|
* Documentation: documentation/features/watched-lists.md
|
||||||
|
*
|
||||||
|
* Checks watched series and watched authors for new releases.
|
||||||
|
* Deduplicates results using the works table, checks against user's library,
|
||||||
|
* and auto-creates requests via the shared request-creator service.
|
||||||
|
* Follows the same pattern as goodreads-sync.service.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getAudibleService, AudibleAudiobook } from '@/lib/integrations/audible.service';
|
||||||
|
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
||||||
|
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
|
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||||
|
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||||
|
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||||
|
import { getSiblingAsins } from '@/lib/services/works.service';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('WatchedLists');
|
||||||
|
|
||||||
|
/** Max books to process per series (avoid excessively long runs) */
|
||||||
|
const MAX_BOOKS_PER_SERIES = 200;
|
||||||
|
|
||||||
|
/** Max author book pages to scrape */
|
||||||
|
const MAX_AUTHOR_PAGES = 4;
|
||||||
|
|
||||||
|
/** Delay between scrapes to avoid rate limiting (ms) */
|
||||||
|
const SCRAPE_DELAY_MS = 2000;
|
||||||
|
|
||||||
|
export interface WatchedListsSyncStats {
|
||||||
|
seriesChecked: number;
|
||||||
|
authorsChecked: number;
|
||||||
|
booksFound: number;
|
||||||
|
requestsCreated: number;
|
||||||
|
skippedOwned: number;
|
||||||
|
skippedExisting: number;
|
||||||
|
errors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchedListsSyncOptions {
|
||||||
|
/** Process only this specific user (for targeted sync) */
|
||||||
|
userId?: string;
|
||||||
|
/** Process only this specific series (for immediate sync on watch) */
|
||||||
|
seriesAsin?: string;
|
||||||
|
/** Process only this specific author (for immediate sync on watch) */
|
||||||
|
authorAsin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all watched series and authors: scrape for new releases,
|
||||||
|
* deduplicate, check library ownership, and create requests.
|
||||||
|
* Called from the check_watched_lists processor.
|
||||||
|
*/
|
||||||
|
export async function processWatchedLists(
|
||||||
|
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
|
||||||
|
options: WatchedListsSyncOptions = {}
|
||||||
|
): Promise<WatchedListsSyncStats> {
|
||||||
|
const log = jobLogger || logger;
|
||||||
|
const stats: WatchedListsSyncStats = {
|
||||||
|
seriesChecked: 0,
|
||||||
|
authorsChecked: 0,
|
||||||
|
booksFound: 0,
|
||||||
|
requestsCreated: 0,
|
||||||
|
skippedOwned: 0,
|
||||||
|
skippedExisting: 0,
|
||||||
|
errors: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Watched Series ----
|
||||||
|
await processAllWatchedSeries(log, stats, options);
|
||||||
|
|
||||||
|
// ---- Watched Authors ----
|
||||||
|
await processAllWatchedAuthors(log, stats, options);
|
||||||
|
|
||||||
|
log.info('Watched lists sync complete', {
|
||||||
|
seriesChecked: stats.seriesChecked,
|
||||||
|
authorsChecked: stats.authorsChecked,
|
||||||
|
booksFound: stats.booksFound,
|
||||||
|
requestsCreated: stats.requestsCreated,
|
||||||
|
skippedOwned: stats.skippedOwned,
|
||||||
|
skippedExisting: stats.skippedExisting,
|
||||||
|
errors: stats.errors,
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Watched Series
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function processAllWatchedSeries(
|
||||||
|
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||||
|
stats: WatchedListsSyncStats,
|
||||||
|
options: WatchedListsSyncOptions
|
||||||
|
): Promise<void> {
|
||||||
|
const whereClause: any = {};
|
||||||
|
if (options.userId) whereClause.userId = options.userId;
|
||||||
|
if (options.seriesAsin) whereClause.seriesAsin = options.seriesAsin;
|
||||||
|
const watchedSeries = await prisma.watchedSeries.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
include: { user: { select: { id: true, plexUsername: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (watchedSeries.length === 0) {
|
||||||
|
log.info('No watched series to process');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by seriesAsin to avoid re-scraping the same series for multiple users
|
||||||
|
const seriesByAsin = new Map<string, typeof watchedSeries>();
|
||||||
|
for (const ws of watchedSeries) {
|
||||||
|
const list = seriesByAsin.get(ws.seriesAsin) || [];
|
||||||
|
list.push(ws);
|
||||||
|
seriesByAsin.set(ws.seriesAsin, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Processing ${seriesByAsin.size} unique watched series (${watchedSeries.length} total subscriptions)`);
|
||||||
|
|
||||||
|
for (const [seriesAsin, subscriptions] of seriesByAsin) {
|
||||||
|
try {
|
||||||
|
await processSeriesForUsers(seriesAsin, subscriptions, log, stats);
|
||||||
|
} catch (error) {
|
||||||
|
stats.errors++;
|
||||||
|
log.error(`Failed to process watched series ${seriesAsin}`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit between series
|
||||||
|
await delay(SCRAPE_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processSeriesForUsers(
|
||||||
|
seriesAsin: string,
|
||||||
|
subscriptions: Array<{ id: string; seriesTitle: string; user: { id: string; plexUsername: string } }>,
|
||||||
|
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||||
|
stats: WatchedListsSyncStats
|
||||||
|
): Promise<void> {
|
||||||
|
const title = subscriptions[0].seriesTitle;
|
||||||
|
log.info(`Scraping watched series: "${title}" (${seriesAsin})`);
|
||||||
|
|
||||||
|
// Scrape all pages of the series (up to MAX_BOOKS_PER_SERIES)
|
||||||
|
const allBooks: AudibleAudiobook[] = [];
|
||||||
|
let page = 1;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore && allBooks.length < MAX_BOOKS_PER_SERIES) {
|
||||||
|
const result = await scrapeSeriesPage(seriesAsin, page);
|
||||||
|
if (!result || result.books.length === 0) break;
|
||||||
|
|
||||||
|
allBooks.push(...result.books);
|
||||||
|
hasMore = result.hasMore;
|
||||||
|
page++;
|
||||||
|
|
||||||
|
if (hasMore) await delay(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allBooks.length === 0) {
|
||||||
|
log.info(`No books found for series "${title}"`);
|
||||||
|
stats.seriesChecked++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.booksFound += allBooks.length;
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(allBooks);
|
||||||
|
|
||||||
|
// Persist dedup groups (fire-and-forget)
|
||||||
|
if (groups.length > 0) {
|
||||||
|
persistDedupGroups(groups).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each user watching this series, create requests for new books
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
await createRequestsForUser(
|
||||||
|
subscription.user.id,
|
||||||
|
subscription.user.plexUsername,
|
||||||
|
dedupedBooks,
|
||||||
|
log,
|
||||||
|
stats
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update lastCheckedAt
|
||||||
|
await prisma.watchedSeries.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: { lastCheckedAt: new Date() },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.seriesChecked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Watched Authors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function processAllWatchedAuthors(
|
||||||
|
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||||
|
stats: WatchedListsSyncStats,
|
||||||
|
options: WatchedListsSyncOptions
|
||||||
|
): Promise<void> {
|
||||||
|
const whereClause: any = {};
|
||||||
|
if (options.userId) whereClause.userId = options.userId;
|
||||||
|
if (options.authorAsin) whereClause.authorAsin = options.authorAsin;
|
||||||
|
const watchedAuthors = await prisma.watchedAuthor.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
include: { user: { select: { id: true, plexUsername: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (watchedAuthors.length === 0) {
|
||||||
|
log.info('No watched authors to process');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by authorAsin to avoid re-scraping the same author for multiple users
|
||||||
|
const authorsByAsin = new Map<string, typeof watchedAuthors>();
|
||||||
|
for (const wa of watchedAuthors) {
|
||||||
|
const list = authorsByAsin.get(wa.authorAsin) || [];
|
||||||
|
list.push(wa);
|
||||||
|
authorsByAsin.set(wa.authorAsin, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Processing ${authorsByAsin.size} unique watched authors (${watchedAuthors.length} total subscriptions)`);
|
||||||
|
|
||||||
|
for (const [authorAsin, subscriptions] of authorsByAsin) {
|
||||||
|
try {
|
||||||
|
await processAuthorForUsers(authorAsin, subscriptions, log, stats);
|
||||||
|
} catch (error) {
|
||||||
|
stats.errors++;
|
||||||
|
log.error(`Failed to process watched author ${authorAsin}`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit between authors
|
||||||
|
await delay(SCRAPE_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processAuthorForUsers(
|
||||||
|
authorAsin: string,
|
||||||
|
subscriptions: Array<{ id: string; authorName: string; user: { id: string; plexUsername: string } }>,
|
||||||
|
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||||
|
stats: WatchedListsSyncStats
|
||||||
|
): Promise<void> {
|
||||||
|
const authorName = subscriptions[0].authorName;
|
||||||
|
log.info(`Scraping watched author: "${authorName}" (${authorAsin})`);
|
||||||
|
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const allBooks: AudibleAudiobook[] = [];
|
||||||
|
let page = 1;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore && page <= MAX_AUTHOR_PAGES) {
|
||||||
|
try {
|
||||||
|
const result = await audibleService.searchByAuthorAsin(authorName, authorAsin, page);
|
||||||
|
if (result.books.length === 0) break;
|
||||||
|
|
||||||
|
allBooks.push(...result.books);
|
||||||
|
hasMore = result.hasMore;
|
||||||
|
page++;
|
||||||
|
|
||||||
|
if (hasMore) await delay(1000);
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Failed to scrape author page ${page} for "${authorName}"`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allBooks.length === 0) {
|
||||||
|
log.info(`No books found for author "${authorName}"`);
|
||||||
|
stats.authorsChecked++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.booksFound += allBooks.length;
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(allBooks);
|
||||||
|
|
||||||
|
// Persist dedup groups (fire-and-forget)
|
||||||
|
if (groups.length > 0) {
|
||||||
|
persistDedupGroups(groups).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each user watching this author, create requests for new books
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
await createRequestsForUser(
|
||||||
|
subscription.user.id,
|
||||||
|
subscription.user.plexUsername,
|
||||||
|
dedupedBooks,
|
||||||
|
log,
|
||||||
|
stats
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update lastCheckedAt
|
||||||
|
await prisma.watchedAuthor.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: { lastCheckedAt: new Date() },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.authorsChecked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared: Create requests for a user from a list of books
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function createRequestsForUser(
|
||||||
|
userId: string,
|
||||||
|
username: string,
|
||||||
|
books: AudibleAudiobook[],
|
||||||
|
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||||
|
stats: WatchedListsSyncStats
|
||||||
|
): Promise<void> {
|
||||||
|
// Filter to books that have an ASIN
|
||||||
|
const booksWithAsin = books.filter(b => b.asin);
|
||||||
|
if (booksWithAsin.length === 0) return;
|
||||||
|
|
||||||
|
// Batch check: which ASINs are already in library (direct + sibling expansion)
|
||||||
|
const ownedAsins = await getOwnedAsins(booksWithAsin.map(b => b.asin));
|
||||||
|
|
||||||
|
for (const book of booksWithAsin) {
|
||||||
|
// Skip if user already owns this (direct or via sibling ASIN)
|
||||||
|
if (ownedAsins.has(book.asin)) {
|
||||||
|
stats.skippedOwned++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createRequestForUser(userId, {
|
||||||
|
asin: book.asin,
|
||||||
|
title: book.title,
|
||||||
|
author: book.author,
|
||||||
|
narrator: book.narrator,
|
||||||
|
description: book.description,
|
||||||
|
coverArtUrl: book.coverArtUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
stats.requestsCreated++;
|
||||||
|
log.info(`Auto-requested "${book.title}" by ${book.author} for ${username}`);
|
||||||
|
} else {
|
||||||
|
// already_available, being_processed, duplicate — all expected
|
||||||
|
stats.skippedExisting++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Failed to create request for "${book.title}" for ${username}`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the set of ASINs that are already in the library (direct match + sibling expansion).
|
||||||
|
*/
|
||||||
|
async function getOwnedAsins(asins: string[]): Promise<Set<string>> {
|
||||||
|
const owned = new Set<string>();
|
||||||
|
|
||||||
|
// Direct library lookup
|
||||||
|
const libraryItems = await prisma.plexLibrary.findMany({
|
||||||
|
where: { asin: { in: asins } },
|
||||||
|
select: { asin: true },
|
||||||
|
});
|
||||||
|
for (const item of libraryItems) {
|
||||||
|
if (item.asin) owned.add(item.asin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sibling expansion via works table
|
||||||
|
try {
|
||||||
|
const siblingMap = await getSiblingAsins(asins);
|
||||||
|
if (siblingMap.size > 0) {
|
||||||
|
const allSiblings = new Set<string>();
|
||||||
|
for (const siblings of siblingMap.values()) {
|
||||||
|
for (const s of siblings) allSiblings.add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allSiblings.size > 0) {
|
||||||
|
const siblingLibrary = await prisma.plexLibrary.findMany({
|
||||||
|
where: { asin: { in: [...allSiblings] } },
|
||||||
|
select: { asin: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of siblingLibrary) {
|
||||||
|
if (item.asin) {
|
||||||
|
// Mark the original ASIN as owned (not the sibling)
|
||||||
|
for (const [originalAsin, siblings] of siblingMap) {
|
||||||
|
if (siblings.includes(item.asin)) {
|
||||||
|
owned.add(originalAsin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Works table expansion is best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
return owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Component: Works Service
|
||||||
|
* Documentation: documentation/integrations/audible.md
|
||||||
|
*
|
||||||
|
* Manages the works table — persistent cross-ASIN audiobook identity mapping.
|
||||||
|
* Layer 1: Auto-populated from dedup logic when users browse search/author/series pages.
|
||||||
|
* Layer 2: Seeded at request time to ensure requested ASINs are tracked.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('WorksService');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Layer 1: Persist dedup groups (fire-and-forget from API routes)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist dedup groups to the works table. For each group of 2+ ASINs that
|
||||||
|
* were identified as the same audiobook, create or update a Work record
|
||||||
|
* linking all ASINs together.
|
||||||
|
*
|
||||||
|
* Safe to call fire-and-forget — never throws.
|
||||||
|
*/
|
||||||
|
export async function persistDedupGroups(groups: DedupGroup[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
for (const group of groups) {
|
||||||
|
await persistSingleGroup(group);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to persist dedup groups', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
groupCount: groups.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a single dedup group. Handles merging when ASINs span multiple
|
||||||
|
* existing works.
|
||||||
|
*/
|
||||||
|
async function persistSingleGroup(group: DedupGroup): Promise<void> {
|
||||||
|
const { canonicalAsin, allAsins, title, author, narrator, durationMinutes } = group;
|
||||||
|
|
||||||
|
// Find which of these ASINs already exist in work_asins
|
||||||
|
const existingEntries = await prisma.workAsin.findMany({
|
||||||
|
where: { asin: { in: allAsins } },
|
||||||
|
select: { asin: true, workId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect unique work IDs that already contain any of our ASINs
|
||||||
|
const existingWorkIds = [...new Set(existingEntries.map(e => e.workId))];
|
||||||
|
const existingAsinSet = new Set(existingEntries.map(e => e.asin));
|
||||||
|
|
||||||
|
if (existingWorkIds.length === 0) {
|
||||||
|
// No existing works — create a new one with all ASINs
|
||||||
|
const work = await prisma.work.create({
|
||||||
|
data: { title, author },
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
allAsins.map(asin =>
|
||||||
|
prisma.workAsin.create({
|
||||||
|
data: {
|
||||||
|
workId: work.id,
|
||||||
|
asin,
|
||||||
|
narrator: asin === canonicalAsin ? narrator : undefined,
|
||||||
|
durationMinutes: asin === canonicalAsin ? durationMinutes : undefined,
|
||||||
|
isCanonical: asin === canonicalAsin,
|
||||||
|
source: 'dedup_auto',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug('Created new work', { workId: work.id, asinCount: allAsins.length });
|
||||||
|
} else {
|
||||||
|
// Use the first existing work as the target
|
||||||
|
const targetWorkId = existingWorkIds[0];
|
||||||
|
|
||||||
|
// If multiple existing works, merge them into the target
|
||||||
|
if (existingWorkIds.length > 1) {
|
||||||
|
const mergeWorkIds = existingWorkIds.slice(1);
|
||||||
|
|
||||||
|
// Move all ASINs from other works to the target
|
||||||
|
await prisma.workAsin.updateMany({
|
||||||
|
where: { workId: { in: mergeWorkIds } },
|
||||||
|
data: { workId: targetWorkId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete the now-empty works
|
||||||
|
await prisma.work.deleteMany({
|
||||||
|
where: { id: { in: mergeWorkIds } },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Merged works', {
|
||||||
|
targetWorkId,
|
||||||
|
mergedWorkIds: mergeWorkIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any new ASINs that don't already exist
|
||||||
|
const newAsins = allAsins.filter(a => !existingAsinSet.has(a));
|
||||||
|
if (newAsins.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
newAsins.map(asin =>
|
||||||
|
prisma.workAsin.create({
|
||||||
|
data: {
|
||||||
|
workId: targetWorkId,
|
||||||
|
asin,
|
||||||
|
narrator: asin === canonicalAsin ? narrator : undefined,
|
||||||
|
durationMinutes: asin === canonicalAsin ? durationMinutes : undefined,
|
||||||
|
isCanonical: asin === canonicalAsin,
|
||||||
|
source: 'dedup_auto',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug('Added ASINs to existing work', {
|
||||||
|
workId: targetWorkId,
|
||||||
|
newAsinCount: newAsins.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update canonical status: ensure the canonical ASIN is marked
|
||||||
|
await prisma.workAsin.updateMany({
|
||||||
|
where: { workId: targetWorkId, asin: canonicalAsin },
|
||||||
|
data: { isCanonical: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Layer 2: Seed ASIN at request time
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure an ASIN is tracked in the works table. Creates a single-ASIN work
|
||||||
|
* if the ASIN isn't already present. Called at request creation time.
|
||||||
|
*
|
||||||
|
* Safe to call fire-and-forget — never throws.
|
||||||
|
*/
|
||||||
|
export async function seedAsin(
|
||||||
|
asin: string,
|
||||||
|
title: string,
|
||||||
|
author: string,
|
||||||
|
narrator?: string,
|
||||||
|
durationMinutes?: number
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Check if ASIN already tracked
|
||||||
|
const existing = await prisma.workAsin.findUnique({
|
||||||
|
where: { asin },
|
||||||
|
});
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
// Create a new single-ASIN work
|
||||||
|
const work = await prisma.work.create({
|
||||||
|
data: { title, author },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.workAsin.create({
|
||||||
|
data: {
|
||||||
|
workId: work.id,
|
||||||
|
asin,
|
||||||
|
narrator,
|
||||||
|
durationMinutes,
|
||||||
|
isCanonical: true,
|
||||||
|
source: 'dedup_auto',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Seeded ASIN', { workId: work.id, asin });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to seed ASIN', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
asin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sibling ASIN lookup (for library matching expansion)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of ASINs, return a map of each input ASIN to its sibling ASINs
|
||||||
|
* (other ASINs in the same work, NOT including the input ASIN itself).
|
||||||
|
*
|
||||||
|
* ASINs not found in the works table are simply omitted from the result.
|
||||||
|
*/
|
||||||
|
export async function getSiblingAsins(
|
||||||
|
asins: string[]
|
||||||
|
): Promise<Map<string, string[]>> {
|
||||||
|
const result = new Map<string, string[]>();
|
||||||
|
if (asins.length === 0) return result;
|
||||||
|
|
||||||
|
// Step 1: Find which input ASINs are in work_asins and their work IDs
|
||||||
|
const inputEntries = await prisma.workAsin.findMany({
|
||||||
|
where: { asin: { in: asins } },
|
||||||
|
select: { asin: true, workId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inputEntries.length === 0) return result;
|
||||||
|
|
||||||
|
// Build map of workId -> input ASINs in that work
|
||||||
|
const workIdToInputAsins = new Map<string, string[]>();
|
||||||
|
for (const entry of inputEntries) {
|
||||||
|
const list = workIdToInputAsins.get(entry.workId);
|
||||||
|
if (list) {
|
||||||
|
list.push(entry.asin);
|
||||||
|
} else {
|
||||||
|
workIdToInputAsins.set(entry.workId, [entry.asin]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Get ALL ASINs in those works
|
||||||
|
const workIds = [...workIdToInputAsins.keys()];
|
||||||
|
const allWorkAsins = await prisma.workAsin.findMany({
|
||||||
|
where: { workId: { in: workIds } },
|
||||||
|
select: { asin: true, workId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build map of workId -> all ASINs
|
||||||
|
const workIdToAllAsins = new Map<string, string[]>();
|
||||||
|
for (const entry of allWorkAsins) {
|
||||||
|
const list = workIdToAllAsins.get(entry.workId);
|
||||||
|
if (list) {
|
||||||
|
list.push(entry.asin);
|
||||||
|
} else {
|
||||||
|
workIdToAllAsins.set(entry.workId, [entry.asin]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: For each input ASIN, compute siblings (all ASINs in same work minus self)
|
||||||
|
for (const entry of inputEntries) {
|
||||||
|
const allInWork = workIdToAllAsins.get(entry.workId) || [];
|
||||||
|
const siblings = allInWork.filter(a => a !== entry.asin);
|
||||||
|
if (siblings.length > 0) {
|
||||||
|
result.set(entry.asin, siblings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Component: API Token Type Definitions
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Base API token as returned by user-facing endpoints */
|
||||||
|
export interface ApiToken {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tokenPrefix: string;
|
||||||
|
role: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extended API token with cross-user fields, returned by admin endpoints */
|
||||||
|
export interface AdminApiToken extends ApiToken {
|
||||||
|
createdBy: string;
|
||||||
|
createdById: string;
|
||||||
|
tokenUser: string;
|
||||||
|
tokenUserId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Component: API Token Generation Utility
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { API_TOKEN_PREFIX, TOKEN_RANDOM_BYTES, TOKEN_PREFIX_LENGTH } from '../constants/api-tokens';
|
||||||
|
|
||||||
|
interface GeneratedToken {
|
||||||
|
/** The full token string to return to the user (shown only once) */
|
||||||
|
fullToken: string;
|
||||||
|
/** SHA-256 hash of the full token (stored in database) */
|
||||||
|
tokenHash: string;
|
||||||
|
/** Display prefix for identification (first 12 chars) */
|
||||||
|
tokenPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new API token with its hash and display prefix.
|
||||||
|
* The full token is: API_TOKEN_PREFIX + random hex string.
|
||||||
|
* Only the hash is stored; the full token is returned once at creation.
|
||||||
|
*/
|
||||||
|
export function generateApiToken(): GeneratedToken {
|
||||||
|
const randomPart = crypto.randomBytes(TOKEN_RANDOM_BYTES).toString('hex');
|
||||||
|
const fullToken = `${API_TOKEN_PREFIX}${randomPart}`;
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(fullToken).digest('hex');
|
||||||
|
const tokenPrefix = fullToken.substring(0, TOKEN_PREFIX_LENGTH);
|
||||||
|
|
||||||
|
return { fullToken, tokenHash, tokenPrefix };
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Component: API Token Rate Limiting
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*
|
||||||
|
* In-memory sliding-window rate limiter with lazy eviction and periodic sweep
|
||||||
|
* to prevent unbounded memory growth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RateLimitResult = {
|
||||||
|
allowed: boolean;
|
||||||
|
retryAfterSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
/** Number of checkRateLimit calls since the last full sweep */
|
||||||
|
let checkCount = 0;
|
||||||
|
|
||||||
|
/** How often (in calls) to perform a full sweep of expired buckets */
|
||||||
|
const SWEEP_INTERVAL = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep the entire bucket map and delete all expired entries.
|
||||||
|
* Called automatically every SWEEP_INTERVAL checks.
|
||||||
|
*/
|
||||||
|
function sweepExpiredBuckets(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, bucket] of buckets) {
|
||||||
|
if (now >= bucket.resetAt) {
|
||||||
|
buckets.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Periodic full sweep every SWEEP_INTERVAL calls
|
||||||
|
checkCount += 1;
|
||||||
|
if (checkCount >= SWEEP_INTERVAL) {
|
||||||
|
checkCount = 0;
|
||||||
|
sweepExpiredBuckets();
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = buckets.get(key);
|
||||||
|
|
||||||
|
// Lazy eviction: if the bucket is expired, delete it and start fresh
|
||||||
|
if (!current || now >= current.resetAt) {
|
||||||
|
if (current) {
|
||||||
|
buckets.delete(key);
|
||||||
|
}
|
||||||
|
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.count >= maxRequests) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
current.count += 1;
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
|
||||||
|
return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
|
||||||
|
return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset all buckets and the sweep counter. For testing only. */
|
||||||
|
export function _resetBuckets(): void {
|
||||||
|
buckets.clear();
|
||||||
|
checkCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current number of tracked buckets. For testing only. */
|
||||||
|
export function _getBucketCount(): number {
|
||||||
|
return buckets.size;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { LibraryItem } from '@/lib/services/library';
|
import { LibraryItem } from '@/lib/services/library';
|
||||||
|
import { getSiblingAsins } from '@/lib/services/works.service';
|
||||||
import { RMABLogger } from './logger';
|
import { RMABLogger } from './logger';
|
||||||
|
|
||||||
// Module-level logger
|
// Module-level logger
|
||||||
@@ -178,6 +179,61 @@ export async function enrichAudiobooksWithMatches(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Works-table sibling expansion: check if unmatched ASINs have siblings in the library
|
||||||
|
try {
|
||||||
|
const unmatchedAsins = results.filter(r => !r.isAvailable).map(r => r.asin);
|
||||||
|
if (unmatchedAsins.length > 0) {
|
||||||
|
const siblingMap = await getSiblingAsins(unmatchedAsins);
|
||||||
|
if (siblingMap.size > 0) {
|
||||||
|
// Collect all sibling ASINs for a single batch library query
|
||||||
|
const allSiblingAsins = new Set<string>();
|
||||||
|
for (const siblings of siblingMap.values()) {
|
||||||
|
for (const s of siblings) allSiblingAsins.add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allSiblingAsins.size > 0) {
|
||||||
|
const siblingLibraryMatches = await prisma.plexLibrary.findMany({
|
||||||
|
where: { asin: { in: [...allSiblingAsins] } },
|
||||||
|
select: { asin: true, plexGuid: true },
|
||||||
|
});
|
||||||
|
const libraryAsinSet = new Set(
|
||||||
|
siblingLibraryMatches.filter(m => m.asin).map(m => m.asin!.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update results where a sibling ASIN is found in the library
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.isAvailable) continue;
|
||||||
|
const siblings = siblingMap.get(result.asin);
|
||||||
|
if (!siblings) continue;
|
||||||
|
const matchedSiblingAsin = siblings.find(s => libraryAsinSet.has(s.toLowerCase()));
|
||||||
|
if (matchedSiblingAsin) {
|
||||||
|
const libMatch = siblingLibraryMatches.find(
|
||||||
|
m => m.asin?.toLowerCase() === matchedSiblingAsin.toLowerCase()
|
||||||
|
);
|
||||||
|
(result as any).isAvailable = true;
|
||||||
|
(result as any).plexGuid = libMatch?.plexGuid || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblingMatchCount = results.filter(r => {
|
||||||
|
if (!r.isAvailable) return false;
|
||||||
|
return siblingMap.has(r.asin);
|
||||||
|
}).length;
|
||||||
|
logger.debug('Sibling expansion', {
|
||||||
|
unmatchedCount: unmatchedAsins.length,
|
||||||
|
siblingGroupsFound: siblingMap.size,
|
||||||
|
siblingMatches: siblingMatchCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Works table expansion is best-effort — direct matches still work
|
||||||
|
logger.error('Sibling ASIN expansion failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Always enrich with request status (check ANY user's requests)
|
// Always enrich with request status (check ANY user's requests)
|
||||||
const asins = audiobooks.map(book => book.asin);
|
const asins = audiobooks.map(book => book.asin);
|
||||||
|
|
||||||
@@ -272,6 +328,57 @@ export async function enrichAudiobooksWithMatches(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all ASINs that are considered "available" — present in library or have completed requests.
|
||||||
|
* Used by paginated API routes to exclude available items at the DB level.
|
||||||
|
*/
|
||||||
|
export async function getAvailableAsins(): Promise<Set<string>> {
|
||||||
|
const [libraryItems, completedRequests] = await Promise.all([
|
||||||
|
// ASINs present in the library (Plex or Audiobookshelf)
|
||||||
|
prisma.plexLibrary.findMany({
|
||||||
|
where: { asin: { not: null } },
|
||||||
|
select: { asin: true },
|
||||||
|
distinct: ['asin'],
|
||||||
|
}),
|
||||||
|
// ASINs with completed audiobook requests
|
||||||
|
prisma.audiobook.findMany({
|
||||||
|
where: {
|
||||||
|
audibleAsin: { not: null },
|
||||||
|
requests: {
|
||||||
|
some: {
|
||||||
|
status: 'completed',
|
||||||
|
type: 'audiobook',
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { audibleAsin: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const asins = new Set<string>();
|
||||||
|
for (const item of libraryItems) {
|
||||||
|
if (item.asin) asins.add(item.asin);
|
||||||
|
}
|
||||||
|
for (const item of completedRequests) {
|
||||||
|
if (item.audibleAsin) asins.add(item.audibleAsin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand with works-table sibling ASINs
|
||||||
|
try {
|
||||||
|
if (asins.size > 0) {
|
||||||
|
const siblingMap = await getSiblingAsins([...asins]);
|
||||||
|
for (const siblings of siblingMap.values()) {
|
||||||
|
for (const s of siblings) asins.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Works table expansion is best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
return asins;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize ISBN for comparison (remove dashes and spaces)
|
* Normalize ISBN for comparison (remove dashes and spaces)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Component: Client-side URL Utilities
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current instance origin URL.
|
||||||
|
* Returns window.location.origin on the client, or a placeholder on the server.
|
||||||
|
*/
|
||||||
|
export function getInstanceUrl(): string {
|
||||||
|
return typeof window !== 'undefined' ? window.location.origin : 'https://your-instance';
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Component: Audiobook Deduplication Utility
|
||||||
|
* Documentation: documentation/integrations/audible.md
|
||||||
|
*
|
||||||
|
* Deduplicates audiobook listings that represent the same recording
|
||||||
|
* under different ASINs (publisher re-listings, rights transfers, etc.).
|
||||||
|
*
|
||||||
|
* Dedup key: normalized title + normalized narrator
|
||||||
|
* Duration tolerance: max(longerDuration * 0.01, 5) minutes
|
||||||
|
* Missing duration treated as compatible (graceful degradation).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AudibleAudiobook } from '../integrations/audible.service';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Title / narrator normalization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Patterns in parentheses or brackets to strip (edition markers, format labels) */
|
||||||
|
const EDITION_PAREN_RE = /[([][^)\]]*?(?:unabridged|abridged|edition|remaster(?:ed)?|anniversary|complete|original|version|narrat(?:ed|or)?|audio(?:book)?|full cast|dramatiz(?:ed|ation))[^)\]]*[)\]]/gi;
|
||||||
|
|
||||||
|
/** Trailing subtitle after colon or long dash */
|
||||||
|
const SUBTITLE_RE = /\s*[:]\s+.+$/;
|
||||||
|
const LONG_DASH_SUBTITLE_RE = /\s+[-\u2013\u2014]\s+.+$/;
|
||||||
|
|
||||||
|
/** Trailing descriptors like "A Novel", "A Memoir" */
|
||||||
|
const TRAILING_DESCRIPTOR_RE = /\s*[-:,]?\s+a\s+(novel|memoir|thriller|mystery|romance|story|tale|novella)\s*$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a title for dedup comparison.
|
||||||
|
* Strips subtitles, edition markers, and trailing descriptors.
|
||||||
|
*/
|
||||||
|
export function normalizeTitle(title: string): string {
|
||||||
|
let t = title.toLowerCase();
|
||||||
|
// Remove parenthesized/bracketed edition markers
|
||||||
|
t = t.replace(EDITION_PAREN_RE, '');
|
||||||
|
// Remove trailing descriptors before subtitle stripping
|
||||||
|
t = t.replace(TRAILING_DESCRIPTOR_RE, '');
|
||||||
|
// Remove subtitle after colon
|
||||||
|
t = t.replace(SUBTITLE_RE, '');
|
||||||
|
// Remove subtitle after long dash (but not short hyphenated words)
|
||||||
|
t = t.replace(LONG_DASH_SUBTITLE_RE, '');
|
||||||
|
// Collapse whitespace and trim
|
||||||
|
return t.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize narrator for comparison. Sorts individual names so order doesn't matter. */
|
||||||
|
function normalizeNarrator(narrator?: string): string {
|
||||||
|
const raw = (narrator || '').toLowerCase().trim();
|
||||||
|
if (!raw) return raw;
|
||||||
|
return raw.split(',').map(n => n.trim()).filter(Boolean).sort().join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Duration compatibility
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two durations are compatible (represent the same recording).
|
||||||
|
* Tolerance: max(longerDuration * 0.01, 5) minutes.
|
||||||
|
* Missing duration on either side is treated as compatible.
|
||||||
|
*/
|
||||||
|
export function areDurationsCompatible(a?: number, b?: number): boolean {
|
||||||
|
if (a == null || b == null) return true;
|
||||||
|
const longer = Math.max(a, b);
|
||||||
|
const tolerance = Math.max(longer * 0.01, 5);
|
||||||
|
return Math.abs(a - b) <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Metadata scoring (for picking best representative)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function metadataScore(book: AudibleAudiobook): number {
|
||||||
|
let score = 0;
|
||||||
|
if (book.coverArtUrl) score++;
|
||||||
|
if (book.rating != null) score++;
|
||||||
|
if (book.durationMinutes != null) score++;
|
||||||
|
if (book.description) score++;
|
||||||
|
if (book.narrator) score++;
|
||||||
|
if (book.releaseDate) score++;
|
||||||
|
if (book.genres && book.genres.length > 0) score++;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dedup group types (for works-table persistence)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Metadata about a group of ASINs that were collapsed during dedup. */
|
||||||
|
export interface DedupGroup {
|
||||||
|
canonicalAsin: string; // ASIN of the "winner" (best metadata score)
|
||||||
|
allAsins: string[]; // All ASINs in this group (including canonical)
|
||||||
|
title: string; // Author from the canonical entry
|
||||||
|
author: string; // Author from the canonical entry
|
||||||
|
narrator?: string; // Narrator from the canonical entry
|
||||||
|
durationMinutes?: number; // Duration from the canonical entry
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of deduplication with group collection. */
|
||||||
|
export interface DeduplicateResult {
|
||||||
|
books: AudibleAudiobook[]; // The deduped list (same as deduplicateAudiobooks returns)
|
||||||
|
groups: DedupGroup[]; // Groups where 2+ ASINs were collapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main dedup functions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate audiobook listings by normalized title + narrator + duration.
|
||||||
|
*
|
||||||
|
* Same narrator + compatible duration + similar title = same recording -> collapse.
|
||||||
|
* Different narrator = different production -> keep both.
|
||||||
|
* Duration outside tolerance = different content (abridged vs unabridged) -> keep both.
|
||||||
|
*
|
||||||
|
* Preserves original ordering (position of first appearance).
|
||||||
|
*/
|
||||||
|
export function deduplicateAudiobooks(books: AudibleAudiobook[]): AudibleAudiobook[] {
|
||||||
|
return deduplicateAndCollectGroups(books).books;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate audiobooks AND return grouping metadata for works-table persistence.
|
||||||
|
* Returns both the deduped list and the groups where 2+ ASINs were collapsed.
|
||||||
|
*/
|
||||||
|
export function deduplicateAndCollectGroups(books: AudibleAudiobook[]): DeduplicateResult {
|
||||||
|
if (books.length <= 1) return { books: [...books], groups: [] };
|
||||||
|
|
||||||
|
// Group by normalized title + narrator
|
||||||
|
const titleNarratorGroups = new Map<string, AudibleAudiobook[]>();
|
||||||
|
const insertionOrder: string[] = [];
|
||||||
|
|
||||||
|
for (const book of books) {
|
||||||
|
const key = `${normalizeTitle(book.title)}|||${normalizeNarrator(book.narrator)}`;
|
||||||
|
const group = titleNarratorGroups.get(key);
|
||||||
|
if (group) {
|
||||||
|
group.push(book);
|
||||||
|
} else {
|
||||||
|
titleNarratorGroups.set(key, [book]);
|
||||||
|
insertionOrder.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: AudibleAudiobook[] = [];
|
||||||
|
const dedupGroups: DedupGroup[] = [];
|
||||||
|
|
||||||
|
for (const key of insertionOrder) {
|
||||||
|
const group = titleNarratorGroups.get(key)!;
|
||||||
|
if (group.length === 1) {
|
||||||
|
result.push(group[0]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within a title+narrator group, further split by duration compatibility.
|
||||||
|
// Build sub-groups where all members are duration-compatible with the
|
||||||
|
// representative (first member). A book joins the first compatible sub-group.
|
||||||
|
const subGroups: AudibleAudiobook[][] = [];
|
||||||
|
|
||||||
|
for (const book of group) {
|
||||||
|
let placed = false;
|
||||||
|
for (const sg of subGroups) {
|
||||||
|
// Check compatibility against the representative (first member)
|
||||||
|
if (areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes)) {
|
||||||
|
sg.push(book);
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) {
|
||||||
|
subGroups.push([book]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From each sub-group, pick the best representative and collect group metadata
|
||||||
|
for (const sg of subGroups) {
|
||||||
|
let best = sg[0];
|
||||||
|
let bestScore = metadataScore(best);
|
||||||
|
for (let i = 1; i < sg.length; i++) {
|
||||||
|
const score = metadataScore(sg[i]);
|
||||||
|
if (score > bestScore) {
|
||||||
|
best = sg[i];
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(best);
|
||||||
|
|
||||||
|
// Collect group metadata for works-table persistence (only multi-ASIN groups)
|
||||||
|
if (sg.length >= 2) {
|
||||||
|
dedupGroups.push({
|
||||||
|
canonicalAsin: best.asin,
|
||||||
|
allAsins: sg.map(b => b.asin),
|
||||||
|
title: best.title,
|
||||||
|
author: best.author,
|
||||||
|
narrator: best.narrator,
|
||||||
|
durationMinutes: best.durationMinutes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { books: result, groups: dedupGroups };
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Component: Runtime Parsing Utility
|
||||||
|
* Documentation: documentation/integrations/audible.md
|
||||||
|
*
|
||||||
|
* Shared runtime/duration text parser extracted from AudibleService.
|
||||||
|
* Handles all i18n patterns (English, German, Spanish, French) via
|
||||||
|
* language-specific regex patterns in LanguageConfig.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LanguageConfig } from '../constants/language-config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse runtime text (e.g. "12 hrs and 30 mins", "5 Std. 20 Min.")
|
||||||
|
* into total minutes using language-specific patterns.
|
||||||
|
*
|
||||||
|
* @param runtimeText - Raw runtime string from Audible HTML
|
||||||
|
* @param langConfig - Language configuration with hour/minute regex patterns
|
||||||
|
* @returns Total minutes, or undefined if no duration could be parsed
|
||||||
|
*/
|
||||||
|
export function parseRuntime(runtimeText: string, langConfig: LanguageConfig): number | undefined {
|
||||||
|
if (!runtimeText) return undefined;
|
||||||
|
|
||||||
|
let totalMinutes = 0;
|
||||||
|
|
||||||
|
// Try each hour pattern until one matches
|
||||||
|
for (const pattern of langConfig.scraping.runtimeHourPatterns) {
|
||||||
|
const match = runtimeText.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
totalMinutes += parseInt(match[1]) * 60;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try each minute pattern until one matches
|
||||||
|
for (const pattern of langConfig.scraping.runtimeMinutePatterns) {
|
||||||
|
const match = runtimeText.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
totalMinutes += parseInt(match[1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalMinutes > 0 ? totalMinutes : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin API Tokens Route Tests
|
||||||
|
* Documentation: documentation/testing.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
|
||||||
|
// Valid UUIDs for testing
|
||||||
|
const ADMIN_ID = '11111111-1111-1111-1111-111111111111';
|
||||||
|
const USER_ID = '22222222-2222-2222-2222-222222222222';
|
||||||
|
const ADMIN2_ID = '33333333-3333-3333-3333-333333333333';
|
||||||
|
const NONEXISTENT_ID = '99999999-9999-9999-9999-999999999999';
|
||||||
|
|
||||||
|
let authRequest: any;
|
||||||
|
|
||||||
|
const prismaMock = createPrismaMock();
|
||||||
|
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||||
|
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||||
|
const checkApiTokenCreateRateLimitMock = vi.hoisted(() => vi.fn());
|
||||||
|
const generateApiTokenMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
prisma: prismaMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/middleware/auth', () => ({
|
||||||
|
requireAuth: requireAuthMock,
|
||||||
|
requireAdmin: requireAdminMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/apiTokenRateLimit', () => ({
|
||||||
|
checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/api-token', () => ({
|
||||||
|
generateApiToken: generateApiTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Admin API tokens routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
authRequest = {
|
||||||
|
user: { id: ADMIN_ID, username: 'admin', role: 'admin' },
|
||||||
|
json: vi.fn(),
|
||||||
|
};
|
||||||
|
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||||
|
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||||
|
checkApiTokenCreateRateLimitMock.mockReturnValue({ allowed: true });
|
||||||
|
generateApiTokenMock.mockReturnValue({
|
||||||
|
fullToken: 'rmab_test_full_token',
|
||||||
|
tokenHash: 'hashed_token',
|
||||||
|
tokenPrefix: 'rmab_test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/admin/api-tokens', () => {
|
||||||
|
it('creates token for self with own role when no userId specified', async () => {
|
||||||
|
authRequest.json.mockResolvedValueOnce({ name: 'Test Token' });
|
||||||
|
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
id: ADMIN_ID,
|
||||||
|
role: 'admin',
|
||||||
|
plexUsername: 'admin',
|
||||||
|
});
|
||||||
|
prismaMock.apiToken.count.mockResolvedValueOnce(0);
|
||||||
|
prismaMock.apiToken.create.mockResolvedValueOnce({
|
||||||
|
id: 'token-1',
|
||||||
|
name: 'Test Token',
|
||||||
|
tokenPrefix: 'rmab_test',
|
||||||
|
role: 'admin',
|
||||||
|
expiresAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||||
|
const response = await POST({} as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(payload.token.role).toBe('admin');
|
||||||
|
expect(payload.fullToken).toBe('rmab_test_full_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates token for another user with their role', async () => {
|
||||||
|
authRequest.json.mockResolvedValueOnce({
|
||||||
|
name: 'Token for User',
|
||||||
|
userId: USER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
id: USER_ID,
|
||||||
|
role: 'user',
|
||||||
|
plexUsername: 'regularuser',
|
||||||
|
});
|
||||||
|
prismaMock.apiToken.count.mockResolvedValueOnce(0);
|
||||||
|
prismaMock.apiToken.create.mockResolvedValueOnce({
|
||||||
|
id: 'token-2',
|
||||||
|
name: 'Token for User',
|
||||||
|
tokenPrefix: 'rmab_test',
|
||||||
|
role: 'user',
|
||||||
|
expiresAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||||
|
const response = await POST({} as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(payload.token.role).toBe('user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects role override when role differs from target user role', async () => {
|
||||||
|
authRequest.json.mockResolvedValueOnce({
|
||||||
|
name: 'Escalation Attempt',
|
||||||
|
userId: USER_ID,
|
||||||
|
role: 'admin', // Trying to give admin role to a regular user
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
id: USER_ID,
|
||||||
|
role: 'user', // Target user is actually a regular user
|
||||||
|
plexUsername: 'regularuser',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||||
|
const response = await POST({} as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(payload.error).toContain("must match target user's role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects role downgrade when role differs from target user role', async () => {
|
||||||
|
authRequest.json.mockResolvedValueOnce({
|
||||||
|
name: 'Downgrade Attempt',
|
||||||
|
userId: ADMIN2_ID,
|
||||||
|
role: 'user', // Trying to give user role to an admin
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
id: ADMIN2_ID,
|
||||||
|
role: 'admin', // Target user is actually an admin
|
||||||
|
plexUsername: 'otheradmin',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||||
|
const response = await POST({} as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(payload.error).toContain("must match target user's role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts role when it matches target user role', async () => {
|
||||||
|
authRequest.json.mockResolvedValueOnce({
|
||||||
|
name: 'Matching Role',
|
||||||
|
userId: USER_ID,
|
||||||
|
role: 'user', // Explicitly specifying role that matches
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
id: USER_ID,
|
||||||
|
role: 'user',
|
||||||
|
plexUsername: 'regularuser',
|
||||||
|
});
|
||||||
|
prismaMock.apiToken.count.mockResolvedValueOnce(0);
|
||||||
|
prismaMock.apiToken.create.mockResolvedValueOnce({
|
||||||
|
id: 'token-3',
|
||||||
|
name: 'Matching Role',
|
||||||
|
tokenPrefix: 'rmab_test',
|
||||||
|
role: 'user',
|
||||||
|
expiresAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||||
|
const response = await POST({} as any);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when target user does not exist', async () => {
|
||||||
|
authRequest.json.mockResolvedValueOnce({
|
||||||
|
name: 'Token for Ghost',
|
||||||
|
userId: NONEXISTENT_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||||
|
const response = await POST({} as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(payload.error).toBe('Target user not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 429 when rate limited', async () => {
|
||||||
|
checkApiTokenCreateRateLimitMock.mockReturnValueOnce({
|
||||||
|
allowed: false,
|
||||||
|
retryAfterSeconds: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
authRequest.json.mockResolvedValueOnce({ name: 'Rate Limited Token' });
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/admin/api-tokens/route');
|
||||||
|
const response = await POST({} as any);
|
||||||
|
|
||||||
|
expect(response.status).toBe(429);
|
||||||
|
expect(response.headers.get('Retry-After')).toBe('60');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,17 +47,22 @@ vi.mock('@/components/ui/CardSizeControls', () => ({
|
|||||||
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
|
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/components/ui/StickyPagination', () => ({
|
vi.mock('@/components/ui/UnifiedPagination', () => ({
|
||||||
StickyPagination: ({
|
UnifiedPagination: ({
|
||||||
label,
|
sections,
|
||||||
onPageChange,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
sections: Array<{
|
||||||
onPageChange: (page: number) => void;
|
label: string;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}>;
|
||||||
}) => (
|
}) => (
|
||||||
<button type="button" onClick={() => onPageChange(2)}>
|
<div>
|
||||||
{label} next
|
{sections.map((s) => (
|
||||||
</button>
|
<button key={s.label} type="button" onClick={() => s.onPageChange(2)}>
|
||||||
|
{s.label} next
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -113,7 +118,7 @@ describe('HomePage', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2);
|
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2, undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Component: Unified 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 { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination';
|
||||||
|
|
||||||
|
type ObserverEntry = {
|
||||||
|
isIntersecting: boolean;
|
||||||
|
intersectionRatio: number;
|
||||||
|
target: Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeSections(
|
||||||
|
overrides?: Partial<PaginationSection>[]
|
||||||
|
): [PaginationSection, PaginationSection] {
|
||||||
|
const defaults: [PaginationSection, PaginationSection] = [
|
||||||
|
{
|
||||||
|
label: 'Popular',
|
||||||
|
accentColor: 'bg-blue-500',
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 3,
|
||||||
|
onPageChange: vi.fn(),
|
||||||
|
sectionRef: { current: document.createElement('section') },
|
||||||
|
onScrollToSection: vi.fn(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'New Releases',
|
||||||
|
accentColor: 'bg-emerald-500',
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 2,
|
||||||
|
onPageChange: vi.fn(),
|
||||||
|
sectionRef: { current: document.createElement('section') },
|
||||||
|
onScrollToSection: vi.fn(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (overrides) {
|
||||||
|
overrides.forEach((o, i) => {
|
||||||
|
if (o) Object.assign(defaults[i], o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UnifiedPagination', () => {
|
||||||
|
const observers: { callback: IntersectionObserverCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }[] = [];
|
||||||
|
|
||||||
|
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('renders nothing when both sections have only one page', () => {
|
||||||
|
const sections = makeSections([{ totalPages: 1 }, { totalPages: 1 }]);
|
||||||
|
const { container } = render(<UnifiedPagination sections={sections} />);
|
||||||
|
// The pill should be hidden (pointer-events-none, opacity-0)
|
||||||
|
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||||
|
expect(root).toHaveClass('pointer-events-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows pagination when the dominant section is visible and has pages', () => {
|
||||||
|
const sections = makeSections();
|
||||||
|
const { container } = render(<UnifiedPagination sections={sections} />);
|
||||||
|
|
||||||
|
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||||
|
expect(root).toHaveClass('opacity-0');
|
||||||
|
|
||||||
|
// Simulate first section becoming visible with high ratio
|
||||||
|
act(() => {
|
||||||
|
observers[0].callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 0.5,
|
||||||
|
target: sections[0].sectionRef.current as Element,
|
||||||
|
} as ObserverEntry,
|
||||||
|
],
|
||||||
|
observers[0] as unknown as IntersectionObserver
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(root).toHaveClass('opacity-100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides when footer becomes visible', () => {
|
||||||
|
const sections = makeSections();
|
||||||
|
const footerRef = { current: document.createElement('footer') };
|
||||||
|
const { container } = render(
|
||||||
|
<UnifiedPagination sections={sections} footerRef={footerRef} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||||
|
|
||||||
|
// Make section visible
|
||||||
|
act(() => {
|
||||||
|
observers[0].callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 0.5,
|
||||||
|
target: sections[0].sectionRef.current as Element,
|
||||||
|
} as ObserverEntry,
|
||||||
|
],
|
||||||
|
observers[0] as unknown as IntersectionObserver
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(root).toHaveClass('opacity-100');
|
||||||
|
|
||||||
|
// Footer observer is the 3rd (index 2): section0, section1, footer
|
||||||
|
act(() => {
|
||||||
|
observers[2].callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 0.1,
|
||||||
|
target: footerRef.current as Element,
|
||||||
|
} as ObserverEntry,
|
||||||
|
],
|
||||||
|
observers[2] as unknown as IntersectionObserver
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(root).toHaveClass('opacity-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onPageChange for prev/next buttons', () => {
|
||||||
|
const sections = makeSections([{ currentPage: 2, totalPages: 4 }]);
|
||||||
|
const { container } = render(<UnifiedPagination sections={sections} />);
|
||||||
|
|
||||||
|
// Make section visible so controls render interactably
|
||||||
|
act(() => {
|
||||||
|
observers[0].callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 0.5,
|
||||||
|
target: sections[0].sectionRef.current as Element,
|
||||||
|
} as ObserverEntry,
|
||||||
|
],
|
||||||
|
observers[0] as unknown as IntersectionObserver
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('Next page'));
|
||||||
|
expect(sections[0].onPageChange).toHaveBeenCalledWith(3);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('Previous page'));
|
||||||
|
expect(sections[0].onPageChange).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles page jump input', () => {
|
||||||
|
const sections = makeSections([{ currentPage: 2, totalPages: 5 }]);
|
||||||
|
render(<UnifiedPagination sections={sections} />);
|
||||||
|
|
||||||
|
// Make section visible
|
||||||
|
act(() => {
|
||||||
|
observers[0].callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 0.5,
|
||||||
|
target: sections[0].sectionRef.current as Element,
|
||||||
|
} as ObserverEntry,
|
||||||
|
],
|
||||||
|
observers[0] as unknown as IntersectionObserver
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Jump to page') as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: '4' } });
|
||||||
|
fireEvent.blur(input);
|
||||||
|
expect(sections[0].onPageChange).toHaveBeenCalledWith(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses pointer-events-none when hidden', () => {
|
||||||
|
const sections = makeSections();
|
||||||
|
const { container } = render(<UnifiedPagination sections={sections} />);
|
||||||
|
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||||
|
expect(root).toHaveClass('pointer-events-none');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,6 +47,11 @@ export const createPrismaMock = () => ({
|
|||||||
bookDateSwipe: createModelMock(),
|
bookDateSwipe: createModelMock(),
|
||||||
goodreadsShelf: createModelMock(),
|
goodreadsShelf: createModelMock(),
|
||||||
goodreadsBookMapping: createModelMock(),
|
goodreadsBookMapping: createModelMock(),
|
||||||
|
apiToken: createModelMock(),
|
||||||
|
work: createModelMock(),
|
||||||
|
workAsin: createModelMock(),
|
||||||
|
watchedSeries: createModelMock(),
|
||||||
|
watchedAuthor: createModelMock(),
|
||||||
$queryRaw: vi.fn(),
|
$queryRaw: vi.fn(),
|
||||||
$disconnect: vi.fn(),
|
$disconnect: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
* Documentation: documentation/backend/services/auth.md
|
* Documentation: documentation/backend/services/auth.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createPrismaMock } from '../helpers/prisma';
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
const prismaMock = createPrismaMock();
|
const prismaMock = createPrismaMock();
|
||||||
const verifyAccessTokenMock = vi.fn();
|
const verifyAccessTokenMock = vi.fn();
|
||||||
@@ -18,7 +20,9 @@ vi.mock('@/lib/utils/jwt', () => ({
|
|||||||
verifyAccessToken: verifyAccessTokenMock,
|
verifyAccessToken: verifyAccessTokenMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const makeRequest = (authHeader?: string) => ({
|
const makeRequest = (authHeader?: string, pathname = '/api/requests', method = 'GET') => ({
|
||||||
|
method,
|
||||||
|
nextUrl: { pathname },
|
||||||
headers: {
|
headers: {
|
||||||
get: (key: string) => {
|
get: (key: string) => {
|
||||||
if (key.toLowerCase() === 'authorization') {
|
if (key.toLowerCase() === 'authorization') {
|
||||||
@@ -29,6 +33,11 @@ const makeRequest = (authHeader?: string) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper to create a valid API token hash for testing
|
||||||
|
const createTestApiToken = (token: string) => {
|
||||||
|
return crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
};
|
||||||
|
|
||||||
describe('auth middleware', () => {
|
describe('auth middleware', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -159,6 +168,189 @@ describe('auth middleware', () => {
|
|||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects JWT tokens for soft-deleted users', async () => {
|
||||||
|
verifyAccessTokenMock.mockReturnValue({
|
||||||
|
sub: 'user-1',
|
||||||
|
plexId: 'plex-1',
|
||||||
|
username: 'user',
|
||||||
|
role: 'user',
|
||||||
|
iat: 1,
|
||||||
|
exp: 2,
|
||||||
|
});
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: 'user-1',
|
||||||
|
deletedAt: new Date(),
|
||||||
|
});
|
||||||
|
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||||
|
|
||||||
|
const response = await requireAuth(makeRequest('Bearer token') as any, vi.fn());
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(payload.message).toMatch(/user not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API token authentication', () => {
|
||||||
|
const testToken = 'rmab_test1234567890abcdef';
|
||||||
|
const testTokenHash = createTestApiToken(testToken);
|
||||||
|
|
||||||
|
it('rejects API tokens for soft-deleted users', async () => {
|
||||||
|
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||||
|
id: 'token-1',
|
||||||
|
tokenHash: testTokenHash,
|
||||||
|
role: 'user',
|
||||||
|
expiresAt: null,
|
||||||
|
tokenUser: {
|
||||||
|
id: 'user-1',
|
||||||
|
plexUsername: 'deleteduser',
|
||||||
|
role: 'user',
|
||||||
|
deletedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||||
|
|
||||||
|
const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn());
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(payload.message).toMatch(/invalid.*expired/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects API tokens for missing users', async () => {
|
||||||
|
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||||
|
id: 'token-1',
|
||||||
|
tokenHash: testTokenHash,
|
||||||
|
role: 'user',
|
||||||
|
expiresAt: null,
|
||||||
|
tokenUser: null,
|
||||||
|
});
|
||||||
|
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||||
|
|
||||||
|
const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn());
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(payload.message).toMatch(/invalid.*expired/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid API tokens for active users on allowed endpoints', async () => {
|
||||||
|
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||||
|
id: 'token-1',
|
||||||
|
tokenHash: testTokenHash,
|
||||||
|
role: 'user',
|
||||||
|
expiresAt: null,
|
||||||
|
tokenUser: {
|
||||||
|
id: 'user-1',
|
||||||
|
plexUsername: 'activeuser',
|
||||||
|
role: 'user',
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
prismaMock.apiToken.update.mockResolvedValue({});
|
||||||
|
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||||
|
|
||||||
|
const handler = vi.fn(async (req: any) =>
|
||||||
|
NextResponse.json({ ok: true, userId: req.user?.id })
|
||||||
|
);
|
||||||
|
const response = await requireAuth(
|
||||||
|
makeRequest(`Bearer ${testToken}`, '/api/requests', 'GET') as any,
|
||||||
|
handler
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalled();
|
||||||
|
expect(payload.userId).toBe('user-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks API tokens on endpoints not in the allowlist', async () => {
|
||||||
|
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||||
|
id: 'token-1',
|
||||||
|
tokenHash: testTokenHash,
|
||||||
|
role: 'admin',
|
||||||
|
expiresAt: null,
|
||||||
|
tokenUser: {
|
||||||
|
id: 'user-1',
|
||||||
|
plexUsername: 'activeuser',
|
||||||
|
role: 'admin',
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
prismaMock.apiToken.update.mockResolvedValue({});
|
||||||
|
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
const response = await requireAuth(
|
||||||
|
makeRequest(`Bearer ${testToken}`, '/api/admin/settings', 'GET') as any,
|
||||||
|
handler
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(payload.message).toMatch(/not available via API token/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows API tokens on all 5 permitted endpoints', async () => {
|
||||||
|
const allowedPaths = [
|
||||||
|
'/api/auth/me',
|
||||||
|
'/api/requests',
|
||||||
|
'/api/admin/metrics',
|
||||||
|
'/api/admin/downloads/active',
|
||||||
|
'/api/admin/requests/recent',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of allowedPaths) {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||||
|
id: 'token-1',
|
||||||
|
tokenHash: testTokenHash,
|
||||||
|
role: 'admin',
|
||||||
|
expiresAt: null,
|
||||||
|
tokenUser: {
|
||||||
|
id: 'user-1',
|
||||||
|
plexUsername: 'activeuser',
|
||||||
|
role: 'admin',
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
prismaMock.apiToken.update.mockResolvedValue({});
|
||||||
|
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||||
|
|
||||||
|
const handler = vi.fn(async () => NextResponse.json({ ok: true }));
|
||||||
|
const response = await requireAuth(
|
||||||
|
makeRequest(`Bearer ${testToken}`, path, 'GET') as any,
|
||||||
|
handler
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalled();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not restrict JWT-authenticated users to the allowlist', async () => {
|
||||||
|
verifyAccessTokenMock.mockReturnValue({
|
||||||
|
sub: 'user-1',
|
||||||
|
plexId: 'plex-1',
|
||||||
|
username: 'user',
|
||||||
|
role: 'admin',
|
||||||
|
iat: 1,
|
||||||
|
exp: 2,
|
||||||
|
});
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1' });
|
||||||
|
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||||
|
|
||||||
|
const handler = vi.fn(async () => NextResponse.json({ ok: true }));
|
||||||
|
// Use a non-allowlisted endpoint — JWT should still work
|
||||||
|
const response = await requireAuth(
|
||||||
|
makeRequest('Bearer jwttoken', '/api/admin/settings', 'POST') as any,
|
||||||
|
handler
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalled();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('returns current user from token', async () => {
|
it('returns current user from token', async () => {
|
||||||
verifyAccessTokenMock.mockReturnValue({
|
verifyAccessTokenMock.mockReturnValue({
|
||||||
sub: 'user-1',
|
sub: 'user-1',
|
||||||
|
|||||||
@@ -167,6 +167,31 @@ describe('LocalAuthProvider', () => {
|
|||||||
expect(result.error).toMatch(/invalid username or password/i);
|
expect(result.error).toMatch(/invalid username or password/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes username to lowercase on login', async () => {
|
||||||
|
prismaMock.user.findFirst.mockResolvedValue({
|
||||||
|
id: 'user-ci',
|
||||||
|
plexId: 'local-admin',
|
||||||
|
plexUsername: 'admin',
|
||||||
|
role: 'admin',
|
||||||
|
authProvider: 'local',
|
||||||
|
authToken: 'enc:hash',
|
||||||
|
registrationStatus: 'approved',
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
prismaMock.user.update.mockResolvedValue({});
|
||||||
|
bcryptCompare.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||||
|
const provider = new LocalAuthProvider();
|
||||||
|
await provider.handleCallback({ username: 'Admin', password: 'pass' });
|
||||||
|
|
||||||
|
expect(prismaMock.user.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ plexUsername: 'admin' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('blocks registration when disabled', async () => {
|
it('blocks registration when disabled', async () => {
|
||||||
configMock.get.mockResolvedValueOnce('false');
|
configMock.get.mockResolvedValueOnce('false');
|
||||||
|
|
||||||
@@ -237,6 +262,51 @@ describe('LocalAuthProvider', () => {
|
|||||||
expect(result.error).toContain('Username already taken');
|
expect(result.error).toContain('Username already taken');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores lowercase username and plexId on registration', async () => {
|
||||||
|
configMock.get.mockResolvedValueOnce('true'); // registration enabled
|
||||||
|
configMock.get.mockResolvedValueOnce('false'); // no admin approval
|
||||||
|
prismaMock.user.findFirst.mockResolvedValue(null);
|
||||||
|
prismaMock.user.count.mockResolvedValue(1);
|
||||||
|
prismaMock.user.create.mockResolvedValue({
|
||||||
|
id: 'user-ci2',
|
||||||
|
plexId: 'local-myuser',
|
||||||
|
plexUsername: 'myuser',
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
bcryptHash.mockResolvedValue('hash');
|
||||||
|
|
||||||
|
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||||
|
const provider = new LocalAuthProvider();
|
||||||
|
await provider.register({ username: 'MyUser', password: 'password123' });
|
||||||
|
|
||||||
|
expect(prismaMock.user.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
plexId: 'local-myuser',
|
||||||
|
plexUsername: 'myuser',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate username case-insensitively on registration', async () => {
|
||||||
|
configMock.get.mockResolvedValueOnce('true'); // registration enabled
|
||||||
|
prismaMock.user.findFirst.mockResolvedValue({ id: 'user-existing' });
|
||||||
|
|
||||||
|
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||||
|
const provider = new LocalAuthProvider();
|
||||||
|
const result = await provider.register({ username: 'User', password: 'password123' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Username already taken');
|
||||||
|
// The lookup should use the lowercased username
|
||||||
|
expect(prismaMock.user.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ plexUsername: 'user' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates admin user on first registration', async () => {
|
it('creates admin user on first registration', async () => {
|
||||||
configMock.get.mockResolvedValueOnce('true'); // registration enabled
|
configMock.get.mockResolvedValueOnce('true'); // registration enabled
|
||||||
configMock.get.mockResolvedValueOnce('false'); // no admin approval
|
configMock.get.mockResolvedValueOnce('false'); // no admin approval
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const processorsMock = vi.hoisted(() => ({
|
|||||||
processRetryFailedImports: vi.fn().mockResolvedValue('ok'),
|
processRetryFailedImports: vi.fn().mockResolvedValue('ok'),
|
||||||
processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'),
|
processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'),
|
||||||
processSyncGoodreadsShelves: vi.fn().mockResolvedValue('ok'),
|
processSyncGoodreadsShelves: vi.fn().mockResolvedValue('ok'),
|
||||||
|
processCheckWatchedLists: vi.fn().mockResolvedValue('ok'),
|
||||||
// Ebook processors
|
// Ebook processors
|
||||||
processSearchEbook: vi.fn().mockResolvedValue('ok'),
|
processSearchEbook: vi.fn().mockResolvedValue('ok'),
|
||||||
processStartDirectDownload: vi.fn().mockResolvedValue('ok'),
|
processStartDirectDownload: vi.fn().mockResolvedValue('ok'),
|
||||||
@@ -120,6 +121,10 @@ vi.mock('@/lib/processors/sync-goodreads-shelves.processor', () => ({
|
|||||||
processSyncGoodreadsShelves: processorsMock.processSyncGoodreadsShelves,
|
processSyncGoodreadsShelves: processorsMock.processSyncGoodreadsShelves,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/processors/check-watched-lists.processor', () => ({
|
||||||
|
processCheckWatchedLists: processorsMock.processCheckWatchedLists,
|
||||||
|
}));
|
||||||
|
|
||||||
// Ebook processors
|
// Ebook processors
|
||||||
vi.mock('@/lib/processors/search-ebook.processor', () => ({
|
vi.mock('@/lib/processors/search-ebook.processor', () => ({
|
||||||
processSearchEbook: processorsMock.processSearchEbook,
|
processSearchEbook: processorsMock.processSearchEbook,
|
||||||
@@ -565,6 +570,7 @@ describe('JobQueueService', () => {
|
|||||||
expect(processorsMock.processRetryFailedImports).toHaveBeenCalled();
|
expect(processorsMock.processRetryFailedImports).toHaveBeenCalled();
|
||||||
expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled();
|
expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled();
|
||||||
expect(processorsMock.processSyncGoodreadsShelves).toHaveBeenCalled();
|
expect(processorsMock.processSyncGoodreadsShelves).toHaveBeenCalled();
|
||||||
|
expect(processorsMock.processCheckWatchedLists).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns repeatable jobs from the queue', async () => {
|
it('returns repeatable jobs from the queue', async () => {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ describe('SchedulerService', () => {
|
|||||||
const service = new SchedulerService();
|
const service = new SchedulerService();
|
||||||
await service.start();
|
await service.start();
|
||||||
|
|
||||||
expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(8);
|
expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(9);
|
||||||
expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
|
expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
|
||||||
'audible_refresh',
|
'audible_refresh',
|
||||||
{ scheduledJobId: 'job-1' },
|
{ scheduledJobId: 'job-1' },
|
||||||
|
|||||||
@@ -0,0 +1,588 @@
|
|||||||
|
/**
|
||||||
|
* Component: Watched Lists Service Tests
|
||||||
|
* Documentation: documentation/features/watched-lists.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
|
||||||
|
const prismaMock = createPrismaMock();
|
||||||
|
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
prisma: prismaMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/logger', () => ({
|
||||||
|
RMABLogger: {
|
||||||
|
create: () => ({
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
}),
|
||||||
|
forJob: () => ({
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock scrapeSeriesPage
|
||||||
|
const mockScrapeSeriesPage = vi.fn();
|
||||||
|
vi.mock('@/lib/integrations/audible-series', () => ({
|
||||||
|
scrapeSeriesPage: (...args: any[]) => mockScrapeSeriesPage(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock AudibleService
|
||||||
|
const mockSearchByAuthorAsin = vi.fn();
|
||||||
|
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||||
|
getAudibleService: () => ({
|
||||||
|
searchByAuthorAsin: mockSearchByAuthorAsin,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock deduplicateAndCollectGroups
|
||||||
|
const mockDeduplicateAndCollectGroups = vi.fn();
|
||||||
|
vi.mock('@/lib/utils/deduplicate-audiobooks', () => ({
|
||||||
|
deduplicateAndCollectGroups: (...args: any[]) => mockDeduplicateAndCollectGroups(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock works service
|
||||||
|
const mockPersistDedupGroups = vi.fn();
|
||||||
|
const mockGetSiblingAsins = vi.fn();
|
||||||
|
vi.mock('@/lib/services/works.service', () => ({
|
||||||
|
persistDedupGroups: (...args: any[]) => mockPersistDedupGroups(...args),
|
||||||
|
getSiblingAsins: (...args: any[]) => mockGetSiblingAsins(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock request creator
|
||||||
|
const mockCreateRequestForUser = vi.fn();
|
||||||
|
vi.mock('@/lib/services/request-creator.service', () => ({
|
||||||
|
createRequestForUser: (...args: any[]) => mockCreateRequestForUser(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock findPlexMatch
|
||||||
|
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||||
|
findPlexMatch: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('processWatchedLists', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
// Default: empty library, no siblings
|
||||||
|
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||||
|
mockGetSiblingAsins.mockResolvedValue(new Map());
|
||||||
|
mockPersistDedupGroups.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes watched series and creates requests for new books', async () => {
|
||||||
|
// Setup: one user watching one series
|
||||||
|
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
seriesAsin: 'B001SERIES1',
|
||||||
|
seriesTitle: 'Test Series',
|
||||||
|
coverArtUrl: null,
|
||||||
|
lastCheckedAt: null,
|
||||||
|
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
// Series page returns 2 books
|
||||||
|
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||||
|
asin: 'B001SERIES1',
|
||||||
|
title: 'Test Series',
|
||||||
|
bookCount: 2,
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A', narrator: 'Narrator' },
|
||||||
|
{ asin: 'B001BOOK02', title: 'Book Two', author: 'Author A', narrator: 'Narrator' },
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// No dedup (each book is unique)
|
||||||
|
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A', narrator: 'Narrator' },
|
||||||
|
{ asin: 'B001BOOK02', title: 'Book Two', author: 'Author A', narrator: 'Narrator' },
|
||||||
|
],
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both requests succeed
|
||||||
|
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||||
|
|
||||||
|
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||||
|
const stats = await processWatchedLists();
|
||||||
|
|
||||||
|
expect(stats.seriesChecked).toBe(1);
|
||||||
|
expect(stats.requestsCreated).toBe(2);
|
||||||
|
expect(mockCreateRequestForUser).toHaveBeenCalledTimes(2);
|
||||||
|
expect(prismaMock.watchedSeries.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'ws-1' },
|
||||||
|
data: { lastCheckedAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips books already in the library', async () => {
|
||||||
|
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
seriesAsin: 'B001SERIES1',
|
||||||
|
seriesTitle: 'Test Series',
|
||||||
|
coverArtUrl: null,
|
||||||
|
lastCheckedAt: null,
|
||||||
|
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||||
|
asin: 'B001SERIES1',
|
||||||
|
title: 'Test Series',
|
||||||
|
bookCount: 2,
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||||
|
{ asin: 'B001BOOK02', title: 'Book Two', author: 'Author A' },
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||||
|
{ asin: 'B001BOOK02', title: 'Book Two', author: 'Author A' },
|
||||||
|
],
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Book One is already in library
|
||||||
|
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||||
|
{ asin: 'B001BOOK01' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||||
|
|
||||||
|
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||||
|
const stats = await processWatchedLists();
|
||||||
|
|
||||||
|
expect(stats.skippedOwned).toBe(1);
|
||||||
|
expect(stats.requestsCreated).toBe(1);
|
||||||
|
expect(mockCreateRequestForUser).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockCreateRequestForUser).toHaveBeenCalledWith('user-1', expect.objectContaining({ asin: 'B001BOOK02' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes watched authors and creates requests', async () => {
|
||||||
|
prismaMock.watchedSeries.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
prismaMock.watchedAuthor.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'wa-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
authorAsin: 'B001AUTH001',
|
||||||
|
authorName: 'Author A',
|
||||||
|
coverArtUrl: null,
|
||||||
|
lastCheckedAt: null,
|
||||||
|
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.watchedAuthor.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
// Author has 1 book
|
||||||
|
mockSearchByAuthorAsin.mockResolvedValueOnce({
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Author Book', author: 'Author A' },
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
totalResults: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Author Book', author: 'Author A' },
|
||||||
|
],
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||||
|
|
||||||
|
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||||
|
const stats = await processWatchedLists();
|
||||||
|
|
||||||
|
expect(stats.authorsChecked).toBe(1);
|
||||||
|
expect(stats.requestsCreated).toBe(1);
|
||||||
|
expect(mockSearchByAuthorAsin).toHaveBeenCalledWith('Author A', 'B001AUTH001', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts duplicate/already-available books as skippedExisting', async () => {
|
||||||
|
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
seriesAsin: 'B001SERIES1',
|
||||||
|
seriesTitle: 'Test Series',
|
||||||
|
coverArtUrl: null,
|
||||||
|
lastCheckedAt: null,
|
||||||
|
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||||
|
asin: 'B001SERIES1',
|
||||||
|
title: 'Test Series',
|
||||||
|
bookCount: 1,
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||||
|
],
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request creation returns duplicate
|
||||||
|
mockCreateRequestForUser.mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
reason: 'duplicate',
|
||||||
|
message: 'Already requested',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||||
|
const stats = await processWatchedLists();
|
||||||
|
|
||||||
|
expect(stats.skippedExisting).toBe(1);
|
||||||
|
expect(stats.requestsCreated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates scraping when multiple users watch same series', async () => {
|
||||||
|
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
seriesAsin: 'B001SERIES1',
|
||||||
|
seriesTitle: 'Same Series',
|
||||||
|
coverArtUrl: null,
|
||||||
|
lastCheckedAt: null,
|
||||||
|
user: { id: 'user-1', plexUsername: 'user1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ws-2',
|
||||||
|
userId: 'user-2',
|
||||||
|
seriesAsin: 'B001SERIES1',
|
||||||
|
seriesTitle: 'Same Series',
|
||||||
|
coverArtUrl: null,
|
||||||
|
lastCheckedAt: null,
|
||||||
|
user: { id: 'user-2', plexUsername: 'user2' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
// Should only scrape once despite 2 subscriptions
|
||||||
|
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||||
|
asin: 'B001SERIES1',
|
||||||
|
title: 'Same Series',
|
||||||
|
bookCount: 1,
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||||
|
],
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||||
|
|
||||||
|
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||||
|
const stats = await processWatchedLists();
|
||||||
|
|
||||||
|
// Scraped once, but created requests for both users
|
||||||
|
expect(mockScrapeSeriesPage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockCreateRequestForUser).toHaveBeenCalledTimes(2);
|
||||||
|
expect(stats.requestsCreated).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty series page gracefully', async () => {
|
||||||
|
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
seriesAsin: 'B001SERIES1',
|
||||||
|
seriesTitle: 'Empty Series',
|
||||||
|
coverArtUrl: null,
|
||||||
|
lastCheckedAt: null,
|
||||||
|
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
mockScrapeSeriesPage.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||||
|
const stats = await processWatchedLists();
|
||||||
|
|
||||||
|
expect(stats.seriesChecked).toBe(1);
|
||||||
|
expect(stats.booksFound).toBe(0);
|
||||||
|
expect(stats.requestsCreated).toBe(0);
|
||||||
|
expect(mockCreateRequestForUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty stats when no watched items exist', async () => {
|
||||||
|
prismaMock.watchedSeries.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||||
|
const stats = await processWatchedLists();
|
||||||
|
|
||||||
|
expect(stats.seriesChecked).toBe(0);
|
||||||
|
expect(stats.authorsChecked).toBe(0);
|
||||||
|
expect(stats.booksFound).toBe(0);
|
||||||
|
expect(stats.requestsCreated).toBe(0);
|
||||||
|
expect(stats.errors).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists dedup groups to works table', async () => {
|
||||||
|
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
seriesAsin: 'B001SERIES1',
|
||||||
|
seriesTitle: 'Test Series',
|
||||||
|
coverArtUrl: null,
|
||||||
|
lastCheckedAt: null,
|
||||||
|
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||||
|
asin: 'B001SERIES1',
|
||||||
|
title: 'Test Series',
|
||||||
|
bookCount: 2,
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||||
|
{ asin: 'B001BOOK02', title: 'Book One (Remastered)', author: 'Author A' },
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dedupGroup = {
|
||||||
|
canonicalAsin: 'B001BOOK01',
|
||||||
|
allAsins: ['B001BOOK01', 'B001BOOK02'],
|
||||||
|
title: 'Book One',
|
||||||
|
author: 'Author A',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||||
|
books: [{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }],
|
||||||
|
groups: [dedupGroup],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||||
|
|
||||||
|
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||||
|
await processWatchedLists();
|
||||||
|
|
||||||
|
expect(mockPersistDedupGroups).toHaveBeenCalledWith([dedupGroup]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Targeted processing tests ----
|
||||||
|
|
||||||
|
it('filters by seriesAsin when provided in options', async () => {
|
||||||
|
// Two series exist, but we only want to process one
|
||||||
|
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
seriesAsin: 'B001SERIES1',
|
||||||
|
seriesTitle: 'Target Series',
|
||||||
|
coverArtUrl: null,
|
||||||
|
lastCheckedAt: null,
|
||||||
|
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||||
|
asin: 'B001SERIES1',
|
||||||
|
title: 'Target Series',
|
||||||
|
bookCount: 1,
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||||
|
],
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||||
|
|
||||||
|
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||||
|
const stats = await processWatchedLists(undefined, {
|
||||||
|
userId: 'user-1',
|
||||||
|
seriesAsin: 'B001SERIES1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have passed both userId and seriesAsin to the Prisma query
|
||||||
|
expect(prismaMock.watchedSeries.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1', seriesAsin: 'B001SERIES1' },
|
||||||
|
include: { user: { select: { id: true, plexUsername: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stats.seriesChecked).toBe(1);
|
||||||
|
expect(stats.requestsCreated).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by authorAsin when provided in options', async () => {
|
||||||
|
prismaMock.watchedSeries.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
prismaMock.watchedAuthor.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'wa-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
authorAsin: 'B001AUTH001',
|
||||||
|
authorName: 'Target Author',
|
||||||
|
coverArtUrl: null,
|
||||||
|
lastCheckedAt: null,
|
||||||
|
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.watchedAuthor.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockSearchByAuthorAsin.mockResolvedValueOnce({
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Author Book', author: 'Target Author' },
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
totalResults: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Author Book', author: 'Target Author' },
|
||||||
|
],
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||||
|
|
||||||
|
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||||
|
const stats = await processWatchedLists(undefined, {
|
||||||
|
userId: 'user-1',
|
||||||
|
authorAsin: 'B001AUTH001',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have passed both userId and authorAsin to the Prisma query
|
||||||
|
expect(prismaMock.watchedAuthor.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1', authorAsin: 'B001AUTH001' },
|
||||||
|
include: { user: { select: { id: true, plexUsername: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stats.authorsChecked).toBe(1);
|
||||||
|
expect(stats.requestsCreated).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips authors when targeted for a specific series only', async () => {
|
||||||
|
// When seriesAsin is provided but no authorAsin, authors should still be queried
|
||||||
|
// but with no authorAsin filter (only userId), so they run normally.
|
||||||
|
// The key behavior: seriesAsin filter applies to series, not authors.
|
||||||
|
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
seriesAsin: 'B001SERIES1',
|
||||||
|
seriesTitle: 'Target Series',
|
||||||
|
coverArtUrl: null,
|
||||||
|
lastCheckedAt: null,
|
||||||
|
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||||
|
asin: 'B001SERIES1',
|
||||||
|
title: 'Target Series',
|
||||||
|
bookCount: 1,
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||||
|
books: [
|
||||||
|
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||||
|
],
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||||
|
|
||||||
|
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||||
|
const stats = await processWatchedLists(undefined, {
|
||||||
|
userId: 'user-1',
|
||||||
|
seriesAsin: 'B001SERIES1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Series should be filtered by seriesAsin
|
||||||
|
expect(prismaMock.watchedSeries.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1', seriesAsin: 'B001SERIES1' },
|
||||||
|
include: { user: { select: { id: true, plexUsername: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authors query should only filter by userId (no authorAsin filter)
|
||||||
|
expect(prismaMock.watchedAuthor.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1' },
|
||||||
|
include: { user: { select: { id: true, plexUsername: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stats.seriesChecked).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
/**
|
||||||
|
* Component: Works Service Tests
|
||||||
|
* Documentation: documentation/integrations/audible.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
|
|
||||||
|
const prismaMock = createPrismaMock();
|
||||||
|
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
prisma: prismaMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/logger', () => ({
|
||||||
|
RMABLogger: {
|
||||||
|
create: () => ({
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('persistDedupGroups', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates new work + work_asins for a fresh group', async () => {
|
||||||
|
prismaMock.workAsin.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.work.create.mockResolvedValue({ id: 'work-1' });
|
||||||
|
prismaMock.workAsin.create.mockResolvedValue({});
|
||||||
|
prismaMock.workAsin.updateMany.mockResolvedValue({ count: 0 });
|
||||||
|
|
||||||
|
const { persistDedupGroups } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const groups: DedupGroup[] = [{
|
||||||
|
canonicalAsin: 'ASIN_A',
|
||||||
|
allAsins: ['ASIN_A', 'ASIN_B'],
|
||||||
|
title: 'Test Book',
|
||||||
|
author: 'Test Author',
|
||||||
|
narrator: 'Test Narrator',
|
||||||
|
durationMinutes: 600,
|
||||||
|
}];
|
||||||
|
|
||||||
|
await persistDedupGroups(groups);
|
||||||
|
|
||||||
|
expect(prismaMock.work.create).toHaveBeenCalledWith({
|
||||||
|
data: { title: 'Test Book', author: 'Test Author' },
|
||||||
|
});
|
||||||
|
expect(prismaMock.workAsin.create).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Canonical ASIN should have narrator, duration, isCanonical=true
|
||||||
|
expect(prismaMock.workAsin.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
workId: 'work-1',
|
||||||
|
asin: 'ASIN_A',
|
||||||
|
narrator: 'Test Narrator',
|
||||||
|
durationMinutes: 600,
|
||||||
|
isCanonical: true,
|
||||||
|
source: 'dedup_auto',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non-canonical ASIN should have isCanonical=false
|
||||||
|
expect(prismaMock.workAsin.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
workId: 'work-1',
|
||||||
|
asin: 'ASIN_B',
|
||||||
|
isCanonical: false,
|
||||||
|
source: 'dedup_auto',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds new ASINs to existing work when canonical already exists', async () => {
|
||||||
|
prismaMock.workAsin.findMany.mockResolvedValue([
|
||||||
|
{ asin: 'ASIN_A', workId: 'existing-work' },
|
||||||
|
]);
|
||||||
|
prismaMock.workAsin.create.mockResolvedValue({});
|
||||||
|
prismaMock.workAsin.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
const { persistDedupGroups } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const groups: DedupGroup[] = [{
|
||||||
|
canonicalAsin: 'ASIN_A',
|
||||||
|
allAsins: ['ASIN_A', 'ASIN_B', 'ASIN_C'],
|
||||||
|
title: 'Test Book',
|
||||||
|
author: 'Test Author',
|
||||||
|
narrator: 'Narrator',
|
||||||
|
durationMinutes: 500,
|
||||||
|
}];
|
||||||
|
|
||||||
|
await persistDedupGroups(groups);
|
||||||
|
|
||||||
|
// Should NOT create a new work
|
||||||
|
expect(prismaMock.work.create).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Should create entries for ASIN_B and ASIN_C only (ASIN_A already exists)
|
||||||
|
expect(prismaMock.workAsin.create).toHaveBeenCalledTimes(2);
|
||||||
|
expect(prismaMock.workAsin.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
workId: 'existing-work',
|
||||||
|
asin: 'ASIN_B',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(prismaMock.workAsin.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
workId: 'existing-work',
|
||||||
|
asin: 'ASIN_C',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges two separate works when dedup groups them together', async () => {
|
||||||
|
// ASIN_A is in work-1, ASIN_B is in work-2
|
||||||
|
prismaMock.workAsin.findMany.mockResolvedValue([
|
||||||
|
{ asin: 'ASIN_A', workId: 'work-1' },
|
||||||
|
{ asin: 'ASIN_B', workId: 'work-2' },
|
||||||
|
]);
|
||||||
|
prismaMock.workAsin.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
prismaMock.work.deleteMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
const { persistDedupGroups } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const groups: DedupGroup[] = [{
|
||||||
|
canonicalAsin: 'ASIN_A',
|
||||||
|
allAsins: ['ASIN_A', 'ASIN_B'],
|
||||||
|
title: 'Merged Book',
|
||||||
|
author: 'Author',
|
||||||
|
}];
|
||||||
|
|
||||||
|
await persistDedupGroups(groups);
|
||||||
|
|
||||||
|
// Should move work-2 ASINs to work-1
|
||||||
|
expect(prismaMock.workAsin.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { workId: { in: ['work-2'] } },
|
||||||
|
data: { workId: 'work-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should delete work-2
|
||||||
|
expect(prismaMock.work.deleteMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: { in: ['work-2'] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('silently catches and logs errors without throwing', async () => {
|
||||||
|
prismaMock.workAsin.findMany.mockRejectedValue(new Error('DB connection failed'));
|
||||||
|
|
||||||
|
const { persistDedupGroups } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const groups: DedupGroup[] = [{
|
||||||
|
canonicalAsin: 'ASIN_A',
|
||||||
|
allAsins: ['ASIN_A', 'ASIN_B'],
|
||||||
|
title: 'Test',
|
||||||
|
author: 'Auth',
|
||||||
|
}];
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await expect(persistDedupGroups(groups)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seedAsin', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates single-ASIN work for new ASIN', async () => {
|
||||||
|
prismaMock.workAsin.findUnique.mockResolvedValue(null);
|
||||||
|
prismaMock.work.create.mockResolvedValue({ id: 'new-work' });
|
||||||
|
prismaMock.workAsin.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const { seedAsin } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
await seedAsin('NEW_ASIN', 'New Book', 'Author', 'Narrator', 300);
|
||||||
|
|
||||||
|
expect(prismaMock.work.create).toHaveBeenCalledWith({
|
||||||
|
data: { title: 'New Book', author: 'Author' },
|
||||||
|
});
|
||||||
|
expect(prismaMock.workAsin.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
workId: 'new-work',
|
||||||
|
asin: 'NEW_ASIN',
|
||||||
|
narrator: 'Narrator',
|
||||||
|
durationMinutes: 300,
|
||||||
|
isCanonical: true,
|
||||||
|
source: 'dedup_auto',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for already-tracked ASIN', async () => {
|
||||||
|
prismaMock.workAsin.findUnique.mockResolvedValue({
|
||||||
|
id: 'existing',
|
||||||
|
asin: 'EXISTING_ASIN',
|
||||||
|
workId: 'work-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { seedAsin } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
await seedAsin('EXISTING_ASIN', 'Book', 'Author');
|
||||||
|
|
||||||
|
expect(prismaMock.work.create).not.toHaveBeenCalled();
|
||||||
|
expect(prismaMock.workAsin.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('silently catches and logs errors without throwing', async () => {
|
||||||
|
prismaMock.workAsin.findUnique.mockRejectedValue(new Error('DB error'));
|
||||||
|
|
||||||
|
const { seedAsin } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
await expect(seedAsin('ASIN', 'Book', 'Auth')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSiblingAsins', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns sibling ASINs correctly', async () => {
|
||||||
|
// First query: find input ASINs and their work IDs
|
||||||
|
prismaMock.workAsin.findMany
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ asin: 'ASIN_A', workId: 'work-1' },
|
||||||
|
{ asin: 'ASIN_C', workId: 'work-2' },
|
||||||
|
])
|
||||||
|
// Second query: all ASINs in those works
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ asin: 'ASIN_A', workId: 'work-1' },
|
||||||
|
{ asin: 'ASIN_B', workId: 'work-1' },
|
||||||
|
{ asin: 'ASIN_C', workId: 'work-2' },
|
||||||
|
{ asin: 'ASIN_D', workId: 'work-2' },
|
||||||
|
{ asin: 'ASIN_E', workId: 'work-2' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { getSiblingAsins } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const result = await getSiblingAsins(['ASIN_A', 'ASIN_C']);
|
||||||
|
|
||||||
|
expect(result.get('ASIN_A')).toEqual(['ASIN_B']);
|
||||||
|
expect(result.get('ASIN_C')).toEqual(['ASIN_D', 'ASIN_E']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty map for unknown ASINs', async () => {
|
||||||
|
prismaMock.workAsin.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { getSiblingAsins } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const result = await getSiblingAsins(['UNKNOWN']);
|
||||||
|
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty map for empty input', async () => {
|
||||||
|
const { getSiblingAsins } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const result = await getSiblingAsins([]);
|
||||||
|
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
// Should not query DB
|
||||||
|
expect(prismaMock.workAsin.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes the input ASIN itself from siblings', async () => {
|
||||||
|
prismaMock.workAsin.findMany
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ asin: 'ASIN_A', workId: 'work-1' },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ asin: 'ASIN_A', workId: 'work-1' },
|
||||||
|
{ asin: 'ASIN_B', workId: 'work-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { getSiblingAsins } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const result = await getSiblingAsins(['ASIN_A']);
|
||||||
|
|
||||||
|
expect(result.get('ASIN_A')).toEqual(['ASIN_B']);
|
||||||
|
expect(result.get('ASIN_A')).not.toContain('ASIN_A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits ASINs with no siblings (single-ASIN works)', async () => {
|
||||||
|
prismaMock.workAsin.findMany
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ asin: 'ASIN_LONELY', workId: 'work-solo' },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ asin: 'ASIN_LONELY', workId: 'work-solo' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { getSiblingAsins } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const result = await getSiblingAsins(['ASIN_LONELY']);
|
||||||
|
|
||||||
|
// No siblings means it shouldn't be in the map at all
|
||||||
|
expect(result.has('ASIN_LONELY')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Component: API Token Rate Limit Tests
|
||||||
|
* Documentation: documentation/backend/services/api-tokens.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
checkApiTokenCreateRateLimit,
|
||||||
|
checkApiTokenRevokeRateLimit,
|
||||||
|
_resetBuckets,
|
||||||
|
_getBucketCount,
|
||||||
|
} from '@/lib/utils/apiTokenRateLimit';
|
||||||
|
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||||
|
|
||||||
|
describe('API Token Rate Limiting', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
_resetBuckets();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
_resetBuckets();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkApiTokenCreateRateLimit', () => {
|
||||||
|
it('allows requests under the limit', () => {
|
||||||
|
const actorId = 'user-create-1';
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const result = checkApiTokenCreateRateLimit(actorId);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks requests over the limit (10/min)', () => {
|
||||||
|
const actorId = 'user-create-2';
|
||||||
|
|
||||||
|
// Use up the limit
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
checkApiTokenCreateRateLimit(actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11th request should be blocked
|
||||||
|
const result = checkApiTokenCreateRateLimit(actorId);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.retryAfterSeconds).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets after the window expires', () => {
|
||||||
|
const actorId = 'user-create-3';
|
||||||
|
|
||||||
|
// Use up the limit
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
checkApiTokenCreateRateLimit(actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be blocked
|
||||||
|
expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(false);
|
||||||
|
|
||||||
|
// Advance time past the window (60 seconds)
|
||||||
|
vi.advanceTimersByTime(61 * 1000);
|
||||||
|
|
||||||
|
// Should be allowed again
|
||||||
|
expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks different actors separately', () => {
|
||||||
|
const actor1 = 'user-create-4';
|
||||||
|
const actor2 = 'user-create-5';
|
||||||
|
|
||||||
|
// Use up actor1's limit
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
checkApiTokenCreateRateLimit(actor1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// actor1 should be blocked
|
||||||
|
expect(checkApiTokenCreateRateLimit(actor1).allowed).toBe(false);
|
||||||
|
|
||||||
|
// actor2 should still be allowed
|
||||||
|
expect(checkApiTokenCreateRateLimit(actor2).allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkApiTokenRevokeRateLimit', () => {
|
||||||
|
it('allows requests under the limit', () => {
|
||||||
|
const actorId = 'user-revoke-1';
|
||||||
|
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const result = checkApiTokenRevokeRateLimit(actorId);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks requests over the limit (20/min)', () => {
|
||||||
|
const actorId = 'user-revoke-2';
|
||||||
|
|
||||||
|
// Use up the limit
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
checkApiTokenRevokeRateLimit(actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 21st request should be blocked
|
||||||
|
const result = checkApiTokenRevokeRateLimit(actorId);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.retryAfterSeconds).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct retryAfterSeconds', () => {
|
||||||
|
const actorId = 'user-revoke-3';
|
||||||
|
|
||||||
|
// Use up the limit
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
checkApiTokenRevokeRateLimit(actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance 30 seconds into the window
|
||||||
|
vi.advanceTimersByTime(30 * 1000);
|
||||||
|
|
||||||
|
const result = checkApiTokenRevokeRateLimit(actorId);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
// Should have ~30 seconds left
|
||||||
|
expect(result.retryAfterSeconds).toBeLessThanOrEqual(30);
|
||||||
|
expect(result.retryAfterSeconds).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lazy eviction', () => {
|
||||||
|
it('deletes expired buckets when they are next accessed', () => {
|
||||||
|
const actorId = 'user-evict-1';
|
||||||
|
|
||||||
|
// Create a bucket
|
||||||
|
checkApiTokenCreateRateLimit(actorId);
|
||||||
|
expect(_getBucketCount()).toBe(1);
|
||||||
|
|
||||||
|
// Expire the window
|
||||||
|
vi.advanceTimersByTime(61 * 1000);
|
||||||
|
|
||||||
|
// Accessing the same key should evict the old bucket and create a fresh one
|
||||||
|
checkApiTokenCreateRateLimit(actorId);
|
||||||
|
// Should still be 1 (old one deleted, new one created)
|
||||||
|
expect(_getBucketCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not delete buckets that are still active', () => {
|
||||||
|
// Create buckets for two actors
|
||||||
|
checkApiTokenCreateRateLimit('actor-a');
|
||||||
|
checkApiTokenCreateRateLimit('actor-b');
|
||||||
|
expect(_getBucketCount()).toBe(2);
|
||||||
|
|
||||||
|
// Advance partially (not past the 60s window)
|
||||||
|
vi.advanceTimersByTime(30 * 1000);
|
||||||
|
|
||||||
|
// Both should still be there
|
||||||
|
checkApiTokenCreateRateLimit('actor-a');
|
||||||
|
expect(_getBucketCount()).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('periodic sweep', () => {
|
||||||
|
it('sweeps all expired buckets every 100 checks', () => {
|
||||||
|
// Create 10 unique actor buckets
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
checkApiTokenCreateRateLimit(`sweep-actor-${i}`);
|
||||||
|
}
|
||||||
|
expect(_getBucketCount()).toBe(10);
|
||||||
|
|
||||||
|
// Expire all windows
|
||||||
|
vi.advanceTimersByTime(61 * 1000);
|
||||||
|
|
||||||
|
// Add some fresh buckets that should NOT be swept
|
||||||
|
checkApiTokenCreateRateLimit('sweep-fresh-1');
|
||||||
|
checkApiTokenCreateRateLimit('sweep-fresh-2');
|
||||||
|
|
||||||
|
// We've done 10 + 2 = 12 calls so far. Need 100 total to trigger sweep.
|
||||||
|
// Do 88 more calls with unique actors to reach 100
|
||||||
|
for (let i = 0; i < 88; i++) {
|
||||||
|
checkApiTokenCreateRateLimit(`sweep-filler-${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the 100th call, the sweep should have removed the 10 expired buckets.
|
||||||
|
// Remaining: 2 fresh + 88 filler = 90
|
||||||
|
expect(_getBucketCount()).toBe(90);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_resetBuckets', () => {
|
||||||
|
it('clears all buckets', () => {
|
||||||
|
checkApiTokenCreateRateLimit('reset-1');
|
||||||
|
checkApiTokenCreateRateLimit('reset-2');
|
||||||
|
expect(_getBucketCount()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
_resetBuckets();
|
||||||
|
expect(_getBucketCount()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MAX_TOKENS_PER_USER constant', () => {
|
||||||
|
it('is set to 25', () => {
|
||||||
|
expect(MAX_TOKENS_PER_USER).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
/**
|
||||||
|
* Component: Audiobook Deduplication Tests
|
||||||
|
* Documentation: documentation/integrations/audible.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
deduplicateAudiobooks,
|
||||||
|
deduplicateAndCollectGroups,
|
||||||
|
normalizeTitle,
|
||||||
|
areDurationsCompatible,
|
||||||
|
} from '@/lib/utils/deduplicate-audiobooks';
|
||||||
|
import type { AudibleAudiobook } from '@/lib/integrations/audible.service';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: minimal AudibleAudiobook factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeBook(overrides: Partial<AudibleAudiobook> & { asin: string; title: string; author: string }): AudibleAudiobook {
|
||||||
|
return {
|
||||||
|
narrator: undefined,
|
||||||
|
coverArtUrl: undefined,
|
||||||
|
durationMinutes: undefined,
|
||||||
|
rating: undefined,
|
||||||
|
description: undefined,
|
||||||
|
releaseDate: undefined,
|
||||||
|
genres: undefined,
|
||||||
|
series: undefined,
|
||||||
|
seriesPart: undefined,
|
||||||
|
seriesAsin: undefined,
|
||||||
|
authorAsin: undefined,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// normalizeTitle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('normalizeTitle', () => {
|
||||||
|
it('lowercases', () => {
|
||||||
|
expect(normalizeTitle('The Black Prism')).toBe('the black prism');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips (Unabridged)', () => {
|
||||||
|
expect(normalizeTitle('The Black Prism (Unabridged)')).toBe('the black prism');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips [Abridged Edition]', () => {
|
||||||
|
expect(normalizeTitle('The Black Prism [Abridged Edition]')).toBe('the black prism');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips (2024 Remastered Edition)', () => {
|
||||||
|
expect(normalizeTitle('The Hobbit (2024 Remastered Edition)')).toBe('the hobbit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips subtitle after colon', () => {
|
||||||
|
expect(normalizeTitle('The Black Prism: Lightbringer, Book 1')).toBe('the black prism');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips subtitle after long dash', () => {
|
||||||
|
expect(normalizeTitle('The Black Prism \u2014 A Lightbringer Novel')).toBe('the black prism');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips trailing "A Novel"', () => {
|
||||||
|
expect(normalizeTitle('The Black Prism: A Novel')).toBe('the black prism');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips (Audiobook)', () => {
|
||||||
|
expect(normalizeTitle('The Hobbit (Audiobook)')).toBe('the hobbit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips (Dramatized Adaptation)', () => {
|
||||||
|
expect(normalizeTitle('The Black Prism (Dramatized Adaptation)')).toBe('the black prism');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips (Full Cast Narration)', () => {
|
||||||
|
expect(normalizeTitle('The Black Prism (Full Cast Narration)')).toBe('the black prism');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses whitespace', () => {
|
||||||
|
expect(normalizeTitle(' The Black Prism ')).toBe('the black prism');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string', () => {
|
||||||
|
expect(normalizeTitle('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves hyphenated words (not subtitles)', () => {
|
||||||
|
// "well-known" has a short dash, not a subtitle separator
|
||||||
|
expect(normalizeTitle('A Well-Known Book')).toBe('a well-known book');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// areDurationsCompatible
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('areDurationsCompatible', () => {
|
||||||
|
it('returns true when both undefined', () => {
|
||||||
|
expect(areDurationsCompatible(undefined, undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when one undefined', () => {
|
||||||
|
expect(areDurationsCompatible(600, undefined)).toBe(true);
|
||||||
|
expect(areDurationsCompatible(undefined, 600)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for identical durations', () => {
|
||||||
|
expect(areDurationsCompatible(600, 600)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses 1% of longer duration as tolerance for long books', () => {
|
||||||
|
// Two 40-hour books (2400 min): tolerance = max(2400*0.01, 5) = 24 min
|
||||||
|
expect(areDurationsCompatible(2400, 2424)).toBe(true); // exactly at tolerance
|
||||||
|
expect(areDurationsCompatible(2400, 2425)).toBe(false); // just over
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses 5-minute minimum tolerance for short books', () => {
|
||||||
|
// Two 2-hour books (120 min): tolerance = max(120*0.01, 5) = max(1.2, 5) = 5 min
|
||||||
|
expect(areDurationsCompatible(120, 125)).toBe(true); // exactly at 5-min minimum
|
||||||
|
expect(areDurationsCompatible(120, 126)).toBe(false); // just over
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps abridged vs unabridged separate (large duration gap)', () => {
|
||||||
|
// Unabridged: 720 min (12 hrs), Abridged: 360 min (6 hrs)
|
||||||
|
expect(areDurationsCompatible(720, 360)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('symmetry: order does not matter', () => {
|
||||||
|
expect(areDurationsCompatible(2400, 2424)).toBe(true);
|
||||||
|
expect(areDurationsCompatible(2424, 2400)).toBe(true);
|
||||||
|
expect(areDurationsCompatible(120, 126)).toBe(false);
|
||||||
|
expect(areDurationsCompatible(126, 120)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deduplicateAudiobooks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('deduplicateAudiobooks', () => {
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(deduplicateAudiobooks([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns single book unchanged', () => {
|
||||||
|
const book = makeBook({ asin: 'A1', title: 'Book One', author: 'Author' });
|
||||||
|
expect(deduplicateAudiobooks([book])).toEqual([book]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through all-unique books unchanged', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'Book One', author: 'Auth', narrator: 'Nar A', durationMinutes: 600 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'Book Two', author: 'Auth', narrator: 'Nar A', durationMinutes: 500 }),
|
||||||
|
makeBook({ asin: 'A3', title: 'Book Three', author: 'Auth', narrator: 'Nar B', durationMinutes: 700 }),
|
||||||
|
];
|
||||||
|
expect(deduplicateAudiobooks(books)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses simple duplicates (same title + narrator + similar duration)', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }),
|
||||||
|
];
|
||||||
|
const result = deduplicateAudiobooks(books);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps books with different narrators (different production)', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Full Cast', durationMinutes: 480 }),
|
||||||
|
];
|
||||||
|
const result = deduplicateAudiobooks(books);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps abridged vs unabridged (same narrator, very different duration)', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 330 }),
|
||||||
|
];
|
||||||
|
const result = deduplicateAudiobooks(books);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses when one book has missing duration', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: undefined }),
|
||||||
|
];
|
||||||
|
const result = deduplicateAudiobooks(books);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses when both books have missing duration', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance' }),
|
||||||
|
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance' }),
|
||||||
|
];
|
||||||
|
const result = deduplicateAudiobooks(books);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses title variants with edition markers', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'The Black Prism (Unabridged)', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1258 }),
|
||||||
|
];
|
||||||
|
const result = deduplicateAudiobooks(books);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses title variants with subtitles', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'The Black Prism: Lightbringer, Book 1', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }),
|
||||||
|
];
|
||||||
|
const result = deduplicateAudiobooks(books);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the representative with most metadata', () => {
|
||||||
|
const sparse = makeBook({
|
||||||
|
asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks',
|
||||||
|
narrator: 'Simon Vance', durationMinutes: 1260,
|
||||||
|
});
|
||||||
|
const rich = makeBook({
|
||||||
|
asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks',
|
||||||
|
narrator: 'Simon Vance', durationMinutes: 1262,
|
||||||
|
coverArtUrl: 'https://img.jpg', rating: 4.5, description: 'Great book',
|
||||||
|
});
|
||||||
|
const result = deduplicateAudiobooks([sparse, rich]);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].asin).toBe('A2'); // rich entry wins
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves original order (first-seen position)', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300 }),
|
||||||
|
makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 400 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }),
|
||||||
|
makeBook({ asin: 'C1', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }),
|
||||||
|
];
|
||||||
|
const result = deduplicateAudiobooks(books);
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result.map(b => b.title)).toEqual(['Alpha', 'Beta', 'Charlie']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Lightbringer-style scenario: unabridged + dramatized', () => {
|
||||||
|
// Simon Vance full narration (long)
|
||||||
|
const vance1 = makeBook({
|
||||||
|
asin: 'SV1', title: 'The Black Prism', author: 'Brent Weeks',
|
||||||
|
narrator: 'Simon Vance', durationMinutes: 1260,
|
||||||
|
coverArtUrl: 'cover1.jpg', rating: 4.7,
|
||||||
|
});
|
||||||
|
// Re-listed Simon Vance (same duration, different ASIN)
|
||||||
|
const vance2 = makeBook({
|
||||||
|
asin: 'SV2', title: 'The Black Prism: Lightbringer Book 1', author: 'Brent Weeks',
|
||||||
|
narrator: 'Simon Vance', durationMinutes: 1262,
|
||||||
|
});
|
||||||
|
// Dramatized with full cast (shorter, different narrator)
|
||||||
|
const drama = makeBook({
|
||||||
|
asin: 'DR1', title: 'The Black Prism (Dramatized Adaptation)', author: 'Brent Weeks',
|
||||||
|
narrator: 'Full Cast', durationMinutes: 480,
|
||||||
|
coverArtUrl: 'cover-drama.jpg',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = deduplicateAudiobooks([vance1, vance2, drama]);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
// Simon Vance should collapse to 1, Full Cast stays
|
||||||
|
expect(result.find(b => b.narrator === 'Simon Vance')).toBeTruthy();
|
||||||
|
expect(result.find(b => b.narrator === 'Full Cast')).toBeTruthy();
|
||||||
|
// Should pick the richer entry for Simon Vance
|
||||||
|
const svResult = result.find(b => b.narrator === 'Simon Vance')!;
|
||||||
|
expect(svResult.asin).toBe('SV1'); // has cover + rating
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses percentage tolerance for very long audiobooks', () => {
|
||||||
|
// Two 40-hour books: tolerance = max(2400*0.01, 5) = 24 min
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2420 }),
|
||||||
|
];
|
||||||
|
expect(deduplicateAudiobooks(books)).toHaveLength(1);
|
||||||
|
|
||||||
|
// Beyond tolerance
|
||||||
|
const booksFar = [
|
||||||
|
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2430 }),
|
||||||
|
];
|
||||||
|
expect(deduplicateAudiobooks(booksFar)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats missing narrator as its own group', () => {
|
||||||
|
// Two entries with same title but no narrator - should collapse
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 302 }),
|
||||||
|
];
|
||||||
|
expect(deduplicateAudiobooks(books)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not collapse empty-narrator with named narrator', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: 'John Smith', durationMinutes: 302 }),
|
||||||
|
];
|
||||||
|
expect(deduplicateAudiobooks(books)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses duplicates when narrators are listed in different order', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({
|
||||||
|
asin: 'A1', title: 'The Passengers', author: 'John Marrs',
|
||||||
|
narrator: 'Kristin Atherton, Roy McMillan, Clare Corbett, Tom Bateman, Patience Tomlinson, Shaheen Khan',
|
||||||
|
durationMinutes: 600,
|
||||||
|
}),
|
||||||
|
makeBook({
|
||||||
|
asin: 'A2', title: 'The Passengers', author: 'John Marrs',
|
||||||
|
narrator: 'Clare Corbett, Roy McMillan, Tom Bateman, Shaheen Khan, Kristin Atherton, Patience Tomlinson',
|
||||||
|
durationMinutes: 602,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const result = deduplicateAudiobooks(books);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deduplicateAndCollectGroups
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('deduplicateAndCollectGroups', () => {
|
||||||
|
it('returns empty groups array when no duplicates', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'Book One', author: 'Auth', narrator: 'Nar A', durationMinutes: 600 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'Book Two', author: 'Auth', narrator: 'Nar A', durationMinutes: 500 }),
|
||||||
|
];
|
||||||
|
const { books: result, groups } = deduplicateAndCollectGroups(books);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(groups).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty groups for empty input', () => {
|
||||||
|
const { books: result, groups } = deduplicateAndCollectGroups([]);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
expect(groups).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty groups for single book', () => {
|
||||||
|
const book = makeBook({ asin: 'A1', title: 'Book One', author: 'Auth' });
|
||||||
|
const { books: result, groups } = deduplicateAndCollectGroups([book]);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(groups).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns group with 2 ASINs when 2 books match', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }),
|
||||||
|
];
|
||||||
|
const { books: result, groups } = deduplicateAndCollectGroups(books);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].allAsins).toHaveLength(2);
|
||||||
|
expect(groups[0].allAsins).toContain('A1');
|
||||||
|
expect(groups[0].allAsins).toContain('A2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns group with 3+ ASINs for multi-duplicate scenario', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 662 }),
|
||||||
|
makeBook({ asin: 'A3', title: 'The Hobbit (Unabridged)', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 658 }),
|
||||||
|
];
|
||||||
|
const { books: result, groups } = deduplicateAndCollectGroups(books);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].allAsins).toHaveLength(3);
|
||||||
|
expect(groups[0].allAsins).toContain('A1');
|
||||||
|
expect(groups[0].allAsins).toContain('A2');
|
||||||
|
expect(groups[0].allAsins).toContain('A3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalAsin is the one with highest metadata score', () => {
|
||||||
|
const sparse = makeBook({
|
||||||
|
asin: 'SPARSE', title: 'The Black Prism', author: 'Brent Weeks',
|
||||||
|
narrator: 'Simon Vance', durationMinutes: 1260,
|
||||||
|
});
|
||||||
|
const rich = makeBook({
|
||||||
|
asin: 'RICH', title: 'The Black Prism', author: 'Brent Weeks',
|
||||||
|
narrator: 'Simon Vance', durationMinutes: 1262,
|
||||||
|
coverArtUrl: 'https://img.jpg', rating: 4.5, description: 'Great book',
|
||||||
|
});
|
||||||
|
const { groups } = deduplicateAndCollectGroups([sparse, rich]);
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].canonicalAsin).toBe('RICH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups only include entries with 2+ ASINs', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }),
|
||||||
|
makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }),
|
||||||
|
];
|
||||||
|
const { groups } = deduplicateAndCollectGroups(books);
|
||||||
|
// Only Alpha group should appear (Beta is a singleton)
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].allAsins).toContain('A1');
|
||||||
|
expect(groups[0].allAsins).toContain('A2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('duration-incompatible books produce separate entries (no group for singletons)', () => {
|
||||||
|
// Same title/narrator but very different durations (abridged vs unabridged)
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 330 }),
|
||||||
|
];
|
||||||
|
const { books: result, groups } = deduplicateAndCollectGroups(books);
|
||||||
|
expect(result).toHaveLength(2); // Not collapsed
|
||||||
|
expect(groups).toHaveLength(0); // No multi-ASIN groups
|
||||||
|
});
|
||||||
|
|
||||||
|
it('books field matches what deduplicateAudiobooks returns', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300, coverArtUrl: 'img.jpg', rating: 4.5 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }),
|
||||||
|
makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }),
|
||||||
|
makeBook({ asin: 'C1', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 600 }),
|
||||||
|
makeBook({ asin: 'C2', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 601 }),
|
||||||
|
];
|
||||||
|
const dedupOnly = deduplicateAudiobooks(books);
|
||||||
|
const { books: withGroups } = deduplicateAndCollectGroups(books);
|
||||||
|
expect(withGroups.map(b => b.asin)).toEqual(dedupOnly.map(b => b.asin));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes narrator and durationMinutes from canonical entry in group', () => {
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: 'Jane Doe', durationMinutes: 480 }),
|
||||||
|
makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: 'Jane Doe', durationMinutes: 482, coverArtUrl: 'img.jpg', rating: 4.0 }),
|
||||||
|
];
|
||||||
|
const { groups } = deduplicateAndCollectGroups(books);
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].canonicalAsin).toBe('A2'); // richer metadata
|
||||||
|
expect(groups[0].narrator).toBe('Jane Doe');
|
||||||
|
expect(groups[0].durationMinutes).toBe(482);
|
||||||
|
expect(groups[0].author).toBe('Auth');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user