From edc56bc457a67cf90c35e1f8d5b70254ca85b1d6 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Fri, 27 Feb 2026 12:15:23 -0500 Subject: [PATCH] Add manual-import and download-access features Introduce manual import workflow and download permission support. Adds a Prisma migration and schema field (users.download_access) to track per-user download access, and updates admin UI to toggle global and per-user download access. Implements new APIs: filesystem browse, manual-import endpoint, download-access settings, audiobook download-status, and on-demand download-token generation. Adds frontend components for manual import and related tests, plus documentation for the manual-import feature and the documentation-agent prompt. Key files: prisma/migrations/20260212000000_add_download_access_permission/migration.sql, prisma/schema.prisma, src/app/api/admin/filesystem/browse/route.ts, src/app/api/admin/manual-import/route.ts, src/app/api/admin/settings/download-access/route.ts, src/app/api/requests/[id]/download-token/route.ts, src/app/api/audiobooks/[asin]/download-status/route.ts, and updated admin users pages/components and permissions util. --- documentation-agent-prompt.md | 335 ++++++++++++++++++ documentation/features/manual-import.md | 87 +++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + src/app/admin/users/page.tsx | 58 +++ src/app/api/admin/filesystem/browse/route.ts | 208 +++++++++++ src/app/api/admin/manual-import/route.ts | 265 ++++++++++++++ .../admin/settings/download-access/route.ts | 91 +++++ src/app/api/admin/users/[id]/route.ts | 22 +- src/app/api/admin/users/route.ts | 1 + .../[asin]/download-status/route.ts | 70 ++++ src/app/api/auth/me/route.ts | 9 + .../api/requests/[id]/download-token/route.ts | 89 +++++ src/app/api/requests/[id]/download/route.ts | 2 +- src/app/api/requests/route.ts | 7 +- .../admin/users/GlobalUserSettingsModal.tsx | 33 ++ .../admin/users/UserPermissionsModal.tsx | 25 ++ src/components/audiobooks/AudiobookCard.tsx | 13 +- .../audiobooks/AudiobookDetailsModal.tsx | 101 +++++- .../audiobooks/ManualImportBrowser.tsx | 302 ++++++++++++++++ .../audiobooks/manual-import/BrowsePhase.tsx | 278 +++++++++++++++ .../audiobooks/manual-import/ConfirmPhase.tsx | 142 ++++++++ .../audiobooks/manual-import/types.ts | 33 ++ src/components/requests/RequestCard.tsx | 14 +- src/contexts/AuthContext.tsx | 1 + src/lib/hooks/useRequests.ts | 19 + src/lib/utils/permissions.ts | 13 + tests/api/requests-approval.routes.test.ts | 1 + .../audiobooks/AudiobookDetailsModal.test.tsx | 1 + 29 files changed, 2196 insertions(+), 27 deletions(-) create mode 100644 documentation-agent-prompt.md create mode 100644 documentation/features/manual-import.md create mode 100644 prisma/migrations/20260212000000_add_download_access_permission/migration.sql create mode 100644 src/app/api/admin/filesystem/browse/route.ts create mode 100644 src/app/api/admin/manual-import/route.ts create mode 100644 src/app/api/admin/settings/download-access/route.ts create mode 100644 src/app/api/audiobooks/[asin]/download-status/route.ts create mode 100644 src/app/api/requests/[id]/download-token/route.ts create mode 100644 src/components/audiobooks/ManualImportBrowser.tsx create mode 100644 src/components/audiobooks/manual-import/BrowsePhase.tsx create mode 100644 src/components/audiobooks/manual-import/ConfirmPhase.tsx create mode 100644 src/components/audiobooks/manual-import/types.ts diff --git a/documentation-agent-prompt.md b/documentation-agent-prompt.md new file mode 100644 index 0000000..13a2d33 --- /dev/null +++ b/documentation-agent-prompt.md @@ -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. diff --git a/documentation/features/manual-import.md b/documentation/features/manual-import.md new file mode 100644 index 0000000..2d530d1 --- /dev/null +++ b/documentation/features/manual-import.md @@ -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 diff --git a/prisma/migrations/20260212000000_add_download_access_permission/migration.sql b/prisma/migrations/20260212000000_add_download_access_permission/migration.sql new file mode 100644 index 0000000..4d57364 --- /dev/null +++ b/prisma/migrations/20260212000000_add_download_access_permission/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "download_access" BOOLEAN; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 817f568..a0d5a26 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 0d52710..1a95f7f 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -28,6 +28,7 @@ interface User { lastLoginAt: string | null; autoApproveRequests: boolean | null; interactiveSearchAccess: boolean | null; + downloadAccess: boolean | null; _count: { requests: number; }; @@ -193,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; @@ -212,6 +217,7 @@ function AdminUsersPageContent() { const [deleting, setDeleting] = useState(false); const [globalAutoApprove, setGlobalAutoApprove] = useState(false); const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState(true); + const [globalDownloadAccess, setGlobalDownloadAccess] = useState(true); const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false); const [permissionsUserId, setPermissionsUserId] = useState(null); const toast = useToast(); @@ -237,6 +243,15 @@ function AdminUsersPageContent() { } }, [globalInteractiveSearchData]); + // 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 { @@ -311,6 +326,43 @@ function AdminUsersPageContent() { } }; + 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); + } + }; + const showEditDialog = (user: User) => { setEditRole(user.role); setEditDialog({ isOpen: true, user }); @@ -909,6 +961,8 @@ function AdminUsersPageContent() { onToggleAutoApprove={handleGlobalAutoApproveToggle} globalInteractiveSearch={globalInteractiveSearch} onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle} + globalDownloadAccess={globalDownloadAccess} + onToggleDownloadAccess={handleGlobalDownloadAccessToggle} /> {/* User Permissions Modal */} @@ -918,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); + }} /> diff --git a/src/app/api/admin/filesystem/browse/route.ts b/src/app/api/admin/filesystem/browse/route.ts new file mode 100644 index 0000000..4f385e2 --- /dev/null +++ b/src/app/api/admin/filesystem/browse/route.ts @@ -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 => { + 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 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/manual-import/route.ts b/src/app/api/admin/manual-import/route.ts new file mode 100644 index 0000000..3117e8c --- /dev/null +++ b/src/app/api/admin/manual-import/route.ts @@ -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 } = 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); + + 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 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/settings/download-access/route.ts b/src/app/api/admin/settings/download-access/route.ts new file mode 100644 index 0000000..d0d5a7d --- /dev/null +++ b/src/app/api/admin/settings/download-access/route.ts @@ -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 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index f10e67f..58e7bf0 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -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, }, }); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 6a517ab..d97373b 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -32,6 +32,7 @@ export async function GET(request: NextRequest) { lastLoginAt: true, autoApproveRequests: true, interactiveSearchAccess: true, + downloadAccess: true, _count: { select: { requests: true, diff --git a/src/app/api/audiobooks/[asin]/download-status/route.ts b/src/app/api/audiobooks/[asin]/download-status/route.ts new file mode 100644 index 0000000..dc25e7e --- /dev/null +++ b/src/app/api/audiobooks/[asin]/download-status/route.ts @@ -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, + }); + }); +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 8bd12fe..001ee14 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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, }, }, }); diff --git a/src/app/api/requests/[id]/download-token/route.ts b/src/app/api/requests/[id]/download-token/route.ts new file mode 100644 index 0000000..7a748ea --- /dev/null +++ b/src/app/api/requests/[id]/download-token/route.ts @@ -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 } + ); + } + }); +} diff --git a/src/app/api/requests/[id]/download/route.ts b/src/app/api/requests/[id]/download/route.ts index 8cf27b3..e476037 100644 --- a/src/app/api/requests/[id]/download/route.ts +++ b/src/app/api/requests/[id]/download/route.ts @@ -50,7 +50,7 @@ export async function GET( } const requestRecord = await prisma.request.findFirst({ - where: { id, userId: payload.sub, deletedAt: null }, + where: { id, deletedAt: null }, include: { audiobook: true }, }); diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index c687abc..eeee7bb 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -9,7 +9,6 @@ import { prisma } from '@/lib/db'; import { z } from 'zod'; import { RMABLogger } from '@/lib/utils/logger'; import { createRequestForUser } from '@/lib/services/request-creator.service'; -import { generateDownloadToken } from '@/lib/utils/jwt'; import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses'; const logger = RMABLogger.create('API.Requests'); @@ -150,12 +149,10 @@ export async function GET(request: NextRequest) { const enriched = requests.map(r => { const isCompleted = COMPLETED_STATUSES.includes(r.status as typeof COMPLETED_STATUSES[number]); - const hasFile = isCompleted && r.audiobook?.filePath; - const token = hasFile ? generateDownloadToken(req.user!.id, r.id) : null; - const downloadUrl = token ? `/api/requests/${r.id}/download?token=${token}` : undefined; + 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, ...(downloadUrl ? { downloadUrl } : {}) }; + return { ...r, audiobook, downloadAvailable }; }); return NextResponse.json({ diff --git a/src/components/admin/users/GlobalUserSettingsModal.tsx b/src/components/admin/users/GlobalUserSettingsModal.tsx index ce86e7d..820e82a 100644 --- a/src/components/admin/users/GlobalUserSettingsModal.tsx +++ b/src/components/admin/users/GlobalUserSettingsModal.tsx @@ -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 ( @@ -84,6 +88,35 @@ export function GlobalUserSettingsModal({

+ + {/* Download Access Setting */} +
+ +
+ +

+ When enabled, all users can download audiobook files. When disabled, you can grant access per-user from the users table. +

+
+
); diff --git a/src/components/admin/users/UserPermissionsModal.tsx b/src/components/admin/users/UserPermissionsModal.tsx index a8948b9..060c483 100644 --- a/src/components/admin/users/UserPermissionsModal.tsx +++ b/src/components/admin/users/UserPermissionsModal.tsx @@ -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 */} + onToggleDownloadAccess(user, !downloadValue)} + /> diff --git a/src/components/audiobooks/AudiobookCard.tsx b/src/components/audiobooks/AudiobookCard.tsx index 9b9ecc3..0afb173 100644 --- a/src/components/audiobooks/AudiobookCard.tsx +++ b/src/components/audiobooks/AudiobookCard.tsx @@ -56,8 +56,13 @@ export function AudiobookCard({ const [showToast, setShowToast] = useState(false); const [error, setError] = useState(null); const [showModal, setShowModal] = useState(false); + const [localRequestStatus, setLocalRequestStatus] = useState(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} diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx index 2b6121d..ebf5df7 100644 --- a/src/components/audiobooks/AudiobookDetailsModal.tsx +++ b/src/components/audiobooks/AudiobookDetailsModal.tsx @@ -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(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); @@ -461,6 +494,36 @@ export function AudiobookDetailsModal({ + + {/* Download Link - subtle utility, visible from any context */} + {isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && ( +
+

Download

+ +
+ )} @@ -485,7 +548,8 @@ export function AudiobookDetailsModal({ )} - {/* Sticky Action Bar - hidden when opened from bookdate */} + + {/* Sticky Action Bar - hidden when opened from read-only contexts */} {audiobook && !isLoading && !hideRequestActions && (
)} + {/* Manual Import - admin only, hidden during active processing and completed states */} + {user?.role === 'admin' && !isAvailable && !['downloading', 'processing', 'searching', 'downloaded', 'completed', 'available'].includes(effectiveStatus || '') && ( + + )} + {/* Ebook Buttons - only when available and enabled */} {canShowEbookButtons && user && ( <> @@ -674,6 +749,26 @@ export function AudiobookDetailsModal({ coverArtUrl={audiobook.coverArtUrl} /> )} + + {/* Manual Import Browser */} + {showManualImport && audiobook && ( + 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, + }} + /> + )} ); } diff --git a/src/components/audiobooks/ManualImportBrowser.tsx b/src/components/audiobooks/ManualImportBrowser.tsx new file mode 100644 index 0000000..4620c4c --- /dev/null +++ b/src/components/audiobooks/ManualImportBrowser.tsx @@ -0,0 +1,302 @@ +/** + * 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('browse'); + const [slideDirection, setSlideDirection] = useState('right'); + + // Browse state + const [roots, setRoots] = useState([]); + const [currentPath, setCurrentPath] = useState(null); + const [entries, setEntries] = useState([]); + const [selectedPath, setSelectedPath] = useState(null); + const [selectedAudioCount, setSelectedAudioCount] = useState(0); + const [selectedSize, setSelectedSize] = useState(0); + const [selectedAudioFiles, setSelectedAudioFiles] = useState([]); + const [currentAudioFiles, setCurrentAudioFiles] = useState([]); + const [pathHistory, setPathHistory] = useState([]); + + // Loading/error state + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(null); + + // Hover state for folder icon swap + const [hoveredFolder, setHoveredFolder] = useState(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, + }), + }); + 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 = ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +

+ {phase === 'browse' ? 'Manual Import' : 'Confirm Import'} +

+
+ +
+ + {/* Content */} +
+ {phase === 'browse' ? ( + fetchDirectory(currentPath) : fetchRoots} + /> + ) : ( + + )} +
+ +
+
+ ); + + return createPortal(modalContent, document.body); +} diff --git a/src/components/audiobooks/manual-import/BrowsePhase.tsx b/src/components/audiobooks/manual-import/BrowsePhase.tsx new file mode 100644 index 0000000..3cbb5a2 --- /dev/null +++ b/src/components/audiobooks/manual-import/BrowsePhase.tsx @@ -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 ( +
+
+
+
+
+
+
+ ); +} + +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 ( +
+ {/* Breadcrumb bar */} + {currentPath && ( +
+ + {breadcrumbs.map((crumb, i) => ( + + + {crumb.index === -1 ? ( + ... + ) : i === breadcrumbs.length - 1 ? ( + + {crumb.label} + + ) : ( + + )} + + ))} +
+ )} + + {/* Listing */} +
+ {/* Loading */} + {isLoading && ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ )} + + {/* Error */} + {error && !isLoading && ( +
+ +

{error}

+ +
+ )} + + {/* Root view */} + {!currentPath && !isLoading && !error && ( +
+ {roots.map((root) => ( + + ))} +
+ )} + + {/* Directory + audio file listing */} + {currentPath && !isLoading && !error && (entries.length > 0 || currentAudioFiles.length > 0) && ( +
+ {/* Subdirectories */} + {entries.map((entry) => { + const hasAudio = entry.audioFileCount > 0; + const isHovered = hoveredFolder === entry.name; + + return ( + + ); + })} + + {/* Audio files in current directory */} + {currentAudioFiles.length > 0 && entries.length > 0 && ( +
+

+ Audio Files +

+
+ )} + {currentAudioFiles.map((file) => ( +
+ + + {file.name} + + + {formatBytes(file.size)} + +
+ ))} +
+ )} + + {/* Empty state */} + {currentPath && !isLoading && !error && entries.length === 0 && currentAudioFiles.length === 0 && ( +
+ +

This folder is empty

+ +
+ )} +
+ + {/* Footer: Select this folder */} + {currentPath && !isLoading && currentAudioFiles.length > 0 && ( +
+

+ {currentAudioFiles.length} + {' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder +

+ +
+ )} +
+ ); +} diff --git a/src/components/audiobooks/manual-import/ConfirmPhase.tsx b/src/components/audiobooks/manual-import/ConfirmPhase.tsx new file mode 100644 index 0000000..7860100 --- /dev/null +++ b/src/components/audiobooks/manual-import/ConfirmPhase.tsx @@ -0,0 +1,142 @@ +/** + * 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; + onBack: () => void; + onStartImport: () => void; +} + +export function ConfirmPhase({ + audiobook, + selectedPath, + audioFileCount, + totalSize, + audioFiles, + isImporting, + importError, + slideClass, + onBack, + onStartImport, +}: ConfirmPhaseProps) { + return ( +
+
+ {/* Book context */} +
+
+ {audiobook.coverArtUrl ? ( + + ) : ( +
+ +
+ )} +
+
+

+ {audiobook.title} +

+

{audiobook.author}

+
+
+ + {/* Selected folder info */} +
+

+ Import from +

+

+ {selectedPath} +

+

+ {audioFileCount} audio file{audioFileCount !== 1 ? 's' : ''} + {totalSize > 0 ? ` \u00B7 ${formatBytes(totalSize)}` : ''} +

+
+ + {/* Audio files to import */} +
+

+ Files to import +

+
+ {audioFiles.map((file) => ( +
+ + + {file.name} + + + {formatBytes(file.size)} + +
+ ))} +
+
+
+ + {/* Error display */} + {importError && ( +
+ +

{importError}

+
+ )} + + {/* Footer */} +
+ + +
+
+ ); +} diff --git a/src/components/audiobooks/manual-import/types.ts b/src/components/audiobooks/manual-import/types.ts new file mode 100644 index 0000000..ffbae16 --- /dev/null +++ b/src/components/audiobooks/manual-import/types.ts @@ -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]}`; +} diff --git a/src/components/requests/RequestCard.tsx b/src/components/requests/RequestCard.tsx index 627bbc1..fcae0e7 100644 --- a/src/components/requests/RequestCard.tsx +++ b/src/components/requests/RequestCard.tsx @@ -27,7 +27,7 @@ interface RequestCardProps { createdAt: string; updatedAt: string; completedAt?: string; - downloadUrl?: string | null; + downloadAvailable?: boolean; audiobook: { id: string; audibleAsin?: string; @@ -276,18 +276,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { )} - {isCompleted && request.downloadUrl && ( - - - - - Download - - )} {canCancel && (