diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index 83f68b3..ba14a94 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -17,6 +17,7 @@
"cheerio": "^1.1.2",
"dotenv": "^17.2.3",
"electron-log": "^5.4.3",
+ "epub": "^1.3.0",
"fastify": "^5.6.2",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.3",
@@ -2695,6 +2696,16 @@
"node": ">=4"
}
},
+ "node_modules/code-point-at": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+ "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2897,7 +2908,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
- "dev": true,
"license": "MIT",
"optional": true
},
@@ -3707,6 +3717,27 @@
"node": ">=6"
}
},
+ "node_modules/epub": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/epub/-/epub-1.3.0.tgz",
+ "integrity": "sha512-6BL8gIitljkTf4HW52Ast6wenPTkMKllU28bRc5awVsT+xCaPl6nWSaqSmHbRgPrl1+5uekOPvOxy7DQzbhM8Q==",
+ "dependencies": {
+ "adm-zip": "^0.4.11",
+ "xml2js": "^0.4.23"
+ },
+ "optionalDependencies": {
+ "zipfile": "^0.5.11"
+ }
+ },
+ "node_modules/epub/node_modules/adm-zip": {
+ "version": "0.4.16",
+ "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.16.tgz",
+ "integrity": "sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.3.0"
+ }
+ },
"node_modules/err-code": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
@@ -4668,6 +4699,29 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/ignore-walk": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz",
+ "integrity": "sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minimatch": "^3.0.4"
+ }
+ },
+ "node_modules/ignore-walk/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -4790,6 +4844,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/isbinaryfile": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz",
@@ -5420,12 +5481,60 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/nan": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz",
+ "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
+ "node_modules/needle": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz",
+ "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "debug": "^3.2.6",
+ "iconv-lite": "^0.4.4",
+ "sax": "^1.2.4"
+ },
+ "bin": {
+ "needle": "bin/needle"
+ },
+ "engines": {
+ "node": ">= 4.4.x"
+ }
+ },
+ "node_modules/needle/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/needle/node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -5577,6 +5686,348 @@
"node": ">=18"
}
},
+ "node_modules/node-pre-gyp": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz",
+ "integrity": "sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A==",
+ "deprecated": "Please upgrade to @mapbox/node-pre-gyp: the non-scoped node-pre-gyp package is deprecated and only the @mapbox scoped package will recieve updates in the future",
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^1.0.2",
+ "mkdirp": "^0.5.1",
+ "needle": "^2.2.1",
+ "nopt": "^4.0.1",
+ "npm-packlist": "^1.1.6",
+ "npmlog": "^4.0.2",
+ "rc": "^1.2.7",
+ "rimraf": "^2.6.1",
+ "semver": "^5.3.0",
+ "tar": "^4"
+ },
+ "bin": {
+ "node-pre-gyp": "bin/node-pre-gyp"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/node-pre-gyp/node_modules/ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/node-pre-gyp/node_modules/are-we-there-yet": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz",
+ "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^2.0.6"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/node-pre-gyp/node_modules/detect-libc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+ "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "bin": {
+ "detect-libc": "bin/detect-libc.js"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/fs-minipass": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz",
+ "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^2.6.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/gauge": {
+ "version": "2.7.4",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+ "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "aproba": "^1.0.3",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.0",
+ "object-assign": "^4.1.0",
+ "signal-exit": "^3.0.0",
+ "string-width": "^1.0.1",
+ "strip-ansi": "^3.0.1",
+ "wide-align": "^1.1.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/is-fullwidth-code-point": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+ "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "number-is-nan": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/minipass": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz",
+ "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "safe-buffer": "^5.1.2",
+ "yallist": "^3.0.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/minizlib": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz",
+ "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^2.9.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/nopt": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz",
+ "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "abbrev": "1",
+ "osenv": "^0.1.4"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/npmlog": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
+ "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "are-we-there-yet": "~1.1.2",
+ "console-control-strings": "~1.1.0",
+ "gauge": "~2.7.3",
+ "set-blocking": "~2.0.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/readable-stream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/node-pre-gyp/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/semver": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+ "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/node-pre-gyp/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/string_decoder/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/node-pre-gyp/node_modules/string-width": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+ "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "code-point-at": "^1.0.0",
+ "is-fullwidth-code-point": "^1.0.0",
+ "strip-ansi": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ansi-regex": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/tar": {
+ "version": "4.4.19",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz",
+ "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "chownr": "^1.1.4",
+ "fs-minipass": "^1.2.7",
+ "minipass": "^2.9.0",
+ "minizlib": "^1.3.3",
+ "mkdirp": "^0.5.5",
+ "safe-buffer": "^5.2.1",
+ "yallist": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=4.5"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "license": "ISC",
+ "optional": true
+ },
"node_modules/nopt": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz",
@@ -5606,6 +6057,35 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/npm-bundled": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz",
+ "integrity": "sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "npm-normalize-package-bin": "^1.0.1"
+ }
+ },
+ "node_modules/npm-normalize-package-bin": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz",
+ "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/npm-packlist": {
+ "version": "1.4.8",
+ "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz",
+ "integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "ignore-walk": "^3.0.1",
+ "npm-bundled": "^1.0.1",
+ "npm-normalize-package-bin": "^1.0.1"
+ }
+ },
"node_modules/npmlog": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
@@ -5635,6 +6115,26 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
+ "node_modules/number-is-nan": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+ "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@@ -5727,6 +6227,38 @@
"node": ">=8"
}
},
+ "node_modules/os-homedir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+ "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/os-tmpdir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+ "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/osenv": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz",
+ "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "os-homedir": "^1.0.0",
+ "os-tmpdir": "^1.0.0"
+ }
+ },
"node_modules/p-cancelable": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
@@ -6044,6 +6576,13 @@
"node": "^20.17.0 || >=22.9.0"
}
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
@@ -6433,7 +6972,6 @@
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
- "dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/secure-json-parse": {
@@ -7938,6 +8476,28 @@
}
}
},
+ "node_modules/xml2js": {
+ "version": "0.4.23",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
+ "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/xml2js/node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
@@ -8071,6 +8631,20 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zipfile": {
+ "version": "0.5.12",
+ "resolved": "https://registry.npmjs.org/zipfile/-/zipfile-0.5.12.tgz",
+ "integrity": "sha512-zA60gW+XgQBu/Q4qV3BCXNIDRald6Xi5UOPj3jWGlnkjmBHaKDwIz7kyXWV3kq7VEsQN/2t/IWjdXdKeVNm6Eg==",
+ "hasInstallScript": true,
+ "optional": true,
+ "dependencies": {
+ "nan": "~2.10.0",
+ "node-pre-gyp": "~0.10.2"
+ },
+ "bin": {
+ "unzip.js": "bin/unzip.js"
+ }
}
}
}
diff --git a/desktop/package.json b/desktop/package.json
index 8cce840..6042b1b 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -20,6 +20,7 @@
"cheerio": "^1.1.2",
"dotenv": "^17.2.3",
"electron-log": "^5.4.3",
+ "epub": "^1.3.0",
"fastify": "^5.6.2",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.3",
diff --git a/desktop/src/api/local/local.controller.ts b/desktop/src/api/local/local.controller.ts
index c07c070..908d669 100644
--- a/desktop/src/api/local/local.controller.ts
+++ b/desktop/src/api/local/local.controller.ts
@@ -1,21 +1,14 @@
import { FastifyRequest, FastifyReply } from 'fastify';
-import { getConfig as loadConfig, setConfig as saveConfig } from '../../shared/config.js';
+import { getConfig as loadConfig } from '../../shared/config.js';
import { queryOne, queryAll, run } from '../../shared/database.js';
import crypto from 'crypto';
import fs from "fs";
import { PathLike } from "node:fs";
import path from "path";
import {getAnimeById, searchAnimeLocal} from "../anime/anime.service";
-import {getBookById, searchBooksAniList, searchBooksLocal} from "../books/books.service";
+import {getBookById, searchBooksAniList} from "../books/books.service";
import AdmZip from 'adm-zip';
-
-type SetConfigBody = {
- library?: {
- anime?: string | null;
- manga?: string | null;
- novels?: string | null;
- };
-};
+import EPub from 'epub';
type ScanQuery = {
mode?: 'full' | 'incremental';
@@ -37,10 +30,21 @@ async function resolveEntryMetadata(entry: any, type: string) {
? await searchAnimeLocal(query)
: await searchBooksAniList(query);
- const first = results?.[0];
+ let picked = null;
- if (first?.id) {
- matchedId = first.id;
+ if (type !== 'anime' && Array.isArray(results)) {
+ console.log(type)
+ if (entry.type === 'novels') {
+ picked = results.find(r => r.format === 'NOVEL');
+ } else if (entry.type === 'manga') {
+ picked = results.find(r => r.format !== 'NOVEL');
+ }
+ }
+
+ picked ??= results?.[0];
+
+ if (picked?.id) {
+ matchedId = picked.id;
await run(
`UPDATE local_entries
@@ -104,9 +108,13 @@ export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQue
}
const files = fs.readdirSync(fullPath, { withFileTypes: true })
- .filter(f => f.isFile())
+ .filter(f =>
+ f.isFile() ||
+ (type === 'manga' && f.isDirectory())
+ )
.sort((a, b) => a.name.localeCompare(b.name));
+
let unit = 1;
for (const file of files) {
@@ -191,27 +199,30 @@ export async function streamUnit(request: FastifyRequest, reply: FastifyReply) {
const parts = range.replace(/bytes=/, '').split('-');
const start = Number(parts[0]);
- let end = parts[1] ? Number(parts[1]) : stat.size - 1;
+ const end = parts[1] ? Number(parts[1]) : stat.size - 1;
+ // Validate range values
if (
Number.isNaN(start) ||
Number.isNaN(end) ||
start < 0 ||
+ start >= stat.size ||
end < start ||
end >= stat.size
) {
- end = stat.size - 1;
+ return reply.status(416).send({ error: 'INVALID_RANGE' });
}
+ const contentLength = end - start + 1;
+
reply
.status(206)
.header('Content-Range', `bytes ${start}-${end}/${stat.size}`)
.header('Accept-Ranges', 'bytes')
- .header('Content-Length', end - start + 1)
+ .header('Content-Length', contentLength)
.header('Content-Type', 'video/mp4');
return fs.createReadStream(file.file_path, { start, end });
-
}
type MatchBody = {
@@ -247,17 +258,13 @@ export async function matchEntry(
return { status: 'OK', matched: !!matched_id };
}
-export async function getUnits(
- request: FastifyRequest<{ Params: Params }>,
- reply: FastifyReply
-) {
+export async function getUnits(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try {
- const { type, id } = request.params as { type: string, id: string };
+ const { id } = request.params as { id: string };
- // Buscar la entrada por matched_id
const entry = await queryOne(
- `SELECT id, type, matched_id FROM local_entries WHERE matched_id = ? AND type = ?`,
- [Number(id), type],
+ `SELECT id, type, matched_id FROM local_entries WHERE matched_id = ?`,
+ [Number(id)],
'local_library'
);
@@ -274,24 +281,82 @@ export async function getUnits(
'local_library'
);
- // Formatear la respuesta según el tipo
- const units = files.map((file: any) => {
- const fileName = path.basename(file.file_path);
- const fileExt = path.extname(file.file_path).toLowerCase();
+ const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
+ const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
- // Detectar si es un archivo comprimido (capítulo único) o carpeta
- const isDirectory = fs.existsSync(file.file_path) &&
- fs.statSync(file.file_path).isDirectory();
+ const NOVEL_EXTS = ['.epub', '.pdf', '.txt', '.md', '.docx', '.mobi'];
+
+ function isImageFolder(folderPath: string): boolean {
+ if (!fs.existsSync(folderPath)) return false;
+ if (!fs.statSync(folderPath).isDirectory()) return false;
+
+ const files = fs.readdirSync(folderPath);
+ return files.some(f => MANGA_IMAGE_EXTS.includes(path.extname(f).toLowerCase()));
+ }
+
+ const units = files
+ .map((file: any) => {
+ const fileExt = path.extname(file.file_path).toLowerCase();
+ const isDir = fs.existsSync(file.file_path) &&
+ fs.statSync(file.file_path).isDirectory();
+
+ // ===== MANGA =====
+ if (entry.type === 'manga') {
+ if (MANGA_ARCHIVES.includes(fileExt)) {
+ return {
+ id: file.id,
+ number: file.unit_number,
+ name: path.basename(file.file_path),
+ type: 'chapter',
+ format: fileExt.replace('.', ''),
+ path: file.file_path
+ };
+ }
+
+ if (isDir && isImageFolder(file.file_path)) {
+ return {
+ id: file.id,
+ number: file.unit_number,
+ name: path.basename(file.file_path),
+ type: 'chapter',
+ format: 'folder',
+ path: file.file_path
+ };
+ }
+
+ return null;
+ }
+
+ if (entry.type === 'novels') {
+ if (NOVEL_EXTS.includes(fileExt)) {
+ return {
+ id: file.id,
+ number: file.unit_number,
+ name: path.basename(file.file_path),
+ type: 'chapter',
+ format: fileExt.replace('.', ''),
+ path: file.file_path
+ };
+ }
+
+ return null;
+ }
+
+ if (entry.type === 'anime') {
+ return {
+ id: file.id,
+ number: file.unit_number,
+ name: path.basename(file.file_path),
+ type: 'episode',
+ format: fileExt.replace('.', ''),
+ path: file.file_path
+ };
+ }
+
+ return null;
+ })
+ .filter(Boolean);
- return {
- id: file.id,
- number: file.unit_number,
- name: fileName,
- type: type === 'anime' ? 'episode' : 'chapter',
- format: fileExt === '.cbz' ? 'cbz' : 'file',
- path: file.file_path
- };
- });
return {
entry_id: entry.id,
@@ -306,7 +371,7 @@ export async function getUnits(
}
}
-export async function getCbzPages(request: FastifyRequest, reply: FastifyReply) {
+export async function getManifest(request: FastifyRequest, reply: FastifyReply) {
const { unitId } = request.params as any;
const file = await queryOne(
@@ -319,20 +384,73 @@ export async function getCbzPages(request: FastifyRequest, reply: FastifyReply)
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
}
- const zip = new AdmZip(file.file_path);
+ const ext = path.extname(file.file_path).toLowerCase();
- const pages = zip.getEntries()
- .filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName))
- .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true }))
- .map((_, i) =>
- `/api/library/manga/cbz/${unitId}/page/${i}`
- );
+ // ===== MANGA =====
+ if (['.cbz', '.cbr', '.zip'].includes(ext)) {
+ const zip = new AdmZip(file.file_path);
- return { pages };
+ const pages = zip.getEntries()
+ .filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName))
+ .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true }))
+ .map((_, i) => ({
+ id: i,
+ url: `/api/library/${unitId}/resource/${i}`
+ }));
+
+ return {
+ type: 'manga',
+ format: 'archive',
+ pages
+ };
+ }
+
+ if (fs.statSync(file.file_path).isDirectory()) {
+ const pages = fs.readdirSync(file.file_path)
+ .filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f))
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
+ .map((_, i) => ({
+ id: i,
+ url: `/api/library/${unitId}/resource/${i}`
+ }));
+
+ return {
+ type: 'manga',
+ format: 'folder',
+ pages
+ };
+ }
+
+ // ===== NOVEL =====
+ if (ext === '.epub') {
+ return {
+ type: 'ln',
+ format: 'epub',
+ url: `/api/library/${unitId}/resource/epub`
+ };
+ }
+
+ if (['.txt', '.md'].includes(ext)) {
+ return {
+ type: 'ln',
+ format: 'text',
+ url: `/api/library/${unitId}/resource/text`
+ };
+ }
+
+ if (ext === '.pdf') {
+ return {
+ type: 'ln',
+ format: 'pdf',
+ url: `/api/library/${unitId}/resource/pdf`
+ };
+ }
+
+ return reply.status(400).send({ error: 'UNSUPPORTED_FORMAT' });
}
-export async function getCbzPage(request: FastifyRequest, reply: FastifyReply) {
- const { unitId, page } = request.params as any;
+export async function getPage(request: FastifyRequest, reply: FastifyReply) {
+ const { unitId, resId } = request.params as any;
const file = await queryOne(
`SELECT file_path FROM local_files WHERE id = ?`,
@@ -342,16 +460,83 @@ export async function getCbzPage(request: FastifyRequest, reply: FastifyReply) {
if (!file) return reply.status(404).send();
- const zip = new AdmZip(file.file_path);
+ const ext = path.extname(file.file_path).toLowerCase();
- const images = zip.getEntries()
- .filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName))
- .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true }));
+ // ===== CBZ PAGE =====
+ if (['.cbz', '.zip', '.cbr'].includes(ext)) {
+ const zip = new AdmZip(file.file_path);
+ const images = zip.getEntries()
+ .filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName))
+ .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true }));
- const entry = images[page];
- if (!entry) return reply.status(404).send();
+ const entry = images[Number(resId)];
+ if (!entry) return reply.status(404).send();
- reply
- .header('Content-Type', 'image/jpeg')
- .send(entry.getData());
+ return reply
+ .header('Content-Type', 'image/jpeg')
+ .send(entry.getData());
+ }
+
+ if (fs.statSync(file.file_path).isDirectory()) {
+ const images = fs.readdirSync(file.file_path)
+ .filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f))
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
+
+ const img = images[Number(resId)];
+ if (!img) return reply.status(404).send();
+
+ const imgPath = path.join(file.file_path, img);
+ const stat = fs.statSync(imgPath);
+
+ reply
+ .header('Content-Length', stat.size)
+ .header('Content-Type', 'image/jpeg');
+
+ return fs.createReadStream(imgPath);
+ }
+
+ if (ext === '.epub') {
+ const html = await parseEpubToHtml(file.file_path);
+
+ return reply
+ .header('Content-Type', 'text/html; charset=utf-8')
+ .send(html);
+ }
+
+ // ===== TXT / MD =====
+ if (['.txt', '.md'].includes(ext)) {
+ const text = fs.readFileSync(file.file_path, 'utf8');
+
+ return reply
+ .header('Content-Type', 'text/html; charset=utf-8')
+ .send(`
`);
+ }
+
+ return reply.status(400).send();
+}
+
+function parseEpubToHtml(filePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ const epub = new EPub(filePath);
+
+ epub.on('end', async () => {
+ let html = '';
+
+ for (const id of epub.flow.map(f => f.id)) {
+ const chapter = await new Promise((res, rej) => {
+ epub.getChapter(id, (err, text) => {
+ if (err) rej(err);
+ else res(text);
+ });
+ });
+
+ html += ``;
+ }
+
+ resolve(html);
+ });
+
+ epub.on('error', reject);
+ epub.parse();
+ });
}
diff --git a/desktop/src/api/local/local.routes.ts b/desktop/src/api/local/local.routes.ts
index 0127890..5d18cf9 100644
--- a/desktop/src/api/local/local.routes.ts
+++ b/desktop/src/api/local/local.routes.ts
@@ -7,9 +7,9 @@ async function localRoutes(fastify: FastifyInstance) {
fastify.get('/library/:type/:id', controller.getEntry);
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
fastify.post('/library/:type/:id/match', controller.matchEntry);
- fastify.get('/library/:type/:id/units', controller.getUnits);
- fastify.get('/library/:type/cbz/:unitId/pages', controller.getCbzPages);
- fastify.get('/library/:type/cbz/:unitId/page/:page', controller.getCbzPage);
+ fastify.get('/library/:id/units', controller.getUnits);
+ fastify.get('/library/:unitId/manifest', controller.getManifest);
+ fastify.get('/library/:unitId/resource/:resId', controller.getPage);
}
export default localRoutes;
\ No newline at end of file
diff --git a/desktop/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js
index 8f901d0..c76fde5 100644
--- a/desktop/src/scripts/anime/player.js
+++ b/desktop/src/scripts/anime/player.js
@@ -32,12 +32,12 @@ let localEntryId = null;
async function checkLocal() {
try {
const res = await fetch(`/api/library/anime/${animeId}`);
- if (!res.ok) return;
-
+ if (!res.ok) return null;
const data = await res.json();
- localEntryId = data.id; // ← ID interna
-
- } catch {}
+ return data.id;
+ } catch {
+ return null;
+ }
}
async function loadAniSkip(malId, episode, duration) {
@@ -53,7 +53,7 @@ async function loadAniSkip(malId, episode, duration) {
}
async function loadMetadata() {
- checkLocal();
+ localEntryId = await checkLocal();
try {
const sourceQuery = (extName === 'local' || !extName) ? "source=anilist" : `source=${extName}`;
const res = await fetch(`/api/anime/${animeId}?${sourceQuery}`);
@@ -133,6 +133,7 @@ async function loadMetadata() {
} catch (error) {
console.error('Error loading metadata:', error);
}
+ await loadExtensions();
}
async function applyAniSkip(video) {
@@ -484,4 +485,3 @@ setInterval(() => {
}, 60000);
loadMetadata();
-loadExtensions();
\ No newline at end of file
diff --git a/desktop/src/scripts/books/book.js b/desktop/src/scripts/books/book.js
index 7384528..73a9643 100644
--- a/desktop/src/scripts/books/book.js
+++ b/desktop/src/scripts/books/book.js
@@ -18,7 +18,10 @@ document.addEventListener('DOMContentLoaded', () => {
async function checkLocalLibraryEntry() {
try {
- const res = await fetch(`/api/library/manga/${bookId}`);
+ const libraryType =
+ bookData?.entry_type === 'NOVEL' ? 'novels' : 'manga';
+
+ const res = await fetch(`/api/library/${libraryType}/${bookId}`);
if (!res.ok) return;
const data = await res.json();
@@ -38,18 +41,6 @@ async function checkLocalLibraryEntry() {
}
}
-function markAsLocal() {
- isLocal = true;
- const pill = document.getElementById('local-pill');
- if (pill) {
- pill.textContent = 'Local';
- pill.style.display = 'inline-flex';
- pill.style.background = 'rgba(34, 197, 94, 0.2)';
- pill.style.color = '#22c55e';
- pill.style.borderColor = 'rgba(34, 197, 94, 0.3)';
- }
-}
-
async function init() {
try {
const urlData = URLUtils.parseEntityPath('book');
@@ -61,8 +52,8 @@ async function init() {
extensionName = urlData.extensionName;
bookId = urlData.entityId;
bookSlug = urlData.slug;
- await checkLocalLibraryEntry();
await loadBookMetadata();
+ await checkLocalLibraryEntry();
await loadAvailableExtensions();
await loadChapters();
@@ -220,7 +211,7 @@ async function loadChapters(targetProvider = null) {
if (isLocalRequest) {
// Nuevo endpoint para archivos locales
- fetchUrl = `/api/library/manga/${bookId}/units`;
+ fetchUrl = `/api/library/${bookId}/units`;
} else {
const source = extensionName || 'anilist';
fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
diff --git a/desktop/src/scripts/books/reader.js b/desktop/src/scripts/books/reader.js
index 05d19d1..200911e 100644
--- a/desktop/src/scripts/books/reader.js
+++ b/desktop/src/scripts/books/reader.js
@@ -132,7 +132,7 @@ async function loadChapter() {
let newEndpoint;
if (provider === 'local') {
- newEndpoint = `/api/library/manga/${bookId}/units`;
+ newEndpoint = `/api/library/${bookId}/units`;
} else {
newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`;
}
@@ -142,29 +142,39 @@ async function loadChapter() {
const data = await res.json();
if (provider === 'local') {
const unit = data.units[Number(chapter)];
+ if (!unit) return;
- if (!unit) {
- reader.innerHTML = 'Chapter not found
';
- return;
- }
+ chapterLabel.textContent = unit.name;
+ document.title = unit.name;
- if (unit.format === 'cbz') {
- chapterLabel.textContent = unit.name; // ✅
- document.title = unit.name;
- const pagesRes = await fetch(
- `/api/library/manga/cbz/${unit.id}/pages`
- );
- const pagesData = await pagesRes.json();
+ const manifestRes = await fetch(`/api/library/${unit.id}/manifest`);
+ const manifest = await manifestRes.json();
+ reader.innerHTML = '';
+
+ // ===== MANGA =====
+ if (manifest.type === 'manga') {
currentType = 'manga';
updateSettingsVisibility();
applyStyles();
- currentPages = pagesData.pages.map(url => ({ url }));
- reader.innerHTML = '';
+ currentPages = manifest.pages;
loadManga(currentPages);
return;
}
+
+ // ===== LN =====
+ if (manifest.type === 'ln') {
+ currentType = 'ln';
+ updateSettingsVisibility();
+ applyStyles();
+
+ const contentRes = await fetch(manifest.url);
+ const html = await contentRes.text();
+
+ loadLN(html);
+ return;
+ }
}
@@ -205,13 +215,8 @@ async function loadChapter() {
reader.innerHTML = '';
if (data.type === 'manga') {
- if (provider === 'local' && data.format === 'cbz') {
- currentPages = data.pages.map(url => ({ url }));
- loadManga(currentPages);
- } else {
- currentPages = data.pages || [];
- loadManga(currentPages);
- }
+ currentPages = data.pages || [];
+ loadManga(currentPages);
} else if (data.type === 'ln') {
loadLN(data.content);
}
diff --git a/desktop/src/scripts/local-library-books.js b/desktop/src/scripts/local-library-books.js
index 63e3f62..3ab311c 100644
--- a/desktop/src/scripts/local-library-books.js
+++ b/desktop/src/scripts/local-library-books.js
@@ -26,21 +26,37 @@ async function loadLocalEntries() {
grid.innerHTML = ''.repeat(6);
try {
- // Cambiado a endpoint de libros
- const response = await fetch('/api/library/manga');
- const entries = await response.json();
- localEntries = entries;
+ const [mangaRes, novelRes] = await Promise.all([
+ fetch('/api/library/manga'),
+ fetch('/api/library/novels')
+ ]);
- if (entries.length === 0) {
- grid.innerHTML = 'No books found in your local library.
';
+ const [manga, novel] = await Promise.all([
+ mangaRes.json(),
+ novelRes.json()
+ ]);
+
+ localEntries = [
+ ...manga.map(e => ({ ...e, type: 'manga' })),
+ ...novel.map(e => ({ ...e, type: 'novel' }))
+ ];
+
+ if (localEntries.length === 0) {
+ grid.innerHTML = 'No books found.
';
return;
}
- renderLocalEntries(entries);
- } catch (err) {
- grid.innerHTML = 'Error loading local books.
';
+
+ renderLocalEntries(localEntries);
+ } catch {
+ grid.innerHTML = 'Error loading library.
';
}
}
+function filterLocal(type) {
+ if (type === 'all') renderLocalEntries(localEntries);
+ else renderLocalEntries(localEntries.filter(e => e.type === type));
+}
+
function renderLocalEntries(entries) {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = entries.map(entry => {
@@ -58,6 +74,7 @@ function renderLocalEntries(entries) {
${chapters} Chapters
+ ${entry.type}
${entry.matched ? '● Linked' : '○ Unlinked'}
diff --git a/docker/package-lock.json b/docker/package-lock.json
index c491add..93995d1 100644
--- a/docker/package-lock.json
+++ b/docker/package-lock.json
@@ -15,6 +15,7 @@
"bindings": "^1.5.0",
"cheerio": "^1.1.2",
"dotenv": "^17.2.3",
+ "epub": "^1.3.0",
"fastify": "^5.6.2",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.3",
@@ -874,6 +875,16 @@
"node": ">=6"
}
},
+ "node_modules/code-point-at": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+ "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -941,6 +952,13 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -1218,6 +1236,27 @@
"node": ">=6"
}
},
+ "node_modules/epub": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/epub/-/epub-1.3.0.tgz",
+ "integrity": "sha512-6BL8gIitljkTf4HW52Ast6wenPTkMKllU28bRc5awVsT+xCaPl6nWSaqSmHbRgPrl1+5uekOPvOxy7DQzbhM8Q==",
+ "dependencies": {
+ "adm-zip": "^0.4.11",
+ "xml2js": "^0.4.23"
+ },
+ "optionalDependencies": {
+ "zipfile": "^0.5.11"
+ }
+ },
+ "node_modules/epub/node_modules/adm-zip": {
+ "version": "0.4.16",
+ "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.16.tgz",
+ "integrity": "sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.3.0"
+ }
+ },
"node_modules/err-code": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
@@ -1701,6 +1740,29 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/ignore-walk": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz",
+ "integrity": "sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minimatch": "^3.0.4"
+ }
+ },
+ "node_modules/ignore-walk/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -1787,6 +1849,13 @@
"license": "MIT",
"optional": true
},
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2234,12 +2303,60 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/nan": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz",
+ "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
+ "node_modules/needle": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz",
+ "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "debug": "^3.2.6",
+ "iconv-lite": "^0.4.4",
+ "sax": "^1.2.4"
+ },
+ "bin": {
+ "needle": "bin/needle"
+ },
+ "engines": {
+ "node": ">= 4.4.x"
+ }
+ },
+ "node_modules/needle/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/needle/node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -2392,6 +2509,348 @@
"node": ">=18"
}
},
+ "node_modules/node-pre-gyp": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz",
+ "integrity": "sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A==",
+ "deprecated": "Please upgrade to @mapbox/node-pre-gyp: the non-scoped node-pre-gyp package is deprecated and only the @mapbox scoped package will recieve updates in the future",
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^1.0.2",
+ "mkdirp": "^0.5.1",
+ "needle": "^2.2.1",
+ "nopt": "^4.0.1",
+ "npm-packlist": "^1.1.6",
+ "npmlog": "^4.0.2",
+ "rc": "^1.2.7",
+ "rimraf": "^2.6.1",
+ "semver": "^5.3.0",
+ "tar": "^4"
+ },
+ "bin": {
+ "node-pre-gyp": "bin/node-pre-gyp"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/node-pre-gyp/node_modules/ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/node-pre-gyp/node_modules/are-we-there-yet": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz",
+ "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^2.0.6"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/node-pre-gyp/node_modules/detect-libc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+ "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "bin": {
+ "detect-libc": "bin/detect-libc.js"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/fs-minipass": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz",
+ "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^2.6.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/gauge": {
+ "version": "2.7.4",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+ "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "aproba": "^1.0.3",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.0",
+ "object-assign": "^4.1.0",
+ "signal-exit": "^3.0.0",
+ "string-width": "^1.0.1",
+ "strip-ansi": "^3.0.1",
+ "wide-align": "^1.1.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/is-fullwidth-code-point": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+ "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "number-is-nan": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/minipass": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz",
+ "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "safe-buffer": "^5.1.2",
+ "yallist": "^3.0.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/minizlib": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz",
+ "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^2.9.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/nopt": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz",
+ "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "abbrev": "1",
+ "osenv": "^0.1.4"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/npmlog": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
+ "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "are-we-there-yet": "~1.1.2",
+ "console-control-strings": "~1.1.0",
+ "gauge": "~2.7.3",
+ "set-blocking": "~2.0.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/readable-stream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/node-pre-gyp/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/semver": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+ "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/node-pre-gyp/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/string_decoder/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/node-pre-gyp/node_modules/string-width": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+ "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "code-point-at": "^1.0.0",
+ "is-fullwidth-code-point": "^1.0.0",
+ "strip-ansi": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ansi-regex": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/tar": {
+ "version": "4.4.19",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz",
+ "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "chownr": "^1.1.4",
+ "fs-minipass": "^1.2.7",
+ "minipass": "^2.9.0",
+ "minizlib": "^1.3.3",
+ "mkdirp": "^0.5.5",
+ "safe-buffer": "^5.2.1",
+ "yallist": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=4.5"
+ }
+ },
+ "node_modules/node-pre-gyp/node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "license": "ISC",
+ "optional": true
+ },
"node_modules/nopt": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz",
@@ -2408,6 +2867,35 @@
"node": "^20.17.0 || >=22.9.0"
}
},
+ "node_modules/npm-bundled": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz",
+ "integrity": "sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "npm-normalize-package-bin": "^1.0.1"
+ }
+ },
+ "node_modules/npm-normalize-package-bin": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz",
+ "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/npm-packlist": {
+ "version": "1.4.8",
+ "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz",
+ "integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "ignore-walk": "^3.0.1",
+ "npm-bundled": "^1.0.1",
+ "npm-normalize-package-bin": "^1.0.1"
+ }
+ },
"node_modules/npmlog": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
@@ -2437,6 +2925,26 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
+ "node_modules/number-is-nan": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+ "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -2455,6 +2963,38 @@
"wrappy": "1"
}
},
+ "node_modules/os-homedir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+ "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/os-tmpdir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+ "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/osenv": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz",
+ "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "os-homedir": "^1.0.0",
+ "os-tmpdir": "^1.0.0"
+ }
+ },
"node_modules/p-map": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
@@ -2672,6 +3212,13 @@
"node": "^20.17.0 || >=22.9.0"
}
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
@@ -2913,6 +3460,12 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/sax": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
+ "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
+ "license": "BlueOak-1.0.0"
+ },
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@@ -4005,6 +4558,28 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
+ "node_modules/xml2js": {
+ "version": "0.4.23",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
+ "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@@ -4020,6 +4595,20 @@
"engines": {
"node": ">=6"
}
+ },
+ "node_modules/zipfile": {
+ "version": "0.5.12",
+ "resolved": "https://registry.npmjs.org/zipfile/-/zipfile-0.5.12.tgz",
+ "integrity": "sha512-zA60gW+XgQBu/Q4qV3BCXNIDRald6Xi5UOPj3jWGlnkjmBHaKDwIz7kyXWV3kq7VEsQN/2t/IWjdXdKeVNm6Eg==",
+ "hasInstallScript": true,
+ "optional": true,
+ "dependencies": {
+ "nan": "~2.10.0",
+ "node-pre-gyp": "~0.10.2"
+ },
+ "bin": {
+ "unzip.js": "bin/unzip.js"
+ }
}
}
}
diff --git a/docker/package.json b/docker/package.json
index 6c4cd76..6375879 100644
--- a/docker/package.json
+++ b/docker/package.json
@@ -18,6 +18,7 @@
"bindings": "^1.5.0",
"cheerio": "^1.1.2",
"dotenv": "^17.2.3",
+ "epub": "^1.3.0",
"fastify": "^5.6.2",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.3",
diff --git a/docker/src/api/local/local.controller.ts b/docker/src/api/local/local.controller.ts
index c07c070..908d669 100644
--- a/docker/src/api/local/local.controller.ts
+++ b/docker/src/api/local/local.controller.ts
@@ -1,21 +1,14 @@
import { FastifyRequest, FastifyReply } from 'fastify';
-import { getConfig as loadConfig, setConfig as saveConfig } from '../../shared/config.js';
+import { getConfig as loadConfig } from '../../shared/config.js';
import { queryOne, queryAll, run } from '../../shared/database.js';
import crypto from 'crypto';
import fs from "fs";
import { PathLike } from "node:fs";
import path from "path";
import {getAnimeById, searchAnimeLocal} from "../anime/anime.service";
-import {getBookById, searchBooksAniList, searchBooksLocal} from "../books/books.service";
+import {getBookById, searchBooksAniList} from "../books/books.service";
import AdmZip from 'adm-zip';
-
-type SetConfigBody = {
- library?: {
- anime?: string | null;
- manga?: string | null;
- novels?: string | null;
- };
-};
+import EPub from 'epub';
type ScanQuery = {
mode?: 'full' | 'incremental';
@@ -37,10 +30,21 @@ async function resolveEntryMetadata(entry: any, type: string) {
? await searchAnimeLocal(query)
: await searchBooksAniList(query);
- const first = results?.[0];
+ let picked = null;
- if (first?.id) {
- matchedId = first.id;
+ if (type !== 'anime' && Array.isArray(results)) {
+ console.log(type)
+ if (entry.type === 'novels') {
+ picked = results.find(r => r.format === 'NOVEL');
+ } else if (entry.type === 'manga') {
+ picked = results.find(r => r.format !== 'NOVEL');
+ }
+ }
+
+ picked ??= results?.[0];
+
+ if (picked?.id) {
+ matchedId = picked.id;
await run(
`UPDATE local_entries
@@ -104,9 +108,13 @@ export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQue
}
const files = fs.readdirSync(fullPath, { withFileTypes: true })
- .filter(f => f.isFile())
+ .filter(f =>
+ f.isFile() ||
+ (type === 'manga' && f.isDirectory())
+ )
.sort((a, b) => a.name.localeCompare(b.name));
+
let unit = 1;
for (const file of files) {
@@ -191,27 +199,30 @@ export async function streamUnit(request: FastifyRequest, reply: FastifyReply) {
const parts = range.replace(/bytes=/, '').split('-');
const start = Number(parts[0]);
- let end = parts[1] ? Number(parts[1]) : stat.size - 1;
+ const end = parts[1] ? Number(parts[1]) : stat.size - 1;
+ // Validate range values
if (
Number.isNaN(start) ||
Number.isNaN(end) ||
start < 0 ||
+ start >= stat.size ||
end < start ||
end >= stat.size
) {
- end = stat.size - 1;
+ return reply.status(416).send({ error: 'INVALID_RANGE' });
}
+ const contentLength = end - start + 1;
+
reply
.status(206)
.header('Content-Range', `bytes ${start}-${end}/${stat.size}`)
.header('Accept-Ranges', 'bytes')
- .header('Content-Length', end - start + 1)
+ .header('Content-Length', contentLength)
.header('Content-Type', 'video/mp4');
return fs.createReadStream(file.file_path, { start, end });
-
}
type MatchBody = {
@@ -247,17 +258,13 @@ export async function matchEntry(
return { status: 'OK', matched: !!matched_id };
}
-export async function getUnits(
- request: FastifyRequest<{ Params: Params }>,
- reply: FastifyReply
-) {
+export async function getUnits(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try {
- const { type, id } = request.params as { type: string, id: string };
+ const { id } = request.params as { id: string };
- // Buscar la entrada por matched_id
const entry = await queryOne(
- `SELECT id, type, matched_id FROM local_entries WHERE matched_id = ? AND type = ?`,
- [Number(id), type],
+ `SELECT id, type, matched_id FROM local_entries WHERE matched_id = ?`,
+ [Number(id)],
'local_library'
);
@@ -274,24 +281,82 @@ export async function getUnits(
'local_library'
);
- // Formatear la respuesta según el tipo
- const units = files.map((file: any) => {
- const fileName = path.basename(file.file_path);
- const fileExt = path.extname(file.file_path).toLowerCase();
+ const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
+ const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
- // Detectar si es un archivo comprimido (capítulo único) o carpeta
- const isDirectory = fs.existsSync(file.file_path) &&
- fs.statSync(file.file_path).isDirectory();
+ const NOVEL_EXTS = ['.epub', '.pdf', '.txt', '.md', '.docx', '.mobi'];
+
+ function isImageFolder(folderPath: string): boolean {
+ if (!fs.existsSync(folderPath)) return false;
+ if (!fs.statSync(folderPath).isDirectory()) return false;
+
+ const files = fs.readdirSync(folderPath);
+ return files.some(f => MANGA_IMAGE_EXTS.includes(path.extname(f).toLowerCase()));
+ }
+
+ const units = files
+ .map((file: any) => {
+ const fileExt = path.extname(file.file_path).toLowerCase();
+ const isDir = fs.existsSync(file.file_path) &&
+ fs.statSync(file.file_path).isDirectory();
+
+ // ===== MANGA =====
+ if (entry.type === 'manga') {
+ if (MANGA_ARCHIVES.includes(fileExt)) {
+ return {
+ id: file.id,
+ number: file.unit_number,
+ name: path.basename(file.file_path),
+ type: 'chapter',
+ format: fileExt.replace('.', ''),
+ path: file.file_path
+ };
+ }
+
+ if (isDir && isImageFolder(file.file_path)) {
+ return {
+ id: file.id,
+ number: file.unit_number,
+ name: path.basename(file.file_path),
+ type: 'chapter',
+ format: 'folder',
+ path: file.file_path
+ };
+ }
+
+ return null;
+ }
+
+ if (entry.type === 'novels') {
+ if (NOVEL_EXTS.includes(fileExt)) {
+ return {
+ id: file.id,
+ number: file.unit_number,
+ name: path.basename(file.file_path),
+ type: 'chapter',
+ format: fileExt.replace('.', ''),
+ path: file.file_path
+ };
+ }
+
+ return null;
+ }
+
+ if (entry.type === 'anime') {
+ return {
+ id: file.id,
+ number: file.unit_number,
+ name: path.basename(file.file_path),
+ type: 'episode',
+ format: fileExt.replace('.', ''),
+ path: file.file_path
+ };
+ }
+
+ return null;
+ })
+ .filter(Boolean);
- return {
- id: file.id,
- number: file.unit_number,
- name: fileName,
- type: type === 'anime' ? 'episode' : 'chapter',
- format: fileExt === '.cbz' ? 'cbz' : 'file',
- path: file.file_path
- };
- });
return {
entry_id: entry.id,
@@ -306,7 +371,7 @@ export async function getUnits(
}
}
-export async function getCbzPages(request: FastifyRequest, reply: FastifyReply) {
+export async function getManifest(request: FastifyRequest, reply: FastifyReply) {
const { unitId } = request.params as any;
const file = await queryOne(
@@ -319,20 +384,73 @@ export async function getCbzPages(request: FastifyRequest, reply: FastifyReply)
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
}
- const zip = new AdmZip(file.file_path);
+ const ext = path.extname(file.file_path).toLowerCase();
- const pages = zip.getEntries()
- .filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName))
- .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true }))
- .map((_, i) =>
- `/api/library/manga/cbz/${unitId}/page/${i}`
- );
+ // ===== MANGA =====
+ if (['.cbz', '.cbr', '.zip'].includes(ext)) {
+ const zip = new AdmZip(file.file_path);
- return { pages };
+ const pages = zip.getEntries()
+ .filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName))
+ .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true }))
+ .map((_, i) => ({
+ id: i,
+ url: `/api/library/${unitId}/resource/${i}`
+ }));
+
+ return {
+ type: 'manga',
+ format: 'archive',
+ pages
+ };
+ }
+
+ if (fs.statSync(file.file_path).isDirectory()) {
+ const pages = fs.readdirSync(file.file_path)
+ .filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f))
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
+ .map((_, i) => ({
+ id: i,
+ url: `/api/library/${unitId}/resource/${i}`
+ }));
+
+ return {
+ type: 'manga',
+ format: 'folder',
+ pages
+ };
+ }
+
+ // ===== NOVEL =====
+ if (ext === '.epub') {
+ return {
+ type: 'ln',
+ format: 'epub',
+ url: `/api/library/${unitId}/resource/epub`
+ };
+ }
+
+ if (['.txt', '.md'].includes(ext)) {
+ return {
+ type: 'ln',
+ format: 'text',
+ url: `/api/library/${unitId}/resource/text`
+ };
+ }
+
+ if (ext === '.pdf') {
+ return {
+ type: 'ln',
+ format: 'pdf',
+ url: `/api/library/${unitId}/resource/pdf`
+ };
+ }
+
+ return reply.status(400).send({ error: 'UNSUPPORTED_FORMAT' });
}
-export async function getCbzPage(request: FastifyRequest, reply: FastifyReply) {
- const { unitId, page } = request.params as any;
+export async function getPage(request: FastifyRequest, reply: FastifyReply) {
+ const { unitId, resId } = request.params as any;
const file = await queryOne(
`SELECT file_path FROM local_files WHERE id = ?`,
@@ -342,16 +460,83 @@ export async function getCbzPage(request: FastifyRequest, reply: FastifyReply) {
if (!file) return reply.status(404).send();
- const zip = new AdmZip(file.file_path);
+ const ext = path.extname(file.file_path).toLowerCase();
- const images = zip.getEntries()
- .filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName))
- .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true }));
+ // ===== CBZ PAGE =====
+ if (['.cbz', '.zip', '.cbr'].includes(ext)) {
+ const zip = new AdmZip(file.file_path);
+ const images = zip.getEntries()
+ .filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName))
+ .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true }));
- const entry = images[page];
- if (!entry) return reply.status(404).send();
+ const entry = images[Number(resId)];
+ if (!entry) return reply.status(404).send();
- reply
- .header('Content-Type', 'image/jpeg')
- .send(entry.getData());
+ return reply
+ .header('Content-Type', 'image/jpeg')
+ .send(entry.getData());
+ }
+
+ if (fs.statSync(file.file_path).isDirectory()) {
+ const images = fs.readdirSync(file.file_path)
+ .filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f))
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
+
+ const img = images[Number(resId)];
+ if (!img) return reply.status(404).send();
+
+ const imgPath = path.join(file.file_path, img);
+ const stat = fs.statSync(imgPath);
+
+ reply
+ .header('Content-Length', stat.size)
+ .header('Content-Type', 'image/jpeg');
+
+ return fs.createReadStream(imgPath);
+ }
+
+ if (ext === '.epub') {
+ const html = await parseEpubToHtml(file.file_path);
+
+ return reply
+ .header('Content-Type', 'text/html; charset=utf-8')
+ .send(html);
+ }
+
+ // ===== TXT / MD =====
+ if (['.txt', '.md'].includes(ext)) {
+ const text = fs.readFileSync(file.file_path, 'utf8');
+
+ return reply
+ .header('Content-Type', 'text/html; charset=utf-8')
+ .send(``);
+ }
+
+ return reply.status(400).send();
+}
+
+function parseEpubToHtml(filePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ const epub = new EPub(filePath);
+
+ epub.on('end', async () => {
+ let html = '';
+
+ for (const id of epub.flow.map(f => f.id)) {
+ const chapter = await new Promise((res, rej) => {
+ epub.getChapter(id, (err, text) => {
+ if (err) rej(err);
+ else res(text);
+ });
+ });
+
+ html += ``;
+ }
+
+ resolve(html);
+ });
+
+ epub.on('error', reject);
+ epub.parse();
+ });
}
diff --git a/docker/src/api/local/local.routes.ts b/docker/src/api/local/local.routes.ts
index 0127890..5d18cf9 100644
--- a/docker/src/api/local/local.routes.ts
+++ b/docker/src/api/local/local.routes.ts
@@ -7,9 +7,9 @@ async function localRoutes(fastify: FastifyInstance) {
fastify.get('/library/:type/:id', controller.getEntry);
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
fastify.post('/library/:type/:id/match', controller.matchEntry);
- fastify.get('/library/:type/:id/units', controller.getUnits);
- fastify.get('/library/:type/cbz/:unitId/pages', controller.getCbzPages);
- fastify.get('/library/:type/cbz/:unitId/page/:page', controller.getCbzPage);
+ fastify.get('/library/:id/units', controller.getUnits);
+ fastify.get('/library/:unitId/manifest', controller.getManifest);
+ fastify.get('/library/:unitId/resource/:resId', controller.getPage);
}
export default localRoutes;
\ No newline at end of file
diff --git a/docker/src/scripts/anime/player.js b/docker/src/scripts/anime/player.js
index da56b29..8023dfe 100644
--- a/docker/src/scripts/anime/player.js
+++ b/docker/src/scripts/anime/player.js
@@ -32,12 +32,12 @@ let localEntryId = null;
async function checkLocal() {
try {
const res = await fetch(`/api/library/anime/${animeId}`);
- if (!res.ok) return;
-
+ if (!res.ok) return null;
const data = await res.json();
- localEntryId = data.id;
-
- } catch {}
+ return data.id;
+ } catch {
+ return null;
+ }
}
async function loadAniSkip(malId, episode, duration) {
@@ -53,7 +53,7 @@ async function loadAniSkip(malId, episode, duration) {
}
async function loadMetadata() {
- checkLocal();
+ localEntryId = await checkLocal();
try {
const sourceQuery = (extName === 'local' || !extName) ? "source=anilist" : `source=${extName}`;
const res = await fetch(`/api/anime/${animeId}?${sourceQuery}`);
@@ -133,6 +133,7 @@ async function loadMetadata() {
} catch (error) {
console.error('Error loading metadata:', error);
}
+ await loadExtensions();
}
async function applyAniSkip(video) {
@@ -439,9 +440,9 @@ document.getElementById('next-btn').onclick = () => {
if (currentEpisode <= 1) document.getElementById('prev-btn').disabled = true;
+// Actualizar progreso cada 1 minuto si el video está reproduciéndose
setInterval(() => {
if (plyrInstance && !plyrInstance.paused) sendProgress();
}, 60000);
loadMetadata();
-loadExtensions();
\ No newline at end of file
diff --git a/docker/src/scripts/books/book.js b/docker/src/scripts/books/book.js
index 7384528..73a9643 100644
--- a/docker/src/scripts/books/book.js
+++ b/docker/src/scripts/books/book.js
@@ -18,7 +18,10 @@ document.addEventListener('DOMContentLoaded', () => {
async function checkLocalLibraryEntry() {
try {
- const res = await fetch(`/api/library/manga/${bookId}`);
+ const libraryType =
+ bookData?.entry_type === 'NOVEL' ? 'novels' : 'manga';
+
+ const res = await fetch(`/api/library/${libraryType}/${bookId}`);
if (!res.ok) return;
const data = await res.json();
@@ -38,18 +41,6 @@ async function checkLocalLibraryEntry() {
}
}
-function markAsLocal() {
- isLocal = true;
- const pill = document.getElementById('local-pill');
- if (pill) {
- pill.textContent = 'Local';
- pill.style.display = 'inline-flex';
- pill.style.background = 'rgba(34, 197, 94, 0.2)';
- pill.style.color = '#22c55e';
- pill.style.borderColor = 'rgba(34, 197, 94, 0.3)';
- }
-}
-
async function init() {
try {
const urlData = URLUtils.parseEntityPath('book');
@@ -61,8 +52,8 @@ async function init() {
extensionName = urlData.extensionName;
bookId = urlData.entityId;
bookSlug = urlData.slug;
- await checkLocalLibraryEntry();
await loadBookMetadata();
+ await checkLocalLibraryEntry();
await loadAvailableExtensions();
await loadChapters();
@@ -220,7 +211,7 @@ async function loadChapters(targetProvider = null) {
if (isLocalRequest) {
// Nuevo endpoint para archivos locales
- fetchUrl = `/api/library/manga/${bookId}/units`;
+ fetchUrl = `/api/library/${bookId}/units`;
} else {
const source = extensionName || 'anilist';
fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
diff --git a/docker/src/scripts/books/reader.js b/docker/src/scripts/books/reader.js
index f0ba41d..609165b 100644
--- a/docker/src/scripts/books/reader.js
+++ b/docker/src/scripts/books/reader.js
@@ -132,7 +132,7 @@ async function loadChapter() {
let newEndpoint;
if (provider === 'local') {
- newEndpoint = `/api/library/manga/${bookId}/units`;
+ newEndpoint = `/api/library/${bookId}/units`;
} else {
newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`;
}
@@ -142,29 +142,39 @@ async function loadChapter() {
const data = await res.json();
if (provider === 'local') {
const unit = data.units[Number(chapter)];
+ if (!unit) return;
- if (!unit) {
- reader.innerHTML = 'Chapter not found
';
- return;
- }
+ chapterLabel.textContent = unit.name;
+ document.title = unit.name;
- if (unit.format === 'cbz') {
- chapterLabel.textContent = unit.name; // ✅
- document.title = unit.name;
- const pagesRes = await fetch(
- `/api/library/manga/cbz/${unit.id}/pages`
- );
- const pagesData = await pagesRes.json();
+ const manifestRes = await fetch(`/api/library/${unit.id}/manifest`);
+ const manifest = await manifestRes.json();
+ reader.innerHTML = '';
+
+ // ===== MANGA =====
+ if (manifest.type === 'manga') {
currentType = 'manga';
updateSettingsVisibility();
applyStyles();
- currentPages = pagesData.pages.map(url => ({ url }));
- reader.innerHTML = '';
+ currentPages = manifest.pages;
loadManga(currentPages);
return;
}
+
+ // ===== LN =====
+ if (manifest.type === 'ln') {
+ currentType = 'ln';
+ updateSettingsVisibility();
+ applyStyles();
+
+ const contentRes = await fetch(manifest.url);
+ const html = await contentRes.text();
+
+ loadLN(html);
+ return;
+ }
}
@@ -193,13 +203,8 @@ async function loadChapter() {
reader.innerHTML = '';
if (data.type === 'manga') {
- if (provider === 'local' && data.format === 'cbz') {
- currentPages = data.pages.map(url => ({ url }));
- loadManga(currentPages);
- } else {
- currentPages = data.pages || [];
- loadManga(currentPages);
- }
+ currentPages = data.pages || [];
+ loadManga(currentPages);
} else if (data.type === 'ln') {
loadLN(data.content);
}
diff --git a/docker/src/scripts/local-library-books.js b/docker/src/scripts/local-library-books.js
index 63e3f62..3ab311c 100644
--- a/docker/src/scripts/local-library-books.js
+++ b/docker/src/scripts/local-library-books.js
@@ -26,21 +26,37 @@ async function loadLocalEntries() {
grid.innerHTML = ''.repeat(6);
try {
- // Cambiado a endpoint de libros
- const response = await fetch('/api/library/manga');
- const entries = await response.json();
- localEntries = entries;
+ const [mangaRes, novelRes] = await Promise.all([
+ fetch('/api/library/manga'),
+ fetch('/api/library/novels')
+ ]);
- if (entries.length === 0) {
- grid.innerHTML = 'No books found in your local library.
';
+ const [manga, novel] = await Promise.all([
+ mangaRes.json(),
+ novelRes.json()
+ ]);
+
+ localEntries = [
+ ...manga.map(e => ({ ...e, type: 'manga' })),
+ ...novel.map(e => ({ ...e, type: 'novel' }))
+ ];
+
+ if (localEntries.length === 0) {
+ grid.innerHTML = 'No books found.
';
return;
}
- renderLocalEntries(entries);
- } catch (err) {
- grid.innerHTML = 'Error loading local books.
';
+
+ renderLocalEntries(localEntries);
+ } catch {
+ grid.innerHTML = 'Error loading library.
';
}
}
+function filterLocal(type) {
+ if (type === 'all') renderLocalEntries(localEntries);
+ else renderLocalEntries(localEntries.filter(e => e.type === type));
+}
+
function renderLocalEntries(entries) {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = entries.map(entry => {
@@ -58,6 +74,7 @@ function renderLocalEntries(entries) {
${chapters} Chapters
+ ${entry.type}
${entry.matched ? '● Linked' : '○ Unlinked'}