mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfd624e120 | |||
| b559835390 | |||
| d25a6ebf79 | |||
| b3dad47aba | |||
| 7891e31893 | |||
| bff74446fe | |||
| 038c92e49f | |||
| 3ee67c8763 | |||
| edc56bc457 | |||
| 73c5fe14e7 | |||
| d9ccbfef5c | |||
| 01cac0e8e6 | |||
| 66f4a215f7 | |||
| 0bd9e88acc | |||
| f0b9bd2688 | |||
| e1629ce516 | |||
| 1006a04337 | |||
| 547af71de8 | |||
| 1b0a80052d | |||
| d38f03b8f4 | |||
| dbea15a34f | |||
| 2972297903 | |||
| 03f82d4841 | |||
| 33c2265e56 | |||
| b15a472bab | |||
| 3c680f2f38 | |||
| 16cd606421 | |||
| 40d5363dc4 | |||
| c138d8e642 | |||
| 3d590b38cc | |||
| aa7ba8a76d | |||
| 328fd8392b | |||
| 9a460f808d | |||
| c60b6214ce | |||
| aff5faaa58 | |||
| c43ce7ba8f | |||
| f570b87343 | |||
| dfa7a11674 | |||
| 7a1a8ffa50 | |||
| d70f6c9957 | |||
| 04dbb05a6e | |||
| cb9f1b81bc | |||
| 5d8ac2f73d | |||
| c146383735 | |||
| 3820b9b21d | |||
| 20798b3dc0 | |||
| 3f8180a246 | |||
| c97df7798a | |||
| c0096cda1a |
+3
-1
@@ -53,4 +53,6 @@ next-env.d.ts
|
||||
/redis
|
||||
/pgdata
|
||||
/test-media
|
||||
/test-data
|
||||
/test-data
|
||||
/bookdrop
|
||||
dockerfile.patch
|
||||
@@ -17,6 +17,11 @@ services:
|
||||
- ./downloads:/downloads
|
||||
- ./media:/media
|
||||
|
||||
# Book Drop: optional folder for Manual Import (Admin → audiobook → Manual Import)
|
||||
# Map any host folder here and it will appear as a browsable root in the file picker.
|
||||
# Example: - /path/to/your/audiobooks:/bookdrop
|
||||
# - ./bookdrop:/bookdrop
|
||||
|
||||
# PostgreSQL data persistence
|
||||
- ./pgdata:/var/lib/postgresql/data
|
||||
|
||||
|
||||
@@ -53,14 +53,75 @@ start_server() {
|
||||
start_server
|
||||
SERVER_PID=$!
|
||||
|
||||
echo "[App] Waiting for server to be ready..."
|
||||
sleep 5
|
||||
# =============================================================================
|
||||
# WAIT FOR SERVER READINESS
|
||||
# =============================================================================
|
||||
# The health endpoint (/api/health) checks both the Next.js server AND database
|
||||
# connectivity. We must wait for both before initializing scheduled jobs.
|
||||
|
||||
# Initialize application services (creates default scheduled jobs)
|
||||
echo "[App] Initializing application services..."
|
||||
curl -sf http://localhost:3030/api/init || echo "[App] Warning: Failed to initialize services (may already be initialized)"
|
||||
HEALTH_URL="http://localhost:3030/api/health"
|
||||
INIT_URL="http://localhost:3030/api/init"
|
||||
READY_TIMEOUT=${APP_READY_TIMEOUT:-60}
|
||||
INIT_RETRIES=${APP_INIT_RETRIES:-5}
|
||||
|
||||
echo "[App] Server ready with PID $SERVER_PID"
|
||||
echo "[App] Waiting for server to be ready (timeout: ${READY_TIMEOUT}s)..."
|
||||
|
||||
READY=false
|
||||
for i in $(seq 1 "$READY_TIMEOUT"); do
|
||||
# Check if the server process is still alive
|
||||
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
echo "[App] ERROR: Server process (PID $SERVER_PID) exited unexpectedly"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
READY=true
|
||||
echo "[App] Server is healthy (took ${i}s)"
|
||||
break
|
||||
fi
|
||||
|
||||
# Log progress every 10 seconds
|
||||
if [ $((i % 10)) -eq 0 ]; then
|
||||
echo "[App] Still waiting for server... (${i}/${READY_TIMEOUT}s)"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$READY" = "false" ]; then
|
||||
echo "[App] ERROR: Server did not become healthy within ${READY_TIMEOUT}s"
|
||||
echo "[App] The scheduler will not be initialized - scheduled jobs may be missing"
|
||||
echo "[App] Check server logs above for errors (database connection, port conflict, etc.)"
|
||||
else
|
||||
# =========================================================================
|
||||
# INITIALIZE APPLICATION SERVICES
|
||||
# =========================================================================
|
||||
# Creates default scheduled jobs, runs credential migration, etc.
|
||||
# Retry with backoff to handle transient failures during startup.
|
||||
|
||||
echo "[App] Initializing application services..."
|
||||
|
||||
INIT_SUCCESS=false
|
||||
for attempt in $(seq 1 "$INIT_RETRIES"); do
|
||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$INIT_URL" 2>/dev/null) || HTTP_CODE="000"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
INIT_SUCCESS=true
|
||||
echo "[App] Services initialized successfully"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "[App] Init attempt $attempt/$INIT_RETRIES failed (HTTP $HTTP_CODE), retrying in ${attempt}s..."
|
||||
sleep "$attempt"
|
||||
done
|
||||
|
||||
if [ "$INIT_SUCCESS" = "false" ]; then
|
||||
echo "[App] ERROR: Failed to initialize services after $INIT_RETRIES attempts"
|
||||
echo "[App] Scheduled jobs may be missing - check application logs for details"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[App] Server running with PID $SERVER_PID"
|
||||
|
||||
# Verify the process is running with correct UID:GID (for debugging)
|
||||
if [ -f "/proc/$SERVER_PID/status" ]; then
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
# Documentation System Agent — Master Prompt
|
||||
|
||||
You are a documentation architect. Your job is to analyze a codebase from scratch and produce a **cascading, token-efficient documentation system** with a navigational index. When you are done, future AI agents dropped into this repo will be able to find any information they need by reading a single table of contents file, then following a link to exactly the right document — never wasting tokens reading irrelevant material.
|
||||
|
||||
---
|
||||
|
||||
## 1. What You Are Building
|
||||
|
||||
You are building three things:
|
||||
|
||||
### A. A `documentation/` directory
|
||||
A tree of concise, AI-optimized markdown files that describe every meaningful part of the codebase. The structure mirrors the codebase's own architecture (backend services, frontend components, integrations, configuration, etc.) rather than imposing an arbitrary layout.
|
||||
|
||||
### B. A `documentation/TABLEOFCONTENTS.md` file
|
||||
The **single entry point** for all documentation. This file maps natural-language questions and topic keywords to specific documentation files. Any agent that needs to understand something reads this file first, finds the 1-3 relevant docs, and reads only those. This is the most important file you will produce.
|
||||
|
||||
### C. A `CLAUDE.md` file at the project root
|
||||
Project instructions that teach future agents how to use the documentation system. This file is automatically loaded into every Claude Code conversation, so it must be concise, directive, and self-contained.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Token-Efficient Documentation Format
|
||||
|
||||
Every documentation file you create MUST follow this format. No exceptions.
|
||||
|
||||
### 2.1 Structure Template
|
||||
|
||||
```markdown
|
||||
# [Title]
|
||||
|
||||
**Status:** [Implemented | Partial | Planned] — [One-line summary of what this is]
|
||||
|
||||
## Overview
|
||||
[1-3 sentences. What is this? What does it do? Why does it exist?]
|
||||
|
||||
## Key Details
|
||||
- Bullet points, not prose
|
||||
- Data models: field names, types, constraints
|
||||
- API endpoints: method, path, request/response shape
|
||||
- Config keys and their values/defaults
|
||||
- Enums, status values, important constants
|
||||
- File paths and code locations
|
||||
- Behavioral rules and edge cases
|
||||
|
||||
## API / Interfaces
|
||||
[If applicable — tables or compact code blocks for endpoints, function signatures, event names, etc.]
|
||||
|
||||
## Dependencies
|
||||
[What this depends on, and what depends on it — keep to a bullet list]
|
||||
|
||||
## Known Issues / Gotchas
|
||||
[Only if there are real, non-obvious pitfalls. Omit section entirely if none.]
|
||||
|
||||
## Related
|
||||
- [Link to related doc 1]
|
||||
- [Link to related doc 2]
|
||||
```
|
||||
|
||||
### 2.2 Format Rules
|
||||
|
||||
**REQUIRED — always include:**
|
||||
- Status line with one-line summary
|
||||
- API endpoints, data models, config keys (complete and accurate)
|
||||
- File paths to source code (so agents can navigate directly)
|
||||
- Enums, constants, and status values (exact strings/numbers)
|
||||
- Dependency relationships between components
|
||||
- Gotchas that have caused or could cause bugs
|
||||
|
||||
**FORBIDDEN — never include:**
|
||||
- Verbose prose or narrative explanations
|
||||
- "Why we chose X" sections (brief rationale in a bullet is fine)
|
||||
- ASCII art diagrams larger than 5 lines
|
||||
- More than 2 code examples per document
|
||||
- "Future enhancements" or roadmap speculation
|
||||
- "Testing strategy" sections (unless tests are the subject of the doc)
|
||||
- "Performance considerations" (unless performance is the subject)
|
||||
- Empty sections or placeholder text
|
||||
- Decorative formatting, horizontal rules between every section, emoji
|
||||
|
||||
**TARGET:** Each doc file should be 30-80 lines. If it exceeds 120 lines, split it into sub-documents and link from a parent. The goal is ~70% fewer tokens than traditional documentation while preserving 100% of the technical details an agent needs.
|
||||
|
||||
---
|
||||
|
||||
## 3. The TABLEOFCONTENTS.md Format
|
||||
|
||||
This is the **router**. It maps questions to files. Format:
|
||||
|
||||
```markdown
|
||||
# Table of Contents — [Project Name]
|
||||
|
||||
> **Read this file first.** Find your topic below, then read ONLY the linked files.
|
||||
|
||||
## Quick Reference
|
||||
| Topic | File |
|
||||
|-------|------|
|
||||
| [Short topic] | [path/to/file.md] |
|
||||
| ... | ... |
|
||||
|
||||
## By Category
|
||||
|
||||
### [Category Name] (e.g., "Authentication", "Database", "API Endpoints")
|
||||
| Question / Topic | File(s) |
|
||||
|-------------------|---------|
|
||||
| How does [X] work? | [path.md] |
|
||||
| What are the [Y] endpoints? | [path.md] |
|
||||
| How is [Z] configured? | [path1.md], [path2.md] |
|
||||
|
||||
### [Next Category]
|
||||
...
|
||||
|
||||
## Architecture Overview
|
||||
[3-10 bullet points describing the high-level architecture — frameworks, major services, data flow. Just enough for an agent to orient itself before diving into specific docs.]
|
||||
```
|
||||
|
||||
**Rules for TABLEOFCONTENTS.md:**
|
||||
- Every documentation file MUST appear in at least one table row
|
||||
- Questions should be phrased the way a developer or AI agent would actually ask them
|
||||
- A single question can map to multiple files (e.g., "How do downloads work?" → `downloads.md`, `jobs.md`)
|
||||
- A single file can appear under multiple questions
|
||||
- Categories should match the codebase's actual domain boundaries, not generic labels
|
||||
- The Architecture Overview section gives agents a 30-second orientation before they search for specifics
|
||||
|
||||
---
|
||||
|
||||
## 4. Execution Plan
|
||||
|
||||
Follow these phases in order. **Delegate heavily using the Task tool** — you should be orchestrating, not doing all the reading yourself.
|
||||
|
||||
### Phase 1: Deep Discovery (Delegate to Explore Agents)
|
||||
|
||||
Launch **3-5 parallel Explore agents** using the Task tool to map the entire codebase. Each agent should focus on a different area. Suggested splits:
|
||||
|
||||
**Agent 1 — Project Structure & Config:**
|
||||
- Map the top-level directory tree (2-3 levels deep)
|
||||
- Identify the tech stack (languages, frameworks, package managers)
|
||||
- Read config files (package.json, tsconfig, docker-compose, .env.example, etc.)
|
||||
- Identify build/deploy pipeline
|
||||
- Note the entry points of the application
|
||||
|
||||
**Agent 2 — Backend / Server-Side:**
|
||||
- Identify all backend services, controllers, routes, middleware
|
||||
- Map API endpoints (paths, methods, handlers)
|
||||
- Identify the database layer (ORM, schema files, migrations)
|
||||
- Note background jobs, queues, cron tasks, workers
|
||||
- Identify authentication/authorization mechanisms
|
||||
|
||||
**Agent 3 — Frontend / Client-Side:**
|
||||
- Identify UI framework and component structure
|
||||
- Map page routes and navigation
|
||||
- Identify state management approach
|
||||
- Note API client/service layer
|
||||
- Identify shared components, layouts, hooks
|
||||
|
||||
**Agent 4 — Integrations & External Services:**
|
||||
- Identify all third-party API integrations
|
||||
- Map external service connections (databases, caches, message queues, cloud services)
|
||||
- Note webhook handlers, OAuth flows, API keys
|
||||
- Identify notification systems (email, push, SMS)
|
||||
|
||||
**Agent 5 — Data Layer & Business Logic:**
|
||||
- Map database schema (tables/collections, relationships, key fields)
|
||||
- Identify core business logic and domain models
|
||||
- Map data validation rules
|
||||
- Note important algorithms or complex logic
|
||||
|
||||
Adjust these splits based on what the repo actually contains. A frontend-only repo doesn't need a backend agent. A CLI tool doesn't need a frontend agent. Use your judgment.
|
||||
|
||||
**Each agent should return:**
|
||||
- A structured summary of what it found
|
||||
- File paths to the most important source files
|
||||
- A suggested list of documentation topics for its area
|
||||
|
||||
### Phase 2: Architecture Synthesis
|
||||
|
||||
After all discovery agents return, synthesize their findings:
|
||||
|
||||
1. **Draw the dependency map** — What are the major components? How do they connect?
|
||||
2. **Identify documentation topics** — Each distinct service, feature, integration, or subsystem gets its own doc file
|
||||
3. **Design the directory structure** — Mirror the codebase's architecture. Example:
|
||||
```
|
||||
documentation/
|
||||
├── TABLEOFCONTENTS.md
|
||||
├── README.md # Project overview (brief)
|
||||
├── architecture.md # System architecture, tech stack, data flow
|
||||
├── backend/
|
||||
│ ├── api-endpoints.md # Or split by domain: users.md, orders.md, etc.
|
||||
│ ├── database.md # Schema, ORM, migrations
|
||||
│ ├── auth.md # Authentication & authorization
|
||||
│ └── jobs.md # Background processing
|
||||
├── frontend/
|
||||
│ ├── components.md # Component tree, shared components
|
||||
│ ├── routing.md # Pages, navigation, guards
|
||||
│ └── state.md # State management
|
||||
├── integrations/
|
||||
│ ├── [service-name].md # One per external integration
|
||||
│ └── ...
|
||||
└── deployment/
|
||||
└── docker.md # Or whatever the deploy mechanism is
|
||||
```
|
||||
4. **Prioritize** — Rank topics by impact. High-impact = core architecture, APIs, database schema, auth, and anything with complex logic or non-obvious behavior. Low-impact = static config files, simple utility functions, standard boilerplate.
|
||||
|
||||
### Phase 3: Documentation Generation (Delegate to Writer Agents)
|
||||
|
||||
Launch **parallel writer agents** using the Task tool. Each agent writes 2-5 related documentation files.
|
||||
|
||||
**Instructions for each writer agent must include:**
|
||||
- The exact file paths to create
|
||||
- The list of source files to read for that topic
|
||||
- The token-efficient format template (copy Section 2.1 into each agent's prompt)
|
||||
- A reminder: "Write concise bullets, not prose. Include all technical details. Target 30-80 lines per file."
|
||||
|
||||
**Suggested batching:**
|
||||
- Agent A: `architecture.md` + `README.md` (needs broadest context)
|
||||
- Agent B: Backend services docs (group related services)
|
||||
- Agent C: Frontend docs
|
||||
- Agent D: Integration docs
|
||||
- Agent E: Database + deployment docs
|
||||
|
||||
Scale the number of agents to the size of the repo. A small repo might need 2-3 writers. A large monorepo might need 8-10.
|
||||
|
||||
**Each writer agent should return:** Confirmation of files written, with a brief summary of what each file covers and a list of cross-references to note for the TOC.
|
||||
|
||||
### Phase 4: Build the TABLEOFCONTENTS.md
|
||||
|
||||
After all writers finish, build the table of contents yourself. This requires you to:
|
||||
|
||||
1. Read or review every documentation file that was created
|
||||
2. For each file, generate 2-5 natural-language questions it answers
|
||||
3. Organize questions into categories that match the codebase's domain
|
||||
4. Write the Architecture Overview section (3-10 bullets, high-level only)
|
||||
5. Cross-check: every doc file appears in at least one row; no dead links
|
||||
|
||||
### Phase 5: Generate the CLAUDE.md
|
||||
|
||||
Write the project-root `CLAUDE.md` using the template in Section 5 below. Customize it for this specific repo — fill in the actual project name, the actual documentation structure, and real examples from the actual TOC.
|
||||
|
||||
### Phase 6: Validate
|
||||
|
||||
Do a final pass:
|
||||
1. Verify every file referenced in TABLEOFCONTENTS.md actually exists
|
||||
2. Verify every file in the `documentation/` directory appears in TABLEOFCONTENTS.md
|
||||
3. Spot-check 2-3 doc files for format compliance (status line, bullets not prose, within line limits)
|
||||
4. Verify CLAUDE.md references the correct paths
|
||||
|
||||
---
|
||||
|
||||
## 5. CLAUDE.md Template
|
||||
|
||||
Generate a `CLAUDE.md` at the project root using this template. **Customize every bracketed item** for the specific repo. Remove sections that don't apply. Keep it under 200 lines — this file is loaded into every conversation and consumes tokens.
|
||||
|
||||
```markdown
|
||||
# CLAUDE.md — [Project Name]
|
||||
|
||||
## Documentation System
|
||||
|
||||
This project uses a cascading, token-efficient documentation system optimized for AI agent consumption.
|
||||
|
||||
### How to Find Information
|
||||
|
||||
1. **Read `documentation/TABLEOFCONTENTS.md` FIRST** — this is the navigation index
|
||||
2. Find your topic in the question-to-file mapping tables
|
||||
3. Read ONLY the 1-3 files relevant to your task
|
||||
4. **Never read all documentation files** — this wastes token budget
|
||||
|
||||
### Documentation Structure
|
||||
[Insert the actual directory tree of documentation/ here]
|
||||
|
||||
### Example Lookups
|
||||
- "[Example question 1]" → `[actual-path-1.md]`
|
||||
- "[Example question 2]" → `[actual-path-2.md]`, `[actual-path-3.md]`
|
||||
- "[Example question 3]" → `[actual-path-4.md]`
|
||||
|
||||
## Token Budget Rules
|
||||
|
||||
- **20-30% of tokens:** Reading documentation (via TABLEOFCONTENTS.md targeting)
|
||||
- **70-80% of tokens:** Implementation and problem-solving
|
||||
|
||||
**Do:**
|
||||
- Use TABLEOFCONTENTS.md to target specific files
|
||||
- Read only "Key Details" and "API/Interfaces" sections
|
||||
- Skip code examples unless implementing similar functionality
|
||||
|
||||
**Don't:**
|
||||
- Read all documentation files sequentially
|
||||
- Read verbose examples when not needed
|
||||
- Re-read the same docs multiple times in one session
|
||||
|
||||
## Documentation Maintenance
|
||||
|
||||
When you modify code that changes behavior documented in `documentation/`:
|
||||
1. Read TABLEOFCONTENTS.md to find the relevant doc(s)
|
||||
2. Update those docs to reflect your changes
|
||||
3. Use the token-efficient format: bullets, tables, compact code blocks — no prose
|
||||
4. If you create a new doc, add it to TABLEOFCONTENTS.md
|
||||
|
||||
### Token-Efficient Format Reference
|
||||
- **Status line:** `**Status:** [Implemented | Partial | Planned] — [one-line summary]`
|
||||
- **Bullets, not paragraphs** — every detail as a dash-prefixed list item
|
||||
- **Tables for APIs** — method, path, request, response
|
||||
- **Code blocks only for schemas/configs** — max 2 per document
|
||||
- **30-80 lines per file** — split if over 120
|
||||
- **No:** prose explanations, future plans, testing strategy, empty sections
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Quality Standards
|
||||
|
||||
Your output will be evaluated on:
|
||||
|
||||
1. **TABLEOFCONTENTS.md completeness** — Can an agent find any topic by searching this one file?
|
||||
2. **Question quality** — Are the TOC questions phrased the way someone would actually ask them?
|
||||
3. **Format compliance** — Do all docs follow the token-efficient format? No prose, no fluff?
|
||||
4. **Accuracy** — Do the docs match what's actually in the code? Are file paths correct?
|
||||
5. **Coverage** — Are all high-impact areas documented? Are low-impact areas at least listed?
|
||||
6. **CLAUDE.md clarity** — Could a brand-new agent read CLAUDE.md and immediately know how to navigate the docs?
|
||||
7. **Cross-referencing** — Do Related sections link to the right companion docs?
|
||||
|
||||
---
|
||||
|
||||
## 7. Important Reminders
|
||||
|
||||
- **You are writing for AI agents, not humans.** Optimize for parseability and token efficiency, not readability or visual appeal.
|
||||
- **Accuracy over completeness.** It's better to document 80% of the codebase accurately than 100% with errors. If a discovery agent can't determine something with confidence, note it as `**Status:** Partial` and move on.
|
||||
- **Mirror the codebase's language.** Use the same names for things that the code uses. If the code calls it a "processor," don't call it a "handler" in the docs.
|
||||
- **File paths are critical.** Every doc should reference the actual source files it describes. Agents will use these paths to navigate directly to code.
|
||||
- **The TOC is the product.** The individual doc files are supporting material. If the TOC is excellent, the whole system works. If the TOC is poor, nothing else matters.
|
||||
- **Delegate aggressively.** You have access to the Task tool with sub-agents. Use it. The discovery phase should be 3-5 parallel agents. The writing phase should be 2-10 parallel agents depending on repo size. Your job is to orchestrate, synthesize, and build the TOC — not to read every file yourself.
|
||||
- **Do not add headers or comments to source code files.** Your output is documentation files only. Do not modify any existing source code.
|
||||
|
||||
---
|
||||
|
||||
## Now Begin
|
||||
|
||||
Start with Phase 1. Launch your discovery agents in parallel. Once they report back, proceed through the remaining phases. When complete, report what you've created and provide the full TABLEOFCONTENTS.md for review.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Manual Import Feature — Acceptance Criteria
|
||||
|
||||
**Status:** ⏳ In Progress
|
||||
|
||||
## Overview
|
||||
Allow admins to manually import audiobook files from the server filesystem into RMAB's processing pipeline for a specific book.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### AC-1: Manual Import Button (Frontend)
|
||||
- [ ] "Manual Import" button visible on `AudiobookDetailsModal` for admin users only
|
||||
- [ ] Button hidden when book is in active processing states: `downloading`, `processing`, `searching`
|
||||
- [ ] Button uses `FolderArrowDownIcon` from Heroicons
|
||||
- [ ] Clicking opens the file browser modal
|
||||
|
||||
### AC-2: File Browser Modal — Phase 1 (Browse)
|
||||
- [ ] Modal opens at `max-w-2xl`, rounded-2xl, with header/breadcrumb/listing/footer regions
|
||||
- [ ] Root view shows two entry tiles: Downloads and Media Library (paths from `download_dir` and `media_dir` config)
|
||||
- [ ] Each folder row shows: folder icon, name, metadata line (audio file count, subfolder count, total size)
|
||||
- [ ] Blue `♪ N` badge on folders containing audio files
|
||||
- [ ] Folder icon swaps to `FolderOpenIcon` on hover (150ms transition)
|
||||
- [ ] Single-click selects folder (only if it has audio files); double-click navigates into it
|
||||
- [ ] Folders without audio files shown at reduced opacity, still navigable but not selectable
|
||||
- [ ] Breadcrumb navigation with clickable segments, home icon for root, ellipsis collapse for deep paths
|
||||
- [ ] Footer shows selected path (monospace), file stats, "Review Import →" button (only when valid selection)
|
||||
- [ ] Directional slide animations: right when going deeper, left when going back
|
||||
- [ ] Loading skeletons during directory fetch
|
||||
- [ ] Empty state for empty directories
|
||||
- [ ] Error state with "Try Again" for failed directory reads
|
||||
- [ ] Dark mode support throughout
|
||||
|
||||
### AC-3: File Browser Modal — Phase 2 (Confirm)
|
||||
- [ ] Slide transition from browse to confirm phase
|
||||
- [ ] Shows book context: cover thumbnail + title + author
|
||||
- [ ] Shows selected folder: path (monospace) + stats in inset block
|
||||
- [ ] Numbered "What will happen" list: (1) copy to media library, (2) tag metadata, (3) download cover art, (4) scan library
|
||||
- [ ] "Back" button returns to browse phase
|
||||
- [ ] "Start Import" primary button triggers the import
|
||||
- [ ] Button shows loading state during API call
|
||||
- [ ] Success: close modal, show success toast, trigger request list refresh
|
||||
- [ ] Error: show error toast, stay on confirm screen
|
||||
|
||||
### AC-4: Filesystem Browse API
|
||||
- [ ] `GET /api/admin/filesystem/browse?path=...` — admin-only endpoint
|
||||
- [ ] Returns directory listing: `{ entries: [{ name, type, audioFileCount, subfolderCount, totalSize }] }`
|
||||
- [ ] If no `path` param, returns root directories (download_dir, media_dir from config)
|
||||
- [ ] Path validation: must be within allowed root directories (prevent directory traversal)
|
||||
- [ ] Handles permission errors gracefully
|
||||
- [ ] Sorts: folders first, then alphabetical
|
||||
|
||||
### AC-5: Manual Import API
|
||||
- [ ] `POST /api/admin/manual-import` — admin-only endpoint
|
||||
- [ ] Request body: `{ audiobookId: string, folderPath: string }`
|
||||
- [ ] Path validation: folderPath must be within allowed roots
|
||||
- [ ] Validates folder exists and contains audio files
|
||||
- [ ] If no existing request: creates request (status: `processing`) + queues `organize_files` job
|
||||
- [ ] If existing request (non-active state): updates status to `processing` + queues `organize_files` job
|
||||
- [ ] Returns: `{ success: true, requestId: string }`
|
||||
- [ ] Proper error responses for: invalid path, no audio files, already processing, book not found
|
||||
|
||||
### AC-6: Integration with Existing Pipeline
|
||||
- [ ] The `organize_files` job processes the manual import folder identically to download-client-delivered folders
|
||||
- [ ] Files are copied (not moved) to the media library
|
||||
- [ ] Metadata tagging, cover art download, file hash generation all work as normal
|
||||
- [ ] Library scan triggered after organization (if configured)
|
||||
- [ ] Request status progresses: processing → downloaded → available (via scheduled scan)
|
||||
|
||||
### AC-7: Docker Build
|
||||
- [ ] `docker compose build readmeabook` succeeds with no errors
|
||||
|
||||
## Non-Goals
|
||||
- No "move" option (copy only, matching existing pipeline)
|
||||
- No file-level selection (folder only)
|
||||
- No drag-and-drop upload
|
||||
- No non-admin access
|
||||
|
||||
## Technical Notes
|
||||
- Audio extensions: `.m4b`, `.m4a`, `.mp3`, `.mp4`, `.aa`, `.aax`, `.flac`, `.ogg` (from `src/lib/constants/audio-formats.ts`)
|
||||
- Config keys: `download_dir` (database), `media_dir` (database)
|
||||
- Existing file organizer: `src/lib/utils/file-organizer.ts`
|
||||
- Organize processor: `src/lib/processors/organize-files.processor.ts`
|
||||
- Job queue service: `src/lib/services/job-queue.service.ts`
|
||||
- Auth middleware: `requireAuth()`, `requireAdmin()` from `src/lib/middleware/auth.ts`
|
||||
- Frontend API pattern: `fetchWithAuth()` from `src/lib/utils/api.ts`
|
||||
- Modal base: `src/components/ui/Modal.tsx`
|
||||
- Audiobook details modal: `src/components/audiobooks/AudiobookDetailsModal.tsx`
|
||||
- Toast: `useToast()` from toast context
|
||||
@@ -33,6 +33,7 @@ Configurable Audible region for accurate metadata matching across different inte
|
||||
- India (`in`) - `audible.in` (English)
|
||||
- Germany (`de`) - `audible.de` (non-English)
|
||||
- Spain (`es`) - `audible.es` (non-English)
|
||||
- French (`fr`) - `audible.fr` (non-English)
|
||||
|
||||
**`isEnglish` Flag:**
|
||||
- Each region has `isEnglish: boolean` in `AudibleRegionConfig`
|
||||
|
||||
@@ -243,7 +243,7 @@ type TorrentState =
|
||||
- `forcedUP` → `seeding`/`completed` enables monitor to trigger import
|
||||
- `stoppedDL` → `paused` ensures qBittorrent v5.x compatibility
|
||||
|
||||
**16. pausedUP/stoppedUP mapped as paused instead of completed** - RDT-Client (and qBittorrent after ratio limits) transitions directly to `pausedUP`/`stoppedUP` without passing through `uploading`/`stalledUP`. The `*UP` suffix means the download phase is complete and the torrent is on the upload side. Both states were incorrectly mapped to `'paused'`, causing the monitor to re-schedule checks indefinitely instead of triggering file organization. Fixed by:
|
||||
**16. pausedUP/stoppedUP mapped as paused instead of completed** - qBittorrent (after ratio limits) transitions directly to `pausedUP`/`stoppedUP` without passing through `uploading`/`stalledUP`. The `*UP` suffix means the download phase is complete and the torrent is on the upload side. Both states were incorrectly mapped to `'paused'`, causing the monitor to re-schedule checks indefinitely instead of triggering file organization. Fixed by:
|
||||
- `pausedUP` → `seeding` (unified) / `completed` (legacy) — triggers completion in monitor
|
||||
- `stoppedUP` → `seeding` (unified) / `completed` (legacy) — same fix for qBittorrent v5.x
|
||||
- `pausedDL`/`stoppedDL` remain `paused` — download phase genuinely paused
|
||||
|
||||
@@ -271,7 +271,7 @@ src/app/admin/settings/
|
||||
|
||||
**PUT /api/admin/settings/audible**
|
||||
- Updates Audible region
|
||||
- Body: `{ region: string }` (one of: us, ca, uk, au, in, es)
|
||||
- Body: `{ region: string }` (one of: us, ca, uk, au, in, es, fr)
|
||||
- No validation required
|
||||
|
||||
**PUT /api/admin/settings/prowlarr/indexers**
|
||||
|
||||
Generated
+820
-31
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.16",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -18,7 +18,9 @@
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.7.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bull": "^4.12.0",
|
||||
@@ -43,9 +45,9 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@types/adm-zip": "^0.5.6",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/adm-zip": "^0.5.6",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/bull": "^4.10.0",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "download_access" BOOLEAN;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "requests" ADD COLUMN "custom_search_terms" TEXT;
|
||||
@@ -55,6 +55,7 @@ model User {
|
||||
|
||||
// Fine-grained permissions
|
||||
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
|
||||
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
|
||||
|
||||
// Soft delete support
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
@@ -176,6 +177,7 @@ model Audiobook {
|
||||
year Int? // Release year extracted from releaseDate
|
||||
series String? // Book series name (e.g., "The Mistborn Saga")
|
||||
seriesPart String? @map("series_part") // Series position (e.g., "1", "1.5", "Book 1")
|
||||
seriesAsin String? @map("series_asin") // Audible series ASIN for linking to series detail page
|
||||
|
||||
// Request tracking
|
||||
status String @default("requested") // requested, downloading, processing, completed, failed
|
||||
@@ -230,6 +232,7 @@ model Request {
|
||||
importAttempts Int @default(0) @map("import_attempts")
|
||||
maxImportRetries Int @default(5) @map("max_import_retries")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
customSearchTerms String? @map("custom_search_terms") @db.Text
|
||||
lastImportAt DateTime? @map("last_import_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -389,7 +392,7 @@ model ScheduledJob {
|
||||
|
||||
model BookDateConfig {
|
||||
id String @id @default(uuid())
|
||||
provider String // 'openai' | 'claude' | 'custom'
|
||||
provider String // 'openai' | 'claude' | 'gemini' | 'custom'
|
||||
apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256)
|
||||
model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929'
|
||||
baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Component: Adjust Search Terms Modal
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
interface AdjustSearchTermsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
currentSearchTerms?: string | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function AdjustSearchTermsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
requestId,
|
||||
title,
|
||||
author,
|
||||
currentSearchTerms,
|
||||
onSuccess,
|
||||
}: AdjustSearchTermsModalProps) {
|
||||
const toast = useToast();
|
||||
const [searchTerms, setSearchTerms] = useState(currentSearchTerms || title);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSavingAndSearching, setIsSavingAndSearching] = useState(false);
|
||||
|
||||
// Reset state when modal opens
|
||||
const handleClose = () => {
|
||||
setSearchTerms(currentSearchTerms || title);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const save = async (triggerSearch: boolean) => {
|
||||
const setter = triggerSearch ? setIsSavingAndSearching : setIsSaving;
|
||||
setter(true);
|
||||
|
||||
try {
|
||||
// If terms match the original title, clear the override
|
||||
const termsToSave = searchTerms.trim() === title ? null : searchTerms.trim() || null;
|
||||
|
||||
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/search-terms`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ searchTerms: termsToSave, triggerSearch }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to update search terms');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.searchTriggered) {
|
||||
toast.success('Search terms saved and search triggered');
|
||||
} else {
|
||||
toast.success('Search terms saved');
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(`Failed to save: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setter(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchTerms(title);
|
||||
};
|
||||
|
||||
const isLoading = isSaving || isSavingAndSearching;
|
||||
const hasChanges = searchTerms.trim() !== (currentSearchTerms || title);
|
||||
const isCustom = searchTerms.trim() !== title;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Adjust Search Terms" size="sm">
|
||||
<div className="space-y-4">
|
||||
{/* Original info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 space-y-1">
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Original Title
|
||||
</div>
|
||||
<div className="text-sm text-gray-900 dark:text-gray-100 font-medium">{title}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">by {author}</div>
|
||||
</div>
|
||||
|
||||
{/* Search terms input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="search-terms"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
|
||||
>
|
||||
Search Terms
|
||||
</label>
|
||||
<input
|
||||
id="search-terms"
|
||||
type="text"
|
||||
value={searchTerms}
|
||||
onChange={(e) => setSearchTerms(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
|
||||
placeholder="Enter custom search terms..."
|
||||
/>
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={isLoading}
|
||||
className="mt-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Reset to original title
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => save(false)}
|
||||
disabled={isLoading || !searchTerms.trim()}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => save(true)}
|
||||
disabled={isLoading || !searchTerms.trim()}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSavingAndSearching ? 'Saving...' : 'Save & Search'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,8 @@ interface RecentRequest {
|
||||
completedAt: Date | null;
|
||||
errorMessage: string | null;
|
||||
torrentUrl?: string | null;
|
||||
downloadAttempts?: number;
|
||||
customSearchTerms?: string | null;
|
||||
}
|
||||
|
||||
interface User {
|
||||
@@ -444,6 +446,29 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryDownload = async (requestId: string) => {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/retry-download`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(responseData.message || 'Failed to retry download');
|
||||
}
|
||||
|
||||
toast.success(responseData.message || 'Download retry initiated');
|
||||
await mutate(apiUrl);
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to retry download:', error);
|
||||
toast.error(`Failed to retry download: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Render loading state
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
@@ -638,6 +663,17 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
||||
Ebook
|
||||
</span>
|
||||
)}
|
||||
{request.customSearchTerms && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200"
|
||||
title={`Custom search: ${request.customSearchTerms}`}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Custom Search
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{request.author}
|
||||
@@ -673,12 +709,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
||||
type: request.type,
|
||||
asin: request.asin,
|
||||
torrentUrl: request.torrentUrl,
|
||||
downloadAttempts: request.downloadAttempts,
|
||||
customSearchTerms: request.customSearchTerms,
|
||||
}}
|
||||
onDelete={handleDeleteClick}
|
||||
onManualSearch={handleManualSearch}
|
||||
onCancel={handleCancel}
|
||||
onRetryDownload={handleRetryDownload}
|
||||
onViewDetails={(asin) => handleViewDetails(asin, request.status)}
|
||||
onFetchEbook={handleFetchEbook}
|
||||
onSearchTermsUpdated={() => mutate(apiUrl)}
|
||||
ebookSidecarEnabled={ebookSidecarEnabled}
|
||||
annasArchiveBaseUrl={annasArchiveBaseUrl}
|
||||
isLoading={isDeleting || isFetchingEbook}
|
||||
@@ -835,7 +875,6 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
||||
}}
|
||||
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
|
||||
requestStatus={viewDetailsStatus}
|
||||
hideRequestActions
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
|
||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||
|
||||
export interface RequestActionsDropdownProps {
|
||||
@@ -21,12 +22,16 @@ export interface RequestActionsDropdownProps {
|
||||
type?: 'audiobook' | 'ebook';
|
||||
asin?: string | null;
|
||||
torrentUrl?: string | null;
|
||||
downloadAttempts?: number;
|
||||
customSearchTerms?: string | null;
|
||||
};
|
||||
onDelete: (requestId: string, title: string) => void;
|
||||
onManualSearch: (requestId: string) => Promise<void>;
|
||||
onCancel: (requestId: string) => Promise<void>;
|
||||
onRetryDownload?: (requestId: string) => Promise<void>;
|
||||
onViewDetails?: (asin: string) => void;
|
||||
onFetchEbook?: (requestId: string) => Promise<void>;
|
||||
onSearchTermsUpdated?: () => void;
|
||||
ebookSidecarEnabled?: boolean;
|
||||
annasArchiveBaseUrl?: string;
|
||||
isLoading?: boolean;
|
||||
@@ -37,8 +42,10 @@ export function RequestActionsDropdown({
|
||||
onDelete,
|
||||
onManualSearch,
|
||||
onCancel,
|
||||
onRetryDownload,
|
||||
onViewDetails,
|
||||
onFetchEbook,
|
||||
onSearchTermsUpdated,
|
||||
ebookSidecarEnabled = false,
|
||||
annasArchiveBaseUrl = 'https://annas-archive.li',
|
||||
isLoading = false,
|
||||
@@ -46,6 +53,7 @@ export function RequestActionsDropdown({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
||||
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
|
||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
|
||||
|
||||
// Determine request type
|
||||
@@ -57,6 +65,8 @@ export function RequestActionsDropdown({
|
||||
// Determine available actions based on status and type
|
||||
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canAdjustSearchTerms = !isEbook && ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
|
||||
@@ -123,11 +133,27 @@ export function RequestActionsDropdown({
|
||||
setShowInteractiveSearch(true);
|
||||
};
|
||||
|
||||
const handleAdjustSearchTerms = () => {
|
||||
setIsOpen(false);
|
||||
setShowAdjustSearchTerms(true);
|
||||
};
|
||||
|
||||
const handleInteractiveSearchEbook = () => {
|
||||
setIsOpen(false);
|
||||
setShowInteractiveSearchEbook(true);
|
||||
};
|
||||
|
||||
const handleRetryDownload = async () => {
|
||||
setIsOpen(false);
|
||||
if (onRetryDownload) {
|
||||
try {
|
||||
await onRetryDownload(request.requestId);
|
||||
} catch (error) {
|
||||
console.error('Failed to retry download:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setIsOpen(false);
|
||||
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
|
||||
@@ -253,6 +279,35 @@ export function RequestActionsDropdown({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Adjust Search Terms */}
|
||||
{canAdjustSearchTerms && (
|
||||
<button
|
||||
onClick={handleAdjustSearchTerms}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="flex items-center gap-1.5">
|
||||
Adjust Search Terms
|
||||
{request.customSearchTerms && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* View Source */}
|
||||
{canViewSource && viewSourceUrl && (
|
||||
<a
|
||||
@@ -328,8 +383,32 @@ export function RequestActionsDropdown({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider if we have search/view actions and other actions */}
|
||||
{(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && (
|
||||
{/* Retry Download */}
|
||||
{canRetryDownload && (
|
||||
<button
|
||||
onClick={handleRetryDownload}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Retry Download
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider if we have search/view/retry actions and other actions */}
|
||||
{(canSearch || canViewSource || canFetchEbook || canRetryDownload) && (canCancel || canDelete) && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
)}
|
||||
|
||||
@@ -358,7 +437,7 @@ export function RequestActionsDropdown({
|
||||
)}
|
||||
|
||||
{/* Divider before delete */}
|
||||
{canDelete && (canSearch || canCancel) && (
|
||||
{canDelete && (canSearch || canRetryDownload || canCancel) && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
)}
|
||||
|
||||
@@ -421,6 +500,7 @@ export function RequestActionsDropdown({
|
||||
title: request.title,
|
||||
author: request.author,
|
||||
}}
|
||||
customSearchTerms={request.customSearchTerms}
|
||||
/>
|
||||
|
||||
{/* Interactive Search Modal (Ebook) */}
|
||||
@@ -434,6 +514,17 @@ export function RequestActionsDropdown({
|
||||
}}
|
||||
searchMode="ebook"
|
||||
/>
|
||||
|
||||
{/* Adjust Search Terms Modal */}
|
||||
<AdjustSearchTermsModal
|
||||
isOpen={showAdjustSearchTerms}
|
||||
onClose={() => setShowAdjustSearchTerms(false)}
|
||||
requestId={request.requestId}
|
||||
title={request.title}
|
||||
author={request.author}
|
||||
currentSearchTerms={request.customSearchTerms}
|
||||
onSuccess={onSearchTermsUpdated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+209
-123
@@ -78,13 +78,11 @@ function AdminJobsPageContent() {
|
||||
const showEditDialog = (job: ScheduledJob) => {
|
||||
setEditForm({ schedule: job.schedule, enabled: job.enabled });
|
||||
|
||||
// Check if it's a preset
|
||||
const preset = SCHEDULE_PRESETS.find(p => p.cron === job.schedule);
|
||||
if (preset) {
|
||||
setScheduleMode('preset');
|
||||
setSelectedPreset(preset.cron);
|
||||
} else {
|
||||
// Try to parse as custom schedule
|
||||
const parsed = cronToCustomSchedule(job.schedule);
|
||||
if (parsed.type === 'custom') {
|
||||
setScheduleMode('advanced');
|
||||
@@ -111,7 +109,7 @@ function AdminJobsPageContent() {
|
||||
method: 'POST',
|
||||
});
|
||||
toast.success(`Job "${jobName}" triggered successfully`);
|
||||
fetchJobs(); // Refresh list
|
||||
fetchJobs();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to trigger job';
|
||||
toast.error(errorMsg);
|
||||
@@ -124,7 +122,6 @@ function AdminJobsPageContent() {
|
||||
const saveJobSchedule = async () => {
|
||||
if (!editDialog.job) return;
|
||||
|
||||
// Calculate final cron expression based on mode
|
||||
let finalCron: string;
|
||||
if (scheduleMode === 'preset') {
|
||||
finalCron = selectedPreset;
|
||||
@@ -134,7 +131,6 @@ function AdminJobsPageContent() {
|
||||
finalCron = editForm.schedule;
|
||||
}
|
||||
|
||||
// Validate cron expression
|
||||
if (!isValidCron(finalCron)) {
|
||||
toast.error('Invalid cron expression. Please check your schedule.');
|
||||
return;
|
||||
@@ -151,7 +147,7 @@ function AdminJobsPageContent() {
|
||||
});
|
||||
toast.success(`Job "${editDialog.job.name}" updated successfully`);
|
||||
hideEditDialog();
|
||||
fetchJobs(); // Refresh list
|
||||
fetchJobs();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update job';
|
||||
toast.error(errorMsg);
|
||||
@@ -173,36 +169,131 @@ function AdminJobsPageContent() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Scheduled Jobs
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Manage recurring tasks and automated jobs
|
||||
</p>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
|
||||
{/* Header — stacks on mobile, row on sm+ */}
|
||||
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Scheduled Jobs
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage recurring tasks and automated jobs
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-red-800 dark:text-red-200">{error}</p>
|
||||
<p className="text-red-800 dark:text-red-200 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jobs Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
{/* Jobs — Card layout on mobile, Table on sm+ */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{jobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
{/* Card header */}
|
||||
<div className="px-4 py-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
|
||||
{job.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{job.type}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`flex-shrink-0 mt-0.5 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full ${
|
||||
job.enabled
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{job.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Card body */}
|
||||
<div className="px-4 pb-3 space-y-2 border-t border-gray-100 dark:border-gray-700/60 pt-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
|
||||
Schedule
|
||||
</div>
|
||||
<div className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{cronToHuman(job.schedule)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5">
|
||||
{job.schedule}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
|
||||
Last Run
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{job.lastRun ? new Date(job.lastRun).toLocaleString() : 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card actions */}
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/60 flex gap-2">
|
||||
<button
|
||||
onClick={() => showEditDialog(job)}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => showConfirmDialog(job.id, job.name)}
|
||||
disabled={triggering === job.id}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40 text-blue-700 dark:text-blue-400 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{triggering === job.id ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Trigger
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{jobs.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">No scheduled jobs found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Jobs Table — hidden on mobile, visible on sm+ */}
|
||||
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
@@ -312,31 +403,31 @@ function AdminJobsPageContent() {
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>• <strong>Library Scan:</strong> Automatically scans your media library for new audiobooks</li>
|
||||
<li>• <strong>Audible Data Refresh:</strong> Caches popular and new release audiobooks from Audible</li>
|
||||
<li>• Trigger jobs manually using the "Trigger Now" button</li>
|
||||
<li>• Trigger jobs manually using the "Trigger Now" button</li>
|
||||
<li>• Schedule format follows cron syntax (minute hour day month weekday)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{confirmDialog.isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl sm:rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Confirm Job Trigger
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-6">
|
||||
Are you sure you want to trigger "{confirmDialog.jobName}" now?
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={hideConfirmDialog}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
className="flex-1 px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={triggerJob}
|
||||
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
className="flex-1 px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
Trigger Job
|
||||
</button>
|
||||
@@ -347,12 +438,27 @@ function AdminJobsPageContent() {
|
||||
|
||||
{/* Edit Job Dialog */}
|
||||
{editDialog.isOpen && editDialog.job && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Edit Job Schedule
|
||||
</h3>
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-0 sm:p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] overflow-y-auto">
|
||||
{/* Dialog header */}
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 px-5 py-4 border-b border-gray-200 dark:border-gray-700 rounded-t-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Edit Job Schedule
|
||||
</h3>
|
||||
<button
|
||||
onClick={hideEditDialog}
|
||||
className="p-2 -mr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className="px-5 py-5 space-y-5">
|
||||
{/* Job Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
@@ -362,46 +468,29 @@ function AdminJobsPageContent() {
|
||||
type="text"
|
||||
value={editDialog.job.name}
|
||||
disabled
|
||||
className="w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-600 rounded-lg cursor-not-allowed"
|
||||
className="w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-600 rounded-lg cursor-not-allowed text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Schedule Mode Tabs */}
|
||||
{/* Schedule Mode Tabs — grid on mobile to avoid overflow */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Schedule Type
|
||||
</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => setScheduleMode('preset')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
scheduleMode === 'preset'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Common Schedules
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScheduleMode('custom')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
scheduleMode === 'custom'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Custom Schedule
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScheduleMode('advanced')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
scheduleMode === 'advanced'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Advanced (Cron)
|
||||
</button>
|
||||
<div className="grid grid-cols-3 gap-1 p-1 bg-gray-100 dark:bg-gray-700/60 rounded-xl mb-4">
|
||||
{(['preset', 'custom', 'advanced'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setScheduleMode(mode)}
|
||||
className={`px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||
scheduleMode === mode
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{mode === 'preset' ? 'Common' : mode === 'custom' ? 'Custom' : 'Advanced'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preset Mode */}
|
||||
@@ -418,16 +507,16 @@ function AdminJobsPageContent() {
|
||||
value={preset.cron}
|
||||
checked={selectedPreset === preset.cron}
|
||||
onChange={(e) => setSelectedPreset(e.target.value)}
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{preset.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{preset.description}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-1">
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5">
|
||||
{preset.cron}
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,8 +534,8 @@ function AdminJobsPageContent() {
|
||||
</label>
|
||||
<select
|
||||
value={customSchedule.type}
|
||||
onChange={(e) => setCustomSchedule({ ...customSchedule, type: e.target.value as any })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
onChange={(e) => setCustomSchedule({ ...customSchedule, type: e.target.value as CustomSchedule['type'] })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="minutes">Every X minutes</option>
|
||||
<option value="hours">Every X hours</option>
|
||||
@@ -456,7 +545,6 @@ function AdminJobsPageContent() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Minutes/Hours Interval */}
|
||||
{(customSchedule.type === 'minutes' || customSchedule.type === 'hours') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
@@ -468,7 +556,7 @@ function AdminJobsPageContent() {
|
||||
max={customSchedule.type === 'minutes' ? 59 : 23}
|
||||
value={customSchedule.interval || 1}
|
||||
onChange={(e) => setCustomSchedule({ ...customSchedule, interval: parseInt(e.target.value, 10) })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Run every {customSchedule.interval || 1} {customSchedule.type}
|
||||
@@ -476,12 +564,11 @@ function AdminJobsPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily/Weekly/Monthly Time */}
|
||||
{(customSchedule.type === 'daily' || customSchedule.type === 'weekly' || customSchedule.type === 'monthly') && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Hour (0-23)
|
||||
Hour (0–23)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -494,12 +581,12 @@ function AdminJobsPageContent() {
|
||||
time: { hour: parseInt(e.target.value, 10), minute: customSchedule.time?.minute || 0 },
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minute (0-59)
|
||||
Minute (0–59)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -512,13 +599,12 @@ function AdminJobsPageContent() {
|
||||
time: { hour: customSchedule.time?.hour || 0, minute: parseInt(e.target.value, 10) },
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weekly Day Selection */}
|
||||
{customSchedule.type === 'weekly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
@@ -527,7 +613,7 @@ function AdminJobsPageContent() {
|
||||
<select
|
||||
value={customSchedule.dayOfWeek || 0}
|
||||
onChange={(e) => setCustomSchedule({ ...customSchedule, dayOfWeek: parseInt(e.target.value, 10) })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="0">Sunday</option>
|
||||
<option value="1">Monday</option>
|
||||
@@ -540,11 +626,10 @@ function AdminJobsPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly Day Selection */}
|
||||
{customSchedule.type === 'monthly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Day of Month (1-31)
|
||||
Day of Month (1–31)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -552,12 +637,11 @@ function AdminJobsPageContent() {
|
||||
max="31"
|
||||
value={customSchedule.dayOfMonth || 1}
|
||||
onChange={(e) => setCustomSchedule({ ...customSchedule, dayOfMonth: parseInt(e.target.value, 10) })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="text-sm font-medium text-blue-900 dark:text-blue-200">
|
||||
Preview: {cronToHuman(customScheduleToCron(customSchedule))}
|
||||
@@ -571,30 +655,32 @@ function AdminJobsPageContent() {
|
||||
|
||||
{/* Advanced Mode */}
|
||||
{scheduleMode === 'advanced' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Cron Expression
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.schedule}
|
||||
onChange={(e) => setEditForm({ ...editForm, schedule: e.target.value })}
|
||||
placeholder="0 */6 * * *"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Format: minute hour day month weekday
|
||||
</p>
|
||||
<div className="mt-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<div>• */15 * * * * = Every 15 minutes</div>
|
||||
<div>• 0 */6 * * * = Every 6 hours</div>
|
||||
<div>• 0 0 * * * = Daily at midnight</div>
|
||||
<div>• 0 0 * * 0 = Weekly on Sunday</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Cron Expression
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.schedule}
|
||||
onChange={(e) => setEditForm({ ...editForm, schedule: e.target.value })}
|
||||
placeholder="0 */6 * * *"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Format: minute hour day month weekday
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1 font-mono">
|
||||
<div>*/15 * * * * = Every 15 minutes</div>
|
||||
<div>0 */6 * * * = Every 6 hours</div>
|
||||
<div>0 0 * * * = Daily at midnight</div>
|
||||
<div>0 0 * * 0 = Weekly on Sunday</div>
|
||||
</div>
|
||||
</div>
|
||||
{editForm.schedule && (
|
||||
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="text-sm font-medium text-blue-900 dark:text-blue-200">
|
||||
Preview: {cronToHuman(editForm.schedule)}
|
||||
</div>
|
||||
@@ -604,34 +690,34 @@ function AdminJobsPageContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enabled Checkbox */}
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{/* Enabled toggle */}
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={editForm.enabled}
|
||||
onChange={(e) => setEditForm({ ...editForm, enabled: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
|
||||
/>
|
||||
<label htmlFor="enabled" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label htmlFor="enabled" className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
Enable this job
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
{/* Dialog footer */}
|
||||
<div className="sticky bottom-0 bg-white dark:bg-gray-800 px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex gap-3">
|
||||
<button
|
||||
onClick={hideEditDialog}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex-1 sm:flex-none px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={saveJobSchedule}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex-1 sm:flex-none px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
|
||||
+236
-148
@@ -56,6 +56,119 @@ interface LogsData {
|
||||
};
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config: Record<string, { dot: string; text: string; bg: string }> = {
|
||||
completed: { dot: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-400', bg: 'bg-emerald-500/10' },
|
||||
failed: { dot: 'bg-red-500', text: 'text-red-700 dark:text-red-400', bg: 'bg-red-500/10' },
|
||||
active: { dot: 'bg-blue-500', text: 'text-blue-700 dark:text-blue-400', bg: 'bg-blue-500/10' },
|
||||
pending: { dot: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-400', bg: 'bg-amber-500/10' },
|
||||
delayed: { dot: 'bg-orange-500', text: 'text-orange-700 dark:text-orange-400', bg: 'bg-orange-500/10' },
|
||||
stuck: { dot: 'bg-purple-500', text: 'text-purple-700 dark:text-purple-400', bg: 'bg-purple-500/10' },
|
||||
};
|
||||
const c = config[status] ?? { dot: 'bg-gray-400', text: 'text-gray-600 dark:text-gray-400', bg: 'bg-gray-500/10' };
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${c.bg} ${c.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${c.dot}`} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LogDetails({ log }: { log: Log }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{log.bullJobId && (
|
||||
<div className="flex flex-wrap gap-1.5 items-baseline">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Bull Job ID:</span>
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300 font-mono break-all">{log.bullJobId}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.events.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
|
||||
Event Log
|
||||
</h4>
|
||||
<div className="space-y-px max-h-72 sm:max-h-96 overflow-y-auto bg-gray-950 dark:bg-black/60 rounded-xl p-3 font-mono text-xs">
|
||||
{log.events.map((event) => {
|
||||
const timestamp = new Date(event.createdAt).toISOString().split('T')[1].split('.')[0];
|
||||
const levelColor = event.level === 'error'
|
||||
? 'text-red-400'
|
||||
: event.level === 'warn'
|
||||
? 'text-amber-400'
|
||||
: 'text-emerald-400';
|
||||
|
||||
return (
|
||||
<div key={event.id} className="text-gray-300 leading-relaxed">
|
||||
<span className={levelColor}>[{event.context}]</span>
|
||||
{' '}
|
||||
<span className="break-words">{event.message}</span>
|
||||
<span className="text-gray-500 ml-2">{timestamp}</span>
|
||||
{event.metadata && Object.keys(event.metadata).length > 0 && (
|
||||
<pre className="ml-4 mt-1 text-gray-400 text-xs overflow-x-auto">
|
||||
{JSON.stringify(event.metadata, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.result && Object.keys(log.result).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
|
||||
Job Result
|
||||
</h4>
|
||||
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto max-h-48">
|
||||
{JSON.stringify(log.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.errorMessage && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
|
||||
Error
|
||||
</h4>
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-xs text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words">
|
||||
{log.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: string | null, completedAt: string | null) {
|
||||
if (!startedAt) return 'N/A';
|
||||
if (!completedAt) return 'Running…';
|
||||
const durationMs = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function formatType(type: string) {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
}
|
||||
|
||||
function formatDateShort(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
|
||||
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
@@ -65,9 +178,7 @@ export default function AdminLogsPage() {
|
||||
const { data, error } = useSWR<LogsData>(
|
||||
`/api/admin/logs?page=${page}&limit=50&status=${statusFilter}&type=${typeFilter}`,
|
||||
authenticatedFetcher,
|
||||
{
|
||||
refreshInterval: 10000, // Refresh every 10 seconds
|
||||
}
|
||||
{ refreshInterval: 10000 }
|
||||
);
|
||||
|
||||
const isLoading = !data && !error;
|
||||
@@ -87,9 +198,7 @@ export default function AdminLogsPage() {
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error Loading Logs
|
||||
</h3>
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Logs</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
{error?.message || 'Failed to load system logs'}
|
||||
</p>
|
||||
@@ -101,80 +210,45 @@ export default function AdminLogsPage() {
|
||||
|
||||
const logs = data?.logs || [];
|
||||
const pagination = data?.pagination;
|
||||
|
||||
const getStatusBadgeColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
||||
case 'active':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||||
case 'delayed':
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';
|
||||
case 'stuck':
|
||||
return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (startedAt: string | null, completedAt: string | null) => {
|
||||
if (!startedAt) return 'N/A';
|
||||
if (!completedAt) return 'Running...';
|
||||
|
||||
const start = new Date(startedAt).getTime();
|
||||
const end = new Date(completedAt).getTime();
|
||||
const durationMs = end - start;
|
||||
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
};
|
||||
const hasDetails = (log: Log) => log.events.length > 0 || !!log.errorMessage || !!log.bullJobId || (log.result && Object.keys(log.result).length > 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
System Logs
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
View background jobs and system activity
|
||||
</p>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
|
||||
{/* Header — stacks on mobile, row on sm+ */}
|
||||
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
System Logs
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
View background jobs and system activity
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-wrap gap-4">
|
||||
{/* Filters — full-width stacked on mobile */}
|
||||
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
@@ -186,16 +260,13 @@ export default function AdminLogsPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
|
||||
Job Type
|
||||
</label>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => {
|
||||
setTypeFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
onChange={(e) => { setTypeFilter(e.target.value); setPage(1); }}
|
||||
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="search_indexers">Search Indexers</option>
|
||||
@@ -215,8 +286,77 @@ export default function AdminLogsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
{/* Mobile card list — hidden on sm+ */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
{/* Card header */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
|
||||
{formatType(log.type)}
|
||||
</div>
|
||||
<StatusBadge status={log.status} />
|
||||
</div>
|
||||
|
||||
{/* Related item */}
|
||||
{log.request?.audiobook ? (
|
||||
<div className="text-sm mb-2">
|
||||
<div className="text-gray-700 dark:text-gray-300 font-medium leading-snug">
|
||||
{log.request.audiobook.title}
|
||||
</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs">
|
||||
by {log.request.audiobook.author} · {log.request.user.plexUsername}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">System job</div>
|
||||
)}
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{formatDateShort(log.createdAt)}</span>
|
||||
<span>Duration: {formatDuration(log.startedAt, log.completedAt)}</span>
|
||||
<span>Attempts: {log.attempts}/{log.maxAttempts}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable details */}
|
||||
{hasDetails(log) && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 border-t border-gray-100 dark:border-gray-700/60 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors"
|
||||
>
|
||||
<span>{expandedLog === log.id ? 'Hide Details' : 'Show Details'}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform duration-200 ${expandedLog === log.id ? 'rotate-180' : ''}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{expandedLog === log.id && (
|
||||
<div className="px-4 pb-4 pt-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700/60">
|
||||
<LogDetails log={log} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">No logs found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop table — hidden on mobile */}
|
||||
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
@@ -253,13 +393,11 @@ export default function AdminLogsPage() {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{log.type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
{formatType(log.type)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusBadgeColor(log.status)}`}>
|
||||
{log.status.toUpperCase()}
|
||||
</span>
|
||||
<StatusBadge status={log.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{log.request?.audiobook ? (
|
||||
@@ -285,7 +423,7 @@ export default function AdminLogsPage() {
|
||||
{log.attempts}/{log.maxAttempts}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{(log.events.length > 0 || log.errorMessage || log.bullJobId || log.result) && (
|
||||
{hasDetails(log) && (
|
||||
<button
|
||||
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
|
||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
@@ -298,63 +436,7 @@ export default function AdminLogsPage() {
|
||||
{expandedLog === log.id && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="space-y-4">
|
||||
{log.bullJobId && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Bull Job ID: </span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">{log.bullJobId}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Logs */}
|
||||
{log.events.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Event Log</h4>
|
||||
<div className="space-y-1 max-h-96 overflow-y-auto bg-black/5 dark:bg-black/30 rounded p-3 font-mono text-xs">
|
||||
{log.events.map((event) => {
|
||||
const timestamp = new Date(event.createdAt).toISOString().split('T')[1].split('.')[0];
|
||||
const levelColor = event.level === 'error'
|
||||
? 'text-red-500'
|
||||
: event.level === 'warn'
|
||||
? 'text-yellow-500'
|
||||
: 'text-green-500';
|
||||
|
||||
return (
|
||||
<div key={event.id} className="text-gray-800 dark:text-gray-200">
|
||||
<span className={levelColor}>[{event.context}]</span> {event.message}
|
||||
<span className="text-gray-500 dark:text-gray-400 ml-2">{timestamp}</span>
|
||||
{event.metadata && Object.keys(event.metadata).length > 0 && (
|
||||
<pre className="ml-4 mt-1 text-gray-600 dark:text-gray-400 text-xs">
|
||||
{JSON.stringify(event.metadata, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result Data */}
|
||||
{log.result && Object.keys(log.result).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Job Result</h4>
|
||||
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto">
|
||||
{JSON.stringify(log.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{log.errorMessage && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Error</h4>
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-sm text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap">
|
||||
{log.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LogDetails log={log} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -373,24 +455,31 @@ export default function AdminLogsPage() {
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total logs)
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-center gap-3 sm:justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 order-2 sm:order-1">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
<span className="hidden sm:inline"> ({pagination.total} total logs)</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 order-1 sm:order-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed 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 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === pagination.totalPages}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,11 +492,10 @@ export default function AdminLogsPage() {
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>• Logs are automatically refreshed every 10 seconds</li>
|
||||
<li>• Click "Show Details" to view detailed event logs, job results, and error messages</li>
|
||||
<li>• Event logs show all internal operations with timestamps (similar to Docker logs)</li>
|
||||
<li>• Tap "Show Details" to view event logs, job results, and errors</li>
|
||||
<li>• Event logs show all internal operations with timestamps</li>
|
||||
<li>• Jobs are retried automatically based on their max attempts setting</li>
|
||||
<li>• Use filters to find specific job types or statuses</li>
|
||||
<li>• All job types are tracked: searches, downloads, file organization, library scans, RSS monitoring, and more</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+157
-70
@@ -13,17 +13,34 @@ import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
|
||||
import { RecentRequestsTable } from './components/RecentRequestsTable';
|
||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface SelectedTorrentData {
|
||||
title?: string;
|
||||
indexer?: string;
|
||||
size?: number;
|
||||
format?: string;
|
||||
ebookFormat?: string;
|
||||
seeders?: number;
|
||||
infoUrl?: string;
|
||||
source?: string;
|
||||
protocol?: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
interface PendingApprovalRequest {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
type: 'audiobook' | 'ebook';
|
||||
selectedTorrent: SelectedTorrentData | null;
|
||||
audiobook: {
|
||||
title: string;
|
||||
author: string;
|
||||
coverArtUrl: string | null;
|
||||
audibleAsin: string | null;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
@@ -32,9 +49,20 @@ interface PendingApprovalRequest {
|
||||
};
|
||||
}
|
||||
|
||||
function formatTorrentSize(bytes: number): string {
|
||||
const gb = bytes / (1024 ** 3);
|
||||
const mb = bytes / (1024 ** 2);
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) {
|
||||
const toast = useToast();
|
||||
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
|
||||
const [searchModalRequestId, setSearchModalRequestId] = useState<string | null>(null);
|
||||
|
||||
const searchModalRequest = searchModalRequestId
|
||||
? requests.find((r) => r.id === searchModalRequestId)
|
||||
: null;
|
||||
|
||||
const handleApproveRequest = async (requestId: string) => {
|
||||
setLoadingStates((prev) => ({ ...prev, [requestId]: true }));
|
||||
@@ -47,7 +75,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
|
||||
toast.success('Request approved');
|
||||
|
||||
// Mutate both pending requests and recent requests caches
|
||||
await mutate('/api/admin/requests/pending-approval');
|
||||
await mutate('/api/admin/requests/recent');
|
||||
await mutate('/api/admin/metrics');
|
||||
@@ -72,7 +99,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
|
||||
toast.success('Request denied');
|
||||
|
||||
// Mutate pending requests cache
|
||||
await mutate('/api/admin/requests/pending-approval');
|
||||
await mutate('/api/admin/metrics');
|
||||
} catch (error) {
|
||||
@@ -85,6 +111,26 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveWithTorrent = async (requestId: string, torrent: TorrentResult) => {
|
||||
await fetchJSON(`/api/admin/requests/${requestId}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'approve', selectedTorrent: torrent }),
|
||||
});
|
||||
|
||||
toast.success('Request approved and download started');
|
||||
|
||||
await mutate('/api/admin/requests/pending-approval');
|
||||
await mutate('/api/admin/requests/recent');
|
||||
await mutate('/api/admin/metrics');
|
||||
};
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<svg className="animate-spin h-4 w-4" 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>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{/* Section Header */}
|
||||
@@ -116,6 +162,9 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{requests.map((request) => {
|
||||
const isLoading = loadingStates[request.id] || false;
|
||||
const torrent = request.selectedTorrent;
|
||||
const displayFormat = torrent?.format || torrent?.ebookFormat;
|
||||
const isAnnasArchive = torrent?.source === 'annas_archive';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -205,89 +254,107 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pre-Selected Release */}
|
||||
{torrent && torrent.title && (
|
||||
<div className="mx-4 mb-3 px-3 py-2.5 bg-gray-50 dark:bg-gray-900/60 rounded-lg border border-gray-200 dark:border-gray-700/60">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<svg className="w-3 h-3 text-gray-400 dark:text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
User-Selected Release
|
||||
</span>
|
||||
</div>
|
||||
{torrent.infoUrl ? (
|
||||
<a
|
||||
href={torrent.infoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors line-clamp-2 leading-snug"
|
||||
title={torrent.title}
|
||||
>
|
||||
{torrent.title}
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 line-clamp-2 leading-snug" title={torrent.title}>
|
||||
{torrent.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 mt-1.5 text-[11px] text-gray-500 dark:text-gray-400 flex-wrap">
|
||||
{isAnnasArchive ? (
|
||||
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
|
||||
) : torrent.indexer ? (
|
||||
<span>{torrent.indexer}</span>
|
||||
) : null}
|
||||
{torrent.size && torrent.size > 0 ? (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span>{formatTorrentSize(torrent.size)}</span>
|
||||
</>
|
||||
) : null}
|
||||
{displayFormat ? (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300">
|
||||
{displayFormat}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{torrent.protocol === 'usenet' ? (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="text-sky-600 dark:text-sky-400 font-medium">NZB</span>
|
||||
</>
|
||||
) : torrent.seeders !== undefined && torrent.seeders !== null ? (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{torrent.seeders} seeds</span>
|
||||
</>
|
||||
) : null}
|
||||
{torrent.score !== undefined && torrent.score !== null ? (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="font-medium">Score {Math.round(torrent.score)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="border-t border-amber-200 dark:border-amber-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleApproveRequest(request.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
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>
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
{isLoading ? <LoadingSpinner /> : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSearchModalRequestId(request.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDenyRequest(request.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
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>
|
||||
) : (
|
||||
<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"
|
||||
/>
|
||||
{isLoading ? <LoadingSpinner /> : (
|
||||
<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>
|
||||
)}
|
||||
<span>Deny</span>
|
||||
@@ -297,6 +364,26 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Interactive Search Modal */}
|
||||
{searchModalRequest && (
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={!!searchModalRequestId}
|
||||
onClose={() => setSearchModalRequestId(null)}
|
||||
requestId={searchModalRequest.id}
|
||||
audiobook={{
|
||||
title: searchModalRequest.audiobook.title,
|
||||
author: searchModalRequest.audiobook.author,
|
||||
}}
|
||||
searchMode={searchModalRequest.type === 'ebook' ? 'ebook' : 'audiobook'}
|
||||
onConfirm={async (torrent) => {
|
||||
await handleApproveWithTorrent(searchModalRequest.id, torrent);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setSearchModalRequestId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,8 @@ export interface PathsSettings {
|
||||
ebookPathTemplate?: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
chapterMergingEnabled: boolean;
|
||||
fileRenameEnabled: boolean;
|
||||
fileRenameTemplate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -295,6 +295,7 @@ export default function AdminSettings() {
|
||||
{activeTab === 'prowlarr' && (
|
||||
<IndexersTab
|
||||
settings={settings}
|
||||
originalSettings={originalSettings}
|
||||
indexers={configuredIndexers}
|
||||
flagConfigs={flagConfigs}
|
||||
onChange={setSettings}
|
||||
|
||||
@@ -90,6 +90,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="gemini">Google Gemini</option>
|
||||
<option value="custom">Custom (OpenAI-compatible)</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -136,7 +137,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
|
||||
? 'Leave blank for local models'
|
||||
: configured
|
||||
? '••••••••••••••••'
|
||||
: (provider === 'openai' ? 'sk-...' : 'sk-ant-...')
|
||||
: (provider === 'openai' ? 'sk-...' : provider === 'gemini' ? 'AIza...' : 'sk-ant-...')
|
||||
}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
|
||||
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
|
||||
@@ -16,6 +17,7 @@ import type { Settings, SavedIndexerConfig } from '../../lib/types';
|
||||
|
||||
interface IndexersTabProps {
|
||||
settings: Settings;
|
||||
originalSettings: Settings | null;
|
||||
indexers: SavedIndexerConfig[];
|
||||
flagConfigs: IndexerFlagConfig[];
|
||||
onChange: (settings: Settings) => void;
|
||||
@@ -27,6 +29,7 @@ interface IndexersTabProps {
|
||||
|
||||
export function IndexersTab({
|
||||
settings,
|
||||
originalSettings,
|
||||
indexers,
|
||||
flagConfigs,
|
||||
onChange,
|
||||
@@ -35,11 +38,23 @@ export function IndexersTab({
|
||||
onValidationChange,
|
||||
onRefreshIndexers,
|
||||
}: IndexersTabProps) {
|
||||
const { testing, testResult, testConnection } = useIndexersSettings({
|
||||
const {
|
||||
testing,
|
||||
testResult,
|
||||
testConnection,
|
||||
showConnectionChangeConfirm,
|
||||
confirmConnectionChange,
|
||||
cancelConnectionChange,
|
||||
configuredIndexersCount,
|
||||
} = useIndexersSettings({
|
||||
prowlarrUrl: settings.prowlarr.url,
|
||||
prowlarrApiKey: settings.prowlarr.apiKey,
|
||||
originalProwlarrUrl: originalSettings?.prowlarr.url ?? '',
|
||||
originalProwlarrApiKey: originalSettings?.prowlarr.apiKey ?? '',
|
||||
configuredIndexersCount: indexers.length,
|
||||
onValidationChange,
|
||||
onRefreshIndexers,
|
||||
onClearIndexers: () => onIndexersChange([]),
|
||||
});
|
||||
|
||||
// Auto-load indexers when component mounts if prowlarr is configured
|
||||
@@ -96,7 +111,7 @@ export function IndexersTab({
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Found in Prowlarr Settings → General → Security → API Key
|
||||
Found in Prowlarr Settings → General → Security → API Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -178,6 +193,19 @@ export function IndexersTab({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmation modal for Prowlarr connection change */}
|
||||
<ConfirmModal
|
||||
isOpen={showConnectionChangeConfirm}
|
||||
onClose={cancelConnectionChange}
|
||||
onConfirm={confirmConnectionChange}
|
||||
title="Prowlarr Connection Change"
|
||||
message={`Changing your Prowlarr connection will remove your ${configuredIndexersCount} configured indexer${configuredIndexersCount === 1 ? '' : 's'}. Indexer IDs are specific to each Prowlarr instance, so existing configurations cannot be preserved. You will need to re-add indexers from the new instance after saving.`}
|
||||
confirmText="Continue"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
isLoading={testing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,30 +5,50 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { TestResult } from '../../lib/types';
|
||||
|
||||
interface UseIndexersSettingsProps {
|
||||
prowlarrUrl: string;
|
||||
prowlarrApiKey: string;
|
||||
originalProwlarrUrl: string;
|
||||
originalProwlarrApiKey: string;
|
||||
configuredIndexersCount: number;
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
onRefreshIndexers?: () => Promise<void>;
|
||||
onClearIndexers: () => void;
|
||||
}
|
||||
|
||||
export function useIndexersSettings({
|
||||
prowlarrUrl,
|
||||
prowlarrApiKey,
|
||||
originalProwlarrUrl,
|
||||
originalProwlarrApiKey,
|
||||
configuredIndexersCount,
|
||||
onValidationChange,
|
||||
onRefreshIndexers,
|
||||
onClearIndexers,
|
||||
}: UseIndexersSettingsProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [showConnectionChangeConfirm, setShowConnectionChangeConfirm] = useState(false);
|
||||
|
||||
/**
|
||||
* Test Prowlarr connection
|
||||
* Detect if the Prowlarr URL or API key has changed from the saved values.
|
||||
* A masked API key (starting with dots) means the user hasn't touched it.
|
||||
*/
|
||||
const testConnection = async () => {
|
||||
const hasConnectionChanged = useCallback((): boolean => {
|
||||
const urlChanged = prowlarrUrl.trim() !== originalProwlarrUrl.trim();
|
||||
const apiKeyChanged = !prowlarrApiKey.startsWith('••••') &&
|
||||
prowlarrApiKey !== originalProwlarrApiKey;
|
||||
return urlChanged || apiKeyChanged;
|
||||
}, [prowlarrUrl, prowlarrApiKey, originalProwlarrUrl, originalProwlarrApiKey]);
|
||||
|
||||
/**
|
||||
* Execute the actual Prowlarr connection test
|
||||
*/
|
||||
const executeTest = async (shouldClearIndexers: boolean) => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
@@ -46,14 +66,23 @@ export function useIndexersSettings({
|
||||
|
||||
if (data.success) {
|
||||
onValidationChange(true);
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`,
|
||||
});
|
||||
|
||||
// Refresh indexers from database if callback provided
|
||||
if (onRefreshIndexers) {
|
||||
await onRefreshIndexers();
|
||||
if (shouldClearIndexers) {
|
||||
onClearIndexers();
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers. Previous indexer configurations have been removed — please re-add indexers from the new instance.`,
|
||||
});
|
||||
} else {
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`,
|
||||
});
|
||||
|
||||
// Refresh indexers from database if callback provided
|
||||
if (onRefreshIndexers) {
|
||||
await onRefreshIndexers();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onValidationChange(false);
|
||||
@@ -74,9 +103,41 @@ export function useIndexersSettings({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle test connection click — shows confirmation if credentials changed
|
||||
* and there are existing configured indexers.
|
||||
*/
|
||||
const testConnection = async () => {
|
||||
if (hasConnectionChanged() && configuredIndexersCount > 0) {
|
||||
setShowConnectionChangeConfirm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeTest(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* User confirmed the credential change — proceed with test and clear indexers on success
|
||||
*/
|
||||
const confirmConnectionChange = async () => {
|
||||
setShowConnectionChangeConfirm(false);
|
||||
await executeTest(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* User cancelled the credential change confirmation
|
||||
*/
|
||||
const cancelConnectionChange = () => {
|
||||
setShowConnectionChangeConfirm(false);
|
||||
};
|
||||
|
||||
return {
|
||||
testing,
|
||||
testResult,
|
||||
testConnection,
|
||||
showConnectionChangeConfirm,
|
||||
confirmConnectionChange,
|
||||
cancelConnectionChange,
|
||||
configuredIndexersCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,11 +164,11 @@ export function AudiobookshelfSection({
|
||||
>
|
||||
{Object.values(AUDIBLE_REGIONS).map((region) => (
|
||||
<option key={region.code} value={region.code}>
|
||||
{region.name}{!region.isEnglish ? ' *' : ''}
|
||||
{region.name}{region.language !== 'en' ? ' *' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && (
|
||||
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.language !== 'en' && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
|
||||
@@ -164,11 +164,11 @@ export function PlexSection({
|
||||
>
|
||||
{Object.values(AUDIBLE_REGIONS).map((region) => (
|
||||
<option key={region.code} value={region.code}>
|
||||
{region.name}{!region.isEnglish ? ' *' : ''}
|
||||
{region.name}{region.language !== 'en' ? ' *' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && (
|
||||
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.language !== 'en' && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { usePathsSettings } from './usePathsSettings';
|
||||
import type { PathsSettings } from '../../lib/types';
|
||||
import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util';
|
||||
import { validateTemplate, generateMockPreviews, validateFilenameTemplate, generateMockFilenamePreviews } from '@/lib/utils/path-template.util';
|
||||
|
||||
interface PathsTabProps {
|
||||
paths: PathsSettings;
|
||||
@@ -24,6 +24,13 @@ interface TemplatePreview {
|
||||
previewPaths?: string[];
|
||||
}
|
||||
|
||||
interface FilenamePreview {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
single?: string[];
|
||||
multi?: string[];
|
||||
}
|
||||
|
||||
export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) {
|
||||
const { testing, testResult, updatePath, testPaths } = usePathsSettings({
|
||||
paths,
|
||||
@@ -73,6 +80,34 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
}
|
||||
}, [paths.ebookPathTemplate]);
|
||||
|
||||
// Live preview state for filename template
|
||||
const [filenamePreview, setFilenamePreview] = useState<FilenamePreview | null>(null);
|
||||
|
||||
// Update filename live preview whenever template changes
|
||||
useEffect(() => {
|
||||
if (!paths.fileRenameEnabled) {
|
||||
setFilenamePreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const template = paths.fileRenameTemplate || '{title}';
|
||||
const validation = validateFilenameTemplate(template);
|
||||
|
||||
if (validation.valid) {
|
||||
const previews = generateMockFilenamePreviews(template);
|
||||
setFilenamePreview({
|
||||
isValid: true,
|
||||
single: previews.single,
|
||||
multi: previews.multi,
|
||||
});
|
||||
} else {
|
||||
setFilenamePreview({
|
||||
isValid: false,
|
||||
error: validation.error,
|
||||
});
|
||||
}
|
||||
}, [paths.fileRenameTemplate, paths.fileRenameEnabled]);
|
||||
|
||||
const audiobookTemplate = paths.audiobookPathTemplate || '{author}/{title} {asin}';
|
||||
const ebookTemplate = paths.ebookPathTemplate || '{author}/{title} {asin}';
|
||||
const ebookMatchesAudiobook = ebookTemplate === audiobookTemplate;
|
||||
@@ -218,6 +253,83 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Rename Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="file-rename-settings"
|
||||
checked={paths.fileRenameEnabled}
|
||||
onChange={(e) => updatePath('fileRenameEnabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="file-rename-settings"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Rename files during organization
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Rename audio and ebook files using a custom naming template when organizing into the media
|
||||
library. When multiple files exist (e.g. chapterized MP3s), an index number is appended.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Naming Template (shown when enabled) */}
|
||||
{paths.fileRenameEnabled && (
|
||||
<div className="mt-4 pl-9">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
File Naming Template
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={paths.fileRenameTemplate || '{title}'}
|
||||
onChange={(e) => updatePath('fileRenameTemplate', e.target.value)}
|
||||
placeholder="{title}"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Uses the same variables as the organization template. Do not include the file extension.
|
||||
</p>
|
||||
|
||||
{/* Filename Validation Error */}
|
||||
{filenamePreview && !filenamePreview.isValid && (
|
||||
<div className="mt-3 p-3 rounded-lg text-sm flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200">
|
||||
<span className="flex-shrink-0 mt-0.5">✗</span>
|
||||
<div className="flex-1">
|
||||
<span>{filenamePreview.error || 'Invalid filename template'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filename Preview */}
|
||||
{filenamePreview && filenamePreview.isValid && (
|
||||
<div className="mt-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Single File
|
||||
</h4>
|
||||
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{filenamePreview.single?.map((preview, index) => (
|
||||
<div key={index} className="text-xs">{preview}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mt-3 mb-2">
|
||||
Multiple Files (chapterized)
|
||||
</h4>
|
||||
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{filenamePreview.multi?.map((preview, index) => (
|
||||
<div key={index} className="text-xs">{preview}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variable Reference Panel (shared for both templates) */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
|
||||
@@ -255,6 +367,27 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditional Syntax Help */}
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-100 mb-2">
|
||||
Conditional Syntax
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Wrap text around a variable in <code className="text-amber-700 dark:text-amber-300 font-mono">{'{ }'}</code> to
|
||||
include that text only when the variable has a value. If the variable is empty, the entire block is removed.
|
||||
</p>
|
||||
<div className="text-sm font-mono bg-white dark:bg-gray-900 rounded px-3 py-2 border border-amber-100 dark:border-amber-900">
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
<code className="text-amber-700 dark:text-amber-300">{'{Book seriesPart - }'}</code>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
With value: <span className="text-green-700 dark:text-green-400">Book 1 - </span>
|
||||
•
|
||||
Without value: <span className="text-red-700 dark:text-red-400">(removed)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Tagging Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
|
||||
+425
-246
@@ -28,6 +28,7 @@ interface User {
|
||||
lastLoginAt: string | null;
|
||||
autoApproveRequests: boolean | null;
|
||||
interactiveSearchAccess: boolean | null;
|
||||
downloadAccess: boolean | null;
|
||||
_count: {
|
||||
requests: number;
|
||||
};
|
||||
@@ -41,6 +42,144 @@ interface PendingUser {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Tinted-dot status badge following admin design system
|
||||
function RoleBadge({ role, isSetupAdmin }: { role: 'user' | 'admin'; isSetupAdmin: boolean }) {
|
||||
if (isSetupAdmin) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-700 dark:text-blue-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-blue-500" />
|
||||
Setup Admin
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (role === 'admin') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-500/10 text-purple-700 dark:text-purple-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-purple-500" />
|
||||
Admin
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-500/10 text-gray-600 dark:text-gray-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-gray-400" />
|
||||
User
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionBadge({
|
||||
user,
|
||||
globalAutoApprove,
|
||||
onClick,
|
||||
}: {
|
||||
user: User;
|
||||
globalAutoApprove: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
let badge: React.ReactNode;
|
||||
if (user.role === 'admin') {
|
||||
badge = (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-500/10 text-purple-700 dark:text-purple-400">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Full Access
|
||||
</span>
|
||||
);
|
||||
} else if (globalAutoApprove) {
|
||||
badge = (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-500/10 text-blue-700 dark:text-blue-400">
|
||||
Global Default
|
||||
</span>
|
||||
);
|
||||
} else if (user.autoApproveRequests ?? false) {
|
||||
badge = (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
|
||||
Auto-Approve
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
badge = (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-500/10 text-gray-600 dark:text-gray-400">
|
||||
Manual
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 text-sm transition-opacity hover:opacity-70"
|
||||
>
|
||||
{badge}
|
||||
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function UserActionsCell({ user, onEdit, onDelete }: { user: User; onEdit: (u: User) => void; onDelete: (u: User) => void }) {
|
||||
if (user.isSetupAdmin) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="Setup admin role cannot be changed">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Protected</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (user.authProvider === 'oidc') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="OIDC user roles are managed by the identity provider">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>OIDC Managed</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (user.authProvider === 'local') {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onEdit(user)}
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>Edit Role</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(user)}
|
||||
className="inline-flex items-center gap-1 text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||
title="Delete user and all their requests"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// plex or other
|
||||
return (
|
||||
<button
|
||||
onClick={() => onEdit(user)}
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>Edit Role</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminUsersPageContent() {
|
||||
const { data, error, mutate } = useSWR('/api/admin/users', authenticatedFetcher);
|
||||
const { data: pendingData, error: pendingError, mutate: mutatePending } = useSWR(
|
||||
@@ -55,6 +194,10 @@ function AdminUsersPageContent() {
|
||||
'/api/admin/settings/interactive-search',
|
||||
authenticatedFetcher
|
||||
);
|
||||
const { data: globalDownloadAccessData, mutate: mutateGlobalDownloadAccess } = useSWR(
|
||||
'/api/admin/settings/download-access',
|
||||
authenticatedFetcher
|
||||
);
|
||||
const [editDialog, setEditDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
user: User | null;
|
||||
@@ -74,6 +217,7 @@ function AdminUsersPageContent() {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [globalAutoApprove, setGlobalAutoApprove] = useState<boolean>(false);
|
||||
const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState<boolean>(true);
|
||||
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
|
||||
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
||||
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
||||
const toast = useToast();
|
||||
@@ -86,7 +230,6 @@ function AdminUsersPageContent() {
|
||||
if (globalAutoApproveData?.autoApproveRequests !== undefined) {
|
||||
setGlobalAutoApprove(globalAutoApproveData.autoApproveRequests);
|
||||
} else if (globalAutoApproveData !== undefined && globalAutoApproveData.autoApproveRequests === undefined) {
|
||||
// API returned but no value - default to true
|
||||
setGlobalAutoApprove(true);
|
||||
}
|
||||
}, [globalAutoApproveData]);
|
||||
@@ -100,10 +243,17 @@ function AdminUsersPageContent() {
|
||||
}
|
||||
}, [globalInteractiveSearchData]);
|
||||
|
||||
const handleGlobalAutoApproveToggle = async (newValue: boolean) => {
|
||||
// Optimistic update
|
||||
setGlobalAutoApprove(newValue);
|
||||
// Sync global download access state (default to true if not set)
|
||||
useEffect(() => {
|
||||
if (globalDownloadAccessData?.downloadAccess !== undefined) {
|
||||
setGlobalDownloadAccess(globalDownloadAccessData.downloadAccess);
|
||||
} else if (globalDownloadAccessData !== undefined && globalDownloadAccessData.downloadAccess === undefined) {
|
||||
setGlobalDownloadAccess(true);
|
||||
}
|
||||
}, [globalDownloadAccessData]);
|
||||
|
||||
const handleGlobalAutoApproveToggle = async (newValue: boolean) => {
|
||||
setGlobalAutoApprove(newValue);
|
||||
try {
|
||||
await fetchJSON('/api/admin/settings/auto-approve', {
|
||||
method: 'PATCH',
|
||||
@@ -111,20 +261,16 @@ function AdminUsersPageContent() {
|
||||
});
|
||||
toast.success(`Global auto-approve ${newValue ? 'enabled' : 'disabled'}`);
|
||||
mutateGlobalAutoApprove();
|
||||
mutate(); // Refresh users list to show updated state
|
||||
mutate();
|
||||
} catch (err) {
|
||||
// Revert on error
|
||||
setGlobalAutoApprove(!newValue);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update auto-approve setting';
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalInteractiveSearchToggle = async (newValue: boolean) => {
|
||||
// Optimistic update
|
||||
setGlobalInteractiveSearch(newValue);
|
||||
|
||||
try {
|
||||
await fetchJSON('/api/admin/settings/interactive-search', {
|
||||
method: 'PATCH',
|
||||
@@ -132,74 +278,88 @@ function AdminUsersPageContent() {
|
||||
});
|
||||
toast.success(`Global interactive search ${newValue ? 'enabled' : 'disabled'}`);
|
||||
mutateGlobalInteractiveSearch();
|
||||
mutate(); // Refresh users list to show updated state
|
||||
mutate();
|
||||
} catch (err) {
|
||||
// Revert on error
|
||||
setGlobalInteractiveSearch(!newValue);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update interactive search setting';
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserAutoApproveToggle = async (user: User, newValue: boolean) => {
|
||||
console.log('[AutoApprove] Toggle clicked:', { userId: user.id, username: user.plexUsername, newValue });
|
||||
|
||||
// Optimistic update
|
||||
const previousUsers = data?.users || [];
|
||||
const optimisticUsers = previousUsers.map((u: User) =>
|
||||
u.id === user.id ? { ...u, autoApproveRequests: newValue } : u
|
||||
);
|
||||
console.log('[AutoApprove] Applying optimistic update');
|
||||
mutate({ users: optimisticUsers }, false);
|
||||
|
||||
try {
|
||||
console.log('[AutoApprove] Sending API request...');
|
||||
const response = await fetchJSON(`/api/admin/users/${user.id}`, {
|
||||
await fetchJSON(`/api/admin/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
role: user.role,
|
||||
autoApproveRequests: newValue
|
||||
}),
|
||||
body: JSON.stringify({ role: user.role, autoApproveRequests: newValue }),
|
||||
});
|
||||
console.log('[AutoApprove] API response received:', response);
|
||||
toast.success(`Auto-approve ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
|
||||
console.log('[AutoApprove] Triggering cache revalidation...');
|
||||
mutate(); // Refresh users list
|
||||
mutate();
|
||||
} catch (err) {
|
||||
// Revert on error
|
||||
console.error('[AutoApprove] Error occurred, reverting:', err);
|
||||
mutate({ users: previousUsers }, false);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update user auto-approve setting';
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserInteractiveSearchToggle = async (user: User, newValue: boolean) => {
|
||||
// Optimistic update
|
||||
const previousUsers = data?.users || [];
|
||||
const optimisticUsers = previousUsers.map((u: User) =>
|
||||
u.id === user.id ? { ...u, interactiveSearchAccess: newValue } : u
|
||||
);
|
||||
mutate({ users: optimisticUsers }, false);
|
||||
|
||||
try {
|
||||
await fetchJSON(`/api/admin/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
role: user.role,
|
||||
interactiveSearchAccess: newValue
|
||||
}),
|
||||
body: JSON.stringify({ role: user.role, interactiveSearchAccess: newValue }),
|
||||
});
|
||||
toast.success(`Interactive search ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
|
||||
mutate(); // Refresh users list
|
||||
mutate();
|
||||
} catch (err) {
|
||||
// Revert on error
|
||||
mutate({ users: previousUsers }, false);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update user interactive search setting';
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalDownloadAccessToggle = async (newValue: boolean) => {
|
||||
setGlobalDownloadAccess(newValue);
|
||||
try {
|
||||
await fetchJSON('/api/admin/settings/download-access', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ downloadAccess: newValue }),
|
||||
});
|
||||
toast.success(`Global download access ${newValue ? 'enabled' : 'disabled'}`);
|
||||
mutateGlobalDownloadAccess();
|
||||
mutate();
|
||||
} catch (err) {
|
||||
setGlobalDownloadAccess(!newValue);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update download access setting';
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserDownloadAccessToggle = async (user: User, newValue: boolean) => {
|
||||
const previousUsers = data?.users || [];
|
||||
const optimisticUsers = previousUsers.map((u: User) =>
|
||||
u.id === user.id ? { ...u, downloadAccess: newValue } : u
|
||||
);
|
||||
mutate({ users: optimisticUsers }, false);
|
||||
try {
|
||||
await fetchJSON(`/api/admin/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ role: user.role, downloadAccess: newValue }),
|
||||
});
|
||||
toast.success(`Download access ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
|
||||
mutate();
|
||||
} catch (err) {
|
||||
mutate({ users: previousUsers }, false);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update user download access setting';
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -214,7 +374,6 @@ function AdminUsersPageContent() {
|
||||
|
||||
const saveUserRole = async () => {
|
||||
if (!editDialog.user) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await fetchJSON(`/api/admin/users/${editDialog.user.id}`, {
|
||||
@@ -223,11 +382,10 @@ function AdminUsersPageContent() {
|
||||
});
|
||||
toast.success(`User "${editDialog.user.plexUsername}" updated successfully`);
|
||||
hideEditDialog();
|
||||
mutate(); // Refresh users list
|
||||
mutate();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update user';
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -242,13 +400,12 @@ function AdminUsersPageContent() {
|
||||
};
|
||||
|
||||
const closeConfirmDialog = () => {
|
||||
if (processingUserId) return; // Don't close while processing
|
||||
if (processingUserId) return;
|
||||
setConfirmDialog({ isOpen: false, type: null, user: null });
|
||||
};
|
||||
|
||||
const handleConfirmAction = async () => {
|
||||
if (!confirmDialog.user) return;
|
||||
|
||||
const isApprove = confirmDialog.type === 'approve';
|
||||
try {
|
||||
setProcessingUserId(confirmDialog.user.id);
|
||||
@@ -261,13 +418,12 @@ function AdminUsersPageContent() {
|
||||
? `User "${confirmDialog.user.plexUsername}" has been approved`
|
||||
: `User "${confirmDialog.user.plexUsername}" has been rejected`
|
||||
);
|
||||
mutatePending(); // Refresh pending users list
|
||||
if (isApprove) mutate(); // Refresh approved users list
|
||||
mutatePending();
|
||||
if (isApprove) mutate();
|
||||
closeConfirmDialog();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : `Failed to ${isApprove ? 'approve' : 'reject'} user`;
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setProcessingUserId(null);
|
||||
}
|
||||
@@ -278,25 +434,23 @@ function AdminUsersPageContent() {
|
||||
};
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
if (deleting) return; // Don't close while processing
|
||||
if (deleting) return;
|
||||
setDeleteDialog({ isOpen: false, user: null });
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!deleteDialog.user) return;
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
const response = await fetchJSON(`/api/admin/users/${deleteDialog.user.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
toast.success(response.message || `User "${deleteDialog.user.plexUsername}" has been deleted`);
|
||||
mutate(); // Refresh users list
|
||||
mutate();
|
||||
closeDeleteDialog();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to delete user';
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
@@ -307,7 +461,6 @@ function AdminUsersPageContent() {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied to clipboard`);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
toast.error('Failed to copy to clipboard');
|
||||
}
|
||||
};
|
||||
@@ -327,9 +480,7 @@ function AdminUsersPageContent() {
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error Loading Users
|
||||
</h3>
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Users</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
{error?.message || 'Failed to load users'}
|
||||
</p>
|
||||
@@ -344,80 +495,81 @@ function AdminUsersPageContent() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
User Management
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Manage user roles and permissions
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setGlobalSettingsOpen(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
<span>Global User Permissions</span>
|
||||
</button>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
|
||||
{/* Header — stacks on mobile, row on sm+ */}
|
||||
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
User Management
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage user roles and permissions
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start sm:self-auto flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setGlobalSettingsOpen(true)}
|
||||
className="inline-flex items-center gap-2 px-3 sm:px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
<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>
|
||||
<span className="hidden sm:inline">Global User Permissions</span>
|
||||
<span className="sm:hidden">Permissions</span>
|
||||
</button>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-3 sm:px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Users Section */}
|
||||
{pendingUsers.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4">
|
||||
<h2 className="text-lg font-semibold text-yellow-900 dark:text-yellow-200 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800/60 rounded-xl p-4">
|
||||
<h2 className="text-base font-semibold text-amber-900 dark:text-amber-200 mb-1 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
Pending Registrations ({pendingUsers.length})
|
||||
</h2>
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-4">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300/80 mb-4">
|
||||
The following users are awaiting approval to access the system.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{pendingUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="bg-white dark:bg-gray-800 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 flex items-center justify-between"
|
||||
className="bg-white dark:bg-gray-800 border border-amber-200 dark:border-amber-800/40 rounded-xl overflow-hidden"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{user.plexUsername}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{user.plexEmail || 'No email'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Registered: {new Date(user.createdAt).toLocaleString()} •
|
||||
Provider: {user.authProvider}
|
||||
</div>
|
||||
</div>
|
||||
{/* Pending card — info */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100 text-sm">
|
||||
{user.plexUsername}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{user.plexEmail || 'No email'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Registered: {new Date(user.createdAt).toLocaleString()} · Provider: {user.authProvider}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Pending card — actions, full-width on mobile */}
|
||||
<div className="px-4 py-3 border-t border-amber-100 dark:border-amber-800/30 flex gap-2">
|
||||
<button
|
||||
onClick={() => showApproveDialog(user)}
|
||||
disabled={processingUserId === user.id}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-emerald-50 dark:bg-emerald-500/10 hover:bg-emerald-100 dark:hover:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 border border-emerald-200/60 dark:border-emerald-500/20 rounded-xl text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
@@ -427,7 +579,7 @@ function AdminUsersPageContent() {
|
||||
<button
|
||||
onClick={() => showRejectDialog(user)}
|
||||
disabled={processingUserId === user.id}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-red-50 dark:bg-red-500/10 hover:bg-red-100 dark:hover:bg-red-500/20 text-red-700 dark:text-red-400 border border-red-200/60 dark:border-red-500/20 rounded-xl text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<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" />
|
||||
@@ -442,8 +594,104 @@ function AdminUsersPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto">
|
||||
{/* Users — Mobile card list (sm:hidden) */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
{/* Card header — avatar + name + role badge */}
|
||||
<div className="px-4 py-3 flex items-start gap-3">
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.plexUsername}
|
||||
className="h-10 w-10 rounded-full flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full flex-shrink-0 mt-0.5 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug truncate">
|
||||
{user.plexUsername}
|
||||
</div>
|
||||
<RoleBadge role={user.role} isSetupAdmin={user.isSetupAdmin} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||
{user.plexEmail || 'No email'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card body — labeled fields */}
|
||||
<div className="px-4 pb-3 pt-2 space-y-2 border-t border-gray-100 dark:border-gray-700/60">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
|
||||
Permissions
|
||||
</div>
|
||||
<PermissionBadge
|
||||
user={user}
|
||||
globalAutoApprove={globalAutoApprove}
|
||||
onClick={() => setPermissionsUserId(user.id)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
|
||||
Requests
|
||||
</div>
|
||||
<div className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{user._count.requests}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
|
||||
Last Login
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{user.lastLoginAt
|
||||
? new Date(user.lastLoginAt).toLocaleDateString()
|
||||
: 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
|
||||
User ID
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToClipboard(user.plexId, 'User ID')}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-3 h-3" 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>
|
||||
{user.plexId.length > 16 ? `${user.plexId.substring(0, 16)}…` : user.plexId}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card actions */}
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/60">
|
||||
<UserActionsCell user={user} onEdit={showEditDialog} onDelete={showDeleteDialog} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">No users found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Users Table — hidden on mobile, visible on sm+ */}
|
||||
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
@@ -472,15 +720,21 @@ function AdminUsersPageContent() {
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{user.avatarUrl && (
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.plexUsername}
|
||||
className="h-10 w-10 rounded-full mr-3"
|
||||
className="h-10 w-10 rounded-full mr-3 flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full mr-3 flex-shrink-0 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
@@ -507,52 +761,14 @@ function AdminUsersPageContent() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{user.role.toUpperCase()}
|
||||
</span>
|
||||
{user.isSetupAdmin && (
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
SETUP ADMIN
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RoleBadge role={user.role} isSetupAdmin={user.isSetupAdmin} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
<PermissionBadge
|
||||
user={user}
|
||||
globalAutoApprove={globalAutoApprove}
|
||||
onClick={() => setPermissionsUserId(user.id)}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
{user.role === 'admin' ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Full Access
|
||||
</span>
|
||||
) : globalAutoApprove ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
Global Default
|
||||
</span>
|
||||
) : (user.autoApproveRequests ?? false) ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
Auto-Approve
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
Manual
|
||||
</span>
|
||||
)}
|
||||
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{user._count.requests}
|
||||
@@ -563,65 +779,7 @@ function AdminUsersPageContent() {
|
||||
: 'Never'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{user.isSetupAdmin ? (
|
||||
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="Setup admin role cannot be changed">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Protected</span>
|
||||
</span>
|
||||
) : user.authProvider === 'oidc' ? (
|
||||
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="OIDC user roles are managed by the identity provider (use admin role mapping in settings)">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>OIDC Managed</span>
|
||||
</span>
|
||||
) : user.authProvider === 'plex' ? (
|
||||
<button
|
||||
onClick={() => showEditDialog(user)}
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>Edit Role</span>
|
||||
</button>
|
||||
) : user.authProvider === 'local' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => showEditDialog(user)}
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>Edit Role</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => showDeleteDialog(user)}
|
||||
className="inline-flex items-center gap-1 text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="Delete user and all their requests"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => showEditDialog(user)}
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>Edit Role</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<UserActionsCell user={user} onEdit={showEditDialog} onDelete={showDeleteDialog} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -643,31 +801,50 @@ function AdminUsersPageContent() {
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>• <strong>User:</strong> Can request audiobooks, view own requests, and search the catalog</li>
|
||||
<li>• <strong>Admin:</strong> Full system access including settings, user management, and all requests</li>
|
||||
<li>• <strong>Setup Admin:</strong> The initial admin account created during setup - this account is protected and cannot be changed or deleted</li>
|
||||
<li>• <strong>Permissions:</strong> Click a user's permission badge to manage individual settings (auto-approve, interactive search). Use Global User Permissions to control system-wide defaults. Admins always have full access.</li>
|
||||
<li>• <strong>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.</li>
|
||||
<li>• <strong>Plex Users:</strong> Can have their roles changed, but cannot be deleted as access is managed by Plex.</li>
|
||||
<li>• <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).</li>
|
||||
<li>• <strong>Setup Admin:</strong> The initial admin account — protected, cannot be changed or deleted</li>
|
||||
<li>• <strong>Permissions:</strong> Click a user's permission badge to manage individual settings. Use Global User Permissions for system-wide defaults. Admins always have full access.</li>
|
||||
<li>• <strong>OIDC Users:</strong> Role management is handled by the identity provider. Cannot be deleted.</li>
|
||||
<li>• <strong>Plex Users:</strong> Role can be changed, but cannot be deleted (access managed by Plex).</li>
|
||||
<li>• <strong>Local Users:</strong> Can have roles freely assigned. Can be deleted (requests are preserved).</li>
|
||||
<li>• You cannot change your own role or delete yourself for security reasons</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Edit User Dialog */}
|
||||
{/* Edit User Dialog — bottom sheet on mobile */}
|
||||
{editDialog.isOpen && editDialog.user && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Edit User Role
|
||||
</h3>
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-0 sm:p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-md">
|
||||
{/* Dialog header */}
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 px-5 py-4 border-b border-gray-200 dark:border-gray-700 rounded-t-2xl flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Edit User Role
|
||||
</h3>
|
||||
<button
|
||||
onClick={hideEditDialog}
|
||||
className="p-2 -mr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<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 className="px-5 py-5 space-y-4">
|
||||
{/* User Info */}
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
{editDialog.user.avatarUrl && (
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-xl">
|
||||
{editDialog.user.avatarUrl ? (
|
||||
<img
|
||||
src={editDialog.user.avatarUrl}
|
||||
alt={editDialog.user.plexUsername}
|
||||
className="h-12 w-12 rounded-full"
|
||||
className="h-12 w-12 rounded-full flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-12 rounded-full flex-shrink-0 bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
@@ -685,38 +862,34 @@ function AdminUsersPageContent() {
|
||||
Role
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
|
||||
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value="user"
|
||||
checked={editRole === 'user'}
|
||||
onChange={(e) => setEditRole(e.target.value as 'user' | 'admin')}
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
User
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">User</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Can request audiobooks and view own requests
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
|
||||
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value="admin"
|
||||
checked={editRole === 'admin'}
|
||||
onChange={(e) => setEditRole(e.target.value as 'user' | 'admin')}
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Admin
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Admin</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Full system access including settings and user management
|
||||
</div>
|
||||
</div>
|
||||
@@ -725,19 +898,19 @@ function AdminUsersPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
{/* Dialog footer */}
|
||||
<div className="sticky bottom-0 bg-white dark:bg-gray-800 px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex gap-3">
|
||||
<button
|
||||
onClick={hideEditDialog}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex-1 px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={saveUserRole}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex-1 px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
@@ -788,6 +961,8 @@ function AdminUsersPageContent() {
|
||||
onToggleAutoApprove={handleGlobalAutoApproveToggle}
|
||||
globalInteractiveSearch={globalInteractiveSearch}
|
||||
onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle}
|
||||
globalDownloadAccess={globalDownloadAccess}
|
||||
onToggleDownloadAccess={handleGlobalDownloadAccessToggle}
|
||||
/>
|
||||
|
||||
{/* User Permissions Modal */}
|
||||
@@ -797,12 +972,16 @@ function AdminUsersPageContent() {
|
||||
user={permissionsUser}
|
||||
globalAutoApprove={globalAutoApprove}
|
||||
globalInteractiveSearch={globalInteractiveSearch}
|
||||
globalDownloadAccess={globalDownloadAccess}
|
||||
onToggleAutoApprove={(user, newValue) => {
|
||||
handleUserAutoApproveToggle(user as User, newValue);
|
||||
}}
|
||||
onToggleInteractiveSearch={(user, newValue) => {
|
||||
handleUserInteractiveSearchToggle(user as User, newValue);
|
||||
}}
|
||||
onToggleDownloadAccess={(user, newValue) => {
|
||||
handleUserDownloadAccessToggle(user as User, newValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Component: Admin Filesystem Browse API
|
||||
* Documentation: documentation/features/manual-import.md
|
||||
*
|
||||
* Lets admins browse server directories for manual audiobook import.
|
||||
* Restricted to download_dir and media_dir roots only.
|
||||
*/
|
||||
|
||||
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 { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Filesystem.Browse');
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
type: 'directory';
|
||||
audioFileCount: number;
|
||||
subfolderCount: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan immediate children of a directory to gather audio file and subfolder stats.
|
||||
*/
|
||||
async function getDirectoryStats(
|
||||
dirPath: string
|
||||
): Promise<{ audioFileCount: number; subfolderCount: number; totalSize: number }> {
|
||||
const fs = await import('fs/promises');
|
||||
const pathModule = await import('path');
|
||||
|
||||
let audioFileCount = 0;
|
||||
let subfolderCount = 0;
|
||||
let totalSize = 0;
|
||||
|
||||
try {
|
||||
const children = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
for (const child of children) {
|
||||
if (child.isDirectory()) {
|
||||
subfolderCount++;
|
||||
} else if (child.isFile()) {
|
||||
const ext = pathModule.extname(child.name).toLowerCase();
|
||||
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||
audioFileCount++;
|
||||
try {
|
||||
const stat = await fs.stat(pathModule.join(dirPath, child.name));
|
||||
totalSize += stat.size;
|
||||
} catch {
|
||||
/* skip unreadable files */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* directory not readable */
|
||||
}
|
||||
|
||||
return { audioFileCount, subfolderCount, totalSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load allowed root directories from Configuration table.
|
||||
*/
|
||||
const BOOKDROP_PATH = '/bookdrop';
|
||||
|
||||
async function getAllowedRoots(): Promise<{ downloadDir: string | null; mediaDir: string | null; bookdropExists: boolean }> {
|
||||
const downloadDirConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'download_dir' },
|
||||
});
|
||||
const mediaDirConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'media_dir' },
|
||||
});
|
||||
|
||||
let bookdropExists = false;
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const stat = await fs.stat(BOOKDROP_PATH);
|
||||
bookdropExists = stat.isDirectory();
|
||||
} catch {
|
||||
/* not mounted */
|
||||
}
|
||||
|
||||
return {
|
||||
downloadDir: downloadDirConfig?.value || null,
|
||||
mediaDir: mediaDirConfig?.value || null,
|
||||
bookdropExists,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a normalized path is within one of the allowed roots.
|
||||
*/
|
||||
function isPathAllowed(normalizedPath: string, roots: string[]): boolean {
|
||||
return roots.some(
|
||||
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const pathModule = await import('path');
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
const { downloadDir, mediaDir, bookdropExists } = await getAllowedRoots();
|
||||
const requestedPath = request.nextUrl.searchParams.get('path');
|
||||
|
||||
// No path param: return root directories
|
||||
if (!requestedPath) {
|
||||
const roots: Array<{ name: string; path: string; icon: string }> = [];
|
||||
if (downloadDir) {
|
||||
roots.push({ name: 'Downloads', path: downloadDir, icon: 'download' });
|
||||
}
|
||||
if (mediaDir) {
|
||||
roots.push({ name: 'Media Library', path: mediaDir, icon: 'library' });
|
||||
}
|
||||
if (bookdropExists) {
|
||||
roots.push({ name: 'Book Drop', path: BOOKDROP_PATH, icon: 'bookdrop' });
|
||||
}
|
||||
|
||||
if (roots.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No browsable directories available' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ roots });
|
||||
}
|
||||
|
||||
// Path param provided: browse that directory
|
||||
// Normalize to forward slashes and resolve
|
||||
const normalizedPath = pathModule.resolve(requestedPath).replace(/\\/g, '/');
|
||||
|
||||
// Build list of allowed roots (normalized)
|
||||
const allowedRoots: string[] = [];
|
||||
if (downloadDir) allowedRoots.push(pathModule.resolve(downloadDir).replace(/\\/g, '/'));
|
||||
if (mediaDir) allowedRoots.push(pathModule.resolve(mediaDir).replace(/\\/g, '/'));
|
||||
if (bookdropExists) allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||
|
||||
if (!isPathAllowed(normalizedPath, allowedRoots)) {
|
||||
logger.warn(`Access denied: ${normalizedPath} is outside allowed directories`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied: path outside allowed directories' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read directory entries
|
||||
const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true });
|
||||
|
||||
// Gather stats for each subdirectory (parallel for performance)
|
||||
const directoryEntries = dirEntries.filter((e) => e.isDirectory());
|
||||
const statsPromises = directoryEntries.map(async (entry): Promise<DirectoryEntry> => {
|
||||
const fullPath = pathModule.join(normalizedPath, entry.name);
|
||||
const stats = await getDirectoryStats(fullPath);
|
||||
return {
|
||||
name: entry.name,
|
||||
type: 'directory',
|
||||
...stats,
|
||||
};
|
||||
});
|
||||
|
||||
const entries = await Promise.all(statsPromises);
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Gather audio files in the current directory
|
||||
const audioFiles: Array<{ name: string; size: number }> = [];
|
||||
for (const entry of dirEntries) {
|
||||
if (entry.isFile()) {
|
||||
const ext = pathModule.extname(entry.name).toLowerCase();
|
||||
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||
try {
|
||||
const stat = await fs.stat(pathModule.join(normalizedPath, entry.name));
|
||||
audioFiles.push({ name: entry.name, size: stat.size });
|
||||
} catch {
|
||||
audioFiles.push({ name: entry.name, size: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
audioFiles.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return NextResponse.json({ path: normalizedPath, entries, audioFiles });
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
|
||||
if (code === 'ENOENT') {
|
||||
return NextResponse.json({ error: 'Directory not found' }, { status: 404 });
|
||||
}
|
||||
if (code === 'EACCES' || code === 'EPERM') {
|
||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
logger.error('Failed to browse directory', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to browse directory' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Component: Admin Manual Import API
|
||||
* Documentation: documentation/features/manual-import.md
|
||||
*
|
||||
* Triggers the organize_files pipeline for a manually-selected folder.
|
||||
* Creates or recycles a request, then queues the organize job.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ManualImport');
|
||||
|
||||
/** Statuses that indicate the request is actively being worked on. */
|
||||
const ACTIVE_STATUSES = ['searching', 'downloading', 'processing', 'awaiting_import'];
|
||||
|
||||
/** Statuses that can be recycled for a new manual import. */
|
||||
const RECYCLABLE_STATUSES = ['failed', 'warn', 'cancelled', 'denied', 'pending', 'awaiting_search', 'awaiting_approval'];
|
||||
|
||||
/**
|
||||
* Check if a directory contains at least one audio file (immediate children only).
|
||||
*/
|
||||
async function hasAudioFiles(dirPath: string): Promise<{ found: boolean; count: number }> {
|
||||
const fs = await import('fs/promises');
|
||||
const pathModule = await import('path');
|
||||
|
||||
let count = 0;
|
||||
try {
|
||||
const children = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
for (const child of children) {
|
||||
if (child.isFile()) {
|
||||
const ext = pathModule.extname(child.name).toLowerCase();
|
||||
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* directory not readable */
|
||||
}
|
||||
|
||||
return { found: count > 0, count };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const pathModule = await import('path');
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
const body = await request.json();
|
||||
const { folderPath, asin, cleanupSource } = body;
|
||||
let { audiobookId } = body;
|
||||
|
||||
// Validate required fields
|
||||
if ((!audiobookId && !asin) || !folderPath) {
|
||||
return NextResponse.json(
|
||||
{ error: 'folderPath and either audiobookId or asin are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Load allowed roots
|
||||
const BOOKDROP_PATH = '/bookdrop';
|
||||
const downloadDirConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'download_dir' },
|
||||
});
|
||||
const mediaDirConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'media_dir' },
|
||||
});
|
||||
|
||||
const allowedRoots: string[] = [];
|
||||
if (downloadDirConfig?.value) {
|
||||
allowedRoots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
|
||||
}
|
||||
if (mediaDirConfig?.value) {
|
||||
allowedRoots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
|
||||
}
|
||||
try {
|
||||
const bookdropStat = await fs.stat(BOOKDROP_PATH);
|
||||
if (bookdropStat.isDirectory()) {
|
||||
allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||
}
|
||||
} catch {
|
||||
/* not mounted */
|
||||
}
|
||||
|
||||
// Normalize and validate path
|
||||
const normalizedPath = pathModule.resolve(folderPath).replace(/\\/g, '/');
|
||||
const isAllowed = allowedRoots.some(
|
||||
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied: path outside allowed directories' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify folder exists and is a directory
|
||||
try {
|
||||
const stat = await fs.stat(normalizedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Path is not a directory' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Directory not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify folder contains audio files
|
||||
const audioCheck = await hasAudioFiles(normalizedPath);
|
||||
if (!audioCheck.found) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No audio files found in the selected directory' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve audiobook by ASIN if audiobookId not provided
|
||||
if (!audiobookId && asin) {
|
||||
const byAsin = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: asin },
|
||||
});
|
||||
if (byAsin) {
|
||||
audiobookId = byAsin.id;
|
||||
} else {
|
||||
// Create audiobook record from Audible cache if available
|
||||
const cached = await prisma.audibleCache.findUnique({
|
||||
where: { asin },
|
||||
});
|
||||
if (cached) {
|
||||
const newBook = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: asin,
|
||||
title: cached.title,
|
||||
author: cached.author,
|
||||
coverArtUrl: cached.coverArtUrl,
|
||||
narrator: cached.narrator,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
audiobookId = newBook.id;
|
||||
logger.info(`Created audiobook record from cache for ASIN ${asin}: ${newBook.id}`);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found for the given ASIN' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify audiobook exists
|
||||
const audiobook = await prisma.audiobook.findUnique({
|
||||
where: { id: audiobookId },
|
||||
});
|
||||
|
||||
if (!audiobook) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for existing requests
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId,
|
||||
type: 'audiobook',
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
let requestId: string;
|
||||
|
||||
if (existingRequest) {
|
||||
// Check if already in an active state
|
||||
if (ACTIVE_STATUSES.includes(existingRequest.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This audiobook is already being processed' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Recycle the existing request
|
||||
if (RECYCLABLE_STATUSES.includes(existingRequest.status) ||
|
||||
existingRequest.status === 'downloaded' ||
|
||||
existingRequest.status === 'available') {
|
||||
await prisma.request.update({
|
||||
where: { id: existingRequest.id },
|
||||
data: {
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
errorMessage: null,
|
||||
importAttempts: 0,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
requestId = existingRequest.id;
|
||||
logger.info(`Recycled existing request ${requestId} for manual import`);
|
||||
} else {
|
||||
// Unknown status - create new
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user!.id,
|
||||
audiobookId,
|
||||
type: 'audiobook',
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
},
|
||||
});
|
||||
requestId = newRequest.id;
|
||||
logger.info(`Created new request ${requestId} (existing had status: ${existingRequest.status})`);
|
||||
}
|
||||
} else {
|
||||
// No existing request - create one
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user!.id,
|
||||
audiobookId,
|
||||
type: 'audiobook',
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
},
|
||||
});
|
||||
requestId = newRequest.id;
|
||||
logger.info(`Created new request ${requestId} for manual import`);
|
||||
}
|
||||
|
||||
// Queue organize_files job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath, undefined, cleanupSource === true);
|
||||
|
||||
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
requestId,
|
||||
message: `Import started for ${audiobook.title}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Manual import failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Manual import failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const logger = RMABLogger.create('API.Admin.Requests.Approve');
|
||||
|
||||
const ApprovalActionSchema = z.object({
|
||||
action: z.enum(['approve', 'deny']),
|
||||
selectedTorrent: z.any().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -37,8 +38,8 @@ export async function POST(
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
// Validate action
|
||||
const { action } = ApprovalActionSchema.parse(body);
|
||||
// Validate action and optional admin-selected torrent
|
||||
const { action, selectedTorrent: adminSelectedTorrent } = ApprovalActionSchema.parse(body);
|
||||
|
||||
// Fetch the request
|
||||
const existingRequest = await prisma.request.findUnique({
|
||||
@@ -78,12 +79,15 @@ export async function POST(
|
||||
const jobQueue = getJobQueueService();
|
||||
const isEbookRequest = existingRequest.type === 'ebook';
|
||||
|
||||
// Check if request has a pre-selected torrent (from interactive search)
|
||||
if (existingRequest.selectedTorrent) {
|
||||
const selectedTorrent = existingRequest.selectedTorrent as any;
|
||||
// Use admin-provided torrent (from admin interactive search) or fall back to user's pre-selected torrent
|
||||
const effectiveTorrent = adminSelectedTorrent || existingRequest.selectedTorrent;
|
||||
|
||||
// User pre-selected a specific torrent - download that torrent directly
|
||||
logger.info(`Request ${id} has pre-selected torrent, starting download`, {
|
||||
if (effectiveTorrent) {
|
||||
const selectedTorrent = effectiveTorrent as any;
|
||||
const torrentSource = adminSelectedTorrent ? 'admin' : 'user';
|
||||
|
||||
// Download the selected torrent directly
|
||||
logger.info(`Request ${id} has ${torrentSource}-selected torrent, starting download`, {
|
||||
requestId: id,
|
||||
userId: existingRequest.userId,
|
||||
adminId: req.user.sub,
|
||||
@@ -167,17 +171,20 @@ export async function POST(
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
logger.info(`Request ${id} approved by admin ${req.user.sub}, downloading pre-selected torrent`, {
|
||||
logger.info(`Request ${id} approved by admin ${req.user.sub}, downloading ${torrentSource}-selected torrent`, {
|
||||
requestId: id,
|
||||
userId: updatedRequest.userId,
|
||||
audiobookTitle: existingRequest.audiobook.title,
|
||||
adminId: req.user.sub,
|
||||
type: existingRequest.type,
|
||||
torrentSource,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Request approved and download started with pre-selected torrent',
|
||||
message: adminSelectedTorrent
|
||||
? 'Request approved and download started with admin-selected torrent'
|
||||
: 'Request approved and download started with pre-selected torrent',
|
||||
request: updatedRequest,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Component: Admin Retry Download API
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*
|
||||
* Retries a failed download by either resuming monitoring of a still-alive
|
||||
* download in the client, or re-adding the download using metadata from the
|
||||
* most recent selected DownloadHistory record.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
|
||||
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Requests.RetryDownload');
|
||||
|
||||
/** Download statuses considered "alive" — monitoring can be resumed */
|
||||
const ALIVE_STATUSES = new Set([
|
||||
'downloading',
|
||||
'queued',
|
||||
'paused',
|
||||
'checking',
|
||||
'seeding',
|
||||
'completed',
|
||||
]);
|
||||
|
||||
/**
|
||||
* POST /api/admin/requests/[id]/retry-download
|
||||
* Retry a failed download for an admin request.
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Fetch the request with audiobook info
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: {
|
||||
audiobook: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingRequest) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Request not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existingRequest.status !== 'failed') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InvalidStatus',
|
||||
message: `Request is not in a failed state (current status: ${existingRequest.status})`,
|
||||
currentStatus: existingRequest.status,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the most recent selected DownloadHistory record
|
||||
const downloadHistory = await prisma.downloadHistory.findFirst({
|
||||
where: { requestId: id, selected: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!downloadHistory) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'NoHistory',
|
||||
message: 'No previous download attempt found to retry',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Require a download URL to be able to re-add
|
||||
if (!downloadHistory.magnetLink) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'NoDownloadUrl',
|
||||
message: 'No download URL available in history to retry',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const jobQueue = getJobQueueService();
|
||||
let retryPath: 'resumed_monitoring' | 're_added';
|
||||
|
||||
// Determine if we can attempt to resume monitoring.
|
||||
// downloadClient is stored as a plain string in the DB (can be 'qbittorrent', 'sabnzbd',
|
||||
// 'nzbget', 'transmission', 'deluge', 'direct', or null).
|
||||
const rawClientType: string | null = downloadHistory.downloadClient;
|
||||
const clientId = downloadHistory.downloadClientId;
|
||||
const isDirect = rawClientType === 'direct';
|
||||
|
||||
// Only attempt to query the download client if we have a known DownloadClientType,
|
||||
// a clientId, and it is not a direct (HTTP) download.
|
||||
const canCheckClient = !isDirect && !!rawClientType && !!clientId;
|
||||
// Safe to cast here: we have already confirmed rawClientType is non-null and non-direct
|
||||
const clientType = rawClientType as DownloadClientType | null;
|
||||
|
||||
if (canCheckClient) {
|
||||
// Try to look up the download in the client
|
||||
try {
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const client = await manager.getClientServiceForProtocol(protocol);
|
||||
|
||||
if (client) {
|
||||
const downloadInfo = await client.getDownload(clientId!);
|
||||
|
||||
if (downloadInfo && ALIVE_STATUSES.has(downloadInfo.status)) {
|
||||
// Download is still alive — restart monitoring
|
||||
logger.info(`Retry download: resuming monitoring for request ${id}`, {
|
||||
requestId: id,
|
||||
downloadClientId: clientId,
|
||||
downloadStatus: downloadInfo.status,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await jobQueue.addMonitorJob(
|
||||
id,
|
||||
downloadHistory.id,
|
||||
clientId!, // canCheckClient guard ensures clientId is non-null
|
||||
clientType as DownloadClientType,
|
||||
0 // no delay — start immediately
|
||||
);
|
||||
|
||||
retryPath = 'resumed_monitoring';
|
||||
} else {
|
||||
// Download not found or is failed — re-add
|
||||
logger.info(`Retry download: download not alive (status: ${downloadInfo?.status ?? 'not found'}), re-adding for request ${id}`, {
|
||||
requestId: id,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
|
||||
retryPath = 're_added';
|
||||
}
|
||||
} else {
|
||||
// No client configured for that protocol — fall through to re-add
|
||||
logger.warn(`Retry download: no ${protocol} client configured, re-adding for request ${id}`, {
|
||||
requestId: id,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
|
||||
retryPath = 're_added';
|
||||
}
|
||||
} catch (clientError) {
|
||||
// Client lookup failed (connection error etc.) — re-add to be safe
|
||||
logger.warn(`Retry download: client check failed, re-adding for request ${id}`, {
|
||||
requestId: id,
|
||||
error: clientError instanceof Error ? clientError.message : String(clientError),
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
|
||||
retryPath = 're_added';
|
||||
}
|
||||
} else {
|
||||
// Direct download (ebook), no clientId, or no clientType — re-add
|
||||
logger.info(`Retry download: re-adding for request ${id} (direct=${isDirect}, hasClientId=${!!clientId})`, {
|
||||
requestId: id,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
|
||||
retryPath = 're_added';
|
||||
}
|
||||
|
||||
// Increment downloadAttempts, clear errorMessage, set status to downloading
|
||||
await prisma.request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'downloading',
|
||||
errorMessage: null,
|
||||
downloadAttempts: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const message =
|
||||
retryPath === 'resumed_monitoring'
|
||||
? 'Download monitoring resumed'
|
||||
: 'Download re-added to client';
|
||||
|
||||
logger.info(`Retry download completed for request ${id} via ${retryPath}`, {
|
||||
requestId: id,
|
||||
adminId: req.user.sub,
|
||||
path: retryPath,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message,
|
||||
path: retryPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to retry download', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'RetryError',
|
||||
message: 'Failed to retry download',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-add the download to the queue using metadata from DownloadHistory.
|
||||
* Reconstructs a TorrentResult from the stored history fields.
|
||||
*/
|
||||
async function reAddDownload(
|
||||
jobQueue: ReturnType<typeof getJobQueueService>,
|
||||
requestId: string,
|
||||
audiobook: { id: string; title: string; author: string },
|
||||
history: {
|
||||
torrentName: string | null;
|
||||
magnetLink: string | null;
|
||||
indexerName: string;
|
||||
indexerId: number | null;
|
||||
torrentSizeBytes: bigint | null;
|
||||
seeders: number | null;
|
||||
leechers: number | null;
|
||||
torrentHash: string | null;
|
||||
torrentUrl: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const torrent: TorrentResult = {
|
||||
title: history.torrentName ?? audiobook.title,
|
||||
downloadUrl: history.magnetLink!, // Validated non-null before calling this function
|
||||
indexer: history.indexerName,
|
||||
indexerId: history.indexerId ?? undefined,
|
||||
size: history.torrentSizeBytes !== null ? Number(history.torrentSizeBytes) : 0,
|
||||
seeders: history.seeders ?? undefined,
|
||||
leechers: history.leechers ?? undefined,
|
||||
infoHash: history.torrentHash ?? undefined,
|
||||
infoUrl: history.torrentUrl ?? undefined,
|
||||
guid: history.torrentUrl ?? history.magnetLink!,
|
||||
publishDate: new Date(), // Not stored; use current date as a safe default
|
||||
};
|
||||
|
||||
await jobQueue.addDownloadJob(requestId, audiobook, torrent);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Component: Admin Custom Search Terms API
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.SearchTerms');
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/requests/[id]/search-terms
|
||||
* Update custom search terms for a request (admin only)
|
||||
* Body: { searchTerms: string | null, triggerSearch?: boolean }
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Parse body
|
||||
let body;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'BadRequest', message: 'Invalid JSON body' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchTerms, triggerSearch } = body;
|
||||
|
||||
// Validate searchTerms is string or null
|
||||
if (searchTerms !== null && searchTerms !== undefined && typeof searchTerms !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'BadRequest', message: 'searchTerms must be a string or null' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Trim and normalize
|
||||
const normalizedTerms = typeof searchTerms === 'string' ? searchTerms.trim() || null : null;
|
||||
|
||||
// Find the request
|
||||
const existingRequest = await prisma.request.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
audiobook: {
|
||||
select: { id: true, title: true, author: true, audibleAsin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingRequest || existingRequest.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Request not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update custom search terms
|
||||
await prisma.request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
customSearchTerms: normalizedTerms,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Custom search terms ${normalizedTerms ? 'set' : 'cleared'} for request ${id}`, {
|
||||
requestId: id,
|
||||
customSearchTerms: normalizedTerms,
|
||||
adminId: req.user.id,
|
||||
});
|
||||
|
||||
// Optionally trigger a new search
|
||||
let searchTriggered = false;
|
||||
if (triggerSearch && ['pending', 'failed', 'awaiting_search'].includes(existingRequest.status)) {
|
||||
// Reset status to pending and clear error
|
||||
await prisma.request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'pending',
|
||||
errorMessage: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Queue search job
|
||||
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchJob(id, {
|
||||
id: existingRequest.audiobook.id,
|
||||
title: existingRequest.audiobook.title,
|
||||
author: existingRequest.audiobook.author,
|
||||
asin: existingRequest.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
searchTriggered = true;
|
||||
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
customSearchTerms: normalizedTerms,
|
||||
searchTriggered,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update search terms', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'ServerError', message: 'Failed to update search terms' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -139,6 +139,8 @@ export async function GET(request: NextRequest) {
|
||||
completedAt: request.completedAt,
|
||||
errorMessage: request.errorMessage,
|
||||
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
|
||||
downloadAttempts: request.downloadAttempts,
|
||||
customSearchTerms: request.customSearchTerms || null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Component: Admin Download Access Settings API
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Settings.DownloadAccess');
|
||||
|
||||
const CONFIG_KEY = 'download_access';
|
||||
|
||||
/**
|
||||
* GET /api/admin/settings/download-access
|
||||
* Get current global download access setting
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const config = await prisma.configuration.findUnique({
|
||||
where: { key: CONFIG_KEY },
|
||||
});
|
||||
|
||||
// Default to true if not configured (backward compatibility)
|
||||
const downloadAccess = config === null ? true : config.value === 'true';
|
||||
|
||||
return NextResponse.json({ downloadAccess });
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch download access setting', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch download access setting' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/settings/download-access
|
||||
* Update global download access setting
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { downloadAccess } = body;
|
||||
|
||||
// Validate input
|
||||
if (typeof downloadAccess !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input. downloadAccess must be a boolean' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: CONFIG_KEY },
|
||||
create: {
|
||||
key: CONFIG_KEY,
|
||||
value: downloadAccess.toString(),
|
||||
},
|
||||
update: {
|
||||
value: downloadAccess.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Download access setting updated to: ${downloadAccess}`, {
|
||||
userId: req.user?.sub,
|
||||
});
|
||||
|
||||
return NextResponse.json({ downloadAccess });
|
||||
} catch (error) {
|
||||
logger.error('Failed to update download access setting', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update download access setting' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
|
||||
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate } = await request.json();
|
||||
|
||||
if (!downloadDir || !mediaDir) {
|
||||
return NextResponse.json(
|
||||
@@ -97,6 +97,32 @@ export async function PUT(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// Update file rename setting
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'file_rename_enabled' },
|
||||
update: { value: String(fileRenameEnabled ?? false) },
|
||||
create: {
|
||||
key: 'file_rename_enabled',
|
||||
value: String(fileRenameEnabled ?? false),
|
||||
category: 'automation',
|
||||
description: 'Rename audio and ebook files using a custom naming template during organization',
|
||||
},
|
||||
});
|
||||
|
||||
// Update file rename template
|
||||
if (fileRenameTemplate !== undefined) {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'file_rename_template' },
|
||||
update: { value: fileRenameTemplate },
|
||||
create: {
|
||||
key: 'file_rename_template',
|
||||
value: fileRenameTemplate,
|
||||
category: 'automation',
|
||||
description: 'Template for renaming audio and ebook files during organization',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Paths settings updated');
|
||||
|
||||
// Clear config cache for all updated keys so services get fresh values
|
||||
@@ -107,6 +133,8 @@ export async function PUT(request: NextRequest) {
|
||||
configService.clearCache('ebook_path_template');
|
||||
configService.clearCache('metadata_tagging_enabled');
|
||||
configService.clearCache('chapter_merging_enabled');
|
||||
configService.clearCache('file_rename_enabled');
|
||||
configService.clearCache('file_rename_template');
|
||||
|
||||
// Invalidate all download client singletons to force reload of download_dir
|
||||
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { invalidateProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Settings.Prowlarr');
|
||||
@@ -42,6 +43,9 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate cached singleton so background jobs use new credentials
|
||||
invalidateProwlarrService();
|
||||
|
||||
logger.info('Prowlarr settings updated');
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -128,6 +128,8 @@ export async function GET(request: NextRequest) {
|
||||
ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
|
||||
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
|
||||
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
|
||||
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
|
||||
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
|
||||
},
|
||||
ebook: {
|
||||
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function PUT(
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { role, autoApproveRequests, interactiveSearchAccess } = body;
|
||||
const { role, autoApproveRequests, interactiveSearchAccess, downloadAccess } = body;
|
||||
|
||||
// Validate role
|
||||
if (!role || (role !== 'user' && role !== 'admin')) {
|
||||
@@ -45,6 +45,14 @@ export async function PUT(
|
||||
);
|
||||
}
|
||||
|
||||
// Validate downloadAccess (optional)
|
||||
if (downloadAccess !== undefined && downloadAccess !== null && typeof downloadAccess !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid downloadAccess. Must be a boolean or null' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent user from demoting themselves
|
||||
if (req.user && id === req.user.sub) {
|
||||
return NextResponse.json(
|
||||
@@ -112,15 +120,24 @@ export async function PUT(
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (role === 'admin' && downloadAccess === false) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admins always have download access. Cannot set downloadAccess to false for admin users.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null } = { role };
|
||||
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null; downloadAccess?: boolean | null } = { role };
|
||||
if (autoApproveRequests !== undefined) {
|
||||
updateData.autoApproveRequests = autoApproveRequests;
|
||||
}
|
||||
if (interactiveSearchAccess !== undefined) {
|
||||
updateData.interactiveSearchAccess = interactiveSearchAccess;
|
||||
}
|
||||
if (downloadAccess !== undefined) {
|
||||
updateData.downloadAccess = downloadAccess;
|
||||
}
|
||||
|
||||
// Update user
|
||||
const updatedUser = await prisma.user.update({
|
||||
@@ -132,6 +149,7 @@ export async function PUT(
|
||||
role: true,
|
||||
autoApproveRequests: true,
|
||||
interactiveSearchAccess: true,
|
||||
downloadAccess: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function GET(request: NextRequest) {
|
||||
lastLoginAt: true,
|
||||
autoApproveRequests: true,
|
||||
interactiveSearchAccess: true,
|
||||
downloadAccess: true,
|
||||
_count: {
|
||||
select: {
|
||||
requests: true,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Component: Audiobook Download Status API Route
|
||||
* Documentation: documentation/backend/api.md
|
||||
*
|
||||
* Returns whether a downloadable file exists for this audiobook (by ASIN).
|
||||
* Used by AudiobookDetailsModal to show the download link regardless of context.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||
import { resolveDownloadAccess } from '@/lib/utils/permissions';
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/[asin]/download-status
|
||||
* Returns { downloadAvailable, requestId } for the current user's completed request.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ asin: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check download permission - if denied, don't reveal file existence
|
||||
const userRecord = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: { role: true, downloadAccess: true },
|
||||
});
|
||||
const hasDownloadAccess = await resolveDownloadAccess(
|
||||
userRecord?.role ?? 'user',
|
||||
userRecord?.downloadAccess ?? null
|
||||
);
|
||||
if (!hasDownloadAccess) {
|
||||
return NextResponse.json({ downloadAvailable: false, requestId: null });
|
||||
}
|
||||
|
||||
const { asin } = await params;
|
||||
|
||||
const audiobook = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: asin },
|
||||
select: { id: true, filePath: true },
|
||||
});
|
||||
|
||||
if (!audiobook) {
|
||||
return NextResponse.json({ downloadAvailable: false, requestId: null });
|
||||
}
|
||||
|
||||
// Find any completed request for this audiobook that has a file
|
||||
const completedRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId: audiobook.id,
|
||||
status: { in: [...COMPLETED_STATUSES] },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const downloadAvailable = !!completedRequest && !!audiobook.filePath;
|
||||
|
||||
return NextResponse.json({
|
||||
downloadAvailable,
|
||||
requestId: downloadAvailable ? completedRequest!.id : null,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
|
||||
import { getLanguageForRegion } from '@/lib/constants/language-config';
|
||||
import type { AudibleRegion } from '@/lib/types/audible';
|
||||
import {
|
||||
searchByAsin,
|
||||
searchByTitle,
|
||||
@@ -227,6 +229,11 @@ export async function POST(
|
||||
const format = preferredFormat || 'epub';
|
||||
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
||||
|
||||
// Get language code from Audible region config
|
||||
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||
const langConfig = getLanguageForRegion(region);
|
||||
const languageCode = langConfig.annasArchiveLang;
|
||||
|
||||
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
|
||||
@@ -250,7 +257,8 @@ export async function POST(
|
||||
audiobook.author,
|
||||
format,
|
||||
annasBaseUrl,
|
||||
flaresolverrUrl || undefined
|
||||
flaresolverrUrl || undefined,
|
||||
languageCode
|
||||
).catch((err) => {
|
||||
logger.error(`Anna's Archive search failed: ${err.message}`);
|
||||
return null;
|
||||
@@ -322,7 +330,8 @@ async function searchAnnasArchiveForInteractive(
|
||||
author: string,
|
||||
preferredFormat: string,
|
||||
baseUrl: string,
|
||||
flaresolverrUrl?: string
|
||||
flaresolverrUrl?: string,
|
||||
languageCode: string = 'en'
|
||||
): Promise<EbookSearchResult[]> {
|
||||
let md5: string | null = null;
|
||||
let searchMethod: 'asin' | 'title' = 'title';
|
||||
@@ -330,7 +339,7 @@ async function searchAnnasArchiveForInteractive(
|
||||
// Try ASIN search first
|
||||
if (asin) {
|
||||
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
|
||||
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
||||
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
|
||||
if (md5) {
|
||||
searchMethod = 'asin';
|
||||
logger.info(`Found via ASIN: ${md5}`);
|
||||
@@ -340,7 +349,7 @@ async function searchAnnasArchiveForInteractive(
|
||||
// Fallback to title search
|
||||
if (!md5) {
|
||||
logger.info(`Searching Anna's Archive by title: "${title}"`);
|
||||
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
||||
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
|
||||
if (md5) {
|
||||
logger.info(`Found via title: ${md5}`);
|
||||
}
|
||||
@@ -461,6 +470,10 @@ async function searchIndexersForInteractive(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get language-specific stop words for ranking
|
||||
const rankRegion = await configService.getAudibleRegion() as AudibleRegion;
|
||||
const rankLangConfig = getLanguageForRegion(rankRegion);
|
||||
|
||||
// Rank results with ebook scoring
|
||||
const rankedResults = rankEbookTorrents(allResults, {
|
||||
title,
|
||||
@@ -470,6 +483,8 @@ async function searchIndexersForInteractive(
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: false,
|
||||
stopWords: rankLangConfig.stopWords,
|
||||
characterReplacements: rankLangConfig.characterReplacements,
|
||||
});
|
||||
|
||||
// Convert to unified result type
|
||||
|
||||
@@ -10,6 +10,8 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
|
||||
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
||||
import { getLanguageForRegion } from '@/lib/constants/language-config';
|
||||
import type { AudibleRegion } from '@/lib/types/audible';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
@@ -140,13 +142,19 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
|
||||
}
|
||||
|
||||
// Get language-specific stop words for ranking
|
||||
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||
const langConfig = getLanguageForRegion(region);
|
||||
|
||||
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
|
||||
// Note: rankTorrents now filters out results < 20 MB internally
|
||||
// requireAuthor: false - interactive search, show all results for user decision
|
||||
const rankedResults = rankTorrents(results, { title, author, durationMinutes }, {
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: false // Interactive mode - let user decide
|
||||
requireAuthor: false, // Interactive mode - let user decide
|
||||
stopWords: langConfig.stopWords,
|
||||
characterReplacements: langConfig.characterReplacements,
|
||||
});
|
||||
|
||||
// Log filter results
|
||||
|
||||
@@ -39,6 +39,7 @@ export async function GET(request: NextRequest) {
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
interactiveSearchAccess: true,
|
||||
downloadAccess: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,6 +64,13 @@ export async function GET(request: NextRequest) {
|
||||
globalInteractiveSearch
|
||||
);
|
||||
|
||||
const globalDownload = await getGlobalBooleanSetting('download_access', true);
|
||||
const effectiveDownload = resolvePermission(
|
||||
user.role,
|
||||
user.downloadAccess,
|
||||
globalDownload
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
@@ -77,6 +85,7 @@ export async function GET(request: NextRequest) {
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
permissions: {
|
||||
interactiveSearch: effectiveInteractiveSearch,
|
||||
download: effectiveDownload,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,23 +46,27 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin})`);
|
||||
const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10);
|
||||
|
||||
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin}), page ${page}`);
|
||||
|
||||
const audibleService = getAudibleService();
|
||||
const books = await audibleService.searchByAuthorAsin(authorName.trim(), asin);
|
||||
const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page);
|
||||
|
||||
// Enrich with library availability and request status
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(books, userId);
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(result.books, userId);
|
||||
|
||||
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books`);
|
||||
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
books: enrichedBooks,
|
||||
authorName: authorName.trim(),
|
||||
authorAsin: asin,
|
||||
totalBooks: enrichedBooks.length,
|
||||
totalBooks: result.totalResults || enrichedBooks.length,
|
||||
hasMore: result.hasMore,
|
||||
page: result.page,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
@@ -59,9 +59,9 @@ async function saveConfig(req: AuthenticatedRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude', 'custom'].includes(provider)) {
|
||||
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ async function saveConfig(req: AuthenticatedRequest) {
|
||||
// No new API key, use existing one
|
||||
encryptedApiKeyToUse = existingConfig.apiKey;
|
||||
} else {
|
||||
// API key required for OpenAI/Claude
|
||||
// API key required for OpenAI/Claude/Gemini
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required' },
|
||||
{ status: 400 }
|
||||
|
||||
@@ -52,6 +52,30 @@ async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: st
|
||||
return allModels;
|
||||
}
|
||||
|
||||
// Fetch available Gemini models from the Google API
|
||||
async function fetchGeminiModels(apiKey: string): Promise<{ id: string; name: string }[]> {
|
||||
const response = await fetch(
|
||||
'https://generativelanguage.googleapis.com/v1beta/models',
|
||||
{ headers: { 'x-goog-api-key': apiKey } }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Gemini API error', { error: errorText });
|
||||
throw new Error('Invalid Gemini API key or connection failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return (data.models || [])
|
||||
.filter((m: any) => m.name?.startsWith('models/gemini-') && m.supportedGenerationMethods?.includes('generateContent'))
|
||||
.map((m: any) => ({
|
||||
id: m.name.replace('models/', ''),
|
||||
name: m.displayName || m.name.replace('models/', ''),
|
||||
}))
|
||||
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Helper functions for custom provider
|
||||
function isValidBaseUrl(url: string): boolean {
|
||||
try {
|
||||
@@ -79,9 +103,9 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude', 'custom'].includes(provider)) {
|
||||
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -193,6 +217,16 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (provider === 'gemini') {
|
||||
// Gemini: Fetch models dynamically from the Google API
|
||||
try {
|
||||
models = await fetchGeminiModels(testApiKey);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid Gemini API key or connection failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (provider === 'custom') {
|
||||
// Custom: Fetch models from custom OpenAI-compatible endpoint
|
||||
const normalizedUrl = normalizeBaseUrl(testBaseUrl);
|
||||
@@ -291,9 +325,9 @@ async function unauthenticatedHandler(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude', 'custom'].includes(provider)) {
|
||||
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -363,6 +397,16 @@ async function unauthenticatedHandler(req: NextRequest) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (provider === 'gemini') {
|
||||
// Gemini: Fetch models dynamically
|
||||
try {
|
||||
models = await fetchGeminiModels(apiKey);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid Gemini API key or connection failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (provider === 'custom') {
|
||||
// Custom: Fetch models from custom OpenAI-compatible endpoint
|
||||
const normalizedUrl = normalizeBaseUrl(baseUrl);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Component: Configuration API Routes (by category)
|
||||
* Documentation: documentation/backend/services/config.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Config.Category');
|
||||
|
||||
// GET /api/config/:category - Get all config for a category
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ category: string }> }
|
||||
) {
|
||||
try {
|
||||
// TODO: Add authentication middleware - admin only
|
||||
const { category } = await params;
|
||||
const configService = getConfigService();
|
||||
|
||||
const config = await configService.getCategory(category);
|
||||
|
||||
return NextResponse.json({
|
||||
category,
|
||||
config,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get config for category', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to get configuration',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Component: Configuration API Routes
|
||||
* Documentation: documentation/backend/services/config.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConfigService, ConfigUpdate } from '@/lib/services/config.service';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Config');
|
||||
|
||||
const ConfigUpdateSchema = z.object({
|
||||
updates: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
encrypted: z.boolean().optional(),
|
||||
category: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
// PUT /api/config - Update multiple configuration values
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
// TODO: Add authentication middleware - admin only
|
||||
|
||||
const body = await request.json();
|
||||
const { updates } = ConfigUpdateSchema.parse(body);
|
||||
|
||||
const configService = getConfigService();
|
||||
await configService.setMany(updates as ConfigUpdate[]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
updated: updates.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update configuration', { 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 update configuration',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/config - Get all configuration (masked sensitive values)
|
||||
export async function GET() {
|
||||
try {
|
||||
// TODO: Add authentication middleware - admin only
|
||||
|
||||
const configService = getConfigService();
|
||||
const allConfig = await configService.getAll();
|
||||
|
||||
return NextResponse.json({
|
||||
config: allConfig,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all configuration', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to get configuration',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Component: On-Demand Download Token Generator
|
||||
* Documentation: documentation/backend/api.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { generateDownloadToken } from '@/lib/utils/jwt';
|
||||
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||
import { resolveDownloadAccess } from '@/lib/utils/permissions';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.DownloadToken');
|
||||
|
||||
/**
|
||||
* POST /api/requests/[id]/download-token
|
||||
* Generate a signed download token on demand (lazy token generation).
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check download permission
|
||||
const userRecord = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: { role: true, downloadAccess: true },
|
||||
});
|
||||
const hasDownloadAccess = await resolveDownloadAccess(
|
||||
userRecord?.role ?? 'user',
|
||||
userRecord?.downloadAccess ?? null
|
||||
);
|
||||
if (!hasDownloadAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden', message: 'You do not have download access' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const requestRecord = await prisma.request.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: { audiobook: true },
|
||||
});
|
||||
|
||||
if (!requestRecord) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Request not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!COMPLETED_STATUSES.includes(requestRecord.status as typeof COMPLETED_STATUSES[number])) {
|
||||
return NextResponse.json(
|
||||
{ error: 'BadRequest', message: 'Request is not yet completed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!requestRecord.audiobook?.filePath) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'No file available for this request' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const token = generateDownloadToken(req.user.id, id);
|
||||
const downloadUrl = `/api/requests/${id}/download?token=${token}`;
|
||||
|
||||
return NextResponse.json({ downloadUrl });
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate download token', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: 'TokenError', message: 'Failed to generate download token' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Component: Request File Download Endpoint
|
||||
* Documentation: documentation/backend/api.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyDownloadToken } from '@/lib/utils/jwt';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { AUDIO_EXTENSIONS, EBOOK_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import archiver from 'archiver';
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
const logger = RMABLogger.create('API.Download');
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/requests/[id]/download?token=<JWT>
|
||||
* Token-authenticated file download — no session cookie required.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const token = request.nextUrl.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized', message: 'Missing download token' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = verifyDownloadToken(token);
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: 'Unauthorized', message: 'Invalid or expired download token' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (payload.requestId !== id) {
|
||||
return NextResponse.json({ error: 'Unauthorized', message: 'Token does not match request' }, { status: 401 });
|
||||
}
|
||||
|
||||
const requestRecord = await prisma.request.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: { audiobook: true },
|
||||
});
|
||||
|
||||
if (!requestRecord) {
|
||||
return NextResponse.json({ error: 'NotFound', message: 'Request not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!COMPLETED_STATUSES.includes(requestRecord.status as typeof COMPLETED_STATUSES[number])) {
|
||||
return NextResponse.json({ error: 'BadRequest', message: 'Request is not yet completed' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!requestRecord.audiobook?.filePath) {
|
||||
return NextResponse.json({ error: 'NotFound', message: 'No file path available for this request' }, { status: 404 });
|
||||
}
|
||||
|
||||
const resolvedDir = path.resolve(requestRecord.audiobook.filePath);
|
||||
|
||||
if (!fs.existsSync(resolvedDir)) {
|
||||
logger.error('Download directory does not exist', { path: resolvedDir });
|
||||
return NextResponse.json({ error: 'NotFound', message: 'File directory not found on disk' }, { status: 404 });
|
||||
}
|
||||
|
||||
const requestType = requestRecord.type || 'audiobook';
|
||||
const allowedExtensions: readonly string[] = requestType === 'ebook' ? EBOOK_EXTENSIONS : AUDIO_EXTENSIONS;
|
||||
|
||||
const allEntries = fs.readdirSync(resolvedDir);
|
||||
const matchingFiles = allEntries
|
||||
.filter(name => allowedExtensions.includes(path.extname(name).toLowerCase()))
|
||||
.map(name => path.join(resolvedDir, name));
|
||||
|
||||
if (matchingFiles.length === 0) {
|
||||
return NextResponse.json({ error: 'NotFound', message: 'No matching files found in directory' }, { status: 404 });
|
||||
}
|
||||
|
||||
const sanitizedTitle = sanitizeFilename(requestRecord.audiobook.title || 'download');
|
||||
|
||||
if (matchingFiles.length === 1) {
|
||||
const filePath = matchingFiles[0];
|
||||
const ext = path.extname(filePath);
|
||||
const stat = fs.statSync(filePath);
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
|
||||
const readableStream = new ReadableStream({
|
||||
start(controller) {
|
||||
fileStream.on('data', chunk => controller.enqueue(chunk));
|
||||
fileStream.on('end', () => controller.close());
|
||||
fileStream.on('error', err => {
|
||||
logger.error('File stream error', { error: err.message });
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
fileStream.destroy();
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(readableStream, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${sanitizedTitle}${ext}"`,
|
||||
'Content-Length': String(stat.size),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Multiple files — stream zip via archiver (avoids loading all files into memory)
|
||||
const passThrough = new PassThrough();
|
||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||
archive.pipe(passThrough);
|
||||
for (const filePath of matchingFiles) {
|
||||
archive.file(filePath, { name: path.basename(filePath) });
|
||||
}
|
||||
archive.finalize();
|
||||
|
||||
const zipReadable = new ReadableStream({
|
||||
start(controller) {
|
||||
passThrough.on('data', chunk => controller.enqueue(new Uint8Array(chunk)));
|
||||
passThrough.on('end', () => controller.close());
|
||||
passThrough.on('error', err => {
|
||||
logger.error('Zip stream error', { error: err.message });
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
archive.abort();
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(zipReadable, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${sanitizedTitle}.zip"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Download failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'DownloadError', message: 'Failed to serve file' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm';
|
||||
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { getLanguageForRegion } from '@/lib/constants/language-config';
|
||||
import type { AudibleRegion } from '@/lib/types/audible';
|
||||
import {
|
||||
searchByAsin,
|
||||
searchByTitle,
|
||||
@@ -121,6 +123,11 @@ export async function POST(
|
||||
const format = preferredFormat || 'epub';
|
||||
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
||||
|
||||
// Get language code from Audible region config
|
||||
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||
const langConfig = getLanguageForRegion(region);
|
||||
const languageCode = langConfig.annasArchiveLang;
|
||||
|
||||
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
|
||||
@@ -145,7 +152,8 @@ export async function POST(
|
||||
audiobook.author,
|
||||
format,
|
||||
annasBaseUrl,
|
||||
flaresolverrUrl || undefined
|
||||
flaresolverrUrl || undefined,
|
||||
languageCode
|
||||
).catch((err) => {
|
||||
logger.error(`Anna's Archive search failed: ${err.message}`);
|
||||
return null;
|
||||
@@ -217,7 +225,8 @@ async function searchAnnasArchiveForInteractive(
|
||||
author: string,
|
||||
preferredFormat: string,
|
||||
baseUrl: string,
|
||||
flaresolverrUrl?: string
|
||||
flaresolverrUrl?: string,
|
||||
languageCode: string = 'en'
|
||||
): Promise<EbookSearchResult[]> {
|
||||
let md5: string | null = null;
|
||||
let searchMethod: 'asin' | 'title' = 'title';
|
||||
@@ -225,7 +234,7 @@ async function searchAnnasArchiveForInteractive(
|
||||
// Try ASIN search first
|
||||
if (asin) {
|
||||
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
|
||||
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
||||
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
|
||||
if (md5) {
|
||||
searchMethod = 'asin';
|
||||
logger.info(`Found via ASIN: ${md5}`);
|
||||
@@ -235,7 +244,7 @@ async function searchAnnasArchiveForInteractive(
|
||||
// Fallback to title search
|
||||
if (!md5) {
|
||||
logger.info(`Searching Anna's Archive by title: "${title}"`);
|
||||
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
||||
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
|
||||
if (md5) {
|
||||
logger.info(`Found via title: ${md5}`);
|
||||
}
|
||||
@@ -356,6 +365,10 @@ async function searchIndexersForInteractive(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get language-specific stop words for ranking
|
||||
const rankRegion = await configService.getAudibleRegion() as AudibleRegion;
|
||||
const rankLangConfig = getLanguageForRegion(rankRegion);
|
||||
|
||||
// Rank results with ebook scoring
|
||||
// Use requireAuthor=false for interactive mode (let user decide)
|
||||
const rankedResults = rankEbookTorrents(allResults, {
|
||||
@@ -366,6 +379,8 @@ async function searchIndexersForInteractive(
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: false,
|
||||
stopWords: rankLangConfig.stopWords,
|
||||
characterReplacements: rankLangConfig.characterReplacements,
|
||||
});
|
||||
|
||||
// Log ranking debug info (same format as search-ebook.processor.ts)
|
||||
|
||||
@@ -9,6 +9,8 @@ import { prisma } from '@/lib/db';
|
||||
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
|
||||
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
||||
import { getLanguageForRegion } from '@/lib/constants/language-config';
|
||||
import type { AudibleRegion } from '@/lib/types/audible';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
|
||||
|
||||
@@ -65,8 +67,8 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
// Check if request is awaiting approval
|
||||
if (requestRecord.status === 'awaiting_approval') {
|
||||
// Check if request is awaiting approval (admins can still search to override the user's selection)
|
||||
if (requestRecord.status === 'awaiting_approval' && req.user.role !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'AwaitingApproval', message: 'This request is awaiting admin approval. You cannot search for torrents until it is approved.' },
|
||||
{ status: 403 }
|
||||
@@ -123,8 +125,8 @@ export async function POST(
|
||||
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
|
||||
}
|
||||
|
||||
// Use custom title if provided, otherwise use audiobook's title
|
||||
const searchTitle = customTitle || requestRecord.audiobook.title;
|
||||
// Use custom title if provided, then custom search terms, then audiobook's title
|
||||
const searchTitle = customTitle || requestRecord.customSearchTerms || requestRecord.audiobook.title;
|
||||
const searchAuthor = requestRecord.audiobook.author;
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle });
|
||||
@@ -189,6 +191,10 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Get language-specific stop words for ranking
|
||||
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||
const langConfig = getLanguageForRegion(region);
|
||||
|
||||
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
|
||||
// Always use the audiobook's title/author for ranking (not custom search query)
|
||||
// requireAuthor: false - interactive mode, show all results for user decision
|
||||
@@ -199,7 +205,9 @@ export async function POST(
|
||||
}, {
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: false // Interactive mode - let user decide
|
||||
requireAuthor: false, // Interactive mode - let user decide
|
||||
stopWords: langConfig.stopWords,
|
||||
characterReplacements: langConfig.characterReplacements,
|
||||
});
|
||||
|
||||
// No threshold filtering for interactive search - show all results
|
||||
|
||||
@@ -9,6 +9,7 @@ import { prisma } from '@/lib/db';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||
|
||||
const logger = RMABLogger.create('API.Requests');
|
||||
|
||||
@@ -146,10 +147,18 @@ export async function GET(request: NextRequest) {
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const enriched = requests.map(r => {
|
||||
const isCompleted = COMPLETED_STATUSES.includes(r.status as typeof COMPLETED_STATUSES[number]);
|
||||
const downloadAvailable = isCompleted && !!r.audiobook?.filePath;
|
||||
// Strip server-side absolute path from client response
|
||||
const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook;
|
||||
return { ...r, audiobook, downloadAvailable };
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
requests,
|
||||
count: requests.length,
|
||||
requests: enriched,
|
||||
count: enriched.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Component: Series Detail API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
|
||||
const logger = RMABLogger.create('API.Series.Detail');
|
||||
|
||||
/**
|
||||
* GET /api/series/{asin}
|
||||
* Fetch series detail: metadata + books (enriched with availability) + similar series
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ asin: string }> }
|
||||
) {
|
||||
try {
|
||||
const currentUser = getCurrentUser(request);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { asin } = await params;
|
||||
|
||||
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Valid series ASIN is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10);
|
||||
|
||||
logger.info(`Fetching series detail: ${asin}, page ${page}`);
|
||||
|
||||
const detail = await scrapeSeriesPage(asin, page);
|
||||
if (!detail) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Series not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Enrich books with library availability and request status
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId);
|
||||
|
||||
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
series: {
|
||||
...detail,
|
||||
books: enrichedBooks,
|
||||
},
|
||||
hasMore: detail.hasMore,
|
||||
page: detail.page,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch series detail', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch series details' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Component: Series Search API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { searchForSeries } from '@/lib/integrations/audible-series';
|
||||
|
||||
const logger = RMABLogger.create('API.Series.Search');
|
||||
|
||||
/**
|
||||
* GET /api/series/search?q=game+of+thrones
|
||||
* Search for audiobook series on Audible, de-duplicate, and return enriched summaries
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Require authentication
|
||||
const currentUser = getCurrentUser(request);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const query = request.nextUrl.searchParams.get('q');
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Search query is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Searching series: "${query}"`);
|
||||
|
||||
const series = await searchForSeries(query.trim());
|
||||
|
||||
logger.info(`Series search complete: "${query}" -> ${series.length} results`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
series,
|
||||
query: query.trim(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to search series', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'SearchError', message: 'Failed to search series' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,17 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { use, useCallback } from 'react';
|
||||
import { use, useCallback, useMemo } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
|
||||
import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard';
|
||||
import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow';
|
||||
import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors';
|
||||
import { Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
export default function AuthorDetailPage({
|
||||
@@ -27,11 +28,11 @@ export default function AuthorDetailPage({
|
||||
const searchParams = useSearchParams();
|
||||
const fromAuthorName = searchParams.get('from');
|
||||
const { author, isLoading: authorLoading } = useAuthorDetail(asin);
|
||||
const { books, totalBooks, isLoading: booksLoading } = useAuthorBooks(
|
||||
const { books, totalBooks, hasMore, isLoading: booksLoading, isLoadingMore, loadMore } = useAuthorBooks(
|
||||
asin,
|
||||
author?.name || null
|
||||
);
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
// Use browser back if we came from within the app, otherwise fallback to /authors
|
||||
@@ -42,6 +43,20 @@ export default function AuthorDetailPage({
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Filter out available titles when hideAvailable is enabled
|
||||
const filteredBooks = useMemo(
|
||||
() => hideAvailable ? books.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : books,
|
||||
[books, hideAvailable]
|
||||
);
|
||||
|
||||
// Header count text: reflects filtered counts
|
||||
const visibleCount = filteredBooks.length;
|
||||
const booksCountText = hasMore && totalBooks > books.length
|
||||
? `${visibleCount.toLocaleString()} of ${totalBooks.toLocaleString()} title${totalBooks !== 1 ? 's' : ''}`
|
||||
: visibleCount > 0
|
||||
? `${visibleCount.toLocaleString()} title${visibleCount !== 1 ? 's' : ''}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen">
|
||||
@@ -91,27 +106,42 @@ export default function AuthorDetailPage({
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Books
|
||||
</h2>
|
||||
{!booksLoading && totalBooks > 0 && (
|
||||
{!booksLoading && booksCountText && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||
({totalBooks} title{totalBooks !== 1 ? 's' : ''})
|
||||
({booksCountText})
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
<SectionToolbar
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={setHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={setSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={setCardSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Books Grid */}
|
||||
<AudiobookGrid
|
||||
audiobooks={books}
|
||||
audiobooks={filteredBooks}
|
||||
isLoading={booksLoading}
|
||||
emptyMessage={`No books found for ${author.name}`}
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
|
||||
{/* Load More Bar */}
|
||||
{filteredBooks.length > 0 && (
|
||||
<LoadMoreBar
|
||||
loadedCount={filteredBooks.length}
|
||||
totalCount={totalBooks > 0 ? totalBooks : undefined}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoadingMore}
|
||||
onLoadMore={loadMore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
+32
-15
@@ -5,20 +5,19 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
|
||||
import { useAudiobooks, Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { StickyPagination } from '@/components/ui/StickyPagination';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
export default function HomePage() {
|
||||
const [popularPage, setPopularPage] = useState(1);
|
||||
const [newReleasesPage, setNewReleasesPage] = useState(1);
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||
|
||||
// Refs for auto-scrolling to section tops
|
||||
const popularSectionRef = useRef<HTMLElement>(null);
|
||||
@@ -39,6 +38,16 @@ export default function HomePage() {
|
||||
message: newReleasesMessage,
|
||||
} = useAudiobooks('new-releases', 20, newReleasesPage);
|
||||
|
||||
// Filter out available titles when hideAvailable is enabled
|
||||
const filteredPopular = useMemo(
|
||||
() => hideAvailable ? popular.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : popular,
|
||||
[popular, 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
|
||||
const handlePopularPageChange = (page: number) => {
|
||||
setPopularPage(page);
|
||||
@@ -66,10 +75,14 @@ export default function HomePage() {
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Popular Audiobooks
|
||||
</h2>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
<SectionToolbar
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={setHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={setSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={setCardSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,7 +100,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
) : (
|
||||
<AudiobookGrid
|
||||
audiobooks={popular}
|
||||
audiobooks={filteredPopular}
|
||||
isLoading={loadingPopular}
|
||||
emptyMessage="No popular audiobooks available"
|
||||
cardSize={cardSize}
|
||||
@@ -107,10 +120,14 @@ export default function HomePage() {
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
New Releases
|
||||
</h2>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
<SectionToolbar
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={setHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={setSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={setCardSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +145,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
) : (
|
||||
<AudiobookGrid
|
||||
audiobooks={newReleases}
|
||||
audiobooks={filteredNewReleases}
|
||||
isLoading={loadingNewReleases}
|
||||
emptyMessage="No new releases available"
|
||||
cardSize={cardSize}
|
||||
|
||||
+41
-37
@@ -5,41 +5,48 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { useSearch } from '@/lib/hooks/useAudiobooks';
|
||||
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
|
||||
import { useSearch, Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||
|
||||
// Debounce search query
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
setPage(1); // Reset to first page on new search
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
const { results, totalResults, hasMore, isLoading } = useSearch(debouncedQuery, page);
|
||||
const { results, totalResults, hasMore, isLoading, isLoadingMore, loadMore } = useSearch(debouncedQuery);
|
||||
|
||||
// Filter out available titles when hideAvailable is enabled
|
||||
const filteredResults = useMemo(
|
||||
() => hideAvailable ? results.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : results,
|
||||
[results, hideAvailable]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setPage((prev) => prev + 1);
|
||||
}, []);
|
||||
// Header count text: reflects filtered counts
|
||||
const visibleCount = filteredResults.length;
|
||||
const countText = hasMore && totalResults > 0
|
||||
? `${visibleCount.toLocaleString()} of ${totalResults.toLocaleString()} result${totalResults !== 1 ? 's' : ''}`
|
||||
: visibleCount > 0
|
||||
? `${visibleCount.toLocaleString()} result${visibleCount !== 1 ? 's' : ''}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
@@ -113,45 +120,42 @@ export default function SearchPage() {
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Search Results
|
||||
</h2>
|
||||
{!isLoading && totalResults > 0 && (
|
||||
{!isLoading && countText && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||
({totalResults.toLocaleString()} result{totalResults !== 1 ? 's' : ''})
|
||||
({countText})
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
<SectionToolbar
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={setHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={setSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={setCardSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Grid */}
|
||||
<AudiobookGrid
|
||||
audiobooks={results}
|
||||
isLoading={!!(isLoading && page === 1)}
|
||||
audiobooks={filteredResults}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={`No results found for "${debouncedQuery}"`}
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
|
||||
{/* Load More */}
|
||||
{hasMore && !isLoading && (
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
className="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Load More Results
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading More Indicator */}
|
||||
{isLoading && page > 1 && (
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
{/* Load More Bar */}
|
||||
{filteredResults.length > 0 && (
|
||||
<LoadMoreBar
|
||||
loadedCount={filteredResults.length}
|
||||
totalCount={totalResults}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoadingMore}
|
||||
onLoadMore={loadMore}
|
||||
itemLabel="results"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Component: Series Detail Page
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { use, useCallback, useMemo } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
|
||||
import { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard';
|
||||
import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow';
|
||||
import { useSeriesDetail } from '@/lib/hooks/useSeries';
|
||||
import { Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
export default function SeriesDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ asin: string }>;
|
||||
}) {
|
||||
const { asin } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const fromSeriesTitle = searchParams.get('from');
|
||||
const { series, hasMore, isLoading: seriesLoading, isLoadingMore, loadMore } = useSeriesDetail(asin);
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
// Use browser back if we came from within the app, otherwise fallback to /series
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/series');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Filter out available titles when hideAvailable is enabled
|
||||
const filteredBooks = useMemo(
|
||||
() => series && hideAvailable
|
||||
? series.books.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed')
|
||||
: series?.books ?? [],
|
||||
[series, hideAvailable]
|
||||
);
|
||||
|
||||
// Header count text: reflects filtered counts
|
||||
const visibleCount = filteredBooks.length;
|
||||
const booksCountText = series
|
||||
? hasMore && series.bookCount > series.books.length
|
||||
? `${visibleCount.toLocaleString()} of ${series.bookCount.toLocaleString()} title${series.bookCount !== 1 ? 's' : ''}`
|
||||
: visibleCount > 0
|
||||
? `${visibleCount.toLocaleString()} title${visibleCount !== 1 ? 's' : ''}`
|
||||
: ''
|
||||
: '';
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8">
|
||||
{/* Back navigation */}
|
||||
<button
|
||||
onClick={handleBack}
|
||||
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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{fromSeriesTitle ? `Back to ${fromSeriesTitle}` : 'Back to Series'}
|
||||
</button>
|
||||
|
||||
{/* Series Detail Card */}
|
||||
{seriesLoading ? (
|
||||
<SeriesDetailSkeleton squareCovers={squareCovers} />
|
||||
) : series ? (
|
||||
<SeriesDetailCard series={series} squareCovers={squareCovers} />
|
||||
) : (
|
||||
<div className="text-center py-16 space-y-4">
|
||||
<svg className="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-400">Series not found</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Similar Series */}
|
||||
{seriesLoading ? (
|
||||
<SimilarSeriesSkeleton squareCovers={squareCovers} />
|
||||
) : series && series.similarSeries.length > 0 ? (
|
||||
<SimilarSeriesRow series={series.similarSeries} currentSeriesTitle={series.title} squareCovers={squareCovers} />
|
||||
) : null}
|
||||
|
||||
{/* Books Section */}
|
||||
{series && (
|
||||
<div className="space-y-6">
|
||||
{/* Sticky Books Header */}
|
||||
<div className="sticky top-14 sm:top-16 z-30">
|
||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Books in Series
|
||||
</h2>
|
||||
{booksCountText && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||
({booksCountText})
|
||||
</span>
|
||||
)}
|
||||
<SectionToolbar
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={setHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={setSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={setCardSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Books Grid */}
|
||||
<AudiobookGrid
|
||||
audiobooks={filteredBooks}
|
||||
isLoading={seriesLoading}
|
||||
emptyMessage={`No books found for ${series.title}`}
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
|
||||
{/* Load More Bar */}
|
||||
{filteredBooks.length > 0 && (
|
||||
<LoadMoreBar
|
||||
loadedCount={filteredBooks.length}
|
||||
totalCount={series.bookCount > 0 ? series.bookCount : undefined}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoadingMore}
|
||||
onLoadMore={loadMore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Component: Series Page
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { SeriesGrid } from '@/components/series/SeriesGrid';
|
||||
import { useSeriesSearch } from '@/lib/hooks/useSeries';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
function SeriesPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const initialQuery = searchParams.get('q') || '';
|
||||
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||
|
||||
// Debounce search query and sync to URL
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
// Update URL without adding history entries
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) {
|
||||
router.replace(`/series?q=${encodeURIComponent(trimmed)}`, { scroll: false });
|
||||
} else {
|
||||
router.replace('/series', { scroll: false });
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, router]);
|
||||
|
||||
const { series, isLoading } = useSeriesSearch(debouncedQuery);
|
||||
|
||||
const handleSearch = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Browse Series
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Search for your favorite audiobook series
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by series name..."
|
||||
className="w-full pl-12 pr-12 py-4 text-lg border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||
autoFocus
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg className="h-5 w-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>
|
||||
</form>
|
||||
|
||||
{/* Results */}
|
||||
{debouncedQuery ? (
|
||||
<div className="space-y-6">
|
||||
{/* Sticky Results Header */}
|
||||
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
|
||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Series
|
||||
</h2>
|
||||
{!isLoading && series.length > 0 && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||
({series.length} result{series.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Series Grid */}
|
||||
<SeriesGrid
|
||||
series={series}
|
||||
isLoading={!!isLoading}
|
||||
emptyMessage={`No series found for "${debouncedQuery}"`}
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Empty State */
|
||||
<div className="text-center py-16 space-y-4">
|
||||
<svg
|
||||
className="mx-auto h-16 w-16 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
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>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-400">
|
||||
Start typing to search for series
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
Search by series name to discover audiobook collections
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SeriesPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SeriesPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -115,11 +115,11 @@ export function BackendSelectionStep({
|
||||
>
|
||||
{Object.values(AUDIBLE_REGIONS).map((region) => (
|
||||
<option key={region.code} value={region.code}>
|
||||
{region.name}{!region.isEnglish ? ' *' : ''}
|
||||
{region.name}{region.language !== 'en' ? ' *' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{AUDIBLE_REGIONS[audibleRegion]?.isEnglish === false && (
|
||||
{AUDIBLE_REGIONS[audibleRegion]?.language !== 'en' && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
|
||||
@@ -134,6 +134,7 @@ export function BookDateStep({
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="gemini">Google Gemini</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +153,7 @@ export function BookDateStep({
|
||||
onUpdate('bookdateConfigured', false);
|
||||
onUpdate('bookdateModels', []);
|
||||
}}
|
||||
placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'}
|
||||
placeholder={bookdateProvider === 'openai' ? 'sk-...' : bookdateProvider === 'gemini' ? 'AIza...' : 'sk-ant-...'}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
|
||||
@@ -29,6 +29,7 @@ export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientC
|
||||
transmission: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
|
||||
sabnzbd: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
|
||||
nzbget: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300',
|
||||
deluge: 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300',
|
||||
};
|
||||
const typeColor = typeColorMap[client.type] || typeColorMap.qbittorrent;
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ export function DownloadClientManagement({
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Add Download Client
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{/* qBittorrent Card */}
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
@@ -316,6 +316,37 @@ export function DownloadClientManagement({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deluge Card */}
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Deluge
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Torrent downloads
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-block text-xs px-2 py-1 rounded bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 font-medium">
|
||||
Torrent
|
||||
</span>
|
||||
</div>
|
||||
{hasTorrentClient ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Protocol already configured
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleAddClient('deluge')}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
Add Deluge
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SABnzbd Card */}
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasUsenetClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
|
||||
@@ -278,7 +278,7 @@ export function DownloadClientModal({
|
||||
type,
|
||||
name,
|
||||
url,
|
||||
username: type !== 'sabnzbd' ? username : undefined,
|
||||
username: type !== 'sabnzbd' && type !== 'deluge' ? username : undefined,
|
||||
password: password === '********' ? undefined : password, // Don't send masked password on edit
|
||||
enabled,
|
||||
disableSSLVerify,
|
||||
@@ -286,7 +286,7 @@ export function DownloadClientModal({
|
||||
remotePath: remotePathMappingEnabled ? remotePath : undefined,
|
||||
localPath: remotePathMappingEnabled ? localPath : undefined,
|
||||
category,
|
||||
customPath: sanitizedCustomPath || undefined,
|
||||
customPath: sanitizedCustomPath,
|
||||
postImportCategory,
|
||||
};
|
||||
|
||||
@@ -338,7 +338,7 @@ export function DownloadClientModal({
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'}
|
||||
placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'deluge' ? 'http://localhost:8112' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'}
|
||||
error={errors.url}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -346,8 +346,8 @@ export function DownloadClientModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Username (qBittorrent and Transmission) */}
|
||||
{type !== 'sabnzbd' && (
|
||||
{/* Username (qBittorrent, Transmission, NZBGet — not SABnzbd or Deluge) */}
|
||||
{type !== 'sabnzbd' && type !== 'deluge' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Username
|
||||
@@ -383,6 +383,11 @@ export function DownloadClientModal({
|
||||
Configured in NZBGet under Settings → Security → ControlPassword
|
||||
</p>
|
||||
)}
|
||||
{type === 'deluge' && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Web UI password configured in Deluge under Preferences → Interface
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SSL Verification */}
|
||||
@@ -448,7 +453,7 @@ export function DownloadClientModal({
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Post-Import Category
|
||||
</label>
|
||||
{type === 'qbittorrent' && availableCategories.length > 0 ? (
|
||||
{(type === 'qbittorrent' || type === 'deluge') && availableCategories.length > 0 ? (
|
||||
<select
|
||||
value={postImportCategory}
|
||||
onChange={(e) => setPostImportCategory(e.target.value)}
|
||||
|
||||
@@ -14,6 +14,8 @@ interface GlobalUserSettingsModalProps {
|
||||
onToggleAutoApprove: (newValue: boolean) => void;
|
||||
globalInteractiveSearch: boolean;
|
||||
onToggleInteractiveSearch: (newValue: boolean) => void;
|
||||
globalDownloadAccess: boolean;
|
||||
onToggleDownloadAccess: (newValue: boolean) => void;
|
||||
}
|
||||
|
||||
export function GlobalUserSettingsModal({
|
||||
@@ -23,6 +25,8 @@ export function GlobalUserSettingsModal({
|
||||
onToggleAutoApprove,
|
||||
globalInteractiveSearch,
|
||||
onToggleInteractiveSearch,
|
||||
globalDownloadAccess,
|
||||
onToggleDownloadAccess,
|
||||
}: GlobalUserSettingsModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Global User Settings" size="sm">
|
||||
@@ -84,6 +88,35 @@ export function GlobalUserSettingsModal({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Access Setting */}
|
||||
<div className="flex items-start gap-4">
|
||||
<button
|
||||
onClick={() => onToggleDownloadAccess(!globalDownloadAccess)}
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
|
||||
style={{ backgroundColor: globalDownloadAccess ? '#3b82f6' : '#d1d5db' }}
|
||||
role="switch"
|
||||
aria-checked={globalDownloadAccess}
|
||||
aria-label="Download Access"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
globalDownloadAccess ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
onClick={() => onToggleDownloadAccess(!globalDownloadAccess)}
|
||||
className="block text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Download Access
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
When enabled, all users can download audiobook files. When disabled, you can grant access per-user from the users table.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ interface UserPermissionsUser {
|
||||
role: 'user' | 'admin';
|
||||
autoApproveRequests: boolean | null;
|
||||
interactiveSearchAccess: boolean | null;
|
||||
downloadAccess: boolean | null;
|
||||
}
|
||||
|
||||
interface UserPermissionsModalProps {
|
||||
@@ -23,8 +24,10 @@ interface UserPermissionsModalProps {
|
||||
user: UserPermissionsUser | null;
|
||||
globalAutoApprove: boolean;
|
||||
globalInteractiveSearch: boolean;
|
||||
globalDownloadAccess: boolean;
|
||||
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||
}
|
||||
|
||||
interface PermissionToggleProps {
|
||||
@@ -86,8 +89,10 @@ export function UserPermissionsModal({
|
||||
user,
|
||||
globalAutoApprove,
|
||||
globalInteractiveSearch,
|
||||
globalDownloadAccess,
|
||||
onToggleAutoApprove,
|
||||
onToggleInteractiveSearch,
|
||||
onToggleDownloadAccess,
|
||||
}: UserPermissionsModalProps) {
|
||||
if (!user) return null;
|
||||
|
||||
@@ -103,6 +108,11 @@ export function UserPermissionsModal({
|
||||
const isSearchDisabled = isAdmin || isSearchGlobalOverride;
|
||||
const searchValue = isAdmin ? true : isSearchGlobalOverride ? true : (user.interactiveSearchAccess ?? false);
|
||||
|
||||
// Download Access resolution
|
||||
const isDownloadGlobalOverride = !isAdmin && globalDownloadAccess;
|
||||
const isDownloadDisabled = isAdmin || isDownloadGlobalOverride;
|
||||
const downloadValue = isAdmin ? true : isDownloadGlobalOverride ? true : (user.downloadAccess ?? false);
|
||||
|
||||
const getDisabledMessage = (isAdminUser: boolean, isGlobalOverride: boolean, adminMessage: string, globalMessage: string): string | undefined => {
|
||||
if (isAdminUser) return adminMessage;
|
||||
if (isGlobalOverride) return globalMessage;
|
||||
@@ -176,6 +186,21 @@ export function UserPermissionsModal({
|
||||
description="When enabled, this user can manually search and select torrents and ebooks"
|
||||
onToggle={() => onToggleInteractiveSearch(user, !searchValue)}
|
||||
/>
|
||||
|
||||
{/* Download Access Permission */}
|
||||
<PermissionToggle
|
||||
label="Download Access"
|
||||
ariaLabel="Download Access"
|
||||
value={downloadValue}
|
||||
disabled={isDownloadDisabled}
|
||||
disabledMessage={getDisabledMessage(
|
||||
isAdmin, isDownloadGlobalOverride,
|
||||
'Admins always have download access',
|
||||
'Controlled by global download access setting'
|
||||
)}
|
||||
description="When enabled, this user can download audiobook files directly"
|
||||
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,8 +56,13 @@ export function AudiobookCard({
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
|
||||
|
||||
const status = getStatusConfig(audiobook);
|
||||
// Build a display-only audiobook with the local status override
|
||||
const displayAudiobook = localRequestStatus !== undefined
|
||||
? { ...audiobook, requestStatus: localRequestStatus }
|
||||
: audiobook;
|
||||
const status = getStatusConfig(displayAudiobook);
|
||||
|
||||
const handleRequest = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -69,6 +74,7 @@ export function AudiobookCard({
|
||||
|
||||
try {
|
||||
await createRequest(audiobook);
|
||||
setLocalRequestStatus('pending');
|
||||
setShowToast(true);
|
||||
setTimeout(() => setShowToast(false), 2500);
|
||||
onRequestSuccess?.();
|
||||
@@ -240,8 +246,9 @@ export function AudiobookCard({
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onRequestSuccess={onRequestSuccess}
|
||||
isRequested={audiobook.isRequested}
|
||||
requestStatus={audiobook.requestStatus}
|
||||
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
|
||||
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
|
||||
requestStatus={displayAudiobook.requestStatus}
|
||||
isAvailable={audiobook.isAvailable}
|
||||
requestedByUsername={audiobook.requestedByUsername}
|
||||
hasReportedIssue={audiobook.hasReportedIssue}
|
||||
|
||||
@@ -13,17 +13,21 @@ import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
|
||||
import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
|
||||
import { useCreateRequest, useEbookStatus, useDownloadStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
|
||||
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
|
||||
import { FolderArrowDownIcon } from '@heroicons/react/24/outline';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
interface AudiobookDetailsModalProps {
|
||||
asin: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onRequestSuccess?: () => void;
|
||||
onStatusChange?: (newStatus: string) => void;
|
||||
isRequested?: boolean;
|
||||
requestStatus?: string | null;
|
||||
isAvailable?: boolean;
|
||||
@@ -63,6 +67,7 @@ export function AudiobookDetailsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onRequestSuccess,
|
||||
onStatusChange,
|
||||
isRequested = false,
|
||||
requestStatus = null,
|
||||
isAvailable = false,
|
||||
@@ -75,6 +80,7 @@ export function AudiobookDetailsModal({
|
||||
const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
|
||||
const { createRequest, isLoading: isRequesting } = useCreateRequest();
|
||||
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
|
||||
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
|
||||
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
||||
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
@@ -84,9 +90,18 @@ export function AudiobookDetailsModal({
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
||||
const [showReportIssue, setShowReportIssue] = useState(false);
|
||||
const [showManualImport, setShowManualImport] = useState(false);
|
||||
const [asinCopied, setAsinCopied] = useState(false);
|
||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const status = getStatusInfo(isAvailable, requestStatus, requestedByUsername);
|
||||
// Sync local status when the prop changes (e.g. page data refreshes)
|
||||
useEffect(() => {
|
||||
setLocalRequestStatus(requestStatus ?? null);
|
||||
}, [requestStatus]);
|
||||
|
||||
const effectiveStatus = localRequestStatus;
|
||||
const status = getStatusInfo(isAvailable, effectiveStatus, requestedByUsername);
|
||||
const canShowEbookButtons = isAvailable && ebookStatus?.ebookSourcesEnabled && !ebookStatus?.hasActiveEbookRequest;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -119,6 +134,8 @@ export function AudiobookDetailsModal({
|
||||
|
||||
try {
|
||||
await createRequest(audiobook);
|
||||
setLocalRequestStatus('pending');
|
||||
onStatusChange?.('pending');
|
||||
showNotification('Request created!');
|
||||
setTimeout(onClose, 1500);
|
||||
onRequestSuccess?.();
|
||||
@@ -160,6 +177,22 @@ export function AudiobookDetailsModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!requestId) return;
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const res = await fetchWithAuth(`/api/requests/${requestId}/download-token`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Failed to get download link');
|
||||
const { downloadUrl } = await res.json();
|
||||
window.location.href = downloadUrl;
|
||||
} catch (err) {
|
||||
console.error('Failed to initiate download:', err);
|
||||
showNotification('Failed to start download. Please try again.', 'error');
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (minutes?: number) => {
|
||||
if (!minutes) return null;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
@@ -307,6 +340,24 @@ export function AudiobookDetailsModal({
|
||||
Narrated by {audiobook.narrator}
|
||||
</p>
|
||||
)}
|
||||
{audiobook.series && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{audiobook.seriesAsin ? (
|
||||
<Link
|
||||
href={`/series/${audiobook.seriesAsin}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
{audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Status Badge */}
|
||||
{status.type !== 'none' && (
|
||||
@@ -443,6 +494,36 @@ export function AudiobookDetailsModal({
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Download Link - subtle utility, visible from any context */}
|
||||
{isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && (
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Download</p>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
|
||||
aria-label={isDownloading ? 'Preparing download...' : 'Download audiobook files'}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span>Preparing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Download files</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -467,7 +548,8 @@ export function AudiobookDetailsModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky Action Bar - hidden when opened from bookdate */}
|
||||
|
||||
{/* Sticky Action Bar - hidden when opened from read-only contexts */}
|
||||
{audiobook && !isLoading && !hideRequestActions && (
|
||||
<div
|
||||
className="sticky bottom-0 z-20 p-4 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50"
|
||||
@@ -538,6 +620,17 @@ export function AudiobookDetailsModal({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Manual Import - admin only, hidden during active processing and completed states */}
|
||||
{user?.role === 'admin' && !isAvailable && !['downloading', 'processing', 'searching', 'downloaded', 'completed', 'available'].includes(effectiveStatus || '') && (
|
||||
<button
|
||||
onClick={() => setShowManualImport(true)}
|
||||
className="p-3 rounded-xl bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400 hover:bg-teal-200 dark:hover:bg-teal-900/50 transition-colors"
|
||||
title="Manual Import"
|
||||
>
|
||||
<FolderArrowDownIcon className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Ebook Buttons - only when available and enabled */}
|
||||
{canShowEbookButtons && user && (
|
||||
<>
|
||||
@@ -656,6 +749,26 @@ export function AudiobookDetailsModal({
|
||||
coverArtUrl={audiobook.coverArtUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Manual Import Browser */}
|
||||
{showManualImport && audiobook && (
|
||||
<ManualImportBrowser
|
||||
isOpen={showManualImport}
|
||||
onClose={() => setShowManualImport(false)}
|
||||
onSuccess={() => {
|
||||
setLocalRequestStatus('processing');
|
||||
onStatusChange?.('processing');
|
||||
showNotification('Import started — files are being processed');
|
||||
onRequestSuccess?.();
|
||||
}}
|
||||
audiobook={{
|
||||
asin: audiobook.asin,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Component: Manual Import File Browser
|
||||
* Documentation: documentation/features/manual-import.md
|
||||
*
|
||||
* Two-phase modal for browsing server directories and importing audiobook files.
|
||||
* Phase 1 (BrowsePhase): Directory navigation with audio file detection.
|
||||
* Phase 2 (ConfirmPhase): Review and start import.
|
||||
*
|
||||
* Sub-components: manual-import/BrowsePhase.tsx, manual-import/ConfirmPhase.tsx
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { FolderArrowDownIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { RootEntry, DirectoryEntry, AudioFileEntry, SlideDirection } from './manual-import/types';
|
||||
import { BrowsePhase } from './manual-import/BrowsePhase';
|
||||
import { ConfirmPhase } from './manual-import/ConfirmPhase';
|
||||
|
||||
interface ManualImportBrowserProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
audiobook: {
|
||||
asin: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverArtUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type Phase = 'browse' | 'confirm';
|
||||
|
||||
export function ManualImportBrowser({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
audiobook,
|
||||
}: ManualImportBrowserProps) {
|
||||
const [phase, setPhase] = useState<Phase>('browse');
|
||||
const [slideDirection, setSlideDirection] = useState<SlideDirection>('right');
|
||||
|
||||
// Browse state
|
||||
const [roots, setRoots] = useState<RootEntry[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [selectedAudioCount, setSelectedAudioCount] = useState(0);
|
||||
const [selectedSize, setSelectedSize] = useState(0);
|
||||
const [selectedAudioFiles, setSelectedAudioFiles] = useState<AudioFileEntry[]>([]);
|
||||
const [currentAudioFiles, setCurrentAudioFiles] = useState<AudioFileEntry[]>([]);
|
||||
const [pathHistory, setPathHistory] = useState<string[]>([]);
|
||||
|
||||
// Loading/error state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
|
||||
// Cleanup source toggle
|
||||
const [cleanupSource, setCleanupSource] = useState(false);
|
||||
|
||||
// Hover state for folder icon swap
|
||||
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
|
||||
|
||||
// Fetch roots on open
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setPhase('browse');
|
||||
setCurrentPath(null);
|
||||
setSelectedPath(null);
|
||||
setPathHistory([]);
|
||||
fetchRoots();
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchRoots = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchWithAuth('/api/admin/filesystem/browse');
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
|
||||
throw new Error(data.error || 'Failed to load directories');
|
||||
}
|
||||
const data = await res.json();
|
||||
setRoots(data.roots || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load directories');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDirectory = useCallback(async (dirPath: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchWithAuth(
|
||||
`/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
|
||||
throw new Error(data.error || 'Failed to browse directory');
|
||||
}
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
setCurrentAudioFiles(data.audioFiles || []);
|
||||
setCurrentPath(data.path || dirPath);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to browse directory');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const navigateInto = (dirPath: string) => {
|
||||
setSlideDirection('right');
|
||||
if (currentPath) {
|
||||
setPathHistory((prev) => [...prev, currentPath]);
|
||||
}
|
||||
setSelectedPath(null);
|
||||
fetchDirectory(dirPath);
|
||||
};
|
||||
|
||||
const navigateBack = () => {
|
||||
setSlideDirection('left');
|
||||
setSelectedPath(null);
|
||||
if (pathHistory.length > 0) {
|
||||
const prevPath = pathHistory[pathHistory.length - 1];
|
||||
setPathHistory((prev) => prev.slice(0, -1));
|
||||
fetchDirectory(prevPath);
|
||||
} else {
|
||||
setCurrentPath(null);
|
||||
setEntries([]);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToRoot = () => {
|
||||
setSlideDirection('left');
|
||||
setSelectedPath(null);
|
||||
setCurrentPath(null);
|
||||
setEntries([]);
|
||||
setCurrentAudioFiles([]);
|
||||
setPathHistory([]);
|
||||
};
|
||||
|
||||
const navigateToBreadcrumb = (index: number) => {
|
||||
if (!currentPath) return;
|
||||
setSlideDirection('left');
|
||||
setSelectedPath(null);
|
||||
const allPaths = [...pathHistory, currentPath];
|
||||
const targetPath = allPaths[index];
|
||||
if (targetPath) {
|
||||
setPathHistory(allPaths.slice(0, index));
|
||||
fetchDirectory(targetPath);
|
||||
} else {
|
||||
navigateToRoot();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderClick = (entry: DirectoryEntry) => {
|
||||
const fullPath = currentPath + '/' + entry.name;
|
||||
navigateInto(fullPath);
|
||||
};
|
||||
|
||||
const handleSelectCurrentFolder = () => {
|
||||
if (!currentPath || currentAudioFiles.length === 0) return;
|
||||
setSelectedPath(currentPath);
|
||||
setSelectedAudioCount(currentAudioFiles.length);
|
||||
setSelectedSize(currentAudioFiles.reduce((sum, f) => sum + f.size, 0));
|
||||
setSelectedAudioFiles(currentAudioFiles);
|
||||
setSlideDirection('right');
|
||||
setPhase('confirm');
|
||||
};
|
||||
|
||||
const handleBackToBrowse = () => {
|
||||
setSlideDirection('left');
|
||||
setPhase('browse');
|
||||
};
|
||||
|
||||
const handleStartImport = async () => {
|
||||
if (!selectedPath) return;
|
||||
setIsImporting(true);
|
||||
setImportError(null);
|
||||
try {
|
||||
const res = await fetchWithAuth('/api/admin/manual-import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
asin: audiobook.asin,
|
||||
folderPath: selectedPath,
|
||||
cleanupSource,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Import failed');
|
||||
}
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setImportError(err instanceof Error ? err.message : 'Import failed');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Build breadcrumb segments
|
||||
const breadcrumbs = (() => {
|
||||
if (!currentPath) return [];
|
||||
const allPaths = [...pathHistory, currentPath];
|
||||
return allPaths.map((p) => {
|
||||
const parts = p.replace(/\\/g, '/').split('/');
|
||||
return parts[parts.length - 1] || p;
|
||||
});
|
||||
})();
|
||||
|
||||
const visibleBreadcrumbs = (() => {
|
||||
if (breadcrumbs.length <= 3) return breadcrumbs.map((b, i) => ({ label: b, index: i }));
|
||||
return [
|
||||
{ label: breadcrumbs[0], index: 0 },
|
||||
{ label: '...', index: -1 },
|
||||
{ label: breadcrumbs[breadcrumbs.length - 1], index: breadcrumbs.length - 1 },
|
||||
];
|
||||
})();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const slideClass =
|
||||
slideDirection === 'right'
|
||||
? 'animate-[slideRight_200ms_ease-out]'
|
||||
: 'animate-[slideLeft_200ms_ease-out]';
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
style={{ height: '100dvh' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-2xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
||||
style={{ height: 'min(640px, 85vh)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700/50">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FolderArrowDownIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{phase === 'browse' ? 'Manual Import' : 'Confirm Import'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{phase === 'browse' ? (
|
||||
<BrowsePhase
|
||||
roots={roots}
|
||||
currentPath={currentPath}
|
||||
entries={entries}
|
||||
currentAudioFiles={currentAudioFiles}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
hoveredFolder={hoveredFolder}
|
||||
breadcrumbs={visibleBreadcrumbs}
|
||||
slideClass={slideClass}
|
||||
onNavigateInto={navigateInto}
|
||||
onNavigateBack={navigateBack}
|
||||
onNavigateToRoot={navigateToRoot}
|
||||
onNavigateToBreadcrumb={navigateToBreadcrumb}
|
||||
onFolderClick={handleFolderClick}
|
||||
onSelectCurrentFolder={handleSelectCurrentFolder}
|
||||
onHoverFolder={setHoveredFolder}
|
||||
onRetry={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
|
||||
/>
|
||||
) : (
|
||||
<ConfirmPhase
|
||||
audiobook={audiobook}
|
||||
selectedPath={selectedPath!}
|
||||
audioFileCount={selectedAudioCount}
|
||||
totalSize={selectedSize}
|
||||
audioFiles={selectedAudioFiles}
|
||||
isImporting={isImporting}
|
||||
importError={importError}
|
||||
slideClass={slideClass}
|
||||
cleanupSource={cleanupSource}
|
||||
onCleanupSourceChange={setCleanupSource}
|
||||
onBack={handleBackToBrowse}
|
||||
onStartImport={handleStartImport}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Component: Manual Import Browse Phase
|
||||
* Documentation: documentation/features/manual-import.md
|
||||
*
|
||||
* Directory listing with root tiles, breadcrumb navigation,
|
||||
* folder metadata, audio file badges, and selection state.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
FolderArrowDownIcon,
|
||||
InboxArrowDownIcon,
|
||||
HomeIcon,
|
||||
ChevronRightIcon,
|
||||
ArrowLeftIcon,
|
||||
MusicalNoteIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { RootEntry, DirectoryEntry, AudioFileEntry, formatBytes } from './types';
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3 animate-pulse">
|
||||
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48" />
|
||||
<div className="h-3 bg-gray-100 dark:bg-gray-800 rounded w-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BrowsePhaseProps {
|
||||
roots: RootEntry[];
|
||||
currentPath: string | null;
|
||||
entries: DirectoryEntry[];
|
||||
currentAudioFiles: AudioFileEntry[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
hoveredFolder: string | null;
|
||||
breadcrumbs: Array<{ label: string; index: number }>;
|
||||
slideClass: string;
|
||||
onNavigateInto: (path: string) => void;
|
||||
onNavigateBack: () => void;
|
||||
onNavigateToRoot: () => void;
|
||||
onNavigateToBreadcrumb: (index: number) => void;
|
||||
onFolderClick: (entry: DirectoryEntry) => void;
|
||||
onSelectCurrentFolder: () => void;
|
||||
onHoverFolder: (name: string | null) => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export function BrowsePhase({
|
||||
roots,
|
||||
currentPath,
|
||||
entries,
|
||||
currentAudioFiles,
|
||||
isLoading,
|
||||
error,
|
||||
hoveredFolder,
|
||||
breadcrumbs,
|
||||
slideClass,
|
||||
onNavigateInto,
|
||||
onNavigateBack,
|
||||
onNavigateToRoot,
|
||||
onNavigateToBreadcrumb,
|
||||
onFolderClick,
|
||||
onSelectCurrentFolder,
|
||||
onHoverFolder,
|
||||
onRetry,
|
||||
}: BrowsePhaseProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Breadcrumb bar */}
|
||||
{currentPath && (
|
||||
<div className="flex items-center gap-1 px-5 py-2.5 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800 text-sm overflow-x-auto">
|
||||
<button
|
||||
onClick={onNavigateToRoot}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<HomeIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<ChevronRightIcon className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
|
||||
{crumb.index === -1 ? (
|
||||
<span className="text-gray-400 px-1">...</span>
|
||||
) : i === breadcrumbs.length - 1 ? (
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{crumb.label}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onNavigateToBreadcrumb(crumb.index)}
|
||||
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 truncate transition-colors"
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Listing */}
|
||||
<div className={`flex-1 overflow-y-auto ${slideClass}`}>
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="py-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<SkeletonRow key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6">
|
||||
<ExclamationTriangleIcon className="w-10 h-10 text-red-400 mb-3" />
|
||||
<p className="text-gray-900 dark:text-gray-100 font-medium text-center">{error}</p>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="mt-4 flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowPathIcon className="w-4 h-4" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Root view */}
|
||||
{!currentPath && !isLoading && !error && (
|
||||
<div className="p-5 grid grid-cols-2 gap-3">
|
||||
{roots.map((root) => (
|
||||
<button
|
||||
key={root.path}
|
||||
onClick={() => onNavigateInto(root.path)}
|
||||
className="flex flex-col items-center gap-3 p-6 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-all group"
|
||||
>
|
||||
{root.icon === 'download' ? (
|
||||
<FolderArrowDownIcon className="w-10 h-10 text-blue-500 group-hover:text-blue-600 transition-colors" />
|
||||
) : root.icon === 'bookdrop' ? (
|
||||
<InboxArrowDownIcon className="w-10 h-10 text-amber-500 group-hover:text-amber-600 transition-colors" />
|
||||
) : (
|
||||
<FolderIcon className="w-10 h-10 text-emerald-500 group-hover:text-emerald-600 transition-colors" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{root.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono truncate max-w-full">
|
||||
{root.path}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Directory + audio file listing */}
|
||||
{currentPath && !isLoading && !error && (entries.length > 0 || currentAudioFiles.length > 0) && (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{/* Subdirectories */}
|
||||
{entries.map((entry) => {
|
||||
const hasAudio = entry.audioFileCount > 0;
|
||||
const isHovered = hoveredFolder === entry.name;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`dir-${entry.name}`}
|
||||
onClick={() => onFolderClick(entry)}
|
||||
onMouseEnter={() => onHoverFolder(entry.name)}
|
||||
onMouseLeave={() => onHoverFolder(null)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-all duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<div className="flex-shrink-0 w-5 h-5 text-gray-400 dark:text-gray-500 transition-all duration-150">
|
||||
{isHovered ? (
|
||||
<FolderOpenIcon className="w-5 h-5 text-blue-500" />
|
||||
) : (
|
||||
<FolderIcon className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{entry.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{entry.subfolderCount > 0 && (
|
||||
<span>{entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
{entry.subfolderCount > 0 && entry.audioFileCount > 0 && <span> · </span>}
|
||||
{entry.audioFileCount > 0 && (
|
||||
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
{entry.totalSize > 0 && (
|
||||
<span> · {formatBytes(entry.totalSize)}</span>
|
||||
)}
|
||||
{entry.subfolderCount === 0 && entry.audioFileCount === 0 && (
|
||||
<span className="italic">Empty</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hasAudio && (
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
|
||||
<MusicalNoteIcon className="w-3 h-3" />
|
||||
{entry.audioFileCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Audio files in current directory */}
|
||||
{currentAudioFiles.length > 0 && entries.length > 0 && (
|
||||
<div className="px-4 py-2 bg-gray-50/50 dark:bg-gray-800/20">
|
||||
<p className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
Audio Files
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{currentAudioFiles.map((file) => (
|
||||
<div
|
||||
key={`file-${file.name}`}
|
||||
className="flex items-center gap-3 px-4 py-2.5"
|
||||
>
|
||||
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{currentPath && !isLoading && !error && entries.length === 0 && currentAudioFiles.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||
<FolderOpenIcon className="w-10 h-10 text-gray-300 dark:text-gray-600 mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400 font-medium">This folder is empty</p>
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
className="mt-4 flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Select this folder */}
|
||||
{currentPath && !isLoading && currentAudioFiles.length > 0 && (
|
||||
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span>
|
||||
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
|
||||
</p>
|
||||
<button
|
||||
onClick={onSelectCurrentFolder}
|
||||
className="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
Select This Folder →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Component: Manual Import Confirm Phase
|
||||
* Documentation: documentation/features/manual-import.md
|
||||
*
|
||||
* Shows book context, selected folder, pipeline steps summary,
|
||||
* and start import / back actions.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { ArrowLeftIcon, ExclamationCircleIcon, MusicalNoteIcon } from '@heroicons/react/24/outline';
|
||||
import { AudioFileEntry, formatBytes } from './types';
|
||||
|
||||
interface ConfirmPhaseProps {
|
||||
audiobook: { asin: string; title: string; author: string; coverArtUrl?: string };
|
||||
selectedPath: string;
|
||||
audioFileCount: number;
|
||||
totalSize: number;
|
||||
audioFiles: AudioFileEntry[];
|
||||
isImporting: boolean;
|
||||
importError: string | null;
|
||||
slideClass: string;
|
||||
cleanupSource: boolean;
|
||||
onCleanupSourceChange: (value: boolean) => void;
|
||||
onBack: () => void;
|
||||
onStartImport: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmPhase({
|
||||
audiobook,
|
||||
selectedPath,
|
||||
audioFileCount,
|
||||
totalSize,
|
||||
audioFiles,
|
||||
isImporting,
|
||||
importError,
|
||||
slideClass,
|
||||
cleanupSource,
|
||||
onCleanupSourceChange,
|
||||
onBack,
|
||||
onStartImport,
|
||||
}: ConfirmPhaseProps) {
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${slideClass}`}>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Book context */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-xl overflow-hidden flex-shrink-0 bg-gray-100 dark:bg-gray-800">
|
||||
{audiobook.coverArtUrl ? (
|
||||
<Image
|
||||
src={audiobook.coverArtUrl}
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<MusicalNoteIcon className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{audiobook.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{audiobook.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected folder info */}
|
||||
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1.5">
|
||||
Import from
|
||||
</p>
|
||||
<p className="text-sm font-mono text-gray-900 dark:text-gray-100 break-all">
|
||||
{selectedPath}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
|
||||
{audioFileCount} audio file{audioFileCount !== 1 ? 's' : ''}
|
||||
{totalSize > 0 ? ` \u00B7 ${formatBytes(totalSize)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Audio files to import */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Files to import
|
||||
</h4>
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden">
|
||||
{audioFiles.map((file) => (
|
||||
<div key={file.name} className="flex items-center gap-3 px-3.5 py-2.5">
|
||||
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cleanup source toggle */}
|
||||
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Cleanup source files
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Delete original files after successful import
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cleanupSource}
|
||||
onChange={(e) => onCleanupSourceChange(e.target.checked)}
|
||||
disabled={isImporting}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{importError && (
|
||||
<div className="mx-5 mb-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50 flex items-start gap-2.5">
|
||||
<ExclamationCircleIcon className="w-5 h-5 text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{importError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
disabled={isImporting}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={onStartImport}
|
||||
disabled={isImporting}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors disabled:opacity-70 flex items-center gap-2"
|
||||
>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
'Start Import'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Component: Manual Import Shared Types
|
||||
* Documentation: documentation/features/manual-import.md
|
||||
*/
|
||||
|
||||
export interface RootEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface DirectoryEntry {
|
||||
name: string;
|
||||
type: 'directory';
|
||||
audioFileCount: number;
|
||||
subfolderCount: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
export interface AudioFileEntry {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type SlideDirection = 'left' | 'right';
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
@@ -166,6 +166,12 @@ export function Header() {
|
||||
>
|
||||
Authors
|
||||
</Link>
|
||||
<Link
|
||||
href="/series"
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
Series
|
||||
</Link>
|
||||
{showBookDate && (
|
||||
<Link
|
||||
href="/bookdate"
|
||||
@@ -277,6 +283,13 @@ export function Header() {
|
||||
>
|
||||
Authors
|
||||
</Link>
|
||||
<Link
|
||||
href="/series"
|
||||
onClick={() => setShowMobileMenu(false)}
|
||||
className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
Series
|
||||
</Link>
|
||||
{showBookDate && (
|
||||
<Link
|
||||
href="/bookdate"
|
||||
|
||||
@@ -34,10 +34,12 @@ interface InteractiveTorrentSearchModalProps {
|
||||
title: string;
|
||||
author: string;
|
||||
};
|
||||
customSearchTerms?: string | null; // Optional - admin-set custom search terms override
|
||||
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
|
||||
onSuccess?: () => void;
|
||||
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
|
||||
replaceIssueId?: string; // Optional - when set, confirm handler calls replace endpoint instead
|
||||
onConfirm?: (torrent: TorrentResult) => Promise<void>; // Optional - overrides default confirm handler
|
||||
}
|
||||
|
||||
// Format relative time from publish date
|
||||
@@ -86,10 +88,12 @@ export function InteractiveTorrentSearchModal({
|
||||
requestId,
|
||||
asin,
|
||||
audiobook,
|
||||
customSearchTerms,
|
||||
fullAudiobook,
|
||||
onSuccess,
|
||||
searchMode = 'audiobook',
|
||||
replaceIssueId,
|
||||
onConfirm,
|
||||
}: InteractiveTorrentSearchModalProps) {
|
||||
// Hooks for existing audiobook request flow
|
||||
const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch();
|
||||
@@ -112,7 +116,8 @@ export function InteractiveTorrentSearchModal({
|
||||
|
||||
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]);
|
||||
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
|
||||
const [searchTitle, setSearchTitle] = useState(audiobook.title);
|
||||
const [searchTitle, setSearchTitle] = useState(customSearchTerms || audiobook.title);
|
||||
const [isCustomConfirming, setIsCustomConfirming] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Stable close handler via ref
|
||||
@@ -130,11 +135,13 @@ export function InteractiveTorrentSearchModal({
|
||||
const isSearching = isEbookMode
|
||||
? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks)
|
||||
: (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook);
|
||||
const isDownloading = replaceIssueId
|
||||
? isReplacing
|
||||
: isEbookMode
|
||||
? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook)
|
||||
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent);
|
||||
const isDownloading = isCustomConfirming
|
||||
? true
|
||||
: replaceIssueId
|
||||
? isReplacing
|
||||
: isEbookMode
|
||||
? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook)
|
||||
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent);
|
||||
const error = replaceIssueId
|
||||
? (replaceError || (hasRequestId ? searchByRequestError : searchByAudiobookError))
|
||||
: isEbookMode
|
||||
@@ -148,9 +155,9 @@ export function InteractiveTorrentSearchModal({
|
||||
|
||||
// Reset search title when modal opens/closes or audiobook changes
|
||||
useEffect(() => {
|
||||
setSearchTitle(audiobook.title);
|
||||
setSearchTitle(customSearchTerms || audiobook.title);
|
||||
setResults([]);
|
||||
}, [isOpen, audiobook.title]);
|
||||
}, [isOpen, audiobook.title, customSearchTerms]);
|
||||
|
||||
// Perform search when modal opens
|
||||
useEffect(() => {
|
||||
@@ -218,7 +225,11 @@ export function InteractiveTorrentSearchModal({
|
||||
const handleConfirmDownload = async () => {
|
||||
if (!confirmTorrent) return;
|
||||
try {
|
||||
if (replaceIssueId) {
|
||||
if (onConfirm) {
|
||||
// Custom confirm handler (e.g., admin approve-with-torrent flow)
|
||||
setIsCustomConfirming(true);
|
||||
await onConfirm(confirmTorrent);
|
||||
} else if (replaceIssueId) {
|
||||
// Reported issue replacement flow
|
||||
await replaceWithTorrent(replaceIssueId, confirmTorrent);
|
||||
} else if (isEbookMode) {
|
||||
@@ -241,6 +252,8 @@ export function InteractiveTorrentSearchModal({
|
||||
} catch (err) {
|
||||
console.error('Failed to download:', err);
|
||||
setConfirmTorrent(null);
|
||||
} finally {
|
||||
setIsCustomConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
|
||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||
|
||||
interface RequestCardProps {
|
||||
request: {
|
||||
@@ -26,12 +27,15 @@ interface RequestCardProps {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
downloadAvailable?: boolean;
|
||||
audiobook: {
|
||||
id: string;
|
||||
audibleAsin?: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverArtUrl?: string;
|
||||
filePath?: string | null;
|
||||
fileFormat?: string | null;
|
||||
};
|
||||
};
|
||||
showActions?: boolean;
|
||||
@@ -49,6 +53,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const requestType = request.type || 'audiobook';
|
||||
const isEbook = requestType === 'ebook';
|
||||
|
||||
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||
const isFailed = request.status === 'failed';
|
||||
@@ -306,7 +311,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
isOpen={showDetailsModal}
|
||||
onClose={() => setShowDetailsModal(false)}
|
||||
requestStatus={request.status}
|
||||
isAvailable={['available', 'downloaded'].includes(request.status)}
|
||||
isAvailable={COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number])}
|
||||
hideRequestActions
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Component: Series Card
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Premium "Cover First" design - metadata integrated into the cover overlay.
|
||||
* Rating badge top-left, book count top-right, tags in bottom gradient overlay.
|
||||
* Only the title lives below the cover, ensuring consistent row heights in the grid.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { SeriesSummary } from '@/lib/hooks/useSeries';
|
||||
|
||||
interface SeriesCardProps {
|
||||
series: SeriesSummary;
|
||||
squareCovers?: boolean;
|
||||
}
|
||||
|
||||
export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) {
|
||||
const visibleTags = series.tags.slice(0, 2);
|
||||
const hasTags = visibleTags.length > 0;
|
||||
const hasRating = series.rating != null && series.rating > 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/series/${series.asin}`}
|
||||
className="group outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent rounded-2xl block"
|
||||
aria-label={`View ${series.title} series`}
|
||||
>
|
||||
{/* Cover Container — The Hero */}
|
||||
<div
|
||||
className={`
|
||||
relative overflow-hidden rounded-xl
|
||||
w-full ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'}
|
||||
shadow-lg shadow-black/20 dark:shadow-black/40
|
||||
group-hover:shadow-xl group-hover:shadow-black/30 dark:group-hover:shadow-black/55
|
||||
transform group-hover:scale-[1.02] group-hover:-translate-y-0.5
|
||||
transition-all duration-300 ease-out
|
||||
`}
|
||||
>
|
||||
{/* Cover Art or Fallback */}
|
||||
{series.coverArtUrl ? (
|
||||
<Image
|
||||
src={series.coverArtUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-600 to-teal-800 dark:from-emerald-700 dark:to-teal-900 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-1/3 h-1/3 text-white/40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.2}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Top-row badges — Rating (left) + Book count (right) */}
|
||||
{/* Rating Badge — top-left, matches AudiobookCard pattern exactly */}
|
||||
{hasRating && (
|
||||
<div className="
|
||||
absolute top-2.5 left-2.5
|
||||
flex items-center gap-1 px-2 py-1
|
||||
rounded-lg bg-black/50 backdrop-blur-md
|
||||
text-white text-xs font-medium
|
||||
transition-opacity duration-300 group-hover:opacity-0
|
||||
">
|
||||
<svg className="w-3.5 h-3.5 text-amber-400 shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span>{series.rating!.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Book count badge — top-right */}
|
||||
{series.bookCount > 0 && (
|
||||
<div className="
|
||||
absolute top-2.5 right-2.5
|
||||
px-2 py-1
|
||||
text-[11px] font-bold rounded-lg
|
||||
bg-black/50 backdrop-blur-md
|
||||
text-white
|
||||
transition-opacity duration-300 group-hover:opacity-0
|
||||
">
|
||||
{series.bookCount} {series.bookCount === 1 ? 'Book' : 'Books'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom gradient overlay — always present, deepens on hover */}
|
||||
<div className={`
|
||||
absolute inset-x-0 bottom-0
|
||||
transition-all duration-300
|
||||
${hasTags
|
||||
? 'h-20 bg-gradient-to-t from-black/75 via-black/30 to-transparent group-hover:h-24 group-hover:from-black/85'
|
||||
: 'h-10 bg-gradient-to-t from-black/40 to-transparent opacity-0 group-hover:opacity-100'
|
||||
}
|
||||
`} />
|
||||
|
||||
{/* Tag pills — pinned to bottom of cover, inside gradient */}
|
||||
{hasTags && (
|
||||
<div className="
|
||||
absolute inset-x-0 bottom-0
|
||||
flex items-end gap-1.5 p-2.5
|
||||
pointer-events-none
|
||||
">
|
||||
{visibleTags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="
|
||||
inline-block px-2.5 py-0.5
|
||||
text-[10px] font-medium
|
||||
rounded-full
|
||||
bg-black/30 backdrop-blur-md
|
||||
text-white/90
|
||||
ring-1 ring-white/15
|
||||
transition-opacity duration-300
|
||||
"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Below-cover: title only — fixed, predictable height across all cards */}
|
||||
<div className="mt-2.5 px-0.5">
|
||||
<h3 className="
|
||||
font-semibold text-[14px] leading-snug
|
||||
text-gray-900 dark:text-gray-100
|
||||
line-clamp-2
|
||||
group-hover:text-emerald-600 dark:group-hover:text-emerald-400
|
||||
transition-colors duration-200
|
||||
">
|
||||
{series.title}
|
||||
</h3>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Component: Series Detail Card
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Hero section for the series detail page with rectangular cover image,
|
||||
* title, book count, rating, collapsible description, and tag pills.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { SeriesDetail } from '@/lib/hooks/useSeries';
|
||||
|
||||
interface SeriesDetailCardProps {
|
||||
series: SeriesDetail;
|
||||
squareCovers?: boolean;
|
||||
}
|
||||
|
||||
export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasLongDescription = (series.description?.length || 0) > 300;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
|
||||
{/* Rectangular Cover */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`relative w-36 sm:w-44 lg:w-52 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-xl overflow-hidden shadow-xl shadow-black/20 dark:shadow-black/40`}>
|
||||
{series.books[0]?.coverArtUrl ? (
|
||||
<Image
|
||||
src={series.books[0].coverArtUrl}
|
||||
alt={series.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
|
||||
<svg className="w-1/3 h-1/3 text-emerald-400 dark:text-emerald-300" 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>
|
||||
</div>
|
||||
|
||||
{/* Series Info */}
|
||||
<div className="flex-1 min-w-0 text-center sm:text-left">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{series.title}
|
||||
</h1>
|
||||
|
||||
{/* Meta row: book count + rating */}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
|
||||
{series.bookCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-300">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
||||
{series.bookCount} Book{series.bookCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{series.rating != null && series.rating > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg className="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
{series.rating.toFixed(1)}
|
||||
{series.ratingCount != null && series.ratingCount > 0 && (
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
({series.ratingCount.toLocaleString()})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag Pills */}
|
||||
{series.tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap justify-center sm:justify-start gap-2">
|
||||
{series.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-block px-3 py-1 text-xs font-medium rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audible Link */}
|
||||
{series.audibleUrl && (
|
||||
<a
|
||||
href={series.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{series.description && (
|
||||
<div className="mt-4">
|
||||
<p
|
||||
className={`text-sm sm:text-base text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-line ${
|
||||
!expanded && hasLongDescription ? 'line-clamp-4' : ''
|
||||
}`}
|
||||
>
|
||||
{series.description}
|
||||
</p>
|
||||
{hasLongDescription && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="mt-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors"
|
||||
>
|
||||
{expanded ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SeriesDetailSkeleton({ squareCovers = false }: { squareCovers?: boolean }) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
|
||||
{/* Cover skeleton */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`w-36 sm:w-44 lg:w-52 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-xl bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 relative overflow-hidden`}>
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info skeleton */}
|
||||
<div className="flex-1 min-w-0 text-center sm:text-left space-y-4">
|
||||
<div className="h-9 bg-gray-200 dark:bg-gray-700 rounded-lg w-64 mx-auto sm:mx-0" />
|
||||
<div className="flex gap-2 justify-center sm:justify-start">
|
||||
<div className="h-7 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-7 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-center sm:justify-start">
|
||||
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-6 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Component: Series Grid
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Grid layout for series cards with loading skeletons and empty state.
|
||||
* Uses the same responsive column system as AudiobookGrid since
|
||||
* series cards use rectangular (2:3) aspect ratios like book covers.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { SeriesCard } from './SeriesCard';
|
||||
import { SeriesSummary } from '@/lib/hooks/useSeries';
|
||||
|
||||
interface SeriesGridProps {
|
||||
series: SeriesSummary[];
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
cardSize?: number;
|
||||
squareCovers?: boolean;
|
||||
}
|
||||
|
||||
function getGridClasses(size: number): string {
|
||||
const sizeMap: Record<number, string> = {
|
||||
1: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
|
||||
2: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
|
||||
3: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8',
|
||||
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7',
|
||||
5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
|
||||
6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
|
||||
7: 'grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
||||
8: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
|
||||
9: 'grid-cols-1 sm:grid-cols-2',
|
||||
};
|
||||
return sizeMap[size] || sizeMap[5];
|
||||
}
|
||||
|
||||
export function SeriesGrid({
|
||||
series,
|
||||
isLoading = false,
|
||||
emptyMessage = 'No series found',
|
||||
cardSize = 5,
|
||||
squareCovers = false,
|
||||
}: SeriesGridProps) {
|
||||
const gridClasses = getGridClasses(cardSize);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`grid ${gridClasses} gap-4 sm:gap-5 lg:gap-6`}>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<SeriesSkeletonCard key={i} index={i} squareCovers={squareCovers} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (series.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-6">
|
||||
<svg className="w-10 h-10 text-gray-400 dark:text-gray-500" 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>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid ${gridClasses} gap-4 sm:gap-5 lg:gap-6`}>
|
||||
{series.map(s => (
|
||||
<SeriesCard key={s.asin} series={s} squareCovers={squareCovers} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesSkeletonCard({ index = 0, squareCovers = false }: { index?: number; squareCovers?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="animate-pulse"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{/* Rectangular cover skeleton */}
|
||||
<div className={`relative overflow-hidden rounded-xl w-full ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800`}>
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Text skeleton */}
|
||||
<div className="mt-3 px-1 flex flex-col items-center space-y-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-4/5" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Component: Similar Series Row
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Horizontal scrollable carousel of similar series cards.
|
||||
* Desktop: left/right nav arrows. Mobile: drag-to-scroll.
|
||||
* Each card navigates to the series detail page.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { SimilarSeries } from '@/lib/hooks/useSeries';
|
||||
|
||||
interface SimilarSeriesRowProps {
|
||||
series: SimilarSeries[];
|
||||
currentSeriesTitle?: string;
|
||||
squareCovers?: boolean;
|
||||
}
|
||||
|
||||
export function SimilarSeriesRow({ series, currentSeriesTitle, squareCovers = false }: SimilarSeriesRowProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const checkScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 4);
|
||||
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkScroll();
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener('scroll', checkScroll, { passive: true });
|
||||
const observer = new ResizeObserver(checkScroll);
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', checkScroll);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [checkScroll, series]);
|
||||
|
||||
const scroll = (direction: 'left' | 'right') => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const scrollAmount = el.clientWidth * 0.7;
|
||||
el.scrollBy({
|
||||
left: direction === 'left' ? -scrollAmount : scrollAmount,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
if (series.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Similar Series
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({series.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
{/* Left arrow */}
|
||||
{canScrollLeft && (
|
||||
<button
|
||||
onClick={() => scroll('left')}
|
||||
className="hidden md:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scrollable row */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-4 sm:gap-5 overflow-x-auto scrollbar-hide pb-2 scroll-smooth"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{series.map(s => (
|
||||
<Link
|
||||
key={s.asin}
|
||||
href={`/series/${s.asin}${currentSeriesTitle ? `?from=${encodeURIComponent(currentSeriesTitle)}` : ''}`}
|
||||
className="flex-shrink-0 w-20 sm:w-24 group/card outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 rounded-xl"
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className={`relative w-20 ${squareCovers ? 'h-20 sm:w-24 sm:h-24' : 'h-[120px] sm:w-24 sm:h-36'} rounded-lg overflow-hidden shadow-md shadow-black/15 dark:shadow-black/30 group-hover/card:shadow-lg group-hover/card:scale-[1.04] group-hover/card:-translate-y-0.5 transition-all duration-300`}>
|
||||
{s.coverArtUrl ? (
|
||||
<Image
|
||||
src={s.coverArtUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="96px"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-emerald-400 dark:text-emerald-300">
|
||||
{s.title.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<p className="mt-2 text-xs sm:text-sm font-medium text-center text-gray-700 dark:text-gray-300 line-clamp-2 group-hover/card:text-emerald-600 dark:group-hover/card:text-emerald-400 transition-colors">
|
||||
{s.title}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right arrow */}
|
||||
{canScrollRight && (
|
||||
<button
|
||||
onClick={() => scroll('right')}
|
||||
className="hidden md:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fade edges */}
|
||||
{canScrollLeft && (
|
||||
<div className="hidden md:block absolute left-0 top-0 bottom-2 w-8 bg-gradient-to-r from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
|
||||
)}
|
||||
{canScrollRight && (
|
||||
<div className="hidden md:block absolute right-0 top-0 bottom-2 w-8 bg-gradient-to-l from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimilarSeriesSkeleton({ squareCovers = false }: { squareCovers?: boolean }) {
|
||||
return (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||
<div className="h-7 w-40 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex gap-4 sm:gap-5 overflow-hidden">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex-shrink-0 w-20 sm:w-24" style={{ animationDelay: `${i * 50}ms` }}>
|
||||
<div className={`w-20 ${squareCovers ? 'h-20 sm:w-24 sm:h-24' : 'h-[120px] sm:w-24 sm:h-36'} rounded-lg bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 relative overflow-hidden`}>
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
</div>
|
||||
<div className="mt-2 h-3 bg-gray-200 dark:bg-gray-700 rounded w-4/5 mx-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Component: Hide Available Toggle
|
||||
* Documentation: UI toggle for hiding titles already in the user's library
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface HideAvailableToggleProps {
|
||||
enabled: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function HideAvailableToggle({ enabled, onToggle }: HideAvailableToggleProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onToggle(!enabled)}
|
||||
aria-label={enabled ? 'Show available titles' : 'Hide available titles'}
|
||||
aria-pressed={enabled}
|
||||
title={enabled ? 'Hide available (on)' : 'Hide available (off)'}
|
||||
className={`
|
||||
p-1.5 rounded-md transition-all duration-200
|
||||
${enabled
|
||||
? 'bg-blue-500/20 dark:bg-blue-400/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/30 dark:ring-blue-400/30 shadow-inner'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-white/20 dark:hover:bg-gray-700/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{enabled ? (
|
||||
<>
|
||||
{/* Eye with slash — hidden state */}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 3l18 18"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.5 10.677a2 2 0 002.823 2.823"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7.362 7.561C5.68 8.74 4.279 10.42 3 12c1.889 2.991 5.282 6 9 6 1.55 0 3.043-.523 4.395-1.35M12 6c3.718 0 7.111 3.009 9 6-.947 1.498-2.057 2.876-3.362 3.939"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Open eye — visible state */}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6c3.718 0 7.111 3.009 9 6-1.889 2.991-5.282 6-9 6s-7.111-3.009-9-6c1.889-2.991 5.282-6 9-6z"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="2"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Component: LoadMoreBar
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface LoadMoreBarProps {
|
||||
loadedCount: number;
|
||||
totalCount?: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
onLoadMore: () => void;
|
||||
itemLabel?: string;
|
||||
}
|
||||
|
||||
export function LoadMoreBar({
|
||||
loadedCount,
|
||||
totalCount,
|
||||
hasMore,
|
||||
isLoading,
|
||||
onLoadMore,
|
||||
itemLabel = 'books',
|
||||
}: LoadMoreBarProps) {
|
||||
if (loadedCount === 0) return null;
|
||||
|
||||
const allLoaded = !hasMore && !isLoading;
|
||||
|
||||
// Count text
|
||||
let countText: string;
|
||||
if (allLoaded) {
|
||||
countText = `All ${loadedCount.toLocaleString()} ${itemLabel} loaded`;
|
||||
} else if (totalCount && totalCount > loadedCount) {
|
||||
countText = `Showing ${loadedCount.toLocaleString()} of ${totalCount.toLocaleString()} ${itemLabel}`;
|
||||
} else {
|
||||
countText = `${loadedCount.toLocaleString()} ${itemLabel} loaded`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left: Count */}
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{countText}
|
||||
</span>
|
||||
|
||||
{/* Right: Action */}
|
||||
{allLoaded ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircleIcon className="w-4 h-4" />
|
||||
Complete
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center gap-2 px-4 py-1.5 text-sm font-medium
|
||||
text-gray-700 dark:text-gray-300
|
||||
border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load more'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Component: Section Toolbar
|
||||
* Documentation: Responsive toolbar that shows inline controls on sm+ and collapses to popover on mobile
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||
import { HideAvailableToggle } from '@/components/ui/HideAvailableToggle';
|
||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
|
||||
interface SectionToolbarProps {
|
||||
hideAvailable: boolean;
|
||||
onToggleHideAvailable: (v: boolean) => void;
|
||||
squareCovers: boolean;
|
||||
onToggleSquareCovers: (v: boolean) => void;
|
||||
cardSize: number;
|
||||
onCardSizeChange: (v: number) => void;
|
||||
}
|
||||
|
||||
export function SectionToolbar({
|
||||
hideAvailable,
|
||||
onToggleHideAvailable,
|
||||
squareCovers,
|
||||
onToggleSquareCovers,
|
||||
cardSize,
|
||||
onCardSizeChange,
|
||||
}: SectionToolbarProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { containerRef, dropdownRef, style } = useSmartDropdownPosition(isOpen);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
containerRef.current && !containerRef.current.contains(target) &&
|
||||
dropdownRef.current && !dropdownRef.current.contains(target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||
}, [isOpen, containerRef, dropdownRef]);
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{/* Inline controls — visible at sm and above */}
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
<HideAvailableToggle enabled={hideAvailable} onToggle={onToggleHideAvailable} />
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={onToggleSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={onCardSizeChange} />
|
||||
</div>
|
||||
|
||||
{/* Collapsed ellipsis trigger — visible below sm */}
|
||||
<div className="sm:hidden" ref={containerRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label="View options"
|
||||
aria-expanded={isOpen}
|
||||
className={`
|
||||
p-1.5 rounded-md transition-all duration-200
|
||||
${isOpen
|
||||
? 'bg-blue-500/20 dark:bg-blue-400/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/30 dark:ring-blue-400/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-white/20 dark:hover:bg-gray-700/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="5" cy="12" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<circle cx="19" cy="12" r="2" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Portal dropdown */}
|
||||
{isOpen && typeof document !== 'undefined' && style && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={style}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg ring-1 ring-black/5 dark:ring-white/10 z-50 py-1 min-w-[220px] animate-in fade-in duration-150"
|
||||
>
|
||||
{/* Hide Available */}
|
||||
<button
|
||||
onClick={() => onToggleHideAvailable(!hideAvailable)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span className={`
|
||||
p-1 rounded-md transition-all duration-200
|
||||
${hideAvailable
|
||||
? 'bg-blue-500/20 dark:bg-blue-400/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/30 dark:ring-blue-400/30 shadow-inner'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}
|
||||
`}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{hideAvailable ? (
|
||||
<>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3l18 18" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.5 10.677a2 2 0 002.823 2.823" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7.362 7.561C5.68 8.74 4.279 10.42 3 12c1.889 2.991 5.282 6 9 6 1.55 0 3.043-.523 4.395-1.35M12 6c3.718 0 7.111 3.009 9 6-.947 1.498-2.057 2.876-3.362 3.939" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6c3.718 0 7.111 3.009 9 6-1.889 2.991-5.282 6-9 6s-7.111-3.009-9-6c1.889-2.991 5.282-6 9-6z" />
|
||||
<circle cx="12" cy="12" r="2" strokeWidth={2} />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">Hide Available</span>
|
||||
{hideAvailable && (
|
||||
<span className="ml-auto text-xs text-blue-600 dark:text-blue-400 font-medium">On</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Square Covers */}
|
||||
<button
|
||||
onClick={() => onToggleSquareCovers(!squareCovers)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span className={`
|
||||
p-1 rounded-md transition-all duration-200
|
||||
${squareCovers
|
||||
? 'bg-blue-500/20 dark:bg-blue-400/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/30 dark:ring-blue-400/30 shadow-inner'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}
|
||||
`}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" strokeWidth={2} />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9h4M3 15h4M21 9h-4M21 15h-4" opacity={squareCovers ? 1 : 0.4} />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">Square Covers</span>
|
||||
{squareCovers && (
|
||||
<span className="ml-auto text-xs text-blue-600 dark:text-blue-400 font-medium">On</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
|
||||
{/* Card Size */}
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 text-sm">
|
||||
<span className="p-1 text-gray-500 dark:text-gray-400">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">Card Size</span>
|
||||
<div className="ml-auto">
|
||||
<CardSizeControls size={cardSize} onSizeChange={onCardSizeChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { isTokenExpired, getRefreshTimeMs } from '@/lib/utils/jwt-client';
|
||||
|
||||
interface UserPermissions {
|
||||
interactiveSearch: boolean;
|
||||
download: boolean;
|
||||
}
|
||||
|
||||
interface User {
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
|
||||
interface Preferences {
|
||||
cardSize: number; // 1-9, default 5
|
||||
squareCovers: boolean; // true = square (1:1), false = rectangle (2:3)
|
||||
hideAvailable: boolean; // true = hide "In Your Library" titles
|
||||
}
|
||||
|
||||
interface PreferencesContextType {
|
||||
@@ -17,6 +18,8 @@ interface PreferencesContextType {
|
||||
setCardSize: (size: number) => void;
|
||||
squareCovers: boolean;
|
||||
setSquareCovers: (enabled: boolean) => void;
|
||||
hideAvailable: boolean;
|
||||
setHideAvailable: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
|
||||
@@ -24,6 +27,7 @@ const PreferencesContext = createContext<PreferencesContextType | undefined>(und
|
||||
const DEFAULT_PREFERENCES: Preferences = {
|
||||
cardSize: 5,
|
||||
squareCovers: true,
|
||||
hideAvailable: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'preferences';
|
||||
@@ -31,6 +35,7 @@ const STORAGE_KEY = 'preferences';
|
||||
export function PreferencesProvider({ children }: { children: ReactNode }) {
|
||||
const [cardSize, setCardSizeState] = useState<number>(DEFAULT_PREFERENCES.cardSize);
|
||||
const [squareCovers, setSquareCoversState] = useState<boolean>(DEFAULT_PREFERENCES.squareCovers);
|
||||
const [hideAvailable, setHideAvailableState] = useState<boolean>(DEFAULT_PREFERENCES.hideAvailable);
|
||||
|
||||
// Load preferences from localStorage on mount
|
||||
useEffect(() => {
|
||||
@@ -49,11 +54,14 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
// Load squareCovers preference (defaults to false if not set)
|
||||
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
|
||||
// Load hideAvailable preference
|
||||
setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences from localStorage:', error);
|
||||
setCardSizeState(DEFAULT_PREFERENCES.cardSize);
|
||||
setSquareCoversState(DEFAULT_PREFERENCES.squareCovers);
|
||||
setHideAvailableState(DEFAULT_PREFERENCES.hideAvailable);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -92,6 +100,22 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Update hideAvailable preference in state and localStorage
|
||||
const setHideAvailable = (enabled: boolean) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
setHideAvailableState(enabled);
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const preferences: Preferences = stored ? JSON.parse(stored) : { ...DEFAULT_PREFERENCES };
|
||||
preferences.hideAvailable = enabled;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
|
||||
} catch (error) {
|
||||
console.error('Failed to save preferences to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for storage changes in other tabs (cross-tab sync)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -106,6 +130,8 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
// Sync squareCovers preference
|
||||
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
|
||||
// Sync hideAvailable preference
|
||||
setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse preferences from storage event:', error);
|
||||
}
|
||||
@@ -119,7 +145,7 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PreferencesContext.Provider value={{ cardSize, setCardSize, squareCovers, setSquareCovers }}>
|
||||
<PreferencesContext.Provider value={{ cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable }}>
|
||||
{children}
|
||||
</PreferencesContext.Provider>
|
||||
);
|
||||
|
||||
@@ -547,7 +547,7 @@ export async function buildAIPrompt(
|
||||
|
||||
/**
|
||||
* Call AI API to get recommendations
|
||||
* @param provider - 'openai' | 'claude'
|
||||
* @param provider - 'openai' | 'claude' | 'gemini' | 'custom'
|
||||
* @param model - Model ID
|
||||
* @param encryptedApiKey - Encrypted API key
|
||||
* @param prompt - JSON prompt string
|
||||
@@ -691,6 +691,74 @@ export async function callAI(
|
||||
logger.debug('Claude cleaned response:', { cleanedContent });
|
||||
return JSON.parse(cleanedContent);
|
||||
|
||||
} else if (provider === 'gemini') {
|
||||
const requestBody = {
|
||||
systemInstruction: {
|
||||
parts: [{ text: systemMessage }],
|
||||
},
|
||||
contents: [
|
||||
{
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: {
|
||||
type: "OBJECT",
|
||||
properties: {
|
||||
recommendations: {
|
||||
type: "ARRAY",
|
||||
items: {
|
||||
type: "OBJECT",
|
||||
properties: {
|
||||
title: { type: "STRING" },
|
||||
author: { type: "STRING" },
|
||||
reason: { type: "STRING" },
|
||||
},
|
||||
required: ["title", "author", "reason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["recommendations"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug('Gemini request body:', { requestBody });
|
||||
|
||||
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Gemini API error', { status: response.status, error: errorText });
|
||||
throw new Error(`Gemini API error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Invalid response format from Gemini API');
|
||||
}
|
||||
|
||||
logger.debug('Gemini raw response:', { content });
|
||||
|
||||
// Clean potential markdown wrapping
|
||||
const cleanedContent = content
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/\s*```$/i, '')
|
||||
.trim();
|
||||
|
||||
logger.debug('Gemini cleaned response:', { cleanedContent });
|
||||
return JSON.parse(cleanedContent);
|
||||
|
||||
} else if (provider === 'custom') {
|
||||
if (!baseUrl) {
|
||||
throw new Error('Base URL is required for custom provider');
|
||||
|
||||
@@ -67,3 +67,16 @@ export type TorrentTitleFormat = (typeof TORRENT_TITLE_FORMATS)[number];
|
||||
* 'OTHER' is used when no recognized format is detected in the title.
|
||||
*/
|
||||
export type AudioFormat = TorrentTitleFormat | 'OTHER';
|
||||
|
||||
/**
|
||||
* All supported ebook file extensions for ebook detection and file serving.
|
||||
*/
|
||||
export const EBOOK_EXTENSIONS = [
|
||||
'.epub',
|
||||
'.pdf',
|
||||
'.mobi',
|
||||
'.azw3',
|
||||
'.fb2',
|
||||
'.cbz',
|
||||
'.cbr',
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Component: Download Client Timeout Constants
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*
|
||||
* Some indexers (e.g. YGGtorrent) enforce a ~30s wait before allowing
|
||||
* .torrent file downloads. 60s gives sufficient headroom.
|
||||
*/
|
||||
|
||||
/** Timeout for download client API calls and .torrent file fetches (ms) */
|
||||
export const DOWNLOAD_CLIENT_TIMEOUT = 60000;
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Component: Centralized Language Configuration
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Single source of truth for all language-specific configuration.
|
||||
* To add a new language:
|
||||
* 1. Add code to SupportedLanguage union
|
||||
* 2. Add full LanguageConfig entry in LANGUAGE_CONFIGS
|
||||
* 3. Map regions in REGION_LANGUAGE_MAP
|
||||
* 4. Add region to AUDIBLE_REGIONS in audible.ts with language: 'xx'
|
||||
*/
|
||||
|
||||
import type { AudibleRegion } from '../types/audible';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SupportedLanguage = 'en' | 'de' | 'es' | 'fr';
|
||||
|
||||
export interface ScrapingConfig {
|
||||
/** Audible locale query-param value (e.g. 'english', 'deutsch') */
|
||||
audibleLocaleParam: string;
|
||||
/** Author label prefixes to strip (e.g. ['By:', 'Written by:']) */
|
||||
authorPrefixes: string[];
|
||||
/** Narrator label prefixes to strip */
|
||||
narratorPrefixes: string[];
|
||||
/** Length / duration labels used in Cheerio :contains() selectors */
|
||||
lengthLabels: string[];
|
||||
/** Language field labels */
|
||||
languageLabels: string[];
|
||||
/** Release date field labels */
|
||||
releaseDateLabels: string[];
|
||||
/** Series label prefixes used to find series links in search results */
|
||||
seriesLabels: string[];
|
||||
/** Accepted language values for filtering (lowercase) */
|
||||
acceptedLanguageValues: string[];
|
||||
/** Regex patterns that match hour portions in runtime strings */
|
||||
runtimeHourPatterns: RegExp[];
|
||||
/** Regex patterns that match minute portions in runtime strings */
|
||||
runtimeMinutePatterns: RegExp[];
|
||||
/** Regex patterns for extracting numeric rating */
|
||||
ratingPatterns: RegExp[];
|
||||
/** Regex patterns for extracting release date text */
|
||||
releaseDatePatterns: RegExp[];
|
||||
/** Promotional / non-description text patterns to exclude */
|
||||
descriptionExcludePatterns: RegExp[];
|
||||
/** Duration detection pattern for generic element scanning */
|
||||
durationDetectionPattern: RegExp;
|
||||
/** Rating text selector pattern (e.g. 'out of 5 stars') */
|
||||
ratingTextSelector: string;
|
||||
}
|
||||
|
||||
export interface LanguageConfig {
|
||||
code: SupportedLanguage;
|
||||
/** Anna's Archive language filter code */
|
||||
annasArchiveLang: string;
|
||||
/** EPUB language code */
|
||||
epubCode: string;
|
||||
/** Stop words for ranking algorithm (filtered from match scoring) */
|
||||
stopWords: string[];
|
||||
/** Character replacements applied before NFD normalization in ranking (e.g. ß→ss) */
|
||||
characterReplacements: Record<string, string>;
|
||||
/** All scraping-related config */
|
||||
scraping: ScrapingConfig;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Language Configurations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ENGLISH_CONFIG: LanguageConfig = {
|
||||
code: 'en',
|
||||
annasArchiveLang: 'en',
|
||||
epubCode: 'en',
|
||||
stopWords: ['the', 'a', 'an', 'of', 'on', 'in', 'at', 'by', 'for'],
|
||||
characterReplacements: {},
|
||||
scraping: {
|
||||
audibleLocaleParam: 'english',
|
||||
authorPrefixes: ['By:', 'Written by:'],
|
||||
narratorPrefixes: ['Narrated by:'],
|
||||
lengthLabels: ['Length:'],
|
||||
languageLabels: ['Language:'],
|
||||
releaseDateLabels: ['Release date:'],
|
||||
seriesLabels: ['Series:'],
|
||||
acceptedLanguageValues: ['english'],
|
||||
runtimeHourPatterns: [/(\d+)\s*hrs?/i, /(\d+)\s*hours?/i],
|
||||
runtimeMinutePatterns: [/(\d+)\s*mins?/i, /(\d+)\s*minutes?/i],
|
||||
ratingPatterns: [/(\d+\.?\d*)\s*out of/i],
|
||||
releaseDatePatterns: [/Release date:\s*(.+)/i],
|
||||
descriptionExcludePatterns: [
|
||||
/\$\d+\.\d+/,
|
||||
/cancel anytime/i,
|
||||
/free trial/i,
|
||||
/membership/i,
|
||||
/subscribe/i,
|
||||
/offer.*ends/i,
|
||||
/^\s*by\s+[\w\s,]+$/i,
|
||||
],
|
||||
durationDetectionPattern: /\d+\s*(hr|hour|h)\s*\d*\s*(min|minute|m)?/i,
|
||||
ratingTextSelector: 'out of 5 stars',
|
||||
},
|
||||
};
|
||||
|
||||
const GERMAN_CONFIG: LanguageConfig = {
|
||||
code: 'de',
|
||||
annasArchiveLang: 'de',
|
||||
epubCode: 'de',
|
||||
stopWords: ['der', 'die', 'das', 'ein', 'eine', 'und', 'von', 'zu', 'den', 'dem', 'des'],
|
||||
characterReplacements: { '\u00df': 'ss' },
|
||||
scraping: {
|
||||
audibleLocaleParam: 'deutsch',
|
||||
authorPrefixes: ['Von:', 'Geschrieben von:', 'Autor:'],
|
||||
narratorPrefixes: ['Gesprochen von:', 'Sprecher:'],
|
||||
lengthLabels: ['Spieldauer:', 'Dauer:', 'L\u00e4nge:'],
|
||||
languageLabels: ['Sprache:'],
|
||||
releaseDateLabels: ['Erscheinungsdatum:'],
|
||||
seriesLabels: ['Serie:', 'Reihe:'],
|
||||
acceptedLanguageValues: ['deutsch', 'german'],
|
||||
runtimeHourPatterns: [/(\d+)\s*Std\.?/i, /(\d+)\s*Stunden?/i],
|
||||
runtimeMinutePatterns: [/(\d+)\s*Min\.?/i, /(\d+)\s*Minuten?/i],
|
||||
ratingPatterns: [/(\d+[.,]?\d*)\s*von\s*5/i],
|
||||
releaseDatePatterns: [/Erscheinungsdatum:\s*(.+)/i],
|
||||
descriptionExcludePatterns: [
|
||||
/\$\d+\.\d+/,
|
||||
/\d+,\d+\s*\u20ac/,
|
||||
/jederzeit k\u00fcndbar/i,
|
||||
/kostenlos testen/i,
|
||||
/Mitgliedschaft/i,
|
||||
/abonnieren/i,
|
||||
/Angebot.*endet/i,
|
||||
/^\s*von\s+[\w\s,]+$/i,
|
||||
],
|
||||
durationDetectionPattern: /\d+\s*(Std|Stunden?|h)\s*\.?\s*\d*\s*(Min|Minuten?|m)?/i,
|
||||
ratingTextSelector: 'von 5 Sternen',
|
||||
},
|
||||
};
|
||||
|
||||
const SPANISH_CONFIG: LanguageConfig = {
|
||||
code: 'es',
|
||||
annasArchiveLang: 'es',
|
||||
epubCode: 'es',
|
||||
stopWords: ['el', 'la', 'los', 'las', 'un', 'una', 'de', 'del', 'en', 'y', 'por'],
|
||||
characterReplacements: {},
|
||||
scraping: {
|
||||
audibleLocaleParam: 'espa\u00f1ol',
|
||||
authorPrefixes: ['De:', 'Escrito por:', 'Autor:'],
|
||||
narratorPrefixes: ['Narrado por:'],
|
||||
lengthLabels: ['Duraci\u00f3n:'],
|
||||
languageLabels: ['Idioma:'],
|
||||
releaseDateLabels: ['Fecha de lanzamiento:'],
|
||||
seriesLabels: ['Serie:'],
|
||||
acceptedLanguageValues: ['espa\u00f1ol', 'spanish'],
|
||||
runtimeHourPatterns: [/(\d+)\s*h\b/i, /(\d+)\s*horas?/i],
|
||||
runtimeMinutePatterns: [/(\d+)\s*min/i, /(\d+)\s*minutos?/i],
|
||||
ratingPatterns: [/(\d+[.,]?\d*)\s*de\s*5/i],
|
||||
releaseDatePatterns: [/Fecha de lanzamiento:\s*(.+)/i],
|
||||
descriptionExcludePatterns: [
|
||||
/\$\d+\.\d+/,
|
||||
/\d+,\d+\s*\u20ac/,
|
||||
/cancela cuando quieras/i,
|
||||
/prueba gratis/i,
|
||||
/suscripci\u00f3n/i,
|
||||
/suscr\u00edbete/i,
|
||||
/oferta.*termina/i,
|
||||
/^\s*de\s+[\w\s,]+$/i,
|
||||
],
|
||||
durationDetectionPattern: /\d+\s*(h|horas?)\s*\d*\s*(min|minutos?)?/i,
|
||||
ratingTextSelector: 'de 5 estrellas',
|
||||
},
|
||||
};
|
||||
|
||||
const FRENCH_CONFIG: LanguageConfig = {
|
||||
code: 'fr',
|
||||
annasArchiveLang: 'fr',
|
||||
epubCode: 'fr',
|
||||
stopWords: ['le', 'la', 'les', 'un', 'une', 'de', 'des', 'sur', 'dans', '\u00e0', 'et', 'par', 'pour'],
|
||||
characterReplacements: {},
|
||||
scraping: {
|
||||
audibleLocaleParam: 'français',
|
||||
authorPrefixes: ['De :', '\u00c9crit par :', 'Auteur :'],
|
||||
narratorPrefixes: ['Lu par :'],
|
||||
lengthLabels: ['Dur\u00e9e :'],
|
||||
languageLabels: ['Langue :'],
|
||||
releaseDateLabels: ['Date de publication :'],
|
||||
seriesLabels: ['S\u00e9rie :'],
|
||||
acceptedLanguageValues: ['français', 'french'],
|
||||
runtimeHourPatterns: [/(\d+)\s*h\b/i, /(\d+)\s*heures?/i],
|
||||
runtimeMinutePatterns: [/(\d+)\s*min/i, /(\d+)\s*minutes?/i],
|
||||
ratingPatterns: [/(\d+[.,]?\d*)\s*de\s*5/i],
|
||||
releaseDatePatterns: [/Date de publication:\s*(.+)/i],
|
||||
descriptionExcludePatterns: [
|
||||
/\$\d+\.\d+/,
|
||||
/\d+,\d+\s*\u20ac/,
|
||||
/Essayer pour/i,
|
||||
/R\u00e9siliez \u00e0 tout moment/i,
|
||||
/Acheter pour/i,
|
||||
/^\s*de\s+[\w\s,]+$/i,
|
||||
],
|
||||
durationDetectionPattern: /\d+\s*(h|heures?)\s*\d*\s*(min|minutes?)?/i,
|
||||
ratingTextSelector: 'sur 5 étoiles',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lookup Maps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const LANGUAGE_CONFIGS: Record<SupportedLanguage, LanguageConfig> = {
|
||||
en: ENGLISH_CONFIG,
|
||||
de: GERMAN_CONFIG,
|
||||
es: SPANISH_CONFIG,
|
||||
fr: FRENCH_CONFIG,
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps Audible region codes to language codes.
|
||||
* All English-speaking regions map to 'en'.
|
||||
*/
|
||||
export const REGION_LANGUAGE_MAP: Record<AudibleRegion, SupportedLanguage> = {
|
||||
us: 'en',
|
||||
ca: 'en',
|
||||
uk: 'en',
|
||||
au: 'en',
|
||||
in: 'en',
|
||||
de: 'de',
|
||||
es: 'es',
|
||||
fr: 'fr',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper Functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the full language configuration for an Audible region.
|
||||
*/
|
||||
export function getLanguageForRegion(region: AudibleRegion): LanguageConfig {
|
||||
const langCode = REGION_LANGUAGE_MAP[region];
|
||||
return LANGUAGE_CONFIGS[langCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip any matching prefixes from text (case-insensitive).
|
||||
* Returns the text with the first matching prefix removed, trimmed.
|
||||
*
|
||||
* Example: stripPrefixes('By: Author Name', ['By:', 'Written by:']) => 'Author Name'
|
||||
*/
|
||||
export function stripPrefixes(text: string, prefixes: string[]): string {
|
||||
const trimmed = text.trim();
|
||||
for (const prefix of prefixes) {
|
||||
if (trimmed.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
return trimmed.slice(prefix.length).trim();
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Cheerio selector that matches any of the given labels using :contains().
|
||||
* Returns a comma-separated selector string.
|
||||
*
|
||||
* Example: buildContainsSelector('span', ['Length:', 'Dauer:'])
|
||||
* => 'span:contains("Length:"), span:contains("Dauer:")'
|
||||
*/
|
||||
export function buildContainsSelector(element: string, labels: string[]): string {
|
||||
return labels.map(label => `${element}:contains("${label}")`).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a value from text by trying multiple label patterns.
|
||||
* Returns the captured group from the first matching pattern, or null.
|
||||
*/
|
||||
export function extractByPatterns(text: string, patterns: RegExp[]): string | null {
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a language value matches the accepted values for a language config.
|
||||
* Comparison is case-insensitive.
|
||||
*/
|
||||
export function isAcceptedLanguage(languageValue: string, config: LanguageConfig): boolean {
|
||||
const normalized = languageValue.toLowerCase().trim();
|
||||
return config.scraping.acceptedLanguageValues.includes(normalized);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Component: Request Status Constants
|
||||
* Documentation: documentation/backend/database.md
|
||||
*/
|
||||
|
||||
/** Terminal statuses indicating a request has been fulfilled and files are ready */
|
||||
export const COMPLETED_STATUSES = ['available', 'downloaded'] as const;
|
||||
@@ -5,6 +5,29 @@
|
||||
|
||||
import { PrismaClient } from '@/generated/prisma/client';
|
||||
|
||||
/**
|
||||
* Append connection pool parameters to DATABASE_URL if not already present.
|
||||
* - connection_limit=20: up from default 9, fits 22 max workers + API routes
|
||||
* - pool_timeout=30: up from default 10s, gives queued requests time
|
||||
*/
|
||||
function getPooledDatabaseUrl(): string {
|
||||
const baseUrl = process.env.DATABASE_URL || '';
|
||||
if (!baseUrl) return baseUrl;
|
||||
|
||||
const separator = baseUrl.includes('?') ? '&' : '?';
|
||||
const params: string[] = [];
|
||||
|
||||
if (!baseUrl.includes('connection_limit')) {
|
||||
params.push('connection_limit=20');
|
||||
}
|
||||
if (!baseUrl.includes('pool_timeout')) {
|
||||
params.push('pool_timeout=30');
|
||||
}
|
||||
|
||||
if (params.length === 0) return baseUrl;
|
||||
return `${baseUrl}${separator}${params.join('&')}`;
|
||||
}
|
||||
|
||||
// Prevent multiple instances of Prisma Client in development
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
@@ -14,6 +37,11 @@ export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
datasources: {
|
||||
db: {
|
||||
url: getPooledDatabaseUrl(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
|
||||
export interface Audiobook {
|
||||
@@ -20,6 +22,9 @@ export interface Audiobook {
|
||||
releaseDate?: string;
|
||||
rating?: number;
|
||||
genres?: string[];
|
||||
series?: string; // Series name (e.g., "A Song of Ice and Fire")
|
||||
seriesPart?: string; // Position in series (e.g., "1", "1.5")
|
||||
seriesAsin?: string; // Audible ASIN for the series (links to /series/{asin})
|
||||
isAvailable?: boolean; // Set by real-time matching against plex_library
|
||||
plexGuid?: string | null;
|
||||
dbId?: string | null;
|
||||
@@ -54,20 +59,58 @@ export function useAudiobooks(type: 'popular' | 'new-releases', limit: number =
|
||||
};
|
||||
}
|
||||
|
||||
export function useSearch(query: string, page: number = 1) {
|
||||
const shouldFetch = query && query.length > 0;
|
||||
const endpoint = shouldFetch ? `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${page}` : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30000, // Cache for 30 seconds
|
||||
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.asin)) return false;
|
||||
seen.add(item.asin);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearch(query: string) {
|
||||
const prevQueryRef = useRef(query);
|
||||
|
||||
const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite(
|
||||
(pageIndex, prevPageData) => {
|
||||
if (!query || query.length === 0) return null;
|
||||
if (pageIndex === 0) return `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=1`;
|
||||
if (!prevPageData?.hasMore) return null;
|
||||
return `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${pageIndex + 1}`;
|
||||
},
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30000,
|
||||
revalidateFirstPage: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Reset to page 1 when query changes
|
||||
useEffect(() => {
|
||||
if (query !== prevQueryRef.current) {
|
||||
prevQueryRef.current = query;
|
||||
setSize(1);
|
||||
}
|
||||
}, [query, setSize]);
|
||||
|
||||
const results = data ? dedupeByAsin(data.flatMap(page => page?.results || [])) : [];
|
||||
const totalResults = data?.[0]?.totalResults || 0;
|
||||
const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore);
|
||||
const isLoadingInitial = !data && !error && !!query;
|
||||
const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setSize(prev => prev + 1);
|
||||
}, [setSize]);
|
||||
|
||||
return {
|
||||
results: data?.results || [],
|
||||
totalResults: data?.totalResults || 0,
|
||||
hasMore: data?.hasMore || false,
|
||||
isLoading: shouldFetch && isLoading,
|
||||
results,
|
||||
totalResults,
|
||||
hasMore,
|
||||
isLoading: isLoadingInitial,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
+52
-12
@@ -5,7 +5,9 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
import { Audiobook } from './useAudiobooks';
|
||||
|
||||
@@ -68,21 +70,59 @@ export function useAuthorDetail(asin: string | null) {
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuthorBooks(asin: string | null, authorName: string | null) {
|
||||
const shouldFetch = asin && authorName;
|
||||
const endpoint = shouldFetch
|
||||
? `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}`
|
||||
: null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 60000, // Cache for 1 minute
|
||||
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.asin)) return false;
|
||||
seen.add(item.asin);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuthorBooks(asin: string | null, authorName: string | null) {
|
||||
const prevIdentityRef = useRef<string | null>(null);
|
||||
const identity = asin && authorName ? `${asin}:${authorName}` : null;
|
||||
|
||||
const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite(
|
||||
(pageIndex, prevPageData) => {
|
||||
if (!asin || !authorName) return null;
|
||||
if (pageIndex === 0) return `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}&page=1`;
|
||||
if (!prevPageData?.hasMore) return null;
|
||||
return `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}&page=${pageIndex + 1}`;
|
||||
},
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 60000,
|
||||
revalidateFirstPage: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Reset when author changes
|
||||
useEffect(() => {
|
||||
if (identity !== prevIdentityRef.current) {
|
||||
prevIdentityRef.current = identity;
|
||||
setSize(1);
|
||||
}
|
||||
}, [identity, setSize]);
|
||||
|
||||
const books = (data ? dedupeByAsin(data.flatMap(page => page?.books || [])) : []) as Audiobook[];
|
||||
const totalBooks = data?.[0]?.totalBooks || 0;
|
||||
const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore);
|
||||
const isLoadingInitial = !data && !error && !!identity;
|
||||
const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setSize(prev => prev + 1);
|
||||
}, [setSize]);
|
||||
|
||||
return {
|
||||
books: (data?.books || []) as Audiobook[],
|
||||
totalBooks: data?.totalBooks || 0,
|
||||
isLoading: !!shouldFetch && isLoading,
|
||||
books,
|
||||
totalBooks,
|
||||
hasMore,
|
||||
isLoading: isLoadingInitial || (!!identity && isLoading),
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -515,6 +515,25 @@ export function useEbookStatus(asin: string | null) {
|
||||
};
|
||||
}
|
||||
|
||||
interface DownloadStatus {
|
||||
downloadAvailable: boolean;
|
||||
requestId: string | null;
|
||||
}
|
||||
|
||||
export function useDownloadStatus(asin: string | null) {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken && asin ? `/api/audiobooks/${asin}/download-status` : null;
|
||||
|
||||
const { data, isLoading } = useSWR<DownloadStatus>(endpoint, fetcher);
|
||||
|
||||
return {
|
||||
downloadAvailable: data?.downloadAvailable ?? false,
|
||||
requestId: data?.requestId ?? null,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useFetchEbookByAsin() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Component: Series Fetching Hooks
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
import { Audiobook } from './useAudiobooks';
|
||||
|
||||
export interface SeriesSummary {
|
||||
asin: string;
|
||||
title: string;
|
||||
bookCount: number;
|
||||
rating?: number;
|
||||
ratingCount?: number;
|
||||
tags: string[];
|
||||
coverArtUrl?: string;
|
||||
audibleUrl: string;
|
||||
}
|
||||
|
||||
export interface SimilarSeries {
|
||||
asin: string;
|
||||
title: string;
|
||||
bookCount?: number;
|
||||
coverArtUrl?: string;
|
||||
}
|
||||
|
||||
export interface SeriesDetail {
|
||||
asin: string;
|
||||
title: string;
|
||||
bookCount: number;
|
||||
rating?: number;
|
||||
ratingCount?: number;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
books: Audiobook[];
|
||||
similarSeries: SimilarSeries[];
|
||||
audibleUrl: string;
|
||||
}
|
||||
|
||||
export function useSeriesSearch(query: string) {
|
||||
const shouldFetch = query && query.length > 0;
|
||||
const endpoint = shouldFetch
|
||||
? `/api/series/search?q=${encodeURIComponent(query)}`
|
||||
: null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30000,
|
||||
});
|
||||
|
||||
return {
|
||||
series: (data?.series || []) as SeriesSummary[],
|
||||
query: data?.query || '',
|
||||
isLoading: shouldFetch && isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.asin)) return false;
|
||||
seen.add(item.asin);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function useSeriesDetail(asin: string | null) {
|
||||
const prevAsinRef = useRef<string | null>(null);
|
||||
|
||||
const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite(
|
||||
(pageIndex, prevPageData) => {
|
||||
if (!asin) return null;
|
||||
if (pageIndex === 0) return `/api/series/${asin}?page=1`;
|
||||
if (!prevPageData?.hasMore) return null;
|
||||
return `/api/series/${asin}?page=${pageIndex + 1}`;
|
||||
},
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 300000,
|
||||
revalidateFirstPage: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Reset when series changes
|
||||
useEffect(() => {
|
||||
if (asin !== prevAsinRef.current) {
|
||||
prevAsinRef.current = asin;
|
||||
setSize(1);
|
||||
}
|
||||
}, [asin, setSize]);
|
||||
|
||||
// Merge pages: use first page's metadata, accumulate all books
|
||||
const firstPageSeries = data?.[0]?.series as SeriesDetail | undefined;
|
||||
const allBooks = (data ? dedupeByAsin(data.flatMap(page => page?.series?.books || [])) : []) as Audiobook[];
|
||||
|
||||
const series: SeriesDetail | null = firstPageSeries
|
||||
? { ...firstPageSeries, books: allBooks }
|
||||
: null;
|
||||
|
||||
const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore);
|
||||
const isLoadingInitial = !data && !error && !!asin;
|
||||
const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setSize(prev => prev + 1);
|
||||
}, [setSize]);
|
||||
|
||||
return {
|
||||
series,
|
||||
hasMore,
|
||||
isLoading: isLoadingInitial || (!!asin && isLoading),
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Component: Audible Series Scraping
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Standalone series scraping module. Uses the AudibleService fetch wrapper
|
||||
* for HTTP requests and Cheerio for HTML parsing.
|
||||
* Kept separate from audible.service.ts to avoid bloating the main service.
|
||||
*/
|
||||
|
||||
import * as cheerio from 'cheerio';
|
||||
import { getAudibleService, AudibleAudiobook } from './audible.service';
|
||||
import { AUDIBLE_REGIONS } from '../types/audible';
|
||||
import {
|
||||
getLanguageForRegion,
|
||||
buildContainsSelector,
|
||||
stripPrefixes,
|
||||
} from '../constants/language-config';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { randomDelay } from '../utils/scrape-resilience';
|
||||
|
||||
const logger = RMABLogger.create('Audible.Series');
|
||||
|
||||
const AUDIBLE_PAGE_SIZE = 50;
|
||||
const MAX_SERIES_RESULTS = 15;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SeriesSummary {
|
||||
asin: string;
|
||||
title: string;
|
||||
bookCount: number;
|
||||
rating?: number;
|
||||
ratingCount?: number;
|
||||
tags: string[];
|
||||
coverArtUrl?: string;
|
||||
audibleUrl: string;
|
||||
}
|
||||
|
||||
export interface SimilarSeries {
|
||||
asin: string;
|
||||
title: string;
|
||||
bookCount?: number;
|
||||
coverArtUrl?: string;
|
||||
}
|
||||
|
||||
export interface SeriesDetail {
|
||||
asin: string;
|
||||
title: string;
|
||||
bookCount: number;
|
||||
rating?: number;
|
||||
ratingCount?: number;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
books: AudibleAudiobook[];
|
||||
similarSeries: SimilarSeries[];
|
||||
audibleUrl: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search: extract series links from Audible search results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Search for series by scraping Audible search results and extracting
|
||||
* series links. De-duplicates by ASIN, then scrapes each unique series
|
||||
* page in parallel (capped at MAX_SERIES_RESULTS).
|
||||
*/
|
||||
export async function searchForSeries(query: string): Promise<SeriesSummary[]> {
|
||||
const service = getAudibleService();
|
||||
const region = service.getRegion();
|
||||
const baseUrl = service.getBaseUrl();
|
||||
const langConfig = getLanguageForRegion(region);
|
||||
const seriesLabels = langConfig.scraping.seriesLabels;
|
||||
|
||||
logger.info(`Searching series for "${query}" (region: ${region})`);
|
||||
|
||||
// Step 1: Fetch search results page
|
||||
let $: cheerio.CheerioAPI;
|
||||
try {
|
||||
const { data: response } = await service.fetch('/search', {
|
||||
params: {
|
||||
ipRedirectOverride: 'true',
|
||||
keywords: query,
|
||||
pageSize: AUDIBLE_PAGE_SIZE,
|
||||
},
|
||||
});
|
||||
$ = cheerio.load(response.data);
|
||||
} catch (error) {
|
||||
logger.error('Series search fetch failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 2: Extract unique series ASINs from search results
|
||||
// Series links appear inside spans containing locale-specific "Series:" text
|
||||
const seriesMap = new Map<string, { title: string; coverArtUrl?: string }>();
|
||||
|
||||
$('.s-result-item, .productListItem').each((_index, element) => {
|
||||
if (seriesMap.size >= MAX_SERIES_RESULTS) return false;
|
||||
|
||||
const $el = $(element);
|
||||
|
||||
// Find the span containing a series label (e.g. "Series:")
|
||||
const seriesSelector = buildContainsSelector('span', seriesLabels);
|
||||
const seriesContainer = $el.find(seriesSelector).first();
|
||||
if (seriesContainer.length === 0) return;
|
||||
|
||||
// Look for series link within or near the series label container
|
||||
// The series link is a child or sibling: <a href="/series/Name/B006K1QER6">
|
||||
const parentEl = seriesContainer.parent();
|
||||
const seriesLink = parentEl.find('a[href*="/series/"]').first();
|
||||
if (seriesLink.length === 0) return;
|
||||
|
||||
const href = seriesLink.attr('href') || '';
|
||||
const asinMatch = href.match(/\/series\/[^/]*\/([A-Z0-9]{10})/);
|
||||
if (!asinMatch) return;
|
||||
|
||||
const asin = asinMatch[1];
|
||||
if (seriesMap.has(asin)) return;
|
||||
|
||||
const title = seriesLink.text().trim();
|
||||
if (!title) return;
|
||||
|
||||
// Use the first book's cover as representative image
|
||||
const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || undefined;
|
||||
|
||||
seriesMap.set(asin, { title, coverArtUrl });
|
||||
});
|
||||
|
||||
if (seriesMap.size === 0) {
|
||||
logger.info(`No series found for "${query}"`);
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info(`Found ${seriesMap.size} unique series, scraping detail pages...`);
|
||||
|
||||
// Step 3: Scrape each series page in parallel (with rate limiting)
|
||||
const entries = Array.from(seriesMap.entries());
|
||||
const BATCH_SIZE = 5;
|
||||
const results: SeriesSummary[] = [];
|
||||
|
||||
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
||||
const batch = entries.slice(i, i + BATCH_SIZE);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async ([asin, meta]) => {
|
||||
try {
|
||||
const detail = await scrapeSeriesPageSummary(asin);
|
||||
if (!detail) return null;
|
||||
return {
|
||||
...detail,
|
||||
coverArtUrl: detail.coverArtUrl || meta.coverArtUrl,
|
||||
audibleUrl: `${baseUrl}/series/${asin}`,
|
||||
} as SeriesSummary;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to scrape series ${asin}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// Return a minimal result from search data
|
||||
return {
|
||||
asin,
|
||||
title: meta.title,
|
||||
bookCount: 0,
|
||||
tags: [],
|
||||
coverArtUrl: meta.coverArtUrl,
|
||||
audibleUrl: `${baseUrl}/series/${asin}`,
|
||||
} as SeriesSummary;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
results.push(...batchResults.filter((r): r is SeriesSummary => r !== null));
|
||||
|
||||
// Rate limit between batches
|
||||
if (i + BATCH_SIZE < entries.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, randomDelay(1500, 3000)));
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Series search complete: "${query}" -> ${results.length} results`);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Series page scraping (summary - for search results)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scrape a series page for summary data (title, book count, rating, tags).
|
||||
* Used during search to enrich each series result.
|
||||
*/
|
||||
async function scrapeSeriesPageSummary(asin: string): Promise<Omit<SeriesSummary, 'audibleUrl'> | null> {
|
||||
const service = getAudibleService();
|
||||
|
||||
try {
|
||||
const { data: response } = await service.fetch(`/series/${asin}`, {
|
||||
params: { ipRedirectOverride: 'true' },
|
||||
});
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
return parseSeriesPageSummary($, asin);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch series page ${asin}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse summary fields from a series page's Cheerio document.
|
||||
*/
|
||||
function parseSeriesPageSummary(
|
||||
$: cheerio.CheerioAPI,
|
||||
asin: string
|
||||
): Omit<SeriesSummary, 'audibleUrl'> {
|
||||
// Title - from h1
|
||||
const title = $('h1').first().text().trim() || '';
|
||||
|
||||
// Book count - multiple strategies, most specific first
|
||||
let bookCount = 0;
|
||||
|
||||
// Primary: adbl-metadata[slot="child-count"] in the page header (NOT inside carousels)
|
||||
// Filter out carousel items by excluding those inside adbl-product-carousel
|
||||
$('adbl-metadata[slot="child-count"]').each((_i, el) => {
|
||||
if (bookCount > 0) return false;
|
||||
const $el = $(el);
|
||||
// Skip if inside a carousel (those are similar-series counts)
|
||||
if ($el.closest('adbl-product-carousel').length > 0) return;
|
||||
const text = $el.text().trim();
|
||||
const match = text.match(/(\d+)/);
|
||||
if (match) bookCount = parseInt(match[1]);
|
||||
});
|
||||
|
||||
// Secondary: text matching in spans/headings for "X books/titles/Titel/libros/Bucher"
|
||||
if (bookCount === 0) {
|
||||
const countText = $('span:contains("book"), span:contains("title"), span:contains("Titel"), span:contains("libro"), span:contains("Buch"), span:contains("B\u00fccher")')
|
||||
.text().trim();
|
||||
const countMatch = countText.match(/(\d+)\s*(books?|titles?|Titel|libros?|B(?:uch|\u00fccher))/i);
|
||||
if (countMatch) {
|
||||
bookCount = parseInt(countMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: count product items on the page
|
||||
if (bookCount === 0) {
|
||||
bookCount = $('.productListItem, .bc-list-item[data-asin]').length;
|
||||
}
|
||||
|
||||
// Rating
|
||||
const { rating, ratingCount } = parseSeriesRating($);
|
||||
|
||||
// Tags/genres: primary from adbl-chip web components, fallback to legacy links
|
||||
const tags: string[] = [];
|
||||
const addTag = (text: string) => {
|
||||
const tag = text.trim();
|
||||
if (tag && tag.length >= 2 && tag.length <= 50 && !tags.includes(tag)) {
|
||||
tags.push(tag);
|
||||
}
|
||||
};
|
||||
|
||||
// Primary: adbl-chip.related-tag elements (modern Audible layout)
|
||||
$('adbl-chip.related-tag').each((_i, el) => {
|
||||
addTag($(el).text());
|
||||
});
|
||||
|
||||
// Fallback: legacy category and tag links
|
||||
if (tags.length === 0) {
|
||||
$('a[href*="/cat/"], a[href*="/tag/"]').each((_i, el) => {
|
||||
addTag($(el).text());
|
||||
});
|
||||
}
|
||||
|
||||
// Cover art from first book image
|
||||
const coverArtUrl = $('.productListItem img, .bc-list-item img').first()
|
||||
.attr('src')?.replace(/\._.*_\./, '._SL500_.') || undefined;
|
||||
|
||||
return { asin, title, bookCount, rating, ratingCount, tags: tags.slice(0, 5), coverArtUrl };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Series page scraping (full detail)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scrape a series page for full detail data including books and similar series.
|
||||
* Used by the detail API endpoint.
|
||||
*/
|
||||
export async function scrapeSeriesPage(asin: string, page: number = 1): Promise<(SeriesDetail & { hasMore: boolean; page: number }) | null> {
|
||||
const service = getAudibleService();
|
||||
const region = service.getRegion();
|
||||
const baseUrl = service.getBaseUrl();
|
||||
const langConfig = getLanguageForRegion(region);
|
||||
|
||||
logger.info(`Scraping series detail page: ${asin}, page ${page}`);
|
||||
|
||||
try {
|
||||
const { data: response } = await service.fetch(`/series/${asin}`, {
|
||||
params: { ipRedirectOverride: 'true', pageSize: AUDIBLE_PAGE_SIZE, page },
|
||||
});
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// Parse summary fields
|
||||
const summary = parseSeriesPageSummary($, asin);
|
||||
|
||||
// Description
|
||||
const description = $('.bc-expander-content').first().text().trim() ||
|
||||
$('[class*="productPublisherSummary"]').first().text().trim() ||
|
||||
undefined;
|
||||
|
||||
// Parse all books from the series page
|
||||
const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes);
|
||||
|
||||
// Use actual book count if we got more from scraping
|
||||
const bookCount = Math.max(summary.bookCount, books.length);
|
||||
|
||||
// Calculate hasMore: use header bookCount if available, otherwise check if full page
|
||||
const hasMore = bookCount > 0
|
||||
? page * AUDIBLE_PAGE_SIZE < bookCount
|
||||
: books.length >= AUDIBLE_PAGE_SIZE;
|
||||
|
||||
// Parse similar series ("Listeners also enjoyed" or similar section)
|
||||
const similarSeries = parseSimilarSeries($);
|
||||
|
||||
logger.info(`Series detail complete: "${summary.title}" (${books.length} books, page ${page}, hasMore: ${hasMore})`);
|
||||
|
||||
return {
|
||||
asin,
|
||||
title: summary.title,
|
||||
bookCount,
|
||||
rating: summary.rating,
|
||||
ratingCount: summary.ratingCount,
|
||||
description,
|
||||
tags: summary.tags,
|
||||
books,
|
||||
similarSeries,
|
||||
audibleUrl: `${baseUrl}/series/${asin}`,
|
||||
hasMore,
|
||||
page,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to scrape series detail ${asin}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract rating and rating count from a series page.
|
||||
*
|
||||
* Real HTML uses:
|
||||
* <div aria-label="4.5 out of 5 stars" class="bc-review-stars ...">
|
||||
* <span class="series-rating bc-color-secondary">8,704 ratings</span>
|
||||
*/
|
||||
function parseSeriesRating($: cheerio.CheerioAPI): { rating?: number; ratingCount?: number } {
|
||||
let rating: number | undefined;
|
||||
let ratingCount: number | undefined;
|
||||
|
||||
// Primary: aria-label on div.bc-review-stars (e.g. "4.5 out of 5 stars")
|
||||
const starsDiv = $('div.bc-review-stars');
|
||||
let ariaLabel = starsDiv.attr('aria-label') || '';
|
||||
|
||||
// Fallback: any element with aria-label containing rating pattern
|
||||
if (!ariaLabel) {
|
||||
const fallbackEl = $('[aria-label*="out of"], [aria-label*="von 5"], [aria-label*="de 5"]').first();
|
||||
ariaLabel = fallbackEl.attr('aria-label') || '';
|
||||
}
|
||||
|
||||
// Extract numeric rating from aria-label (handles "4.5 out of 5", "4,5 von 5", "4,5 de 5")
|
||||
const ratingMatch = ariaLabel.match(/(\d+[.,]?\d*)\s*(?:out of|von|de)\s*5/i);
|
||||
if (ratingMatch) {
|
||||
rating = parseFloat(ratingMatch[1].replace(',', '.'));
|
||||
}
|
||||
|
||||
// Rating count from span.series-rating (e.g. "8,704 ratings")
|
||||
const seriesRatingSpan = $('span.series-rating').first();
|
||||
let countText = seriesRatingSpan.text().trim();
|
||||
|
||||
// Fallback: look in broader context for rating count text
|
||||
if (!countText) {
|
||||
const fallbackContainer = $('[class*="rating"], .ratingsLabel').first();
|
||||
countText = fallbackContainer.text().trim();
|
||||
}
|
||||
|
||||
const countMatch = countText.match(/([\d,.]+)\s*(?:ratings?|Bewertungen?|calificaciones?)/i);
|
||||
if (countMatch) {
|
||||
ratingCount = parseInt(countMatch[1].replace(/[.,]/g, ''));
|
||||
}
|
||||
|
||||
return { rating, ratingCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all books from a series page's product list items.
|
||||
*/
|
||||
function parseSeriesBooks(
|
||||
$: cheerio.CheerioAPI,
|
||||
authorPrefixes: string[],
|
||||
narratorPrefixes: string[]
|
||||
): AudibleAudiobook[] {
|
||||
const books: AudibleAudiobook[] = [];
|
||||
const seenAsins = new Set<string>();
|
||||
|
||||
$('.productListItem, .bc-list-item').each((_index, element) => {
|
||||
const $el = $(element);
|
||||
|
||||
// Extract ASIN
|
||||
const bookAsin = $el.attr('data-asin') ||
|
||||
$el.find('li').attr('data-asin') ||
|
||||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||
|
||||
if (!bookAsin || seenAsins.has(bookAsin)) return;
|
||||
seenAsins.add(bookAsin);
|
||||
|
||||
// Title: h3 a / .bc-heading a hold the real book title;
|
||||
// h2 on series pages is the position label ("Book 1"), so try it last.
|
||||
const title = $el.find('h3 a').first().text().trim() ||
|
||||
$el.find('.bc-heading a').first().text().trim() ||
|
||||
$el.find('h2 a').first().text().trim() ||
|
||||
$el.find('h2').first().text().trim() ||
|
||||
'';
|
||||
|
||||
if (!title) return;
|
||||
|
||||
// Author
|
||||
const authorLink = $el.find('a[href*="/author/"]').first();
|
||||
const authorText = authorLink.text().trim() ||
|
||||
$el.find('.authorLabel').text().trim() ||
|
||||
'';
|
||||
const authorHref = authorLink.attr('href') || '';
|
||||
const authorAsinMatch = authorHref.match(/\/author\/[^/]+\/([A-Z0-9]{10})/);
|
||||
|
||||
// Narrator
|
||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||
$el.find('.narratorLabel').text().trim() ||
|
||||
'';
|
||||
|
||||
// Cover art
|
||||
const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || '';
|
||||
|
||||
// Rating
|
||||
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
||||
$el.find('.a-icon-star span').first().text().trim();
|
||||
const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null;
|
||||
const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined;
|
||||
|
||||
books.push({
|
||||
asin: bookAsin,
|
||||
title,
|
||||
author: stripPrefixes(authorText, authorPrefixes),
|
||||
authorAsin: authorAsinMatch?.[1] || undefined,
|
||||
narrator: stripPrefixes(narratorText, narratorPrefixes),
|
||||
coverArtUrl,
|
||||
rating,
|
||||
});
|
||||
});
|
||||
|
||||
return books;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse similar series from the "Listeners also enjoyed" carousel.
|
||||
*
|
||||
* Real HTML uses web components:
|
||||
* <adbl-product-carousel id="SeriestoSeries">
|
||||
* <adbl-product-grid-item>
|
||||
* <div class="adbl-impression-emitted" data-asin="B0CGS1LPWJ">
|
||||
* <adbl-metadata slot="title"><a>Hockey Guys</a></adbl-metadata>
|
||||
* <adbl-metadata slot="child-count">3 titles</adbl-metadata>
|
||||
* </adbl-product-grid-item>
|
||||
*/
|
||||
function parseSimilarSeries($: cheerio.CheerioAPI): SimilarSeries[] {
|
||||
const similar: SimilarSeries[] = [];
|
||||
const seenAsins = new Set<string>();
|
||||
|
||||
// Scope to the SeriestoSeries carousel to avoid picking up other series links
|
||||
const carousel = $('adbl-product-carousel#SeriestoSeries');
|
||||
if (carousel.length === 0) return similar;
|
||||
|
||||
carousel.find('adbl-product-grid-item').each((_i, el) => {
|
||||
if (similar.length >= 15) return false;
|
||||
|
||||
const $el = $(el);
|
||||
|
||||
// Extract ASIN: prefer data-asin on impression div, fallback to series href
|
||||
let asin = $el.find('.adbl-impression-emitted, .adbl-asin-impression').first().attr('data-asin') || '';
|
||||
if (!asin) {
|
||||
const seriesHref = $el.find('a[href*="/series/"]').first().attr('href') || '';
|
||||
const hrefMatch = seriesHref.match(/\/series\/[^/]*\/([A-Z0-9]{10})/);
|
||||
if (hrefMatch) asin = hrefMatch[1];
|
||||
}
|
||||
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) return;
|
||||
if (seenAsins.has(asin)) return;
|
||||
seenAsins.add(asin);
|
||||
|
||||
// Title from metadata slot
|
||||
const title = $el.find('adbl-metadata[slot="title"] a').first().text().trim() ||
|
||||
$el.find('adbl-metadata[slot="title"]').first().text().trim() || '';
|
||||
if (!title || title.length > 200) return;
|
||||
|
||||
// Book count from child-count slot (e.g. "3 titles")
|
||||
const countText = $el.find('adbl-metadata[slot="child-count"]').first().text().trim();
|
||||
const countMatch = countText.match(/(\d+)/);
|
||||
const bookCount = countMatch ? parseInt(countMatch[1]) : undefined;
|
||||
|
||||
// Cover image from adbl-collection-image
|
||||
const coverArtUrl = $el.find('adbl-collection-image img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') ||
|
||||
$el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') ||
|
||||
undefined;
|
||||
|
||||
similar.push({ asin, title, bookCount, coverArtUrl });
|
||||
});
|
||||
|
||||
return similar;
|
||||
}
|
||||
@@ -8,6 +8,14 @@ import * as cheerio from 'cheerio';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible';
|
||||
import {
|
||||
getLanguageForRegion,
|
||||
stripPrefixes,
|
||||
buildContainsSelector,
|
||||
extractByPatterns,
|
||||
isAcceptedLanguage,
|
||||
type LanguageConfig,
|
||||
} from '../constants/language-config';
|
||||
import {
|
||||
pickUserAgent,
|
||||
getBrowserHeaders,
|
||||
@@ -40,6 +48,7 @@ export interface AudibleAudiobook {
|
||||
genres?: string[];
|
||||
series?: string;
|
||||
seriesPart?: string;
|
||||
seriesAsin?: string;
|
||||
}
|
||||
|
||||
export interface AudibleSearchResult {
|
||||
@@ -50,6 +59,13 @@ export interface AudibleSearchResult {
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface AuthorBooksResult {
|
||||
books: AudibleAudiobook[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
totalResults: number;
|
||||
}
|
||||
|
||||
export class AudibleService {
|
||||
private client!: AxiosInstance;
|
||||
private baseUrl: string = 'https://www.audible.com';
|
||||
@@ -69,6 +85,29 @@ export class AudibleService {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Audible region code
|
||||
*/
|
||||
public getRegion(): AudibleRegion {
|
||||
return this.region;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public fetch wrapper for external scraping modules (e.g. audible-series.ts).
|
||||
* Ensures the service is initialized and delegates to fetchWithRetry.
|
||||
*/
|
||||
public async fetch(url: string, config: any = {}): Promise<{ data: any; meta: FetchResultMeta }> {
|
||||
await this.initialize();
|
||||
return this.fetchWithRetry(url, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the language config for the current region
|
||||
*/
|
||||
private getLangConfig(): LanguageConfig {
|
||||
return getLanguageForRegion(this.region);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-initialization (used when region config changes)
|
||||
*/
|
||||
@@ -106,6 +145,9 @@ export class AudibleService {
|
||||
|
||||
logger.info(`Initializing Audible service with region: ${this.region} (${this.baseUrl})`);
|
||||
|
||||
// Get language config for the region
|
||||
const langConfig = getLanguageForRegion(this.region);
|
||||
|
||||
// Create axios client with region-specific base URL and realistic browser headers
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
@@ -113,7 +155,7 @@ export class AudibleService {
|
||||
headers: getBrowserHeaders(this.sessionUserAgent),
|
||||
params: {
|
||||
ipRedirectOverride: 'true', // Prevent IP-based region redirects
|
||||
language: 'english', // Force English locale (prevents IP-based language serving for non-English IPs)
|
||||
language: langConfig.scraping.audibleLocaleParam, // Force locale (prevents IP-based language serving)
|
||||
},
|
||||
});
|
||||
|
||||
@@ -125,13 +167,16 @@ export class AudibleService {
|
||||
this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl;
|
||||
this.sessionUserAgent = pickUserAgent();
|
||||
this.pacer.reset();
|
||||
|
||||
const fallbackLangConfig = getLanguageForRegion(this.region);
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: 15000,
|
||||
headers: getBrowserHeaders(this.sessionUserAgent),
|
||||
params: {
|
||||
ipRedirectOverride: 'true',
|
||||
language: 'english',
|
||||
language: fallbackLangConfig.scraping.audibleLocaleParam,
|
||||
},
|
||||
});
|
||||
this.initialized = true;
|
||||
@@ -289,12 +334,14 @@ export class AudibleService {
|
||||
const ratingText = $el.find('.ratingsLabel').text().trim();
|
||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||
|
||||
const langConfig = this.getLangConfig();
|
||||
|
||||
audiobooks.push({
|
||||
asin,
|
||||
title,
|
||||
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
|
||||
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
|
||||
authorAsin: authorAsinMatch?.[1] || undefined,
|
||||
narrator: narratorText.replace('Narrated by:', '').trim(),
|
||||
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
rating,
|
||||
});
|
||||
@@ -391,12 +438,14 @@ export class AudibleService {
|
||||
const ratingText = $el.find('.ratingsLabel').text().trim();
|
||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||
|
||||
const langConfig = this.getLangConfig();
|
||||
|
||||
audiobooks.push({
|
||||
asin,
|
||||
title,
|
||||
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
|
||||
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
|
||||
authorAsin: authorAsinMatch?.[1] || undefined,
|
||||
narrator: narratorText.replace('Narrated by:', '').trim(),
|
||||
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
rating,
|
||||
});
|
||||
@@ -487,9 +536,11 @@ export class AudibleService {
|
||||
|
||||
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||
|
||||
const langConfig = this.getLangConfig();
|
||||
|
||||
// Extract runtime/duration
|
||||
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||
$el.find('span:contains("Length:")').text().trim();
|
||||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
||||
const durationMinutes = this.parseRuntime(runtimeText);
|
||||
|
||||
// Extract rating
|
||||
@@ -500,9 +551,9 @@ export class AudibleService {
|
||||
audiobooks.push({
|
||||
asin,
|
||||
title,
|
||||
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
|
||||
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
|
||||
authorAsin: authorAsinMatch?.[1] || undefined,
|
||||
narrator: narratorText.replace('Narrated by:', '').trim(),
|
||||
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
durationMinutes,
|
||||
rating,
|
||||
@@ -520,7 +571,9 @@ export class AudibleService {
|
||||
results: audiobooks,
|
||||
totalResults,
|
||||
page,
|
||||
hasMore: audiobooks.length > 0 && totalResults > page * AUDIBLE_PAGE_SIZE,
|
||||
hasMore: audiobooks.length > 0 && (totalResults > 0
|
||||
? totalResults > page * AUDIBLE_PAGE_SIZE
|
||||
: audiobooks.length >= AUDIBLE_PAGE_SIZE),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Search failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
@@ -539,121 +592,111 @@ export class AudibleService {
|
||||
* Uses Audible's searchAuthor parameter and paginates through all results.
|
||||
* Filters: (1) author link must contain the target ASIN, (2) language must be English.
|
||||
*/
|
||||
async searchByAuthorAsin(authorName: string, authorAsin: string): Promise<AudibleAudiobook[]> {
|
||||
async searchByAuthorAsin(authorName: string, authorAsin: string, page: number = 1): Promise<AuthorBooksResult> {
|
||||
await this.initialize();
|
||||
|
||||
const MAX_PAGES = 10;
|
||||
const allBooks: AudibleAudiobook[] = [];
|
||||
const books: AudibleAudiobook[] = [];
|
||||
const seenAsins = new Set<string>();
|
||||
|
||||
try {
|
||||
logger.info(`Searching books by author "${authorName}" (ASIN: ${authorAsin})...`);
|
||||
logger.info(`Searching books by author "${authorName}" (ASIN: ${authorAsin}), page ${page}...`);
|
||||
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const { data: response, meta } = await this.fetchWithRetry('/search', {
|
||||
params: {
|
||||
ipRedirectOverride: 'true',
|
||||
searchAuthor: authorName,
|
||||
pageSize: AUDIBLE_PAGE_SIZE,
|
||||
page,
|
||||
},
|
||||
const { data: response } = await this.fetchWithRetry('/search', {
|
||||
params: {
|
||||
ipRedirectOverride: 'true',
|
||||
searchAuthor: authorName,
|
||||
pageSize: AUDIBLE_PAGE_SIZE,
|
||||
page,
|
||||
},
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// Count raw items on page before filtering (for hasMore fallback)
|
||||
const pageItemCount = $('.s-result-item, .productListItem').length;
|
||||
|
||||
$('.s-result-item, .productListItem').each((_index, element) => {
|
||||
const $el = $(element);
|
||||
|
||||
// --- Language filter: require matching language for region ---
|
||||
const langConfig = this.getLangConfig();
|
||||
const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() ||
|
||||
$el.find('.languageLabel').text().trim();
|
||||
const langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i');
|
||||
const langMatch = langText.match(langLabelPattern);
|
||||
const language = langMatch?.[1]?.trim();
|
||||
if (!language || !isAcceptedLanguage(language, langConfig)) return;
|
||||
|
||||
// --- Author ASIN filter: verify target ASIN in author links ---
|
||||
const authorLinks = $el.find('a[href*="/author/"]');
|
||||
let hasMatchingAuthor = false;
|
||||
authorLinks.each((_i, link) => {
|
||||
const href = $(link).attr('href') || '';
|
||||
const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
||||
if (asinMatch && asinMatch[1] === authorAsin) {
|
||||
hasMatchingAuthor = true;
|
||||
return false; // break .each()
|
||||
}
|
||||
});
|
||||
if (!hasMatchingAuthor) return;
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
let pageResults = 0;
|
||||
// --- Extract book ASIN ---
|
||||
const bookAsin = $el.find('li').attr('data-asin') ||
|
||||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||
if (!bookAsin || seenAsins.has(bookAsin)) return;
|
||||
seenAsins.add(bookAsin);
|
||||
|
||||
$('.s-result-item, .productListItem').each((_index, element) => {
|
||||
const $el = $(element);
|
||||
// --- Parse book details ---
|
||||
const title = $el.find('h2').first().text().trim() ||
|
||||
$el.find('h3 a').text().trim() ||
|
||||
$el.find('.bc-heading a').text().trim();
|
||||
|
||||
// --- Language filter: require explicit "English" ---
|
||||
const langText = $el.find('span:contains("Language:")').text().trim() ||
|
||||
$el.find('.languageLabel').text().trim();
|
||||
// Extract language value (e.g. "Language: English" → "English")
|
||||
const langMatch = langText.match(/Language:\s*(.+)/i);
|
||||
const language = langMatch?.[1]?.trim();
|
||||
if (!language || language.toLowerCase() !== 'english') return;
|
||||
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
|
||||
$el.find('.authorLabel').text().trim() ||
|
||||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
||||
|
||||
// --- Author ASIN filter: verify target ASIN in author links ---
|
||||
const authorLinks = $el.find('a[href*="/author/"]');
|
||||
let hasMatchingAuthor = false;
|
||||
authorLinks.each((_i, link) => {
|
||||
const href = $(link).attr('href') || '';
|
||||
const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
||||
if (asinMatch && asinMatch[1] === authorAsin) {
|
||||
hasMatchingAuthor = true;
|
||||
return false; // break .each()
|
||||
}
|
||||
});
|
||||
if (!hasMatchingAuthor) return;
|
||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||
$el.find('.narratorLabel').text().trim();
|
||||
|
||||
// --- Extract book ASIN ---
|
||||
const bookAsin = $el.find('li').attr('data-asin') ||
|
||||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||
if (!bookAsin || seenAsins.has(bookAsin)) return;
|
||||
seenAsins.add(bookAsin);
|
||||
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||
|
||||
// --- Parse book details ---
|
||||
const title = $el.find('h2').first().text().trim() ||
|
||||
$el.find('h3 a').text().trim() ||
|
||||
$el.find('.bc-heading a').text().trim();
|
||||
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
||||
const durationMinutes = this.parseRuntime(runtimeText);
|
||||
|
||||
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
|
||||
$el.find('.authorLabel').text().trim() ||
|
||||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
||||
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
||||
$el.find('.a-icon-star span').first().text().trim();
|
||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||
|
||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||
$el.find('.narratorLabel').text().trim();
|
||||
|
||||
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||
|
||||
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||
$el.find('span:contains("Length:")').text().trim();
|
||||
const durationMinutes = this.parseRuntime(runtimeText);
|
||||
|
||||
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
||||
$el.find('.a-icon-star span').first().text().trim();
|
||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||
|
||||
allBooks.push({
|
||||
asin: bookAsin,
|
||||
title,
|
||||
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
|
||||
authorAsin,
|
||||
narrator: narratorText.replace('Narrated by:', '').trim(),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
durationMinutes,
|
||||
rating,
|
||||
});
|
||||
|
||||
pageResults++;
|
||||
books.push({
|
||||
asin: bookAsin,
|
||||
title,
|
||||
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
|
||||
authorAsin,
|
||||
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
durationMinutes,
|
||||
rating,
|
||||
});
|
||||
});
|
||||
|
||||
// Check if there are more pages
|
||||
const resultsText = $('.resultsInfo').text().trim();
|
||||
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
|
||||
const hasMore = totalResults > page * AUDIBLE_PAGE_SIZE;
|
||||
// Check total results for pagination
|
||||
const resultsText = $('.resultsInfo').text().trim();
|
||||
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
|
||||
// Use totalResults if available; otherwise fall back to whether Audible returned a full page
|
||||
const hasMore = books.length > 0 && (totalResults > 0
|
||||
? totalResults > page * AUDIBLE_PAGE_SIZE
|
||||
: pageItemCount >= AUDIBLE_PAGE_SIZE);
|
||||
|
||||
logger.info(`Author books page ${page}: ${pageResults} valid results (${allBooks.length} total, ${totalResults} Audible total)`);
|
||||
|
||||
if (!hasMore || pageResults === 0) break;
|
||||
|
||||
// Pace between pages
|
||||
if (page < MAX_PAGES) {
|
||||
await this.delay(this.pacer.reportPageResult(meta));
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Author books search complete: "${authorName}" → ${allBooks.length} books`);
|
||||
return allBooks;
|
||||
logger.info(`Author books page ${page}: ${books.length} valid results (${totalResults} Audible total)`);
|
||||
return { books, hasMore, page, totalResults };
|
||||
} catch (error) {
|
||||
logger.error(`Author books search failed for "${authorName}"`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
collectedSoFar: allBooks.length,
|
||||
});
|
||||
// Return what we collected before the error
|
||||
return allBooks;
|
||||
return { books, hasMore: false, page, totalResults: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,6 +763,7 @@ export class AudibleService {
|
||||
genres: data.genres?.map((g: any) => typeof g === 'string' ? g : g.name).slice(0, 5) || undefined,
|
||||
series: data.seriesPrimary?.name || undefined,
|
||||
seriesPart: data.seriesPrimary?.position || undefined,
|
||||
seriesAsin: data.seriesPrimary?.asin || undefined,
|
||||
};
|
||||
|
||||
// Ensure cover art URL is high quality
|
||||
@@ -736,7 +780,8 @@ export class AudibleService {
|
||||
rating: result.rating,
|
||||
genreCount: result.genres?.length || 0,
|
||||
series: result.series,
|
||||
seriesPart: result.seriesPart
|
||||
seriesPart: result.seriesPart,
|
||||
seriesAsin: result.seriesAsin
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -867,7 +912,8 @@ export class AudibleService {
|
||||
result.author = [...new Set(authors)].slice(0, 3).join(', ');
|
||||
}
|
||||
|
||||
result.author = result.author.replace(/^By:\s*/i, '').replace(/^Written by:\s*/i, '').trim();
|
||||
const authorLangConfig = this.getLangConfig();
|
||||
result.author = stripPrefixes(result.author, authorLangConfig.scraping.authorPrefixes);
|
||||
logger.info(` Author from HTML: "${result.author}"`);
|
||||
}
|
||||
|
||||
@@ -911,22 +957,16 @@ export class AudibleService {
|
||||
}
|
||||
|
||||
if (result.narrator) {
|
||||
result.narrator = result.narrator.replace(/^Narrated by:\s*/i, '').trim();
|
||||
const detailLangConfig = this.getLangConfig();
|
||||
result.narrator = stripPrefixes(result.narrator, detailLangConfig.scraping.narratorPrefixes);
|
||||
}
|
||||
logger.info(` Narrator from HTML: "${result.narrator || ''}"`);
|
||||
}
|
||||
|
||||
// Description - try multiple approaches with strict filtering
|
||||
if (!result.description) {
|
||||
const excludePatterns = [
|
||||
/\$\d+\.\d+/, // Price patterns
|
||||
/cancel anytime/i,
|
||||
/free trial/i,
|
||||
/membership/i,
|
||||
/subscribe/i,
|
||||
/offer.*ends/i,
|
||||
/^\s*by\s+[\w\s,]+$/i, // Just author names
|
||||
];
|
||||
const descLangConfig = this.getLangConfig();
|
||||
const excludePatterns = descLangConfig.scraping.descriptionExcludePatterns;
|
||||
|
||||
const isValidDescription = (text: string): boolean => {
|
||||
if (!text || text.length < 50 || text.length > 5000) return false;
|
||||
@@ -982,18 +1022,20 @@ export class AudibleService {
|
||||
|
||||
// Runtime/Duration - try multiple approaches
|
||||
if (!result.durationMinutes) {
|
||||
const rtLangConfig = this.getLangConfig();
|
||||
|
||||
// Look for runtime text in various places
|
||||
const runtimeText =
|
||||
$('li.runtimeLabel span').text().trim() ||
|
||||
$('.runtimeLabel').text().trim() ||
|
||||
$('span:contains("Length:")').parent().text().trim() ||
|
||||
$('li:contains("Length:")').text().trim() ||
|
||||
$(buildContainsSelector('span', rtLangConfig.scraping.lengthLabels)).parent().text().trim() ||
|
||||
$(buildContainsSelector('li', rtLangConfig.scraping.lengthLabels)).text().trim() ||
|
||||
(() => {
|
||||
// Look for any text matching duration pattern
|
||||
let found = '';
|
||||
$('li, span, div').each((_, elem) => {
|
||||
const text = $(elem).text().trim();
|
||||
if (text.match(/\d+\s*(hr|hour|h)\s*\d*\s*(min|minute|m)?/i) && text.length < 100) {
|
||||
if (text.match(rtLangConfig.scraping.durationDetectionPattern) && text.length < 100) {
|
||||
found = text;
|
||||
return false; // break
|
||||
}
|
||||
@@ -1007,41 +1049,55 @@ export class AudibleService {
|
||||
|
||||
// Rating - try multiple approaches
|
||||
if (!result.rating) {
|
||||
const ratingLangConfig = this.getLangConfig();
|
||||
const ratingText =
|
||||
$('.ratingsLabel').text().trim() ||
|
||||
$('[class*="rating"]').first().text().trim() ||
|
||||
$('span:contains("out of 5 stars")').parent().text().trim() ||
|
||||
$(`span:contains("${ratingLangConfig.scraping.ratingTextSelector}")`).parent().text().trim() ||
|
||||
(() => {
|
||||
// Look for rating pattern
|
||||
// Look for rating pattern using language-specific patterns
|
||||
let found = '';
|
||||
$('span, div').each((_, elem) => {
|
||||
const text = $(elem).text().trim();
|
||||
if (text.match(/\d+\.?\d*\s*out of\s*5/i) && text.length < 50) {
|
||||
found = text;
|
||||
return false;
|
||||
if (text.length < 50) {
|
||||
for (const pattern of ratingLangConfig.scraping.ratingPatterns) {
|
||||
if (pattern.test(text)) {
|
||||
found = text;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return found;
|
||||
})();
|
||||
|
||||
if (ratingText) {
|
||||
const ratingMatch = ratingText.match(/(\d+\.?\d*)\s*out of/i);
|
||||
result.rating = ratingMatch ? parseFloat(ratingMatch[1]) : undefined;
|
||||
let ratingValue: number | undefined;
|
||||
for (const pattern of ratingLangConfig.scraping.ratingPatterns) {
|
||||
const ratingMatch = ratingText.match(pattern);
|
||||
if (ratingMatch) {
|
||||
// Handle comma as decimal separator (e.g. "4,5" in German/Spanish)
|
||||
ratingValue = parseFloat(ratingMatch[1].replace(',', '.'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.rating = ratingValue;
|
||||
}
|
||||
logger.info(` Rating from "${ratingText}": ${result.rating}`);
|
||||
}
|
||||
|
||||
// Release date - try multiple selectors
|
||||
if (!result.releaseDate) {
|
||||
const rdLangConfig = this.getLangConfig();
|
||||
const releaseDateText =
|
||||
$('li:contains("Release date:")').text().trim() ||
|
||||
$('span:contains("Release date:")').parent().text().trim() ||
|
||||
$(buildContainsSelector('li', rdLangConfig.scraping.releaseDateLabels)).text().trim() ||
|
||||
$(buildContainsSelector('span', rdLangConfig.scraping.releaseDateLabels)).parent().text().trim() ||
|
||||
$('[class*="release"]').text().trim();
|
||||
|
||||
const dateMatch = releaseDateText.match(/Release date:\s*(.+)/i) ||
|
||||
releaseDateText.match(/(\w+ \d{1,2},? \d{4})/);
|
||||
const dateMatch = extractByPatterns(releaseDateText, rdLangConfig.scraping.releaseDatePatterns) ||
|
||||
releaseDateText.match(/(\w+ \d{1,2},? \d{4})/)?.[1];
|
||||
if (dateMatch) {
|
||||
result.releaseDate = dateMatch[1].trim();
|
||||
result.releaseDate = dateMatch.trim();
|
||||
}
|
||||
logger.info(` Release date from "${releaseDateText}": ${result.releaseDate}`);
|
||||
}
|
||||
@@ -1078,20 +1134,30 @@ export class AudibleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse runtime text to minutes
|
||||
* Parse runtime text to minutes using language-specific patterns
|
||||
*/
|
||||
private parseRuntime(runtimeText: string): number | undefined {
|
||||
if (!runtimeText) return undefined;
|
||||
|
||||
const hoursMatch = runtimeText.match(/(\d+)\s*hrs?/i);
|
||||
const minutesMatch = runtimeText.match(/(\d+)\s*mins?/i);
|
||||
|
||||
const langConfig = this.getLangConfig();
|
||||
let totalMinutes = 0;
|
||||
if (hoursMatch) {
|
||||
totalMinutes += parseInt(hoursMatch[1]) * 60;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
if (minutesMatch) {
|
||||
totalMinutes += parseInt(minutesMatch[1]);
|
||||
|
||||
// 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,386 @@
|
||||
/**
|
||||
* Component: Deluge Integration Service
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import path from 'path';
|
||||
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
|
||||
import * as parseTorrentModule from 'parse-torrent';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
import {
|
||||
IDownloadClient, DownloadClientType, ProtocolType,
|
||||
DownloadInfo, DownloadStatus, AddDownloadOptions, ConnectionTestResult,
|
||||
} from '../interfaces/download-client.interface';
|
||||
|
||||
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
|
||||
const logger = RMABLogger.create('Deluge');
|
||||
|
||||
export class DelugeService implements IDownloadClient {
|
||||
readonly clientType: DownloadClientType = 'deluge';
|
||||
readonly protocol: ProtocolType = 'torrent';
|
||||
|
||||
private client: AxiosInstance;
|
||||
private baseUrl: string;
|
||||
private password: string;
|
||||
private defaultSavePath: string;
|
||||
private defaultCategory: string;
|
||||
private pathMappingConfig: PathMappingConfig;
|
||||
private sessionCookie: string = '';
|
||||
private requestId: number = 0;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
_username: string, // Unused — Deluge uses password-only auth; kept for consistent signature
|
||||
password: string,
|
||||
defaultSavePath: string = '/downloads',
|
||||
defaultCategory: string = 'readmeabook',
|
||||
disableSSLVerify: boolean = false,
|
||||
pathMappingConfig?: PathMappingConfig
|
||||
) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.password = password;
|
||||
this.defaultSavePath = defaultSavePath;
|
||||
this.defaultCategory = defaultCategory;
|
||||
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
|
||||
|
||||
const httpsAgent = disableSSLVerify && this.baseUrl.startsWith('https')
|
||||
? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
||||
if (httpsAgent) logger.info('[Deluge] SSL certificate verification disabled');
|
||||
|
||||
this.client = axios.create({ baseURL: this.baseUrl, timeout: DOWNLOAD_CLIENT_TIMEOUT, httpsAgent });
|
||||
}
|
||||
|
||||
/** JSON-RPC call with automatic re-authentication on auth failure */
|
||||
private async rpc(method: string, params: any[] = [], retried = false): Promise<any> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.sessionCookie) headers['Cookie'] = this.sessionCookie;
|
||||
|
||||
try {
|
||||
const reqId = ++this.requestId;
|
||||
const { data } = await this.client.post('/json', { method, params, id: reqId }, { headers });
|
||||
// Deluge error.code === 1: "Not authenticated" — re-login then retry
|
||||
if (data.error?.code === 1 && !retried) {
|
||||
await this.login();
|
||||
return this.rpc(method, params, true);
|
||||
}
|
||||
// Deluge error.code === 2: "Unknown method" — daemon disconnected, force reconnect
|
||||
// Only retry for core.* methods — plugin methods (label.*) fail because the plugin
|
||||
// isn't enabled, not because the daemon is disconnected.
|
||||
if (data.error?.code === 2 && !retried && method.startsWith('core.')) {
|
||||
await this.login(true);
|
||||
return this.rpc(method, params, true);
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (!retried) { await this.login(); return this.rpc(method, params, true); }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async login(forceReconnect: boolean = false): Promise<void> {
|
||||
const { data, headers } = await this.client.post(
|
||||
'/json',
|
||||
{ method: 'auth.login', params: [this.password], id: ++this.requestId },
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
if (!data?.result) throw new Error('Failed to authenticate with Deluge — check your password');
|
||||
const cookies = headers['set-cookie'];
|
||||
if (cookies?.length) this.sessionCookie = cookies[0].split(';')[0];
|
||||
logger.info('Successfully authenticated with Deluge');
|
||||
|
||||
// Deluge Web UI requires a daemon connection before core.* methods work.
|
||||
// When forceReconnect is true, skip the web.connected check and force a fresh connection.
|
||||
await this.ensureDaemonConnected(forceReconnect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the Web UI is connected to a deluged daemon host.
|
||||
* Uses web.connected (returns boolean) as the check — daemon.info is NOT a valid
|
||||
* method through the Deluge Web UI JSON-RPC; only web.* and core.* methods work.
|
||||
*/
|
||||
private async ensureDaemonConnected(force: boolean = false): Promise<void> {
|
||||
if (!force) {
|
||||
const test = await this.rpc('web.connected', [], true);
|
||||
if (test.result === true) return;
|
||||
}
|
||||
|
||||
logger.info('Connecting to daemon...');
|
||||
|
||||
const hostsData = await this.rpc('web.get_hosts', [], true);
|
||||
const hosts: any[] = hostsData.result || [];
|
||||
|
||||
if (hosts.length === 0) {
|
||||
throw new Error('Deluge has no daemon hosts configured. Add a host in the Deluge Web UI under Connection Manager.');
|
||||
}
|
||||
|
||||
const hostId = hosts[0][0];
|
||||
const connectResult = await this.rpc('web.connect', [hostId], true);
|
||||
if (connectResult.error) {
|
||||
throw new Error(`Failed to connect to Deluge daemon: ${connectResult.error.message}`);
|
||||
}
|
||||
|
||||
// Verify connection is established
|
||||
const verify = await this.rpc('web.connected', [], true);
|
||||
if (verify.result !== true) {
|
||||
throw new Error('Deluge daemon failed to respond after web.connect. Check that deluged is running.');
|
||||
}
|
||||
|
||||
logger.info('Connected to Deluge daemon');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// IDownloadClient Implementation
|
||||
// =========================================================================
|
||||
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
await this.login();
|
||||
return { success: true, message: 'Connected to Deluge' };
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Connection failed';
|
||||
if (axios.isAxiosError(error)) {
|
||||
const c = error.code;
|
||||
if (c?.includes('CERT') || c?.includes('SSL')) return { success: false, message: `SSL verification failed (${c}). Enable "Disable SSL Verification".` };
|
||||
if (c === 'ECONNREFUSED') return { success: false, message: `Connection refused at: ${this.baseUrl}` };
|
||||
if (c === 'ETIMEDOUT' || c === 'ECONNABORTED') return { success: false, message: `Connection timeout: ${this.baseUrl}` };
|
||||
if (c === 'ENOTFOUND') return { success: false, message: `Host not found: ${this.baseUrl}` };
|
||||
if (error.response?.status === 401) return { success: false, message: 'Authentication failed. Check your password.' };
|
||||
}
|
||||
logger.error('Connection test failed', { error: msg });
|
||||
return { success: false, message: msg };
|
||||
}
|
||||
}
|
||||
|
||||
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
|
||||
if (!url || typeof url !== 'string' || url.trim() === '') {
|
||||
throw new Error('Invalid download URL: URL is required and must be a non-empty string');
|
||||
}
|
||||
const category = options?.category || this.defaultCategory;
|
||||
return url.startsWith('magnet:')
|
||||
? this.addMagnetLink(url, category, options)
|
||||
: this.addTorrentFile(url, category, options);
|
||||
}
|
||||
|
||||
private async addMagnetLink(magnetUrl: string, category: string, options?: AddDownloadOptions): Promise<string> {
|
||||
const infoHash = this.extractHashFromMagnet(magnetUrl);
|
||||
if (!infoHash) throw new Error('Invalid magnet link - could not extract info_hash');
|
||||
logger.info(`Extracted info_hash from magnet: ${infoHash}`);
|
||||
|
||||
const existing = await this.rpc('core.get_torrent_status', [infoHash, ['name']]);
|
||||
if (existing.result && Object.keys(existing.result).length > 0) {
|
||||
logger.info(`Torrent ${infoHash} already exists (duplicate)`);
|
||||
return infoHash;
|
||||
}
|
||||
|
||||
const opts = this.buildTorrentOptions(options?.paused);
|
||||
const data = await this.rpc('core.add_torrent_magnet', [magnetUrl, opts]);
|
||||
if (!data.result) throw new Error(`Deluge rejected magnet link: ${data.error?.message || 'unknown error'}`);
|
||||
|
||||
await this.postAddSetup(data.result, category);
|
||||
logger.info(`Successfully added magnet link: ${infoHash}`);
|
||||
return infoHash;
|
||||
}
|
||||
|
||||
private async addTorrentFile(torrentUrl: string, category: string, options?: AddDownloadOptions): Promise<string> {
|
||||
logger.info(`Downloading .torrent file from: ${torrentUrl}`);
|
||||
|
||||
let torrentResponse;
|
||||
try {
|
||||
torrentResponse = await axios.get(torrentUrl, {
|
||||
responseType: 'arraybuffer', maxRedirects: 0,
|
||||
validateStatus: (s) => s >= 200 && s < 300, timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
});
|
||||
if (torrentResponse.data.length > 0) {
|
||||
const magnetMatch = torrentResponse.data.toString().match(/^magnet:\?[^\s]+$/);
|
||||
if (magnetMatch) return this.addMagnetLink(magnetMatch[0], category, options);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!axios.isAxiosError(error) || !error.response) throw error;
|
||||
const status = error.response.status;
|
||||
if (status >= 300 && status < 400) {
|
||||
const loc = error.response.headers['location'];
|
||||
if (loc?.startsWith('magnet:')) return this.addMagnetLink(loc, category, options);
|
||||
if (loc?.startsWith('http://') || loc?.startsWith('https://')) {
|
||||
try { torrentResponse = await axios.get(loc, { responseType: 'arraybuffer', timeout: DOWNLOAD_CLIENT_TIMEOUT, maxRedirects: 5 }); }
|
||||
catch { throw new Error('Failed to download torrent file after redirect'); }
|
||||
} else { throw new Error(`Invalid redirect location: ${loc}`); }
|
||||
} else { throw new Error(`Failed to download torrent: HTTP ${status}`); }
|
||||
}
|
||||
|
||||
const torrentBuffer = Buffer.from(torrentResponse.data);
|
||||
let parsed: any;
|
||||
try { parsed = await parseTorrent(torrentBuffer); }
|
||||
catch { throw new Error('Invalid .torrent file - failed to parse'); }
|
||||
|
||||
const infoHash = parsed.infoHash;
|
||||
if (!infoHash) throw new Error('Failed to extract info_hash from .torrent file');
|
||||
logger.info(`Extracted info_hash: ${infoHash}`);
|
||||
|
||||
const existing = await this.rpc('core.get_torrent_status', [infoHash, ['name']]);
|
||||
if (existing.result && Object.keys(existing.result).length > 0) {
|
||||
logger.info(`Torrent ${infoHash} already exists (duplicate)`);
|
||||
return infoHash;
|
||||
}
|
||||
|
||||
const filename = parsed.name ? `${parsed.name}.torrent` : 'torrent.torrent';
|
||||
const opts = this.buildTorrentOptions(options?.paused);
|
||||
const data = await this.rpc('core.add_torrent_file', [filename, torrentBuffer.toString('base64'), opts]);
|
||||
if (!data.result) throw new Error(`Deluge rejected .torrent file: ${data.error?.message || 'unknown error'}`);
|
||||
|
||||
await this.postAddSetup(infoHash, category);
|
||||
logger.info(`Successfully added torrent: ${infoHash}`);
|
||||
return infoHash;
|
||||
}
|
||||
|
||||
async getDownload(id: string): Promise<DownloadInfo | null> {
|
||||
const fields = ['name', 'total_size', 'total_done', 'progress', 'state',
|
||||
'download_payload_rate', 'eta', 'label', 'save_path',
|
||||
'time_added', 'is_finished', 'seeding_time', 'ratio', 'message'];
|
||||
|
||||
for (let attempt = 0; attempt <= 3; attempt++) {
|
||||
const { result } = await this.rpc('core.get_torrent_status', [id, fields]);
|
||||
if (result && Object.keys(result).length > 0) return this.mapToDownloadInfo(id, result);
|
||||
if (attempt === 3) return null;
|
||||
const delay = 500 * Math.pow(2, attempt);
|
||||
logger.warn(`Torrent ${id} not found, retrying in ${delay}ms (${attempt + 1}/3)`);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async pauseDownload(id: string): Promise<void> {
|
||||
await this.rpc('core.pause_torrent', [[id]]);
|
||||
logger.info(`Paused torrent: ${id}`);
|
||||
}
|
||||
|
||||
async resumeDownload(id: string): Promise<void> {
|
||||
await this.rpc('core.resume_torrent', [[id]]);
|
||||
logger.info(`Resumed torrent: ${id}`);
|
||||
}
|
||||
|
||||
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
|
||||
await this.rpc('core.remove_torrent', [id, deleteFiles]);
|
||||
logger.info(`Deleted torrent: ${id}`);
|
||||
}
|
||||
|
||||
async postProcess(_id: string): Promise<void> {} // No-op: seeding cleanup scheduler manages lifecycle
|
||||
|
||||
async getCategories(): Promise<string[]> {
|
||||
try { const { result } = await this.rpc('label.get_labels'); return Array.isArray(result) ? result : []; }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
async setCategory(id: string, category: string): Promise<void> {
|
||||
await this.applyLabel(id, category);
|
||||
logger.info(`Set label for torrent ${id}: ${category}`);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal Helpers
|
||||
// =========================================================================
|
||||
|
||||
private buildTorrentOptions(paused?: boolean): Record<string, any> {
|
||||
const remoteSavePath = PathMapper.reverseTransform(this.defaultSavePath, this.pathMappingConfig);
|
||||
const opts: Record<string, any> = { download_location: remoteSavePath, move_completed: false, move_completed_path: '' };
|
||||
if (paused) opts.add_paused = true;
|
||||
return opts;
|
||||
}
|
||||
|
||||
private async postAddSetup(hash: string, category: string): Promise<void> {
|
||||
await this.disableSeedLimits(hash);
|
||||
await this.applyLabel(hash, category);
|
||||
}
|
||||
|
||||
private async applyLabel(hash: string, label: string): Promise<void> {
|
||||
try {
|
||||
try { await this.rpc('label.add', [label]); } catch { /* may already exist */ }
|
||||
await this.rpc('label.set_torrent', [hash, label]);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to apply label "${label}" to ${hash}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async disableSeedLimits(hash: string): Promise<void> {
|
||||
try {
|
||||
await this.rpc('core.set_torrent_options', [[hash], { stop_at_ratio: false, seed_time_limit: -1 }]);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to disable seed limits for ${hash}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private mapToDownloadInfo(hash: string, t: Record<string, any>): DownloadInfo {
|
||||
return {
|
||||
id: hash, name: t.name || '', size: t.total_size || 0,
|
||||
bytesDownloaded: t.total_done || 0, progress: (t.progress || 0) / 100,
|
||||
status: this.mapStatus(t.state), downloadSpeed: t.download_payload_rate || 0,
|
||||
eta: t.eta > 0 ? t.eta : 0, category: t.label || '',
|
||||
downloadPath: t.save_path ? path.join(t.save_path, t.name || '') : undefined,
|
||||
completedAt: t.is_finished && t.time_added ? new Date(t.time_added * 1000) : undefined,
|
||||
errorMessage: t.message || undefined, seedingTime: t.seeding_time,
|
||||
ratio: t.ratio >= 0 ? t.ratio : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private mapStatus(state: string): DownloadStatus {
|
||||
const map: Record<string, DownloadStatus> = {
|
||||
'Downloading': 'downloading', 'Seeding': 'seeding', 'Paused': 'paused',
|
||||
'Checking': 'checking', 'Queued': 'queued', 'Error': 'failed', 'Moving': 'downloading',
|
||||
};
|
||||
return map[state] || 'downloading';
|
||||
}
|
||||
|
||||
private extractHashFromMagnet(magnetUrl: string): string | null {
|
||||
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton factory (matches Transmission/qBittorrent pattern)
|
||||
let delugeServiceInstance: DelugeService | null = null;
|
||||
let configLoaded = false;
|
||||
|
||||
export async function getDelugeService(): Promise<DelugeService> {
|
||||
if (delugeServiceInstance && configLoaded) return delugeServiceInstance;
|
||||
|
||||
try {
|
||||
const { getConfigService } = await import('../services/config.service');
|
||||
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
|
||||
const configService = await getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
|
||||
const clientConfig = await manager.getClientForProtocol('torrent');
|
||||
if (!clientConfig) throw new Error('Deluge is not configured. Please configure a Deluge client in admin settings.');
|
||||
if (clientConfig.type !== 'deluge') throw new Error(`Expected Deluge client but found ${clientConfig.type}`);
|
||||
if (!clientConfig.url) throw new Error('Deluge is not fully configured. Check your configuration in admin settings.');
|
||||
|
||||
const baseDir = await configService.get('download_dir') || '/downloads';
|
||||
const downloadDir = clientConfig.customPath ? require('path').join(baseDir, clientConfig.customPath) : baseDir;
|
||||
|
||||
delugeServiceInstance = new DelugeService(
|
||||
clientConfig.url, clientConfig.username || '', clientConfig.password || '',
|
||||
downloadDir, clientConfig.category || 'readmeabook', clientConfig.disableSSLVerify,
|
||||
{ enabled: clientConfig.remotePathMappingEnabled || false, remotePath: clientConfig.remotePath || '', localPath: clientConfig.localPath || '' }
|
||||
);
|
||||
|
||||
const result = await delugeServiceInstance.testConnection();
|
||||
if (!result.success) throw new Error(result.message || 'Deluge connection test failed.');
|
||||
|
||||
logger.info('[Deluge] Connection test successful');
|
||||
configLoaded = true;
|
||||
return delugeServiceInstance;
|
||||
} catch (error) {
|
||||
logger.error('[Deluge] Failed to initialize service', { error: error instanceof Error ? error.message : String(error) });
|
||||
delugeServiceInstance = null;
|
||||
configLoaded = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateDelugeService(): void {
|
||||
delugeServiceInstance = null;
|
||||
configLoaded = false;
|
||||
logger.info('[Deluge] Service singleton invalidated');
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
|
||||
import { TorrentResult } from '../utils/ranking-algorithm';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
@@ -87,7 +88,7 @@ export class ProwlarrService {
|
||||
headers: {
|
||||
'X-Api-Key': this.apiKey,
|
||||
},
|
||||
timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
paramsSerializer: {
|
||||
serialize: (params) => {
|
||||
// Custom serializer to handle arrays correctly for Prowlarr API
|
||||
@@ -314,7 +315,7 @@ export class ProwlarrService {
|
||||
limit: 100,
|
||||
extended: 1,
|
||||
},
|
||||
timeout: 60000,
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
responseType: 'text', // Get XML as text
|
||||
});
|
||||
|
||||
@@ -640,6 +641,18 @@ export class ProwlarrService {
|
||||
// Singleton instance
|
||||
let prowlarrService: ProwlarrService | null = null;
|
||||
|
||||
/**
|
||||
* Invalidate the cached ProwlarrService singleton.
|
||||
* Must be called after updating Prowlarr URL or API key so that
|
||||
* background jobs (search, RSS monitor, etc.) pick up the new credentials.
|
||||
*/
|
||||
export function invalidateProwlarrService(): void {
|
||||
if (prowlarrService) {
|
||||
logger.info('Prowlarr service singleton invalidated — will reconnect with new credentials on next use');
|
||||
}
|
||||
prowlarrService = null;
|
||||
}
|
||||
|
||||
export async function getProwlarrService(): Promise<ProwlarrService> {
|
||||
if (!prowlarrService) {
|
||||
// Get configuration from database
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user