From 2ef9ac7be18c9915fb24ba3f3efd08b8bc4a2bcc Mon Sep 17 00:00:00 2001
From: kikootwo tags without src. organize-files.processor now detects EPUB downloads, runs the fixer (produces a temp fixed EPUB), uses the fixed file for organization, logs fixes, and cleans up temporary files; fix failures are non-blocking and the original download is preserved. Adds dependencies adm-zip and @types/adm-zip and updates documentation and types/UI to expose the new setting. Also includes helper functions to detect EPUB paths in downloads.
---
documentation/integrations/ebook-sidecar.md | 25 +-
documentation/settings-pages.md | 3 +
package-lock.json | 251 +---------
package.json | 2 +
src/app/admin/settings/lib/types.ts | 2 +
.../admin/settings/tabs/EbookTab/EbookTab.tsx | 26 +
.../tabs/EbookTab/useEbookSettings.ts | 1 +
src/app/api/admin/settings/ebook/route.ts | 9 +-
src/app/api/admin/settings/route.ts | 2 +
.../processors/organize-files.processor.ts | 124 ++++-
src/lib/utils/epub-fixer.ts | 465 ++++++++++++++++++
11 files changed, 677 insertions(+), 233 deletions(-)
create mode 100644 src/lib/utils/epub-fixer.ts
diff --git a/documentation/integrations/ebook-sidecar.md b/documentation/integrations/ebook-sidecar.md
index 3116e30..6ef526e 100644
--- a/documentation/integrations/ebook-sidecar.md
+++ b/documentation/integrations/ebook-sidecar.md
@@ -66,8 +66,30 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
|-----|---------|---------|-------------|
| `ebook_sidecar_preferred_format` | `epub` | `epub, pdf, mobi, azw3, any` | Preferred format |
| `ebook_auto_grab_enabled` | `true` | `true, false` | Auto-create ebook requests after audiobook downloads |
+| `ebook_kindle_fix_enabled` | `false` | `true, false` | Apply Kindle compatibility fixes to EPUB files |
-*Note: Auto-grab is automatically disabled if no ebook sources are enabled. Manual fetch via admin buttons still works.*
+*Notes:*
+- *Auto-grab is automatically disabled if no ebook sources are enabled. Manual fetch via admin buttons still works.*
+- *Kindle fix toggle only visible when preferred format is EPUB.*
+
+### Kindle EPUB Fix
+
+**Purpose:** Apply compatibility fixes to EPUB files before organizing, ensuring successful Kindle import.
+
+**Fixes Applied:**
+1. **Encoding declaration** - Adds UTF-8 XML declaration to files missing it
+2. **Body ID link fix** - Removes `#body`/`#bodymatter` fragments from hyperlinks that break on Kindle
+3. **Language validation** - Ensures `dc:language` uses Amazon KDP-approved codes (defaults to `en` if invalid)
+4. **Stray IMG removal** - Removes `
` tags without `src` attributes
+
+**How It Works:**
+- Enabled via toggle in E-book Sidecar settings (only visible when EPUB format selected)
+- Applied during `organize_files` job, before copying to final location
+- Creates temp fixed file → organizes temp file → cleans up temp file
+- Original download file stays intact (important for seeding torrents)
+- Non-blocking: if fix fails, continues with original file
+
+**Source:** Based on [kindle-epub-fix](https://github.com/innocenat/kindle-epub-fix)
## Database Schema
@@ -204,6 +226,7 @@ Search: https://annas-archive.li/search?q=Title+Author&ext=epub&lang=en
- `src/lib/utils/file-organizer.ts` (`organizeEbook` method)
- `src/lib/utils/ranking-algorithm.ts` (`rankEbookTorrents` function)
- `src/lib/utils/indexer-grouping.ts` (supports `'ebook'` type)
+- `src/lib/utils/epub-fixer.ts` (Kindle EPUB compatibility fixes)
**UI:**
- `src/components/requests/RequestCard.tsx` (ebook badge)
diff --git a/documentation/settings-pages.md b/documentation/settings-pages.md
index 935dfe9..1c788f8 100644
--- a/documentation/settings-pages.md
+++ b/documentation/settings-pages.md
@@ -91,6 +91,7 @@ src/app/admin/settings/
3. **General Settings Section** (visible when any source enabled)
- Preferred format: EPUB (recommended), PDF, MOBI, AZW3, Any
- Auto-grab toggle: Automatically create ebook requests after audiobook downloads
+ - Kindle fix toggle: Apply compatibility fixes to EPUB files (only visible when EPUB format selected)
**Configuration Keys:**
| Key | Default | Description |
@@ -99,6 +100,7 @@ src/app/admin/settings/
| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search via Prowlarr |
| `ebook_sidecar_preferred_format` | `epub` | Preferred format |
| `ebook_auto_grab_enabled` | `true` | Auto-create ebook requests after audiobook downloads |
+| `ebook_kindle_fix_enabled` | `false` | Apply Kindle compatibility fixes to EPUB files |
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror |
| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL |
@@ -107,6 +109,7 @@ src/app/admin/settings/
- If Indexer Search enabled → Falls back to indexer search if Anna's Archive fails/disabled
- If both disabled → Ebook downloads completely off
- If auto-grab disabled → Manual "Fetch Ebook" button only (admin buttons still work)
+- If Kindle fix enabled (and EPUB format) → Applies compatibility fixes during organization
## Indexer Categories (Tabbed)
diff --git a/package-lock.json b/package-lock.json
index 8f07910..9cc1c26 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,9 @@
"name": "readmeabook",
"version": "0.1.0",
"dependencies": {
- "@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0",
+ "adm-zip": "^0.5.16",
"axios": "^1.7.2",
"bcrypt": "^5.1.1",
"bull": "^4.12.0",
@@ -26,7 +26,6 @@
"parse-torrent": "^11.0.19",
"react": "19.2.1",
"react-dom": "19.2.1",
- "react-hook-form": "^7.66.0",
"react-swipeable": "^7.0.1",
"string-similarity": "^4.0.4",
"swr": "^2.3.6",
@@ -38,6 +37,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
+ "@types/adm-zip": "^0.5.6",
"@types/bcrypt": "^5.0.2",
"@types/bull": "^4.10.0",
"@types/jsonwebtoken": "^9.0.6",
@@ -1217,79 +1217,6 @@
}
}
},
- "node_modules/@floating-ui/core": {
- "version": "1.7.3",
- "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
- "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/utils": "^0.2.10"
- }
- },
- "node_modules/@floating-ui/dom": {
- "version": "1.7.4",
- "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
- "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/core": "^1.7.3",
- "@floating-ui/utils": "^0.2.10"
- }
- },
- "node_modules/@floating-ui/react": {
- "version": "0.26.28",
- "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
- "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/react-dom": "^2.1.2",
- "@floating-ui/utils": "^0.2.8",
- "tabbable": "^6.0.0"
- },
- "peerDependencies": {
- "react": ">=16.8.0",
- "react-dom": ">=16.8.0"
- }
- },
- "node_modules/@floating-ui/react-dom": {
- "version": "2.1.6",
- "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
- "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/dom": "^1.7.4"
- },
- "peerDependencies": {
- "react": ">=16.8.0",
- "react-dom": ">=16.8.0"
- }
- },
- "node_modules/@floating-ui/utils": {
- "version": "0.2.10",
- "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
- "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
- "license": "MIT"
- },
- "node_modules/@headlessui/react": {
- "version": "2.2.9",
- "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz",
- "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/react": "^0.26.16",
- "@react-aria/focus": "^3.20.2",
- "@react-aria/interactions": "^3.25.0",
- "@tanstack/react-virtual": "^3.13.9",
- "use-sync-external-store": "^1.5.0"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "react": "^18 || ^19 || ^19.0.0-rc",
- "react-dom": "^18 || ^19 || ^19.0.0-rc"
- }
- },
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
@@ -2270,103 +2197,6 @@
"@prisma/debug": "6.19.0"
}
},
- "node_modules/@react-aria/focus": {
- "version": "3.21.2",
- "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz",
- "integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "@react-aria/interactions": "^3.25.6",
- "@react-aria/utils": "^3.31.0",
- "@react-types/shared": "^3.32.1",
- "@swc/helpers": "^0.5.0",
- "clsx": "^2.0.0"
- },
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
- "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
- }
- },
- "node_modules/@react-aria/interactions": {
- "version": "3.25.6",
- "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz",
- "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==",
- "license": "Apache-2.0",
- "dependencies": {
- "@react-aria/ssr": "^3.9.10",
- "@react-aria/utils": "^3.31.0",
- "@react-stately/flags": "^3.1.2",
- "@react-types/shared": "^3.32.1",
- "@swc/helpers": "^0.5.0"
- },
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
- "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
- }
- },
- "node_modules/@react-aria/ssr": {
- "version": "3.9.10",
- "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
- "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "@swc/helpers": "^0.5.0"
- },
- "engines": {
- "node": ">= 12"
- },
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
- }
- },
- "node_modules/@react-aria/utils": {
- "version": "3.31.0",
- "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz",
- "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==",
- "license": "Apache-2.0",
- "dependencies": {
- "@react-aria/ssr": "^3.9.10",
- "@react-stately/flags": "^3.1.2",
- "@react-stately/utils": "^3.10.8",
- "@react-types/shared": "^3.32.1",
- "@swc/helpers": "^0.5.0",
- "clsx": "^2.0.0"
- },
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
- "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
- }
- },
- "node_modules/@react-stately/flags": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
- "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
- "license": "Apache-2.0",
- "dependencies": {
- "@swc/helpers": "^0.5.0"
- }
- },
- "node_modules/@react-stately/utils": {
- "version": "3.10.8",
- "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz",
- "integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==",
- "license": "Apache-2.0",
- "dependencies": {
- "@swc/helpers": "^0.5.0"
- },
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
- }
- },
- "node_modules/@react-types/shared": {
- "version": "3.32.1",
- "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz",
- "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==",
- "license": "Apache-2.0",
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
- }
- },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
@@ -2743,15 +2573,6 @@
"devOptional": true,
"license": "MIT"
},
- "node_modules/@swc/helpers": {
- "version": "0.5.17",
- "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
- "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.8.0"
- }
- },
"node_modules/@szmarczak/http-timer": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
@@ -3035,33 +2856,6 @@
"tailwindcss": "4.1.17"
}
},
- "node_modules/@tanstack/react-virtual": {
- "version": "3.13.12",
- "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
- "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
- "license": "MIT",
- "dependencies": {
- "@tanstack/virtual-core": "3.13.12"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/tannerlinsley"
- },
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
- "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
- "node_modules/@tanstack/virtual-core": {
- "version": "3.13.12",
- "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
- "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/tannerlinsley"
- }
- },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -3214,6 +3008,16 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/adm-zip": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz",
+ "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -4179,6 +3983,15 @@
"node": ">=0.4.0"
}
},
+ "node_modules/adm-zip": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
+ "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0"
+ }
+ },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -9871,22 +9684,6 @@
"react": "^19.2.1"
}
},
- "node_modules/react-hook-form": {
- "version": "7.66.0",
- "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
- "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
- "license": "MIT",
- "engines": {
- "node": ">=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/react-hook-form"
- },
- "peerDependencies": {
- "react": "^16.8.0 || ^17 || ^18 || ^19"
- }
- },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -10833,12 +10630,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/tabbable": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz",
- "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==",
- "license": "MIT"
- },
"node_modules/tailwindcss": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
diff --git a/package.json b/package.json
index fa58690..cf966c1 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"dependencies": {
"@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0",
+ "adm-zip": "^0.5.16",
"axios": "^1.7.2",
"bcrypt": "^5.1.1",
"bull": "^4.12.0",
@@ -42,6 +43,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
+ "@types/adm-zip": "^0.5.6",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/bcrypt": "^5.0.2",
diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts
index c4d1f3a..9c290bd 100644
--- a/src/app/admin/settings/lib/types.ts
+++ b/src/app/admin/settings/lib/types.ts
@@ -115,6 +115,8 @@ export interface EbookSettings {
// General settings (shared across sources)
preferredFormat: string;
autoGrabEnabled: boolean;
+ // Kindle compatibility
+ kindleFixEnabled: boolean;
}
/**
diff --git a/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx
index 90c35be..6719e93 100644
--- a/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx
+++ b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx
@@ -254,6 +254,32 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E
+ Apply compatibility fixes before organizing EPUB files. Fixes encoding declarations, + broken hyperlinks, invalid language tags, and orphaned image elements that can + cause Kindle import failures. +
+