Compare commits

...

29 Commits

Author SHA1 Message Date
kikootwo 3ee67c8763 Bump package version to 1.0.15
Update package.json version from 1.0.14 to 1.0.15 to reflect a new release.
2026-02-27 12:15:42 -05:00
kikootwo edc56bc457 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.
2026-02-27 12:15:23 -05:00
kikootwo 73c5fe14e7 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-02-27 09:42:45 -05:00
kikootwo d9ccbfef5c Add optional bookdrop volume and .gitignore entry
Document an optional 'bookdrop' host folder in docker-compose.yml with a commented example volume mount for the Manual Import (Admin → audiobook → Manual Import) file picker, and add /bookdrop to .gitignore so local bookdrop mounts are not tracked.
2026-02-27 09:41:48 -05:00
kikootwo 01cac0e8e6 Merge pull request #115 from razzamatazm/fix/folder-organization-collisions
Fix file organizer collisions for nested duplicate audio names
2026-02-27 08:54:04 -05:00
kikootwo 66f4a215f7 Merge pull request #113 from razzamatazm/feature/direct-download-links
Add direct file download links to completed requests
2026-02-27 08:52:21 -05:00
razzamatazm 0bd9e88acc Fix organizer collisions for nested duplicate track names 2026-02-26 17:27:15 -08:00
razzamatazm f0b9bd2688 Fix organizer collisions for nested duplicate track names 2026-02-26 17:23:04 -08:00
razzamatazm e1629ce516 Address PR review: dedicated download secret, shared constants, strip filePath, streaming zip
- jwt.ts: Use JWT_DOWNLOAD_SECRET instead of JWT_SECRET for download tokens
- audio-formats.ts: Add EBOOK_EXTENSIONS export alongside AUDIO_EXTENSIONS
- request-statuses.ts: New shared COMPLETED_STATUSES constant used across requests API, download route, and RequestCard
- requests/route.ts: Import COMPLETED_STATUSES; strip filePath from audiobook in API response
- download/route.ts: Import format/status constants; add archiver dependency and replace adm-zip with streaming archiver for multi-file zips
- RequestCard.tsx: Use shared COMPLETED_STATUSES constant
2026-02-26 16:20:37 -08:00
razzamatazm 1006a04337 Add direct file download links to completed requests
Embeds a signed JWT download token (30-day expiry) in the requests API
response so users can download completed audiobook/ebook files directly
from the UI or by sharing the URL to apps like BookPlayer — no session
cookie required.

- jwt.ts: add generateDownloadToken / verifyDownloadToken helpers
- api/requests: append downloadUrl to completed requests with a filePath
- api/requests/[id]/download: new token-authenticated streaming endpoint;
  serves single files directly or zips multi-file audiobooks with adm-zip
- RequestCard: add Download link in the actions area for completed requests
2026-02-26 11:33:32 -08:00
kikootwo 547af71de8 Bump package version to 1.0.14
Update package.json version from 1.0.13 to 1.0.14 to reflect a new patch release. No other changes included in this commit.
2026-02-26 12:46:10 -05:00
kikootwo 1b0a80052d Use content_path and add savePath/path-wait
Always use qBittorrent's content_path as the canonical downloadPath and expose savePath on DownloadInfo instead of reconstructing paths from save_path + basename. Add path-waiting logic to the monitor: track consecutive pathWaitCount polls, re-queue the monitor with exponential-ish backoff while content_path remains outside save_path (to handle TempPathEnabled races), and give up after a configurable max attempts. Extend the MonitorDownload payload and JobQueue APIs to carry pathWaitCount. Organize-files processor now attempts to refresh the stored downloadPath from the download client and updates downloadHistory if the client reports a different path (applying path mapping). Update tests to reflect the new behavior and expectations.
2026-02-26 12:45:24 -05:00
kikootwo d38f03b8f4 Bump version to 1.0.13
Update package.json version from 1.0.12 to 1.0.13 to mark a new patch release.
2026-02-26 09:45:45 -05:00
kikootwo dbea15a34f Use content_path basename for finished torrents
When a torrent is finished (seeding/completed), build the download path from save_path combined with the basename of content_path instead of using torrent.name or the full content_path. This fixes a race with qBittorrent's TempPathEnabled (where content_path may still point to the temp dir) and addresses cases where the displayed torrent.name differs from the actual root folder/filename on disk. Added/updated tests to cover the TempPathEnabled race, name-mismatch scenarios, empty content_path fallback, and single-file torrents.
2026-02-26 09:45:23 -05:00
kikootwo 2972297903 Bump package version to 1.0.12
Update package.json version from 1.0.11 to 1.0.12 to publish a new patch release. No other changes were made in this commit.
2026-02-25 11:20:43 -05:00
kikootwo 03f82d4841 File rename templates & admin torrent approval
Add support for admin-driven interactive torrent selection and a file rename/template feature. Integrates an InteractiveTorrentSearchModal into the pending-approval admin UI, adds an admin approve flow that accepts an admin-selected torrent, and surfaces user/admin-selected torrent details in the UI. Introduces fileRenameEnabled and fileRenameTemplate settings (API + UI), persists them to configuration, and clears related caches. Pass renameConfig through the organize/organizeEbook flows and implement renaming in the FileOrganizer (single/multi-file handling). Enhance path-template utilities with conditional block resolution, filename-template validation, mock filename previews, and a buildRenamedFilename helper. Update tests to cover conditional templates and filename preview behavior.
2026-02-25 09:47:57 -05:00
kikootwo 33c2265e56 Use save_path for completed/seeding torrents
Resolve downloadPath using save_path for finished torrents to avoid TempPathEnabled race conditions where content_path can point to a stale temp location. Compute status once, treat 'seeding'/'completed' as finished, and prefer path.join(save_path, name) for those states while still using content_path (or falling back to save_path) for active downloads. Added tests covering multiple qBittorrent states (seeding/stalledUP/pausedUP/stoppedUP/forcedUP/queuedUP/downloading and empty content_path) and imported path in tests.
2026-02-24 02:03:20 -05:00
kikootwo b15a472bab Centralize download client timeout constant
Add DOWNLOAD_CLIENT_TIMEOUT (60000ms) in src/lib/constants/download-timeouts.ts and replace hardcoded 60000ms timeouts across Deluge, Prowlarr, qBittorrent and Transmission integrations. This centralizes the download/API timeout (gives headroom for indexers that enforce ~30s waits) and makes future adjustments easier without changing behavior.
2026-02-24 01:09:58 -05:00
kikootwo 3c680f2f38 Merge pull request #102 from Kikipeuk/ygg_timeout2
Extend the default timeout to add a torrent (Qbit, Transmission, Deluge)
2026-02-24 00:56:37 -05:00
kikootwo 16cd606421 Merge pull request #107 from kikootwo/feature-france-region
Feature france region
2026-02-24 00:53:01 -05:00
kikootwo 40d5363dc4 Fix French stopWords spacing and region name
Trim whitespace in the French stopWords array (add missing space after comma) to keep formatting consistent, and rename AUDIBLE_REGIONS.fr.name from "French" to "France" to better reflect the region label used for the Audible configuration.
2026-02-24 00:51:55 -05:00
kikootwo c138d8e642 Merge pull request #100 from Kikipeuk/french-traduction
Add French as Audible region
2026-02-24 00:40:50 -05:00
root 328fd8392b ygg_timeout2 2026-02-21 14:30:51 +01:00
root 9a460f808d french-Traduction 2026-02-21 13:57:47 +01:00
root c60b6214ce French Traduction 2026-02-21 12:44:56 +01:00
root aff5faaa58 French Traduction 2026-02-21 11:43:06 +01:00
root c43ce7ba8f French Traduction 2026-02-21 11:40:48 +01:00
root f570b87343 French Traduction 2026-02-21 10:48:24 +01:00
root dfa7a11674 French Traduction 2026-02-21 10:43:49 +01:00
63 changed files with 4819 additions and 171 deletions
+3 -1
View File
@@ -53,4 +53,6 @@ next-env.d.ts
/redis /redis
/pgdata /pgdata
/test-media /test-media
/test-data /test-data
/bookdrop
dockerfile.patch
+5
View File
@@ -17,6 +17,11 @@ services:
- ./downloads:/downloads - ./downloads:/downloads
- ./media:/media - ./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 # PostgreSQL data persistence
- ./pgdata:/var/lib/postgresql/data - ./pgdata:/var/lib/postgresql/data
+335
View File
@@ -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.
+87
View File
@@ -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
+1
View File
@@ -33,6 +33,7 @@ Configurable Audible region for accurate metadata matching across different inte
- India (`in`) - `audible.in` (English) - India (`in`) - `audible.in` (English)
- Germany (`de`) - `audible.de` (non-English) - Germany (`de`) - `audible.de` (non-English)
- Spain (`es`) - `audible.es` (non-English) - Spain (`es`) - `audible.es` (non-English)
- French (`fr`) - `audible.fr` (non-English)
**`isEnglish` Flag:** **`isEnglish` Flag:**
- Each region has `isEnglish: boolean` in `AudibleRegionConfig` - Each region has `isEnglish: boolean` in `AudibleRegionConfig`
+1 -1
View File
@@ -271,7 +271,7 @@ src/app/admin/settings/
**PUT /api/admin/settings/audible** **PUT /api/admin/settings/audible**
- Updates Audible region - 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 - No validation required
**PUT /api/admin/settings/prowlarr/indexers** **PUT /api/admin/settings/prowlarr/indexers**
+820 -31
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.0.11", "version": "1.0.15",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -18,7 +18,9 @@
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0", "@prisma/client": "^6.19.0",
"@types/archiver": "^7.0.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"axios": "^1.7.2", "axios": "^1.7.2",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bull": "^4.12.0", "bull": "^4.12.0",
@@ -43,9 +45,9 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@types/adm-zip": "^0.5.6",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/adm-zip": "^0.5.6",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/bull": "^4.10.0", "@types/bull": "^4.10.0",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "download_access" BOOLEAN;
+1
View File
@@ -55,6 +55,7 @@ model User {
// Fine-grained permissions // Fine-grained permissions
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny 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 // Soft delete support
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
+157 -70
View File
@@ -13,17 +13,34 @@ import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
import { RecentRequestsTable } from './components/RecentRequestsTable'; import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast'; import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ReportedIssuesSection } from './components/ReportedIssuesSection'; import { ReportedIssuesSection } from './components/ReportedIssuesSection';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react'; 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 { interface PendingApprovalRequest {
id: string; id: string;
createdAt: string; createdAt: string;
type: 'audiobook' | 'ebook'; type: 'audiobook' | 'ebook';
selectedTorrent: SelectedTorrentData | null;
audiobook: { audiobook: {
title: string; title: string;
author: string; author: string;
coverArtUrl: string | null; coverArtUrl: string | null;
audibleAsin: string | null;
}; };
user: { user: {
id: string; 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[] }) { function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) {
const toast = useToast(); const toast = useToast();
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({}); 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) => { const handleApproveRequest = async (requestId: string) => {
setLoadingStates((prev) => ({ ...prev, [requestId]: true })); setLoadingStates((prev) => ({ ...prev, [requestId]: true }));
@@ -47,7 +75,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
toast.success('Request approved'); toast.success('Request approved');
// Mutate both pending requests and recent requests caches
await mutate('/api/admin/requests/pending-approval'); await mutate('/api/admin/requests/pending-approval');
await mutate('/api/admin/requests/recent'); await mutate('/api/admin/requests/recent');
await mutate('/api/admin/metrics'); await mutate('/api/admin/metrics');
@@ -72,7 +99,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
toast.success('Request denied'); toast.success('Request denied');
// Mutate pending requests cache
await mutate('/api/admin/requests/pending-approval'); await mutate('/api/admin/requests/pending-approval');
await mutate('/api/admin/metrics'); await mutate('/api/admin/metrics');
} catch (error) { } 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 ( return (
<div className="mb-8"> <div className="mb-8">
{/* Section Header */} {/* 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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{requests.map((request) => { {requests.map((request) => {
const isLoading = loadingStates[request.id] || false; const isLoading = loadingStates[request.id] || false;
const torrent = request.selectedTorrent;
const displayFormat = torrent?.format || torrent?.ebookFormat;
const isAnnasArchive = torrent?.source === 'annas_archive';
return ( return (
<div <div
@@ -205,89 +254,107 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
</div> </div>
</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&apos;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">&middot;</span>
<span>{formatTorrentSize(torrent.size)}</span>
</>
) : null}
{displayFormat ? (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</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">&middot;</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">&middot;</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">&middot;</span>
<span className="font-medium">Score {Math.round(torrent.score)}</span>
</>
) : null}
</div>
</div>
)}
{/* Action Buttons */} {/* 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"> <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 <button
onClick={() => handleApproveRequest(request.id)} onClick={() => handleApproveRequest(request.id)}
disabled={isLoading} 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 ? ( {isLoading ? <LoadingSpinner /> : (
<svg <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="animate-spin h-4 w-4" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
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"
/>
</svg> </svg>
)} )}
<span>Approve</span> <span>Approve</span>
</button> </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 <button
onClick={() => handleDenyRequest(request.id)} onClick={() => handleDenyRequest(request.id)}
disabled={isLoading} 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 ? ( {isLoading ? <LoadingSpinner /> : (
<svg <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="animate-spin h-4 w-4" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
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"
/>
</svg> </svg>
)} )}
<span>Deny</span> <span>Deny</span>
@@ -297,6 +364,26 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
); );
})} })}
</div> </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> </div>
); );
} }
+2
View File
@@ -100,6 +100,8 @@ export interface PathsSettings {
ebookPathTemplate?: string; ebookPathTemplate?: string;
metadataTaggingEnabled: boolean; metadataTaggingEnabled: boolean;
chapterMergingEnabled: boolean; chapterMergingEnabled: boolean;
fileRenameEnabled: boolean;
fileRenameTemplate?: string;
} }
/** /**
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { usePathsSettings } from './usePathsSettings'; import { usePathsSettings } from './usePathsSettings';
import type { PathsSettings } from '../../lib/types'; 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 { interface PathsTabProps {
paths: PathsSettings; paths: PathsSettings;
@@ -24,6 +24,13 @@ interface TemplatePreview {
previewPaths?: string[]; previewPaths?: string[];
} }
interface FilenamePreview {
isValid: boolean;
error?: string;
single?: string[];
multi?: string[];
}
export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) { export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) {
const { testing, testResult, updatePath, testPaths } = usePathsSettings({ const { testing, testResult, updatePath, testPaths } = usePathsSettings({
paths, paths,
@@ -73,6 +80,34 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
} }
}, [paths.ebookPathTemplate]); }, [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 audiobookTemplate = paths.audiobookPathTemplate || '{author}/{title} {asin}';
const ebookTemplate = paths.ebookPathTemplate || '{author}/{title} {asin}'; const ebookTemplate = paths.ebookPathTemplate || '{author}/{title} {asin}';
const ebookMatchesAudiobook = ebookTemplate === audiobookTemplate; const ebookMatchesAudiobook = ebookTemplate === audiobookTemplate;
@@ -218,6 +253,83 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
)} )}
</div> </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) */} {/* 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"> <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"> <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>
</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>
&nbsp;&bull;&nbsp;
Without value: <span className="text-red-700 dark:text-red-400">(removed)</span>
</div>
</div>
</div>
{/* Metadata Tagging Toggle */} {/* 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="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"> <div className="flex items-start gap-4">
+58
View File
@@ -28,6 +28,7 @@ interface User {
lastLoginAt: string | null; lastLoginAt: string | null;
autoApproveRequests: boolean | null; autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null; interactiveSearchAccess: boolean | null;
downloadAccess: boolean | null;
_count: { _count: {
requests: number; requests: number;
}; };
@@ -193,6 +194,10 @@ function AdminUsersPageContent() {
'/api/admin/settings/interactive-search', '/api/admin/settings/interactive-search',
authenticatedFetcher authenticatedFetcher
); );
const { data: globalDownloadAccessData, mutate: mutateGlobalDownloadAccess } = useSWR(
'/api/admin/settings/download-access',
authenticatedFetcher
);
const [editDialog, setEditDialog] = useState<{ const [editDialog, setEditDialog] = useState<{
isOpen: boolean; isOpen: boolean;
user: User | null; user: User | null;
@@ -212,6 +217,7 @@ function AdminUsersPageContent() {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [globalAutoApprove, setGlobalAutoApprove] = useState<boolean>(false); const [globalAutoApprove, setGlobalAutoApprove] = useState<boolean>(false);
const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState<boolean>(true); const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState<boolean>(true);
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false); const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null); const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
const toast = useToast(); const toast = useToast();
@@ -237,6 +243,15 @@ function AdminUsersPageContent() {
} }
}, [globalInteractiveSearchData]); }, [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) => { const handleGlobalAutoApproveToggle = async (newValue: boolean) => {
setGlobalAutoApprove(newValue); setGlobalAutoApprove(newValue);
try { 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) => { const showEditDialog = (user: User) => {
setEditRole(user.role); setEditRole(user.role);
setEditDialog({ isOpen: true, user }); setEditDialog({ isOpen: true, user });
@@ -909,6 +961,8 @@ function AdminUsersPageContent() {
onToggleAutoApprove={handleGlobalAutoApproveToggle} onToggleAutoApprove={handleGlobalAutoApproveToggle}
globalInteractiveSearch={globalInteractiveSearch} globalInteractiveSearch={globalInteractiveSearch}
onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle} onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle}
globalDownloadAccess={globalDownloadAccess}
onToggleDownloadAccess={handleGlobalDownloadAccessToggle}
/> />
{/* User Permissions Modal */} {/* User Permissions Modal */}
@@ -918,12 +972,16 @@ function AdminUsersPageContent() {
user={permissionsUser} user={permissionsUser}
globalAutoApprove={globalAutoApprove} globalAutoApprove={globalAutoApprove}
globalInteractiveSearch={globalInteractiveSearch} globalInteractiveSearch={globalInteractiveSearch}
globalDownloadAccess={globalDownloadAccess}
onToggleAutoApprove={(user, newValue) => { onToggleAutoApprove={(user, newValue) => {
handleUserAutoApproveToggle(user as User, newValue); handleUserAutoApproveToggle(user as User, newValue);
}} }}
onToggleInteractiveSearch={(user, newValue) => { onToggleInteractiveSearch={(user, newValue) => {
handleUserInteractiveSearchToggle(user as User, newValue); handleUserInteractiveSearchToggle(user as User, newValue);
}} }}
onToggleDownloadAccess={(user, newValue) => {
handleUserDownloadAccessToggle(user as User, newValue);
}}
/> />
</div> </div>
</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 }
);
}
});
});
}
+265
View File
@@ -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 }
);
}
});
});
}
@@ -14,6 +14,7 @@ const logger = RMABLogger.create('API.Admin.Requests.Approve');
const ApprovalActionSchema = z.object({ const ApprovalActionSchema = z.object({
action: z.enum(['approve', 'deny']), action: z.enum(['approve', 'deny']),
selectedTorrent: z.any().optional(),
}); });
/** /**
@@ -37,8 +38,8 @@ export async function POST(
const { id } = await params; const { id } = await params;
const body = await request.json(); const body = await request.json();
// Validate action // Validate action and optional admin-selected torrent
const { action } = ApprovalActionSchema.parse(body); const { action, selectedTorrent: adminSelectedTorrent } = ApprovalActionSchema.parse(body);
// Fetch the request // Fetch the request
const existingRequest = await prisma.request.findUnique({ const existingRequest = await prisma.request.findUnique({
@@ -78,12 +79,15 @@ export async function POST(
const jobQueue = getJobQueueService(); const jobQueue = getJobQueueService();
const isEbookRequest = existingRequest.type === 'ebook'; const isEbookRequest = existingRequest.type === 'ebook';
// Check if request has a pre-selected torrent (from interactive search) // Use admin-provided torrent (from admin interactive search) or fall back to user's pre-selected torrent
if (existingRequest.selectedTorrent) { const effectiveTorrent = adminSelectedTorrent || existingRequest.selectedTorrent;
const selectedTorrent = existingRequest.selectedTorrent as any;
// User pre-selected a specific torrent - download that torrent directly if (effectiveTorrent) {
logger.info(`Request ${id} has pre-selected torrent, starting download`, { 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, requestId: id,
userId: existingRequest.userId, userId: existingRequest.userId,
adminId: req.user.sub, 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.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, requestId: id,
userId: updatedRequest.userId, userId: updatedRequest.userId,
audiobookTitle: existingRequest.audiobook.title, audiobookTitle: existingRequest.audiobook.title,
adminId: req.user.sub, adminId: req.user.sub,
type: existingRequest.type, type: existingRequest.type,
torrentSource,
}); });
return NextResponse.json({ return NextResponse.json({
success: true, 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, request: updatedRequest,
}); });
} else { } else {
@@ -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 }
);
}
});
});
}
+29 -1
View File
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => { return requireAdmin(req, async () => {
try { 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) { if (!downloadDir || !mediaDir) {
return NextResponse.json( 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'); logger.info('Paths settings updated');
// Clear config cache for all updated keys so services get fresh values // 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('ebook_path_template');
configService.clearCache('metadata_tagging_enabled'); configService.clearCache('metadata_tagging_enabled');
configService.clearCache('chapter_merging_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 // Invalidate all download client singletons to force reload of download_dir
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+2
View File
@@ -128,6 +128,8 @@ export async function GET(request: NextRequest) {
ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}', ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true', metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true', chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
}, },
ebook: { ebook: {
// New granular source toggles (with migration from legacy ebook_sidecar_enabled) // New granular source toggles (with migration from legacy ebook_sidecar_enabled)
+20 -2
View File
@@ -19,7 +19,7 @@ export async function PUT(
try { try {
const { id } = await params; const { id } = await params;
const body = await request.json(); const body = await request.json();
const { role, autoApproveRequests, interactiveSearchAccess } = body; const { role, autoApproveRequests, interactiveSearchAccess, downloadAccess } = body;
// Validate role // Validate role
if (!role || (role !== 'user' && role !== 'admin')) { 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 // Prevent user from demoting themselves
if (req.user && id === req.user.sub) { if (req.user && id === req.user.sub) {
return NextResponse.json( return NextResponse.json(
@@ -112,15 +120,24 @@ export async function PUT(
{ status: 400 } { 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 // 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) { if (autoApproveRequests !== undefined) {
updateData.autoApproveRequests = autoApproveRequests; updateData.autoApproveRequests = autoApproveRequests;
} }
if (interactiveSearchAccess !== undefined) { if (interactiveSearchAccess !== undefined) {
updateData.interactiveSearchAccess = interactiveSearchAccess; updateData.interactiveSearchAccess = interactiveSearchAccess;
} }
if (downloadAccess !== undefined) {
updateData.downloadAccess = downloadAccess;
}
// Update user // Update user
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
@@ -132,6 +149,7 @@ export async function PUT(
role: true, role: true,
autoApproveRequests: true, autoApproveRequests: true,
interactiveSearchAccess: true, interactiveSearchAccess: true,
downloadAccess: true,
}, },
}); });
+1
View File
@@ -32,6 +32,7 @@ export async function GET(request: NextRequest) {
lastLoginAt: true, lastLoginAt: true,
autoApproveRequests: true, autoApproveRequests: true,
interactiveSearchAccess: true, interactiveSearchAccess: true,
downloadAccess: true,
_count: { _count: {
select: { select: {
requests: true, 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,
});
});
}
+9
View File
@@ -39,6 +39,7 @@ export async function GET(request: NextRequest) {
createdAt: true, createdAt: true,
lastLoginAt: true, lastLoginAt: true,
interactiveSearchAccess: true, interactiveSearchAccess: true,
downloadAccess: true,
}, },
}); });
@@ -63,6 +64,13 @@ export async function GET(request: NextRequest) {
globalInteractiveSearch globalInteractiveSearch
); );
const globalDownload = await getGlobalBooleanSetting('download_access', true);
const effectiveDownload = resolvePermission(
user.role,
user.downloadAccess,
globalDownload
);
return NextResponse.json({ return NextResponse.json({
user: { user: {
id: user.id, id: user.id,
@@ -77,6 +85,7 @@ export async function GET(request: NextRequest) {
lastLoginAt: user.lastLoginAt, lastLoginAt: user.lastLoginAt,
permissions: { permissions: {
interactiveSearch: effectiveInteractiveSearch, interactiveSearch: effectiveInteractiveSearch,
download: effectiveDownload,
}, },
}, },
}); });
@@ -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 }
);
}
});
}
+152
View File
@@ -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 });
}
}
@@ -67,8 +67,8 @@ export async function POST(
); );
} }
// Check if request is awaiting approval // Check if request is awaiting approval (admins can still search to override the user's selection)
if (requestRecord.status === 'awaiting_approval') { if (requestRecord.status === 'awaiting_approval' && req.user.role !== 'admin') {
return NextResponse.json( return NextResponse.json(
{ error: 'AwaitingApproval', message: 'This request is awaiting admin approval. You cannot search for torrents until it is approved.' }, { error: 'AwaitingApproval', message: 'This request is awaiting admin approval. You cannot search for torrents until it is approved.' },
{ status: 403 } { status: 403 }
+11 -2
View File
@@ -9,6 +9,7 @@ import { prisma } from '@/lib/db';
import { z } from 'zod'; import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { createRequestForUser } from '@/lib/services/request-creator.service'; import { createRequestForUser } from '@/lib/services/request-creator.service';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
const logger = RMABLogger.create('API.Requests'); const logger = RMABLogger.create('API.Requests');
@@ -146,10 +147,18 @@ export async function GET(request: NextRequest) {
take: limit, 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({ return NextResponse.json({
success: true, success: true,
requests, requests: enriched,
count: requests.length, count: enriched.length,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) });
@@ -14,6 +14,8 @@ interface GlobalUserSettingsModalProps {
onToggleAutoApprove: (newValue: boolean) => void; onToggleAutoApprove: (newValue: boolean) => void;
globalInteractiveSearch: boolean; globalInteractiveSearch: boolean;
onToggleInteractiveSearch: (newValue: boolean) => void; onToggleInteractiveSearch: (newValue: boolean) => void;
globalDownloadAccess: boolean;
onToggleDownloadAccess: (newValue: boolean) => void;
} }
export function GlobalUserSettingsModal({ export function GlobalUserSettingsModal({
@@ -23,6 +25,8 @@ export function GlobalUserSettingsModal({
onToggleAutoApprove, onToggleAutoApprove,
globalInteractiveSearch, globalInteractiveSearch,
onToggleInteractiveSearch, onToggleInteractiveSearch,
globalDownloadAccess,
onToggleDownloadAccess,
}: GlobalUserSettingsModalProps) { }: GlobalUserSettingsModalProps) {
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title="Global User Settings" size="sm"> <Modal isOpen={isOpen} onClose={onClose} title="Global User Settings" size="sm">
@@ -84,6 +88,35 @@ export function GlobalUserSettingsModal({
</p> </p>
</div> </div>
</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> </div>
</Modal> </Modal>
); );
@@ -15,6 +15,7 @@ interface UserPermissionsUser {
role: 'user' | 'admin'; role: 'user' | 'admin';
autoApproveRequests: boolean | null; autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null; interactiveSearchAccess: boolean | null;
downloadAccess: boolean | null;
} }
interface UserPermissionsModalProps { interface UserPermissionsModalProps {
@@ -23,8 +24,10 @@ interface UserPermissionsModalProps {
user: UserPermissionsUser | null; user: UserPermissionsUser | null;
globalAutoApprove: boolean; globalAutoApprove: boolean;
globalInteractiveSearch: boolean; globalInteractiveSearch: boolean;
globalDownloadAccess: boolean;
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void; onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void; onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
} }
interface PermissionToggleProps { interface PermissionToggleProps {
@@ -86,8 +89,10 @@ export function UserPermissionsModal({
user, user,
globalAutoApprove, globalAutoApprove,
globalInteractiveSearch, globalInteractiveSearch,
globalDownloadAccess,
onToggleAutoApprove, onToggleAutoApprove,
onToggleInteractiveSearch, onToggleInteractiveSearch,
onToggleDownloadAccess,
}: UserPermissionsModalProps) { }: UserPermissionsModalProps) {
if (!user) return null; if (!user) return null;
@@ -103,6 +108,11 @@ export function UserPermissionsModal({
const isSearchDisabled = isAdmin || isSearchGlobalOverride; const isSearchDisabled = isAdmin || isSearchGlobalOverride;
const searchValue = isAdmin ? true : isSearchGlobalOverride ? true : (user.interactiveSearchAccess ?? false); 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 => { const getDisabledMessage = (isAdminUser: boolean, isGlobalOverride: boolean, adminMessage: string, globalMessage: string): string | undefined => {
if (isAdminUser) return adminMessage; if (isAdminUser) return adminMessage;
if (isGlobalOverride) return globalMessage; if (isGlobalOverride) return globalMessage;
@@ -176,6 +186,21 @@ export function UserPermissionsModal({
description="When enabled, this user can manually search and select torrents and ebooks" description="When enabled, this user can manually search and select torrents and ebooks"
onToggle={() => onToggleInteractiveSearch(user, !searchValue)} 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> </div>
</div> </div>
+10 -3
View File
@@ -56,8 +56,13 @@ export function AudiobookCard({
const [showToast, setShowToast] = useState(false); const [showToast, setShowToast] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false); 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) => { const handleRequest = async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -69,6 +74,7 @@ export function AudiobookCard({
try { try {
await createRequest(audiobook); await createRequest(audiobook);
setLocalRequestStatus('pending');
setShowToast(true); setShowToast(true);
setTimeout(() => setShowToast(false), 2500); setTimeout(() => setShowToast(false), 2500);
onRequestSuccess?.(); onRequestSuccess?.();
@@ -240,8 +246,9 @@ export function AudiobookCard({
isOpen={showModal} isOpen={showModal}
onClose={() => setShowModal(false)} onClose={() => setShowModal(false)}
onRequestSuccess={onRequestSuccess} onRequestSuccess={onRequestSuccess}
isRequested={audiobook.isRequested} onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
requestStatus={audiobook.requestStatus} isRequested={audiobook.isRequested || localRequestStatus !== undefined}
requestStatus={displayAudiobook.requestStatus}
isAvailable={audiobook.isAvailable} isAvailable={audiobook.isAvailable}
requestedByUsername={audiobook.requestedByUsername} requestedByUsername={audiobook.requestedByUsername}
hasReportedIssue={audiobook.hasReportedIssue} hasReportedIssue={audiobook.hasReportedIssue}
@@ -13,17 +13,21 @@ import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks'; 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 { useAuth } from '@/contexts/AuthContext';
import { usePreferences } from '@/contexts/PreferencesContext'; import { usePreferences } from '@/contexts/PreferencesContext';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal'; 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 { interface AudiobookDetailsModalProps {
asin: string; asin: string;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onRequestSuccess?: () => void; onRequestSuccess?: () => void;
onStatusChange?: (newStatus: string) => void;
isRequested?: boolean; isRequested?: boolean;
requestStatus?: string | null; requestStatus?: string | null;
isAvailable?: boolean; isAvailable?: boolean;
@@ -63,6 +67,7 @@ export function AudiobookDetailsModal({
isOpen, isOpen,
onClose, onClose,
onRequestSuccess, onRequestSuccess,
onStatusChange,
isRequested = false, isRequested = false,
requestStatus = null, requestStatus = null,
isAvailable = false, isAvailable = false,
@@ -75,6 +80,7 @@ export function AudiobookDetailsModal({
const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null); const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
const { createRequest, isLoading: isRequesting } = useCreateRequest(); const { createRequest, isLoading: isRequesting } = useCreateRequest();
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null); const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin(); const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
const [showToast, setShowToast] = useState(false); const [showToast, setShowToast] = useState(false);
@@ -84,9 +90,18 @@ export function AudiobookDetailsModal({
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false); const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false); const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showReportIssue, setShowReportIssue] = useState(false); const [showReportIssue, setShowReportIssue] = useState(false);
const [showManualImport, setShowManualImport] = useState(false);
const [asinCopied, setAsinCopied] = 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; const canShowEbookButtons = isAvailable && ebookStatus?.ebookSourcesEnabled && !ebookStatus?.hasActiveEbookRequest;
useEffect(() => { useEffect(() => {
@@ -119,6 +134,8 @@ export function AudiobookDetailsModal({
try { try {
await createRequest(audiobook); await createRequest(audiobook);
setLocalRequestStatus('pending');
onStatusChange?.('pending');
showNotification('Request created!'); showNotification('Request created!');
setTimeout(onClose, 1500); setTimeout(onClose, 1500);
onRequestSuccess?.(); 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) => { const formatDuration = (minutes?: number) => {
if (!minutes) return null; if (!minutes) return null;
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
@@ -461,6 +494,36 @@ export function AudiobookDetailsModal({
</svg> </svg>
</a> </a>
</div> </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>
</div> </div>
@@ -485,7 +548,8 @@ export function AudiobookDetailsModal({
)} )}
</div> </div>
{/* Sticky Action Bar - hidden when opened from bookdate */}
{/* Sticky Action Bar - hidden when opened from read-only contexts */}
{audiobook && !isLoading && !hideRequestActions && ( {audiobook && !isLoading && !hideRequestActions && (
<div <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" 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"
@@ -556,6 +620,17 @@ export function AudiobookDetailsModal({
</button> </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 */} {/* Ebook Buttons - only when available and enabled */}
{canShowEbookButtons && user && ( {canShowEbookButtons && user && (
<> <>
@@ -674,6 +749,26 @@ export function AudiobookDetailsModal({
coverArtUrl={audiobook.coverArtUrl} 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,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<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);
// 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,
}),
});
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}
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> &middot; </span>}
{entry.audioFileCount > 0 && (
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
)}
{entry.totalSize > 0 && (
<span> &middot; {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 &rarr;
</button>
</div>
)}
</div>
);
}
@@ -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 (
<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>
</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]}`;
}
@@ -38,6 +38,7 @@ interface InteractiveTorrentSearchModalProps {
onSuccess?: () => void; onSuccess?: () => void;
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
replaceIssueId?: string; // Optional - when set, confirm handler calls replace endpoint instead 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 // Format relative time from publish date
@@ -90,6 +91,7 @@ export function InteractiveTorrentSearchModal({
onSuccess, onSuccess,
searchMode = 'audiobook', searchMode = 'audiobook',
replaceIssueId, replaceIssueId,
onConfirm,
}: InteractiveTorrentSearchModalProps) { }: InteractiveTorrentSearchModalProps) {
// Hooks for existing audiobook request flow // Hooks for existing audiobook request flow
const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch(); const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch();
@@ -113,6 +115,7 @@ export function InteractiveTorrentSearchModal({
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]); const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]);
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null); const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
const [searchTitle, setSearchTitle] = useState(audiobook.title); const [searchTitle, setSearchTitle] = useState(audiobook.title);
const [isCustomConfirming, setIsCustomConfirming] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Stable close handler via ref // Stable close handler via ref
@@ -130,11 +133,13 @@ export function InteractiveTorrentSearchModal({
const isSearching = isEbookMode const isSearching = isEbookMode
? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks) ? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks)
: (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook); : (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook);
const isDownloading = replaceIssueId const isDownloading = isCustomConfirming
? isReplacing ? true
: isEbookMode : replaceIssueId
? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook) ? isReplacing
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent); : isEbookMode
? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook)
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent);
const error = replaceIssueId const error = replaceIssueId
? (replaceError || (hasRequestId ? searchByRequestError : searchByAudiobookError)) ? (replaceError || (hasRequestId ? searchByRequestError : searchByAudiobookError))
: isEbookMode : isEbookMode
@@ -218,7 +223,11 @@ export function InteractiveTorrentSearchModal({
const handleConfirmDownload = async () => { const handleConfirmDownload = async () => {
if (!confirmTorrent) return; if (!confirmTorrent) return;
try { 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 // Reported issue replacement flow
await replaceWithTorrent(replaceIssueId, confirmTorrent); await replaceWithTorrent(replaceIssueId, confirmTorrent);
} else if (isEbookMode) { } else if (isEbookMode) {
@@ -241,6 +250,8 @@ export function InteractiveTorrentSearchModal({
} catch (err) { } catch (err) {
console.error('Failed to download:', err); console.error('Failed to download:', err);
setConfirmTorrent(null); setConfirmTorrent(null);
} finally {
setIsCustomConfirming(false);
} }
}; };
+6 -1
View File
@@ -15,6 +15,7 @@ import { usePreferences } from '@/contexts/PreferencesContext';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal'; import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
interface RequestCardProps { interface RequestCardProps {
request: { request: {
@@ -26,12 +27,15 @@ interface RequestCardProps {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
completedAt?: string; completedAt?: string;
downloadAvailable?: boolean;
audiobook: { audiobook: {
id: string; id: string;
audibleAsin?: string; audibleAsin?: string;
title: string; title: string;
author: string; author: string;
coverArtUrl?: string; coverArtUrl?: string;
filePath?: string | null;
fileFormat?: string | null;
}; };
}; };
showActions?: boolean; showActions?: boolean;
@@ -49,6 +53,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
const requestType = request.type || 'audiobook'; const requestType = request.type || 'audiobook';
const isEbook = requestType === 'ebook'; 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 canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
const isActive = ['searching', 'downloading', 'processing'].includes(request.status); const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
const isFailed = request.status === 'failed'; const isFailed = request.status === 'failed';
@@ -306,7 +311,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
isOpen={showDetailsModal} isOpen={showDetailsModal}
onClose={() => setShowDetailsModal(false)} onClose={() => setShowDetailsModal(false)}
requestStatus={request.status} requestStatus={request.status}
isAvailable={['available', 'downloaded'].includes(request.status)} isAvailable={COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number])}
hideRequestActions hideRequestActions
/> />
)} )}
+1
View File
@@ -10,6 +10,7 @@ import { isTokenExpired, getRefreshTimeMs } from '@/lib/utils/jwt-client';
interface UserPermissions { interface UserPermissions {
interactiveSearch: boolean; interactiveSearch: boolean;
download: boolean;
} }
interface User { interface User {
+13
View File
@@ -67,3 +67,16 @@ export type TorrentTitleFormat = (typeof TORRENT_TITLE_FORMATS)[number];
* 'OTHER' is used when no recognized format is detected in the title. * 'OTHER' is used when no recognized format is detected in the title.
*/ */
export type AudioFormat = TorrentTitleFormat | 'OTHER'; 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;
+10
View File
@@ -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;
+35 -1
View File
@@ -16,7 +16,7 @@ import type { AudibleRegion } from '../types/audible';
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type SupportedLanguage = 'en' | 'de' | 'es'; export type SupportedLanguage = 'en' | 'de' | 'es' | 'fr';
export interface ScrapingConfig { export interface ScrapingConfig {
/** Audible locale query-param value (e.g. 'english', 'deutsch') */ /** Audible locale query-param value (e.g. 'english', 'deutsch') */
@@ -170,6 +170,38 @@ const SPANISH_CONFIG: LanguageConfig = {
}, },
}; };
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 // Lookup Maps
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -178,6 +210,7 @@ export const LANGUAGE_CONFIGS: Record<SupportedLanguage, LanguageConfig> = {
en: ENGLISH_CONFIG, en: ENGLISH_CONFIG,
de: GERMAN_CONFIG, de: GERMAN_CONFIG,
es: SPANISH_CONFIG, es: SPANISH_CONFIG,
fr: FRENCH_CONFIG,
}; };
/** /**
@@ -192,6 +225,7 @@ export const REGION_LANGUAGE_MAP: Record<AudibleRegion, SupportedLanguage> = {
in: 'en', in: 'en',
de: 'de', de: 'de',
es: 'es', es: 'es',
fr: 'fr',
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+7
View File
@@ -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;
+19
View File
@@ -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() { export function useFetchEbookByAsin() {
const { accessToken } = useAuth(); const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
+4 -3
View File
@@ -6,6 +6,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import https from 'https'; import https from 'https';
import path from 'path'; import path from 'path';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import * as parseTorrentModule from 'parse-torrent'; import * as parseTorrentModule from 'parse-torrent';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
@@ -49,7 +50,7 @@ export class DelugeService implements IDownloadClient {
? new https.Agent({ rejectUnauthorized: false }) : undefined; ? new https.Agent({ rejectUnauthorized: false }) : undefined;
if (httpsAgent) logger.info('[Deluge] SSL certificate verification disabled'); if (httpsAgent) logger.info('[Deluge] SSL certificate verification disabled');
this.client = axios.create({ baseURL: this.baseUrl, timeout: 30000, httpsAgent }); this.client = axios.create({ baseURL: this.baseUrl, timeout: DOWNLOAD_CLIENT_TIMEOUT, httpsAgent });
} }
/** JSON-RPC call with automatic re-authentication on auth failure */ /** JSON-RPC call with automatic re-authentication on auth failure */
@@ -190,7 +191,7 @@ export class DelugeService implements IDownloadClient {
try { try {
torrentResponse = await axios.get(torrentUrl, { torrentResponse = await axios.get(torrentUrl, {
responseType: 'arraybuffer', maxRedirects: 0, responseType: 'arraybuffer', maxRedirects: 0,
validateStatus: (s) => s >= 200 && s < 300, timeout: 30000, validateStatus: (s) => s >= 200 && s < 300, timeout: DOWNLOAD_CLIENT_TIMEOUT,
}); });
if (torrentResponse.data.length > 0) { if (torrentResponse.data.length > 0) {
const magnetMatch = torrentResponse.data.toString().match(/^magnet:\?[^\s]+$/); const magnetMatch = torrentResponse.data.toString().match(/^magnet:\?[^\s]+$/);
@@ -203,7 +204,7 @@ export class DelugeService implements IDownloadClient {
const loc = error.response.headers['location']; const loc = error.response.headers['location'];
if (loc?.startsWith('magnet:')) return this.addMagnetLink(loc, category, options); if (loc?.startsWith('magnet:')) return this.addMagnetLink(loc, category, options);
if (loc?.startsWith('http://') || loc?.startsWith('https://')) { if (loc?.startsWith('http://') || loc?.startsWith('https://')) {
try { torrentResponse = await axios.get(loc, { responseType: 'arraybuffer', timeout: 30000, maxRedirects: 5 }); } 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'); } catch { throw new Error('Failed to download torrent file after redirect'); }
} else { throw new Error(`Invalid redirect location: ${loc}`); } } else { throw new Error(`Invalid redirect location: ${loc}`); }
} else { throw new Error(`Failed to download torrent: HTTP ${status}`); } } else { throw new Error(`Failed to download torrent: HTTP ${status}`); }
+3 -2
View File
@@ -5,6 +5,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import { XMLParser } from 'fast-xml-parser'; import { XMLParser } from 'fast-xml-parser';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import { TorrentResult } from '../utils/ranking-algorithm'; import { TorrentResult } from '../utils/ranking-algorithm';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
@@ -87,7 +88,7 @@ export class ProwlarrService {
headers: { headers: {
'X-Api-Key': this.apiKey, '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: { paramsSerializer: {
serialize: (params) => { serialize: (params) => {
// Custom serializer to handle arrays correctly for Prowlarr API // Custom serializer to handle arrays correctly for Prowlarr API
@@ -314,7 +315,7 @@ export class ProwlarrService {
limit: 100, limit: 100,
extended: 1, extended: 1,
}, },
timeout: 60000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
responseType: 'text', // Get XML as text responseType: 'text', // Get XML as text
}); });
+16 -5
View File
@@ -6,6 +6,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import https from 'https'; import https from 'https';
import path from 'path'; import path from 'path';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import * as parseTorrentModule from 'parse-torrent'; import * as parseTorrentModule from 'parse-torrent';
import FormData from 'form-data'; import FormData from 'form-data';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
@@ -140,7 +141,7 @@ export class QBittorrentService implements IDownloadClient {
this.client = axios.create({ this.client = axios.create({
baseURL: `${this.baseUrl}/api/v2`, baseURL: `${this.baseUrl}/api/v2`,
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
httpsAgent: this.httpsAgent, httpsAgent: this.httpsAgent,
// Support nginx/Apache reverse proxy with HTTP Basic Auth // Support nginx/Apache reverse proxy with HTTP Basic Auth
auth: { auth: {
@@ -352,7 +353,7 @@ export class QBittorrentService implements IDownloadClient {
responseType: 'arraybuffer', responseType: 'arraybuffer',
maxRedirects: 0, maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 300, // Only 2xx is success validateStatus: (status) => status >= 200 && status < 300, // Only 2xx is success
timeout: 30000, // 30 seconds - public indexers can be slow timeout: DOWNLOAD_CLIENT_TIMEOUT,
}); });
logger.info(` Got 2xx response, size=${torrentResponse.data.length} bytes`); logger.info(` Got 2xx response, size=${torrentResponse.data.length} bytes`);
@@ -394,7 +395,7 @@ export class QBittorrentService implements IDownloadClient {
try { try {
torrentResponse = await axios.get(location, { torrentResponse = await axios.get(location, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
maxRedirects: 5, maxRedirects: 5,
}); });
logger.info(` After following redirect: size=${torrentResponse.data.length} bytes`); logger.info(` After following redirect: size=${torrentResponse.data.length} bytes`);
@@ -1088,17 +1089,27 @@ export class QBittorrentService implements IDownloadClient {
* Map a TorrentInfo object to the unified DownloadInfo format. * Map a TorrentInfo object to the unified DownloadInfo format.
*/ */
protected mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo { protected mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo {
const status = this.mapStateToDownloadStatus(torrent.state);
// content_path is the canonical path from qBittorrent — always use it directly.
// It correctly handles all torrent structures (multi-file folders, single files,
// single files in wrapper folders, name mismatches).
// For TempPathEnabled race detection, we expose save_path so the monitor can
// compare and wait for files to relocate before triggering file organization.
const downloadPath = torrent.content_path || path.join(torrent.save_path, torrent.name);
return { return {
id: torrent.hash, id: torrent.hash,
name: torrent.name, name: torrent.name,
size: torrent.size, size: torrent.size,
bytesDownloaded: torrent.downloaded, bytesDownloaded: torrent.downloaded,
progress: torrent.progress, progress: torrent.progress,
status: this.mapStateToDownloadStatus(torrent.state), status,
downloadSpeed: torrent.dlspeed, downloadSpeed: torrent.dlspeed,
eta: torrent.eta, eta: torrent.eta,
category: torrent.category, category: torrent.category,
downloadPath: torrent.content_path || path.join(torrent.save_path, torrent.name), downloadPath,
savePath: torrent.save_path,
completedAt: torrent.completion_on > 0 ? new Date(torrent.completion_on * 1000) : undefined, completedAt: torrent.completion_on > 0 ? new Date(torrent.completion_on * 1000) : undefined,
seedingTime: torrent.seeding_time, seedingTime: torrent.seeding_time,
ratio: torrent.ratio, ratio: torrent.ratio,
+4 -3
View File
@@ -6,6 +6,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import https from 'https'; import https from 'https';
import path from 'path'; import path from 'path';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import * as parseTorrentModule from 'parse-torrent'; import * as parseTorrentModule from 'parse-torrent';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
@@ -106,7 +107,7 @@ export class TransmissionService implements IDownloadClient {
this.client = axios.create({ this.client = axios.create({
baseURL: this.baseUrl, baseURL: this.baseUrl,
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
httpsAgent: this.httpsAgent, httpsAgent: this.httpsAgent,
}); });
} }
@@ -274,7 +275,7 @@ export class TransmissionService implements IDownloadClient {
responseType: 'arraybuffer', responseType: 'arraybuffer',
maxRedirects: 0, maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 300, validateStatus: (status) => status >= 200 && status < 300,
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
}); });
// Check if response body is a magnet link // Check if response body is a magnet link
@@ -302,7 +303,7 @@ export class TransmissionService implements IDownloadClient {
try { try {
torrentResponse = await axios.get(location, { torrentResponse = await axios.get(location, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
maxRedirects: 5, maxRedirects: 5,
}); });
} catch { } catch {
@@ -82,6 +82,8 @@ export interface DownloadInfo {
category: string; category: string;
/** Filesystem path where download is stored (available after completion) */ /** Filesystem path where download is stored (available after completion) */
downloadPath?: string; downloadPath?: string;
/** Configured save directory (torrent clients only, used for path readiness detection) */
savePath?: string;
/** When the download completed */ /** When the download completed */
completedAt?: Date; completedAt?: Date;
/** Error message if download failed */ /** Error message if download failed */
@@ -32,7 +32,7 @@ function getBackoffDelay(stallCount: number): number {
export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> { export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> {
const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId, const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId,
lastProgress: prevProgress, stallCount: prevStallCount } = payload; lastProgress: prevProgress, stallCount: prevStallCount, pathWaitCount: prevPathWaitCount } = payload;
const logger = RMABLogger.forJob(jobId, 'MonitorDownload'); const logger = RMABLogger.forJob(jobId, 'MonitorDownload');
@@ -95,6 +95,32 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
throw new Error('Download path not available from download client'); throw new Error('Download path not available from download client');
} }
// Detect TempPathEnabled race: content_path hasn't been relocated to save_path yet
if (info.savePath && downloadPath) {
const normalizedSave = info.savePath.endsWith('/') ? info.savePath : info.savePath + '/';
if (!downloadPath.startsWith(normalizedSave)) {
const waitCount = (prevPathWaitCount ?? 0) + 1;
const MAX_PATH_WAIT = 30; // Give up after ~5 minutes
if (waitCount < MAX_PATH_WAIT) {
const delay = Math.min(10, waitCount * 2); // 2s, 4s, 6s... up to 10s
logger.info(`Download path still in temp location, waiting for relocation (${waitCount}/${MAX_PATH_WAIT})`, {
downloadPath, savePath: info.savePath,
});
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId, downloadHistoryId, downloadClientId, downloadClient,
delay, 100, 0, waitCount
);
return { success: true, completed: false, message: 'Waiting for file relocation', pathWaitCount: waitCount };
}
logger.warn(`Download path still in temp location after ${waitCount} checks, proceeding with organization`);
}
}
// Get path mapping configuration from the specific download client // Get path mapping configuration from the specific download client
const clientConfig = await manager.getClientForProtocol(protocol); const clientConfig = await manager.getClientForProtocol(protocol);
+67 -3
View File
@@ -11,6 +11,7 @@ import { getLibraryService } from '../services/library';
import { getConfigService } from '../services/config.service'; import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service'; import { getDownloadClientManager } from '../services/download-client-manager.service';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface'; import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import { generateFilesHash } from '../utils/files-hash'; import { generateFilesHash } from '../utils/files-hash';
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer'; import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers'; import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
@@ -128,7 +129,19 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
}); });
const template = templateConfig?.value || '{author}/{title} {asin}'; const template = templateConfig?.value || '{author}/{title} {asin}';
// Organize files (pass template and logger to file organizer) // Read file rename configuration
const fileRenameEnabledConfig = await prisma.configuration.findUnique({
where: { key: 'file_rename_enabled' },
});
const fileRenameTemplateConfig = await prisma.configuration.findUnique({
where: { key: 'file_rename_template' },
});
const renameConfig = {
enabled: fileRenameEnabledConfig?.value === 'true',
template: fileRenameTemplateConfig?.value || '{title}',
};
// Organize files (pass template, logger, and rename config to file organizer)
const result = await organizer.organize( const result = await organizer.organize(
downloadPath, downloadPath,
{ {
@@ -142,7 +155,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
seriesPart: audiobook.seriesPart || undefined, seriesPart: audiobook.seriesPart || undefined,
}, },
template, template,
jobId ? { jobId, context: 'FileOrganizer' } : undefined jobId ? { jobId, context: 'FileOrganizer' } : undefined,
renameConfig
); );
if (!result.success) { if (!result.success) {
@@ -296,6 +310,43 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
// Still have retries left - queue for re-import // Still have retries left - queue for re-import
logger.warn(`Retryable error for request ${requestId}, queueing for retry (attempt ${newAttempts}/${currentRequest.maxImportRetries})`); logger.warn(`Retryable error for request ${requestId}, queueing for retry (attempt ${newAttempts}/${currentRequest.maxImportRetries})`);
// Re-query download client for fresh path (content_path may have been updated since handoff)
try {
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
if (downloadHistory?.downloadClientId && downloadHistory?.downloadClient && downloadHistory.downloadClient !== 'direct') {
const configService = getConfigService();
const dlManager = getDownloadClientManager(configService);
const dlProtocol = CLIENT_PROTOCOL_MAP[downloadHistory.downloadClient as DownloadClientType];
if (dlProtocol) {
const dlClient = await dlManager.getClientServiceForProtocol(dlProtocol);
if (dlClient) {
const freshInfo = await dlClient.getDownload(downloadHistory.downloadClientId);
if (freshInfo?.downloadPath && freshInfo.downloadPath !== downloadPath) {
// Apply path mapping and update stored path
const clientConfig = await dlManager.getClientForProtocol(dlProtocol);
const pathMappingConfig: PathMappingConfig = clientConfig?.remotePathMappingEnabled
? { enabled: true, remotePath: clientConfig.remotePath || '', localPath: clientConfig.localPath || '' }
: { enabled: false, remotePath: '', localPath: '' };
const freshPath = PathMapper.transform(freshInfo.downloadPath, pathMappingConfig);
logger.info(`Download client returned updated path: ${freshPath} (was: ${downloadPath})`);
await prisma.downloadHistory.update({
where: { id: downloadHistory.id },
data: { downloadPath: freshPath },
});
}
}
}
}
} catch (refreshError) {
logger.warn(`Failed to refresh download path: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`);
}
await prisma.request.update({ await prisma.request.update({
where: { id: requestId }, where: { id: requestId },
data: { data: {
@@ -556,6 +607,18 @@ async function processEbookOrganization(
} }
} }
// Read file rename configuration
const fileRenameEnabledConfig = await prisma.configuration.findUnique({
where: { key: 'file_rename_enabled' },
});
const fileRenameTemplateConfig = await prisma.configuration.findUnique({
where: { key: 'file_rename_template' },
});
const ebookRenameConfig = {
enabled: fileRenameEnabledConfig?.value === 'true',
template: fileRenameTemplateConfig?.value || '{title}',
};
// Organize ebook files (organizer will detect ebook type and skip audio-specific processing) // Organize ebook files (organizer will detect ebook type and skip audio-specific processing)
// Pass all metadata that could be used in path templates (same as audiobooks) // Pass all metadata that could be used in path templates (same as audiobooks)
const result = await organizer.organizeEbook( const result = await organizer.organizeEbook(
@@ -571,7 +634,8 @@ async function processEbookOrganization(
}, },
template, template,
jobId ? { jobId, context: 'FileOrganizer.Ebook' } : undefined, jobId ? { jobId, context: 'FileOrganizer.Ebook' } : undefined,
isIndexerDownload isIndexerDownload,
ebookRenameConfig
); );
// Clean up fixed EPUB temp file after organization (regardless of success) // Clean up fixed EPUB temp file after organization (regardless of success)
+4 -1
View File
@@ -65,6 +65,7 @@ export interface MonitorDownloadPayload extends JobPayload {
downloadClient: DownloadClientType; downloadClient: DownloadClientType;
lastProgress?: number; // Previous poll's progress (0-100) for stall detection lastProgress?: number; // Previous poll's progress (0-100) for stall detection
stallCount?: number; // Consecutive polls with no progress change (drives backoff) stallCount?: number; // Consecutive polls with no progress change (drives backoff)
pathWaitCount?: number; // Consecutive polls waiting for content_path to relocate to save_path
} }
export interface OrganizeFilesPayload extends JobPayload { export interface OrganizeFilesPayload extends JobPayload {
@@ -567,7 +568,8 @@ export class JobQueueService {
downloadClient: DownloadClientType, downloadClient: DownloadClientType,
delaySeconds: number = 0, delaySeconds: number = 0,
lastProgress?: number, lastProgress?: number,
stallCount?: number stallCount?: number,
pathWaitCount?: number
): Promise<string> { ): Promise<string> {
return await this.addJob( return await this.addJob(
'monitor_download', 'monitor_download',
@@ -578,6 +580,7 @@ export class JobQueueService {
downloadClient, downloadClient,
lastProgress, lastProgress,
stallCount, stallCount,
pathWaitCount,
} as MonitorDownloadPayload, } as MonitorDownloadPayload,
{ {
priority: 5, // Medium priority priority: 5, // Medium priority
+8 -1
View File
@@ -5,7 +5,7 @@
import type { SupportedLanguage } from '../constants/language-config'; import type { SupportedLanguage } from '../constants/language-config';
export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in' | 'de' | 'es'; export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in' | 'de' | 'es' | 'fr';
export interface AudibleRegionConfig { export interface AudibleRegionConfig {
code: AudibleRegion; code: AudibleRegion;
@@ -64,6 +64,13 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
baseUrl: 'https://www.audible.es', baseUrl: 'https://www.audible.es',
audnexusParam: 'es', audnexusParam: 'es',
language: 'es', language: 'es',
},
fr: {
code: 'fr',
name: 'France',
baseUrl: 'https://www.audible.fr',
audnexusParam: 'fr',
language: 'fr',
} }
}; };
+132 -7
View File
@@ -20,7 +20,7 @@ import {
checkDiskSpace, checkDiskSpace,
} from './chapter-merger'; } from './chapter-merger';
import { prisma } from '../db'; import { prisma } from '../db';
import { substituteTemplate, type TemplateVariables } from './path-template.util'; import { substituteTemplate, buildRenamedFilename, type TemplateVariables } from './path-template.util';
import { AUDIO_EXTENSIONS } from '../constants/audio-formats'; import { AUDIO_EXTENSIONS } from '../constants/audio-formats';
export interface AudiobookMetadata { export interface AudiobookMetadata {
@@ -77,7 +77,8 @@ export class FileOrganizer {
downloadPath: string, downloadPath: string,
audiobook: AudiobookMetadata, audiobook: AudiobookMetadata,
template: string, template: string,
loggerConfig?: LoggerConfig loggerConfig?: LoggerConfig,
renameConfig?: { enabled: boolean; template: string }
): Promise<OrganizationResult> { ): Promise<OrganizationResult> {
// Create logger if config provided // Create logger if config provided
const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null; const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null;
@@ -294,8 +295,21 @@ export class FileOrganizer {
// Create target directory // Create target directory
await fs.mkdir(targetPath, { recursive: true }); await fs.mkdir(targetPath, { recursive: true });
// Determine if file renaming should be applied
const shouldRename = renameConfig?.enabled && renameConfig.template;
const isMultiFile = audioFiles.length > 1;
const duplicateBasenames = this.findDuplicateBasenames(audioFiles);
const usedTargetFilenames = new Set<string>();
if (shouldRename) {
await logger?.info(`File renaming enabled with template: ${renameConfig.template}${isMultiFile ? ` (${audioFiles.length} files, indices will be appended)` : ''}`);
} else if (duplicateBasenames.size > 0) {
await logger?.info(`Detected ${duplicateBasenames.size} duplicate source filename(s); applying folder-aware naming to avoid collisions`);
}
// Copy audio files (do NOT delete originals - needed for seeding) // Copy audio files (do NOT delete originals - needed for seeding)
for (const audioFile of audioFiles) { for (let i = 0; i < audioFiles.length; i++) {
const audioFile = audioFiles[i];
// Handle merged files (absolute paths) vs original files (relative paths) // Handle merged files (absolute paths) vs original files (relative paths)
const isAbsolutePath = path.isAbsolute(audioFile); const isAbsolutePath = path.isAbsolute(audioFile);
const originalSourcePath = isAbsolutePath const originalSourcePath = isAbsolutePath
@@ -303,7 +317,35 @@ export class FileOrganizer {
: isFile : isFile
? downloadPath ? downloadPath
: path.join(downloadPath, audioFile); : path.join(downloadPath, audioFile);
const filename = path.basename(audioFile);
// Determine target filename (apply rename template if enabled)
let filename: string;
if (shouldRename) {
const ext = path.extname(audioFile);
const variables: TemplateVariables = {
author: audiobook.author,
title: audiobook.title,
narrator: audiobook.narrator,
asin: audiobook.asin,
year: audiobook.year,
series: audiobook.series,
seriesPart: audiobook.seriesPart,
};
filename = buildRenamedFilename(
renameConfig.template,
variables,
ext,
isMultiFile ? i + 1 : undefined,
);
filename = this.makeUniqueFilename(filename, usedTargetFilenames);
} else {
filename = this.buildSourceAwareFilename(
audioFile,
duplicateBasenames,
usedTargetFilenames
);
}
const targetFilePath = path.join(targetPath, filename); const targetFilePath = path.join(targetPath, filename);
// Check if we have a tagged version of this file // Check if we have a tagged version of this file
@@ -595,6 +637,72 @@ export class FileOrganizer {
); );
} }
private findDuplicateBasenames(files: string[]): Set<string> {
const counts = new Map<string, number>();
for (const file of files) {
const basename = path.basename(file);
counts.set(basename, (counts.get(basename) || 0) + 1);
}
return new Set(
Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([basename]) => basename)
);
}
private buildSourceAwareFilename(
sourcePath: string,
duplicateBasenames: Set<string>,
usedFilenames: Set<string>
): string {
const basename = path.basename(sourcePath);
const ext = path.extname(basename);
const stem = path.basename(basename, ext);
let candidate = basename;
// Preserve folder context for duplicate track names (e.g. CD1/Track01.mp3,
// CD2/Track01.mp3) so each file keeps a unique target name.
if (duplicateBasenames.has(basename) && !path.isAbsolute(sourcePath)) {
const folder = path.dirname(sourcePath);
if (folder !== '.') {
const folderPrefix = folder
.split(path.sep)
.filter(Boolean)
.map((segment) => this.sanitizePath(segment))
.join('-');
if (folderPrefix) {
candidate = `${folderPrefix}-${stem}${ext}`;
}
}
}
return this.makeUniqueFilename(candidate, usedFilenames);
}
private makeUniqueFilename(filename: string, usedFilenames: Set<string>): string {
if (!usedFilenames.has(filename)) {
usedFilenames.add(filename);
return filename;
}
const ext = path.extname(filename);
const stem = path.basename(filename, ext);
let suffix = 2;
while (true) {
const candidate = `${stem} (${suffix})${ext}`;
if (!usedFilenames.has(candidate)) {
usedFilenames.add(candidate);
return candidate;
}
suffix++;
}
}
/** /**
* Download cover art from URL or copy from local cache * Download cover art from URL or copy from local cache
*/ */
@@ -690,7 +798,8 @@ export class FileOrganizer {
metadata: { title: string; author: string; narrator?: string; asin?: string; year?: number; series?: string; seriesPart?: string }, metadata: { title: string; author: string; narrator?: string; asin?: string; year?: number; series?: string; seriesPart?: string },
template: string, template: string,
loggerConfig?: LoggerConfig, loggerConfig?: LoggerConfig,
isIndexerDownload: boolean = false isIndexerDownload: boolean = false,
renameConfig?: { enabled: boolean; template: string }
): Promise<EbookOrganizationResult> { ): Promise<EbookOrganizationResult> {
const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null; const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null;
@@ -739,9 +848,25 @@ export class FileOrganizer {
// Create target directory // Create target directory
await fs.mkdir(targetDir, { recursive: true }); await fs.mkdir(targetDir, { recursive: true });
// Build target filename (sanitize source filename) // Build target filename (apply rename template if enabled, otherwise sanitize source filename)
const sourceFilename = path.basename(ebookFile); const sourceFilename = path.basename(ebookFile);
const targetFilename = this.sanitizePath(sourceFilename); let targetFilename: string;
if (renameConfig?.enabled && renameConfig.template) {
const originalExt = path.extname(ebookFile);
const variables: TemplateVariables = {
author: metadata.author,
title: metadata.title,
narrator: metadata.narrator,
asin: metadata.asin,
year: metadata.year,
series: metadata.series,
seriesPart: metadata.seriesPart,
};
targetFilename = buildRenamedFilename(renameConfig.template, variables, originalExt);
await logger?.info(`Renamed ebook file: ${sourceFilename} -> ${targetFilename}`);
} else {
targetFilename = this.sanitizePath(sourceFilename);
}
const targetPath = path.join(targetDir, targetFilename); const targetPath = path.join(targetDir, targetFilename);
// Check if target already exists // Check if target already exists
+30
View File
@@ -10,6 +10,7 @@ const logger = RMABLogger.create('JWT');
const JWT_SECRET = process.env.JWT_SECRET || 'change-this-to-a-random-secret-key'; const JWT_SECRET = process.env.JWT_SECRET || 'change-this-to-a-random-secret-key';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-this-to-another-random-secret-key'; const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-this-to-another-random-secret-key';
const JWT_DOWNLOAD_SECRET = process.env.JWT_DOWNLOAD_SECRET || JWT_SECRET + '-download';
const ACCESS_TOKEN_EXPIRY = '1h'; // 1 hour const ACCESS_TOKEN_EXPIRY = '1h'; // 1 hour
const REFRESH_TOKEN_EXPIRY = '7d'; // 7 days const REFRESH_TOKEN_EXPIRY = '7d'; // 7 days
@@ -78,6 +79,35 @@ export function verifyRefreshToken(token: string): RefreshTokenPayload | null {
} }
} }
const DOWNLOAD_TOKEN_EXPIRY = '30d';
export interface DownloadTokenPayload {
sub: string; // userId
requestId: string;
type: 'download';
}
/**
* Generate download token (30-day, stateless, URL-embeddable)
*/
export function generateDownloadToken(userId: string, requestId: string): string {
const payload: DownloadTokenPayload = { sub: userId, requestId, type: 'download' };
return jwt.sign(payload, JWT_DOWNLOAD_SECRET, { expiresIn: DOWNLOAD_TOKEN_EXPIRY });
}
/**
* Verify download token
*/
export function verifyDownloadToken(token: string): DownloadTokenPayload | null {
try {
const decoded = jwt.verify(token, JWT_DOWNLOAD_SECRET) as DownloadTokenPayload;
if (decoded.type !== 'download') return null;
return decoded;
} catch {
return null;
}
}
/** /**
* Decode token without verification (for debugging) * Decode token without verification (for debugging)
*/ */
+299 -6
View File
@@ -68,6 +68,81 @@ function sanitizePath(name: string): string {
); );
} }
/**
* Find valid template variable names within arbitrary content text.
* Sorts by length descending to prevent substring false matches
* (e.g., 'seriesPart' matched before 'series').
* Uses word-boundary detection to avoid matching variable names
* that are substrings of other words.
*/
function findVariablesInContent(content: string): string[] {
const sortedVars = [...VALID_VARIABLES].sort((a, b) => b.length - a.length);
const found: string[] = [];
for (const varName of sortedVars) {
const regex = new RegExp(`(?<![a-zA-Z0-9])${varName}(?![a-zA-Z0-9])`);
if (regex.test(content)) {
found.push(varName);
}
}
return found;
}
/**
* Resolve conditional blocks in the template.
* A conditional block is {literal text varName more text} where the entire
* content is rendered only if ALL variables inside have non-empty values.
* If any variable is empty/missing, the entire block is removed.
*
* Simple variable references like {author} are left untouched for the
* existing substitution logic to handle.
*
* Must run after escaped-brace replacement and before simple variable substitution.
*/
function resolveConditionalBlocks(
template: string,
variables: TemplateVariables
): string {
return template.replace(/\{([^}]+)\}/g, (match, content: string) => {
// If content is exactly a valid variable name, skip (leave for simple substitution)
if (VALID_VARIABLES.includes(content)) {
return match;
}
// Find variables in the content
const foundVars = findVariablesInContent(content);
// If no variables found, leave as-is (validation will catch it)
if (foundVars.length === 0) {
return match;
}
// Check if all found variables have non-empty values
const allPresent = foundVars.every(varName => {
const value = variables[varName as keyof TemplateVariables];
return value !== undefined && value !== null && String(value).trim() !== '';
});
if (!allPresent) {
return '';
}
// Substitute variables within the content, output rest as literal text
// Sort by length descending to prevent substring false matches
let result = content;
const sortedVars = [...foundVars].sort((a, b) => b.length - a.length);
for (const varName of sortedVars) {
const value = variables[varName as keyof TemplateVariables];
const sanitizedValue = sanitizePath(String(value).trim());
const regex = new RegExp(`(?<![a-zA-Z0-9])${varName}(?![a-zA-Z0-9])`, 'g');
result = result.replace(regex, sanitizedValue);
}
return result;
});
}
/** /**
* Substitute template variables with actual values * Substitute template variables with actual values
* *
@@ -99,6 +174,9 @@ export function substituteTemplate(
// so they survive the variable substitution and path cleanup steps // so they survive the variable substitution and path cleanup steps
result = result.replace(/\\\{/g, LBRACE_PLACEHOLDER).replace(/\\\}/g, RBRACE_PLACEHOLDER); result = result.replace(/\\\{/g, LBRACE_PLACEHOLDER).replace(/\\\}/g, RBRACE_PLACEHOLDER);
// Resolve conditional blocks before simple variable substitution
result = resolveConditionalBlocks(result, variables);
// Substitute each variable // Substitute each variable
for (const key of VALID_VARIABLES) { for (const key of VALID_VARIABLES) {
const value = variables[key as keyof TemplateVariables]; const value = variables[key as keyof TemplateVariables];
@@ -187,21 +265,47 @@ export function validateTemplate(template: string): ValidationResult {
if (variableMatches) { if (variableMatches) {
for (const match of variableMatches) { for (const match of variableMatches) {
const varName = match.slice(1, -1); // Remove { and } const content = match.slice(1, -1); // Remove { and }
if (!VALID_VARIABLES.includes(varName)) { // Simple variable — exact match to a valid variable name
if (VALID_VARIABLES.includes(content)) {
continue;
}
// Conditional block — must contain at least one valid variable
const foundVars = findVariablesInContent(content);
if (foundVars.length === 0) {
return { return {
valid: false, valid: false,
error: `Unknown variable: {${varName}}. Valid variables are: ${VALID_VARIABLES.map(v => `{${v}}`).join(', ')}` error: `No valid variable found in conditional block: {${content}}. Valid variables are: ${VALID_VARIABLES.map(v => `{${v}}`).join(', ')}`
};
}
// Check literal text inside conditional block for invalid path chars
let literalText = content;
const sortedVars = [...foundVars].sort((a, b) => b.length - a.length);
for (const varName of sortedVars) {
literalText = literalText.replace(
new RegExp(`(?<![a-zA-Z0-9])${varName}(?![a-zA-Z0-9])`, 'g'),
''
);
}
const invalidCharsInBlock = literalText.match(INVALID_PATH_CHARS);
if (invalidCharsInBlock) {
return {
valid: false,
error: `Invalid characters found: ${[...new Set(invalidCharsInBlock)].join(', ')}. These characters are not allowed in path templates.`
}; };
} }
} }
} }
// Remove valid variables temporarily to check for invalid characters // Remove valid variables and conditional blocks to check remaining text for invalid chars
let templateWithoutVars = templateWithoutEscapedBraces; let templateWithoutVars = templateWithoutEscapedBraces;
for (const varName of VALID_VARIABLES) { if (variableMatches) {
templateWithoutVars = templateWithoutVars.replace(new RegExp(`\\{${varName}\\}`, 'g'), ''); for (const match of variableMatches) {
templateWithoutVars = templateWithoutVars.replace(match, '');
}
} }
// Check for invalid characters outside of variables // Check for invalid characters outside of variables
@@ -286,3 +390,192 @@ export function generateMockPreviews(template: string): string[] {
export function getValidVariables(): string[] { export function getValidVariables(): string[] {
return [...VALID_VARIABLES]; return [...VALID_VARIABLES];
} }
/**
* Validate a filename template string
*
* Similar to validateTemplate but for filenames (not paths):
* - Disallows forward slashes (no directory separators in filenames)
* - Does not require relative path structure
* - Must contain at least one variable
*
* @param template - Filename template string to validate
* @returns Validation result with error message if invalid
*/
export function validateFilenameTemplate(template: string): ValidationResult {
if (!template || template.trim().length === 0) {
return {
valid: false,
error: 'Filename template cannot be empty',
};
}
// Disallow forward slashes — filenames cannot contain directory separators
if (template.includes('/')) {
return {
valid: false,
error: 'Filename template cannot contain "/" (directory separators). Use the organization template for directory structure.',
};
}
// Disallow backslashes that aren't brace escapes
if (/\\(?![{}])/.test(template)) {
return {
valid: false,
error: 'Filename template cannot contain backslashes. Use the organization template for directory structure.',
};
}
// Strip escaped braces before parsing
const templateWithoutEscapedBraces = template.replace(/\\[{}]/g, '');
// Extract all variables from the stripped template
const variableMatches = templateWithoutEscapedBraces.match(/\{[^}]+\}/g);
if (variableMatches) {
for (const match of variableMatches) {
const content = match.slice(1, -1);
// Simple variable
if (VALID_VARIABLES.includes(content)) {
continue;
}
// Conditional block — must contain at least one valid variable
const foundVars = findVariablesInContent(content);
if (foundVars.length === 0) {
return {
valid: false,
error: `No valid variable found in: {${content}}. Valid variables are: ${VALID_VARIABLES.map(v => `{${v}}`).join(', ')}`,
};
}
// Check literal text inside conditional block for invalid filename chars
let literalText = content;
const sortedVars = [...foundVars].sort((a, b) => b.length - a.length);
for (const varName of sortedVars) {
literalText = literalText.replace(
new RegExp(`(?<![a-zA-Z0-9])${varName}(?![a-zA-Z0-9])`, 'g'),
''
);
}
const invalidCharsInBlock = literalText.match(INVALID_PATH_CHARS);
if (invalidCharsInBlock) {
return {
valid: false,
error: `Invalid characters found: ${[...new Set(invalidCharsInBlock)].join(', ')}. These characters are not allowed in filenames.`,
};
}
}
}
// Remove valid variables and conditional blocks to check remaining text
let templateWithoutVars = templateWithoutEscapedBraces;
if (variableMatches) {
for (const match of variableMatches) {
templateWithoutVars = templateWithoutVars.replace(match, '');
}
}
// Check for invalid characters outside of variables
const invalidChars = templateWithoutVars.match(INVALID_PATH_CHARS);
if (invalidChars) {
return {
valid: false,
error: `Invalid characters found: ${[...new Set(invalidChars)].join(', ')}. These characters are not allowed in filenames.`,
};
}
return { valid: true };
}
/**
* Generate mock filename previews using sample audiobook data
*
* Creates example filenames with extensions to demonstrate how the template will look.
* Shows both single-file and multi-file (with index) examples.
*
* @param template - Filename template string
* @returns Object with single and multi-file preview arrays
*/
export function generateMockFilenamePreviews(template: string): {
single: string[];
multi: string[];
} {
const mockData: TemplateVariables[] = [
{
author: 'Brandon Sanderson',
title: 'Mistborn: The Final Empire',
narrator: 'Michael Kramer',
asin: 'B002UZMLXM',
year: 2006,
series: 'The Mistborn Saga',
seriesPart: '1',
},
{
author: 'Douglas Adams',
title: "The Hitchhiker's Guide to the Galaxy",
narrator: 'Stephen Fry',
asin: 'B0009JKV9W',
year: 2005,
series: "Hitchhiker's Guide",
seriesPart: '1',
},
];
const single = mockData.map((variables) => {
const name = substituteTemplate(template, variables);
return `${name}.m4b`;
});
// Show multi-file example with first mock data only
const multiName = substituteTemplate(template, mockData[0]);
const multi = [
`${multiName} - 1.mp3`,
`${multiName} - 2.mp3`,
`${multiName} - 3.mp3`,
];
return { single, multi };
}
/**
* Build a renamed filename from a template, metadata variables, and original extension.
* Optionally appends a 1-based index for multi-file scenarios.
*
* @param template - Filename template string (e.g., "{title}")
* @param variables - Template variables with metadata values
* @param originalExtension - File extension including dot (e.g., ".m4b")
* @param index - Optional 1-based index for multi-file scenarios
* @returns Sanitized filename with extension
*/
export function buildRenamedFilename(
template: string,
variables: TemplateVariables,
originalExtension: string,
index?: number,
): string {
let baseName = substituteTemplate(template, variables);
// substituteTemplate cleans up slashes for paths — but since this is a filename,
// remove any residual slashes that conditional blocks might have introduced
baseName = baseName.replace(/[/\\]/g, '');
// Sanitize again for filename safety
baseName = baseName
.replace(/[<>:"/\\|?*]/g, '')
.trim()
.replace(/^\.+/, '')
.replace(/\.+$/, '')
.replace(/\s+/g, ' ')
.slice(0, 200);
if (index !== undefined) {
baseName = `${baseName} - ${index}`;
}
// Ensure extension starts with a dot
const ext = originalExtension.startsWith('.') ? originalExtension : `.${originalExtension}`;
return `${baseName}${ext}`;
}
+13
View File
@@ -55,3 +55,16 @@ export async function resolveInteractiveSearchAccess(
if (userInteractiveSearchAccess === false) return false; if (userInteractiveSearchAccess === false) return false;
return getGlobalBooleanSetting('interactive_search_access', true); return getGlobalBooleanSetting('interactive_search_access', true);
} }
/**
* Resolve a user's effective download access permission.
*/
export async function resolveDownloadAccess(
userRole: string,
userDownloadAccess: boolean | null
): Promise<boolean> {
if (userRole === 'admin') return true;
if (userDownloadAccess === true) return true;
if (userDownloadAccess === false) return false;
return getGlobalBooleanSetting('download_access', true);
}
@@ -469,6 +469,7 @@ describe('Request Approval Workflow', () => {
role: true, role: true,
autoApproveRequests: true, autoApproveRequests: true,
interactiveSearchAccess: true, interactiveSearchAccess: true,
downloadAccess: true,
}, },
}); });
}); });
+1 -1
View File
@@ -433,7 +433,7 @@ describe('Setup test routes', () => {
expect(payload.success).toBe(true); expect(payload.success).toBe(true);
expect(payload.template).toBeDefined(); expect(payload.template).toBeDefined();
expect(payload.template.isValid).toBe(false); expect(payload.template.isValid).toBe(false);
expect(payload.template.error).toContain('Unknown variable'); expect(payload.template.error).toContain('No valid variable found in conditional block');
expect(payload.template.previewPaths).toBeUndefined(); expect(payload.template.previewPaths).toBeUndefined();
}); });
@@ -33,6 +33,7 @@ vi.mock('@/lib/hooks/useRequests', () => ({
ebookStatus: { ebookSourcesEnabled: false, hasActiveEbookRequest: false }, ebookStatus: { ebookSourcesEnabled: false, hasActiveEbookRequest: false },
revalidate: revalidateEbookStatusMock, revalidate: revalidateEbookStatusMock,
}), }),
useDownloadStatus: () => ({ downloadAvailable: false, requestId: null }),
useFetchEbookByAsin: () => ({ fetchEbook: fetchEbookMock, isLoading: false }), useFetchEbookByAsin: () => ({ fetchEbook: fetchEbookMock, isLoading: false }),
})); }));
@@ -3,6 +3,7 @@
* Documentation: documentation/phase3/qbittorrent.md * Documentation: documentation/phase3/qbittorrent.md
*/ */
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { QBittorrentService, getQBittorrentService, invalidateQBittorrentService } from '@/lib/integrations/qbittorrent.service'; import { QBittorrentService, getQBittorrentService, invalidateQBittorrentService } from '@/lib/integrations/qbittorrent.service';
@@ -328,6 +329,209 @@ describe('QBittorrentService', () => {
}); });
}); });
describe('downloadPath resolution', () => {
describe('normal operation (content_path under save_path)', () => {
it('uses content_path directly for seeding multi-file torrent', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=normal-multi';
clientMock.get.mockResolvedValueOnce({
data: [{
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500,
eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
save_path: '/downloads/', content_path: '/downloads/Audiobook',
completion_on: 1700000000, added_on: 1699000000,
}],
});
const info = await service.getDownload('abc123');
expect(info).not.toBeNull();
expect(info!.status).toBe('seeding');
expect(info!.downloadPath).toBe('/downloads/Audiobook');
expect(info!.savePath).toBe('/downloads/');
});
it('uses content_path directly for single-file torrent in folder', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=normal-single-folder';
clientMock.get.mockResolvedValueOnce({
data: [{
hash: 'abc123', name: 'Audiobook Name', size: 3700000000, progress: 1.0,
dlspeed: 0, upspeed: 0, downloaded: 3700000000, uploaded: 100000,
eta: 0, state: 'stalledUP', category: 'readmeabook', tags: '',
save_path: '/downloads/books/',
content_path: '/downloads/books/Audiobook Folder/Audiobook.m4b',
completion_on: 1700000000, added_on: 1699000000,
}],
});
const info = await service.getDownload('abc123');
expect(info!.status).toBe('seeding');
// Must preserve the full path including intermediate folder
expect(info!.downloadPath).toBe('/downloads/books/Audiobook Folder/Audiobook.m4b');
expect(info!.savePath).toBe('/downloads/books/');
});
it('uses content_path directly when torrent name differs from folder name', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=name-mismatch';
clientMock.get.mockResolvedValueOnce({
data: [{
hash: 'abc123',
name: 'Harry Potter [Full-Cast] (aka Philosophers Stone) - J.K. Rowling',
size: 3006477107, progress: 1.0,
dlspeed: 0, upspeed: 0, downloaded: 3006477107, uploaded: 500000,
eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
save_path: '/downloads/books/',
content_path: '/downloads/books/Harry Potter (Full-Cast Edition) EAC3 6ch - J.K. Rowling',
completion_on: 1700000000, added_on: 1699000000,
}],
});
const info = await service.getDownload('abc123');
expect(info!.status).toBe('seeding');
// Must use content_path (real folder name), NOT torrent.name
expect(info!.downloadPath).toBe(
'/downloads/books/Harry Potter (Full-Cast Edition) EAC3 6ch - J.K. Rowling'
);
expect(info!.downloadPath).not.toContain('[Full-Cast]');
expect(info!.savePath).toBe('/downloads/books/');
});
it('uses content_path directly for all seeding states (pausedUP, stalledUP, forcedUP, queuedUP, stoppedUP)', async () => {
const seedingStates = ['pausedUP', 'stalledUP', 'forcedUP', 'queuedUP', 'stoppedUP'];
for (const state of seedingStates) {
const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = `SID=state-${state}`;
clientMock.get.mockResolvedValueOnce({
data: [{
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 100,
eta: 0, state, category: 'readmeabook', tags: '',
save_path: '/downloads/', content_path: '/downloads/Audiobook',
completion_on: 1700000000, added_on: 1699000000,
}],
});
const info = await service.getDownload('abc123');
expect(info!.status).toBe('seeding');
expect(info!.downloadPath).toBe('/downloads/Audiobook');
}
});
});
describe('TempPathEnabled (content_path outside save_path)', () => {
it('passes through content_path as-is even when pointing to temp dir (monitor handles wait)', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=temppath';
clientMock.get.mockResolvedValueOnce({
data: [{
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500,
eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
save_path: '/downloads/', content_path: '/incomplete/Audiobook',
completion_on: 1700000000, added_on: 1699000000,
}],
});
const info = await service.getDownload('abc123');
expect(info!.status).toBe('seeding');
// content_path is always used directly — monitor detects temp path via savePath
expect(info!.downloadPath).toBe('/incomplete/Audiobook');
expect(info!.savePath).toBe('/downloads/');
});
it('exposes savePath so monitor can detect temp path for pausedUP', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=pausedup-temp';
clientMock.get.mockResolvedValueOnce({
data: [{
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 0,
eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
save_path: '/data/torrents/readmeabook/', content_path: '/tmp/incomplete/Audiobook',
completion_on: 1700000000, added_on: 1699000000,
}],
});
const info = await service.getDownload('abc123');
expect(info!.status).toBe('seeding');
// content_path is always used directly — no reconstruction
expect(info!.downloadPath).toBe('/tmp/incomplete/Audiobook');
expect(info!.savePath).toBe('/data/torrents/readmeabook/');
});
});
describe('downloading torrents', () => {
it('uses content_path for actively downloading torrents', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=downloading';
clientMock.get.mockResolvedValueOnce({
data: [{
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.5,
dlspeed: 5000, upspeed: 0, downloaded: 500, uploaded: 0,
eta: 100, state: 'downloading', category: 'readmeabook', tags: '',
save_path: '/downloads/', content_path: '/incomplete/Audiobook',
completion_on: 0, added_on: 1699000000,
}],
});
const info = await service.getDownload('abc123');
expect(info!.status).toBe('downloading');
// During download, content_path is used as-is (points to where files currently are)
expect(info!.downloadPath).toBe('/incomplete/Audiobook');
});
it('falls back to save_path + name when content_path is empty', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=nocontent';
clientMock.get.mockResolvedValueOnce({
data: [{
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.3,
dlspeed: 1000, upspeed: 0, downloaded: 300, uploaded: 0,
eta: 700, state: 'downloading', category: 'readmeabook', tags: '',
save_path: '/downloads/', content_path: '',
completion_on: 0, added_on: 1699000000,
}],
});
const info = await service.getDownload('abc123');
expect(info!.status).toBe('downloading');
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
});
});
describe('empty content_path fallback', () => {
it('falls back to save_path + name for finished torrents with no content_path', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=nocontent-finished';
clientMock.get.mockResolvedValueOnce({
data: [{
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 0,
eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
save_path: '/downloads/', content_path: '',
completion_on: 1700000000, added_on: 1699000000,
}],
});
const info = await service.getDownload('abc123');
expect(info!.status).toBe('seeding');
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
});
});
});
it('authenticates and stores a session cookie', async () => { it('authenticates and stores a session cookie', async () => {
axiosMock.post.mockResolvedValue({ axiosMock.post.mockResolvedValue({
status: 200, status: 200,
+350 -2
View File
@@ -8,6 +8,9 @@ import {
validateTemplate, validateTemplate,
generateMockPreviews, generateMockPreviews,
getValidVariables, getValidVariables,
validateFilenameTemplate,
generateMockFilenamePreviews,
buildRenamedFilename,
type TemplateVariables type TemplateVariables
} from '@/lib/utils/path-template.util'; } from '@/lib/utils/path-template.util';
@@ -213,6 +216,142 @@ describe('substituteTemplate', () => {
const result = substituteTemplate(template, variables); const result = substituteTemplate(template, variables);
expect(result).toBe('Author/{narrated}/Title'); expect(result).toBe('Author/{narrated}/Title');
}); });
// Conditional block tests
it('should render conditional block when variable has a value', () => {
const template = '{author}/{Book seriesPart - }{title}';
const variables: TemplateVariables = {
author: 'Brandon Sanderson',
title: 'Mistborn',
seriesPart: '1'
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Brandon Sanderson/Book 1 - Mistborn');
});
it('should remove conditional block when variable is missing', () => {
const template = '{author}/{Book seriesPart - }{title}';
const variables: TemplateVariables = {
author: 'Andy Weir',
title: 'Project Hail Mary'
// seriesPart is missing
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Andy Weir/Project Hail Mary');
});
it('should handle conditional block with path separator', () => {
const template = '{author}/{series/Book seriesPart - }{title}';
const variables: TemplateVariables = {
author: 'Brandon Sanderson',
title: 'Mistborn',
series: 'The Mistborn Saga',
seriesPart: '1'
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Brandon Sanderson/The Mistborn Saga/Book 1 - Mistborn');
});
it('should render conditional block when all variables present', () => {
const template = '{author}/{series Book seriesPart}/{title}';
const variables: TemplateVariables = {
author: 'Brandon Sanderson',
title: 'Mistborn',
series: 'The Mistborn Saga',
seriesPart: '1'
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Brandon Sanderson/The Mistborn Saga Book 1/Mistborn');
});
it('should remove conditional block when any variable is missing', () => {
const template = '{author}/{series Book seriesPart}/{title}';
const variables: TemplateVariables = {
author: 'Andy Weir',
title: 'Project Hail Mary',
series: 'Some Series'
// seriesPart is missing
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Andy Weir/Project Hail Mary');
});
it('should handle adjacent conditional blocks', () => {
const template = '{author}/{series - }{Book seriesPart - }{title}';
const variables: TemplateVariables = {
author: 'Brandon Sanderson',
title: 'Mistborn',
series: 'The Mistborn Saga',
seriesPart: '1'
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Brandon Sanderson/The Mistborn Saga - Book 1 - Mistborn');
});
it('should handle conditional block next to simple variable', () => {
const template = '{author}/{series - }{title}';
const variables: TemplateVariables = {
author: 'Andy Weir',
title: 'Project Hail Mary'
// series is missing
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Andy Weir/Project Hail Mary');
});
it('should handle conditional block with year variable', () => {
const template = '{author}/{title} {(year)}';
const variables: TemplateVariables = {
author: 'Brandon Sanderson',
title: 'Mistborn',
year: 2006
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Brandon Sanderson/Mistborn (2006)');
});
it('should remove year conditional block when year is missing', () => {
const template = '{author}/{title} {(year)}';
const variables: TemplateVariables = {
author: 'Andy Weir',
title: 'Project Hail Mary'
// year is missing
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Andy Weir/Project Hail Mary');
});
it('should still handle simple variables correctly (regression)', () => {
const template = '{author}/{title}';
const variables: TemplateVariables = {
author: 'Brandon Sanderson',
title: 'Mistborn'
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Brandon Sanderson/Mistborn');
});
it('should remove conditional block when variable is empty string', () => {
const template = '{author}/{Book seriesPart - }{title}';
const variables: TemplateVariables = {
author: 'Author',
title: 'Title',
seriesPart: ''
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Author/Title');
});
}); });
describe('validateTemplate', () => { describe('validateTemplate', () => {
@@ -247,7 +386,7 @@ describe('validateTemplate', () => {
it('should reject unknown variables', () => { it('should reject unknown variables', () => {
const result = validateTemplate('{author}/{invalid}'); const result = validateTemplate('{author}/{invalid}');
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.error).toContain('Unknown variable'); expect(result.error).toContain('No valid variable found in conditional block');
expect(result.error).toContain('{invalid}'); expect(result.error).toContain('{invalid}');
}); });
@@ -287,12 +426,13 @@ describe('validateTemplate', () => {
it('should provide helpful error messages for multiple unknown variables', () => { it('should provide helpful error messages for multiple unknown variables', () => {
const result = validateTemplate('{author}/{invalid1}/{invalid2}'); const result = validateTemplate('{author}/{invalid1}/{invalid2}');
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.error).toContain('Unknown variable'); expect(result.error).toContain('No valid variable found in conditional block');
}); });
it('should list valid variables in error message', () => { it('should list valid variables in error message', () => {
const result = validateTemplate('{invalid}'); const result = validateTemplate('{invalid}');
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.error).toContain('No valid variable found in conditional block');
expect(result.error).toContain('{author}'); expect(result.error).toContain('{author}');
expect(result.error).toContain('{title}'); expect(result.error).toContain('{title}');
expect(result.error).toContain('{narrator}'); expect(result.error).toContain('{narrator}');
@@ -334,6 +474,34 @@ describe('validateTemplate', () => {
const result = validateTemplate('\\{\\}'); const result = validateTemplate('\\{\\}');
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
}); });
// Conditional block validation tests
it('should accept conditional blocks with valid variables', () => {
const result = validateTemplate('{author}/{Book seriesPart - }{title}');
expect(result.valid).toBe(true);
});
it('should accept conditional blocks with multiple variables', () => {
const result = validateTemplate('{author}/{series Book seriesPart}/{title}');
expect(result.valid).toBe(true);
});
it('should reject conditional blocks with no valid variables', () => {
const result = validateTemplate('{author}/{random text}/{title}');
expect(result.valid).toBe(false);
expect(result.error).toContain('No valid variable found in conditional block');
});
it('should reject conditional blocks with invalid path chars inside', () => {
const result = validateTemplate('{author}/{series: part}/{title}');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid characters');
});
it('should accept mix of simple variables and conditional blocks', () => {
const result = validateTemplate('{author}/{series - }{Book seriesPart - }{title} {(year)}');
expect(result.valid).toBe(true);
});
}); });
describe('generateMockPreviews', () => { describe('generateMockPreviews', () => {
@@ -444,3 +612,183 @@ describe('getValidVariables', () => {
expect(vars1).not.toBe(vars2); // Different array instances expect(vars1).not.toBe(vars2); // Different array instances
}); });
}); });
describe('validateFilenameTemplate', () => {
it('should accept valid filename templates', () => {
const templates = [
'{title}',
'{author} - {title}',
'{title} ({year})',
'{author} - {title} {(year)}',
];
templates.forEach(template => {
const result = validateFilenameTemplate(template);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
});
it('should reject empty templates', () => {
const result = validateFilenameTemplate('');
expect(result.valid).toBe(false);
expect(result.error).toContain('empty');
});
it('should reject templates containing forward slashes', () => {
const result = validateFilenameTemplate('{author}/{title}');
expect(result.valid).toBe(false);
expect(result.error).toContain('/');
expect(result.error).toContain('directory separator');
});
it('should reject templates containing backslashes (not brace escapes)', () => {
const result = validateFilenameTemplate('{author}\\n{title}');
expect(result.valid).toBe(false);
expect(result.error).toContain('backslash');
});
it('should accept escaped braces in filename templates', () => {
const result = validateFilenameTemplate('\\{{title}\\}');
expect(result.valid).toBe(true);
});
it('should reject unknown variables', () => {
const result = validateFilenameTemplate('{invalid}');
expect(result.valid).toBe(false);
expect(result.error).toContain('No valid variable found');
});
it('should reject invalid characters', () => {
const invalidChars = ['<', '>', ':', '"', '|', '?', '*'];
invalidChars.forEach(char => {
const result = validateFilenameTemplate(`{title}${char}extra`);
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid characters');
});
});
it('should accept conditional blocks in filename templates', () => {
const result = validateFilenameTemplate('{title} {(year)}');
expect(result.valid).toBe(true);
});
it('should accept templates with only static text', () => {
const result = validateFilenameTemplate('audiobook');
expect(result.valid).toBe(true);
});
});
describe('generateMockFilenamePreviews', () => {
it('should return single and multi-file previews', () => {
const result = generateMockFilenamePreviews('{title}');
expect(result.single).toBeDefined();
expect(result.multi).toBeDefined();
expect(result.single.length).toBe(2);
expect(result.multi.length).toBe(3);
});
it('should include file extensions in single previews', () => {
const result = generateMockFilenamePreviews('{title}');
result.single.forEach(preview => {
expect(preview).toMatch(/\.m4b$/);
});
});
it('should include index and extensions in multi-file previews', () => {
const result = generateMockFilenamePreviews('{title}');
expect(result.multi[0]).toMatch(/ - 1\.mp3$/);
expect(result.multi[1]).toMatch(/ - 2\.mp3$/);
expect(result.multi[2]).toMatch(/ - 3\.mp3$/);
});
it('should substitute variables correctly', () => {
const result = generateMockFilenamePreviews('{author} - {title}');
expect(result.single[0]).toContain('Brandon Sanderson');
expect(result.single[0]).toContain('Mistborn');
expect(result.single[1]).toContain('Douglas Adams');
});
});
describe('buildRenamedFilename', () => {
const baseVariables: TemplateVariables = {
author: 'Brandon Sanderson',
title: 'Mistborn: The Final Empire',
narrator: 'Michael Kramer',
asin: 'B002UZMLXM',
year: 2006,
};
it('should build a renamed filename with extension', () => {
const result = buildRenamedFilename('{title}', baseVariables, '.m4b');
expect(result).toBe('Mistborn The Final Empire.m4b');
});
it('should append index for multi-file scenarios', () => {
const result = buildRenamedFilename('{title}', baseVariables, '.mp3', 1);
expect(result).toBe('Mistborn The Final Empire - 1.mp3');
});
it('should handle multiple variables', () => {
const result = buildRenamedFilename('{author} - {title}', baseVariables, '.m4b');
expect(result).toBe('Brandon Sanderson - Mistborn The Final Empire.m4b');
});
it('should handle extension without leading dot', () => {
const result = buildRenamedFilename('{title}', baseVariables, 'mp3');
expect(result).toBe('Mistborn The Final Empire.mp3');
});
it('should sanitize invalid characters from variable values', () => {
const vars: TemplateVariables = {
author: 'Author: <Test>',
title: 'Title|Book*'
};
const result = buildRenamedFilename('{author} - {title}', vars, '.m4b');
expect(result).not.toContain(':');
expect(result).not.toContain('<');
expect(result).not.toContain('>');
expect(result).not.toContain('|');
expect(result).not.toContain('*');
});
it('should strip slashes from conditional block output', () => {
const result = buildRenamedFilename('{author}/{title}', baseVariables, '.m4b');
expect(result).not.toContain('/');
expect(result).not.toContain('\\');
});
it('should handle conditional blocks', () => {
const result = buildRenamedFilename('{title} {(year)}', baseVariables, '.m4b');
expect(result).toBe('Mistborn The Final Empire (2006).m4b');
});
it('should remove conditional blocks when variable is missing', () => {
const vars: TemplateVariables = {
author: 'Andy Weir',
title: 'Project Hail Mary',
};
const result = buildRenamedFilename('{title} {(year)}', vars, '.m4b');
expect(result).toBe('Project Hail Mary.m4b');
});
it('should handle index appended after conditional blocks', () => {
const result = buildRenamedFilename('{title} {(year)}', baseVariables, '.mp3', 5);
expect(result).toBe('Mistborn The Final Empire (2006) - 5.mp3');
});
it('should limit very long filenames', () => {
const vars: TemplateVariables = {
author: 'Author',
title: 'A'.repeat(300),
};
const result = buildRenamedFilename('{title}', vars, '.m4b');
// 200 char limit on base name + extension
expect(result.length).toBeLessThanOrEqual(204); // 200 + '.m4b'
});
});
+49
View File
@@ -468,6 +468,55 @@ describe('file organizer', () => {
expect(result.isFile).toBe(false); expect(result.isFile).toBe(false);
}); });
it('keeps nested duplicate track names unique when renaming is disabled', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: [
path.join('CD1', 'Track01.mp3'),
path.join('CD1', 'Track02.mp3'),
path.join('CD2', 'Track01.mp3'),
path.join('CD2', 'Track02.mp3'),
],
coverFile: undefined,
isFile: false,
});
const sourceRoot = path.normalize('/downloads/book');
fsMock.access.mockImplementation(async (filePath: string) => {
const normalized = path.normalize(filePath);
if (normalized.startsWith(sourceRoot)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
}, '{author}/{title}');
const expectedDir = path.join('/media', 'Author', 'Book');
expect(result.success).toBe(true);
expect(result.filesMovedCount).toBe(4);
expect(result.audioFiles).toEqual([
path.join(expectedDir, 'CD1-Track01.mp3'),
path.join(expectedDir, 'CD1-Track02.mp3'),
path.join(expectedDir, 'CD2-Track01.mp3'),
path.join(expectedDir, 'CD2-Track02.mp3'),
]);
expect(copyFileMock.copyFile).toHaveBeenCalledWith(
path.join('/downloads', 'book', 'CD1', 'Track01.mp3'),
path.join(expectedDir, 'CD1-Track01.mp3')
);
expect(copyFileMock.copyFile).toHaveBeenCalledWith(
path.join('/downloads', 'book', 'CD2', 'Track01.mp3'),
path.join(expectedDir, 'CD2-Track01.mp3')
);
});
it('returns no audio files for unsupported single files', async () => { it('returns no audio files for unsupported single files', async () => {
const organizer = new FileOrganizer('/media', '/tmp'); const organizer = new FileOrganizer('/media', '/tmp');
fsMock.stat.mockResolvedValue({ isFile: () => true }); fsMock.stat.mockResolvedValue({ isFile: () => true });