Patch: Various fixes (#660)

Various fixes from the last couple days: 

- Add manual approval option for book/audiobook requests (#651)
- Add flagged HTTP headers 
- Add filesystem fallback - copy + delete when hardlink/move fails
across filesystems (#647)
- Dependency updates
- Tweak frontend test config (simplified tsconfig for tests)
- Fix overlapping sort scoring in release modal - duplicate scoring keys
caused incorrect release ordering (#654 )
- Fix stale search session after download - search state was not
refreshed when returning from a download (#659)
- Fix multi-format release filtering - releases with multiple formats
were incorrectly excluded by the format filter (#658)
- Fix config persistence when action button is used - clicking "Test
connection" reset unsaved settings (#657)
- Fix request grid text positioning in admin request policy panel 
- Fix rTorrent path discovery (#653)
- Fix `/login` API check (#650)
This commit is contained in:
Alex
2026-02-25 18:44:46 +00:00
committed by GitHub
parent 91cbd51b67
commit ac36d539c8
33 changed files with 789 additions and 611 deletions
+75 -530
View File
@@ -10,7 +10,7 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.2",
"react-router-dom": "^6.30.3",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
@@ -69,6 +69,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -711,24 +712,6 @@
"node": ">=12"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -817,21 +800,10 @@
"node": ">= 8"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.1",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz",
"integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==",
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -1216,6 +1188,7 @@
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1233,6 +1206,7 @@
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1269,32 +1243,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -1361,13 +1309,6 @@
"postcss": "^8.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz",
@@ -1391,16 +1332,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -1434,6 +1365,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -1517,26 +1449,6 @@
"node": ">= 6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -1554,21 +1466,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1621,13 +1518,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.249",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.249.tgz",
@@ -1635,13 +1525,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
@@ -1783,23 +1666,6 @@
"node": ">=8"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -1849,27 +1715,6 @@
"node": ">=6.9.0"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -1935,16 +1780,6 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -1968,35 +1803,13 @@
"node": ">=0.12.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -2099,32 +1912,6 @@
"node": ">=8.6"
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2209,23 +1996,6 @@
"node": ">= 6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -2233,30 +2003,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2317,6 +2063,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -2486,6 +2233,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -2498,6 +2246,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -2517,12 +2266,12 @@
}
},
"node_modules/react-router": {
"version": "6.30.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz",
"integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==",
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.1"
"@remix-run/router": "1.23.2"
},
"engines": {
"node": ">=14.0.0"
@@ -2532,13 +2281,13 @@
}
},
"node_modules/react-router-dom": {
"version": "6.30.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz",
"integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==",
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.1",
"react-router": "6.30.2"
"@remix-run/router": "1.23.2",
"react-router": "6.30.3"
},
"engines": {
"node": ">=14.0.0"
@@ -2688,42 +2437,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
@@ -2796,123 +2509,19 @@
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
"commander": "^4.0.0",
"glob": "^10.3.10",
"lines-and-columns": "^1.1.6",
"mz": "^2.7.0",
"pirates": "^4.0.1",
"tinyglobby": "^0.2.11",
"ts-interface-checker": "^0.1.9"
},
"bin": {
@@ -2997,6 +2606,55 @@
"node": ">=0.8"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3082,6 +2740,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -3136,120 +2795,6 @@
}
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+1 -1
View File
@@ -14,7 +14,7 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.2",
"react-router-dom": "^6.30.3",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
+13
View File
@@ -1039,12 +1039,25 @@ function App() {
record: RequestRecord,
options?: {
browseOnly?: boolean;
manualApproval?: boolean;
}
) => {
if (!requestRoleIsAdmin) {
return;
}
if (options?.manualApproval) {
try {
await fulfilSidebarRequest(requestId, undefined, undefined, true);
await refreshActivitySnapshot();
showToast('Request approved', 'success');
await fetchStatus();
} catch (error) {
showToast(getErrorMessage(error, 'Failed to approve request'), 'error');
}
return;
}
const shouldBrowse = Boolean(options?.browseOnly) || record.request_level === 'book';
if (!shouldBrowse && record.request_level === 'release') {
+16 -16
View File
@@ -31,6 +31,7 @@ import {
releaseLanguageMatchesFilter,
buildLanguageNormalizer,
} from '../utils/languageFilters';
import { getReleaseFormats } from '../utils/releaseFormats';
// Module-level cache for release search results
// Key format: `${provider}:${provider_id}:${source}:${contentType}`
@@ -266,9 +267,7 @@ function getTitleMatchScore(title: string, titleCandidate: string): number {
if (normalizedTitle.startsWith(titleCandidate)) {
score += 6000;
}
if (normalizedTitle.includes(titleCandidate)) {
} else if (normalizedTitle.includes(titleCandidate)) {
score += 3000;
}
@@ -1247,16 +1246,16 @@ export const ReleaseModal = ({
const availableFormats = useMemo(() => {
const releases = releasesBySource[activeTab]?.releases || [];
const formats = new Set<string>();
const effectiveLower = effectiveFormats.map((f) => f.toLowerCase());
const effectiveLower = new Set(effectiveFormats.map((f) => f.toLowerCase()));
releases.forEach((r) => {
if (r.format) {
const fmt = r.format.toLowerCase();
const releaseFormats = getReleaseFormats(r);
releaseFormats.forEach((fmt) => {
// Only include formats that are in the supported list
if (effectiveLower.includes(fmt)) {
if (effectiveLower.has(fmt)) {
formats.add(fmt);
}
}
});
});
return Array.from(formats).sort();
}, [releasesBySource, activeTab, effectiveFormats]);
@@ -1390,22 +1389,23 @@ export const ReleaseModal = ({
// Filter and sort releases based on settings and user selection
const filteredReleases = useMemo(() => {
const releases = releasesBySource[activeTab]?.releases || [];
const effectiveLower = effectiveFormats.map((f) => f.toLowerCase());
const effectiveLower = new Set(effectiveFormats.map((f) => f.toLowerCase()));
const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer');
const selectedFormat = formatFilter.toLowerCase();
// First, filter
let filtered = releases.filter((r) => {
// Format filtering
const fmt = r.format?.toLowerCase();
const releaseFormats = getReleaseFormats(r);
if (formatFilter) {
// User selected a specific format - must match exactly
if (!fmt || fmt !== formatFilter.toLowerCase()) return false;
} else if (fmt) {
// No specific filter - show only supported formats
if (!effectiveLower.includes(fmt)) return false;
// User selected a specific format - match if any format on the release matches
if (!releaseFormats.includes(selectedFormat)) return false;
} else if (releaseFormats.length > 0) {
// No specific filter - show only releases that include at least one supported format
if (!releaseFormats.some((fmt) => effectiveLower.has(fmt))) return false;
}
// Releases with no format pass through when no filter is set (show all)
// Releases with no format info pass through when no filter is set (show all)
// Language filtering - use r.language when provided by enriched indexers
// Releases with no language (null/undefined) always pass
@@ -12,6 +12,7 @@ import {
interface RequestApproveOptions {
browseOnly?: boolean;
manualApproval?: boolean;
}
type RequestApproveHandler = (
@@ -431,6 +432,7 @@ export const ActivityCard = ({
? 'Browse Releases To Retry'
: 'Browse Releases To Approve'
: 'Approve Attached File';
const canMarkAsApprovedWithoutRelease = requiresBrowseBeforeApprove && !hasAttachedRelease;
const provider = toOptionalText(bookData.provider)?.toLowerCase();
const providerId = toOptionalText(bookData.provider_id);
@@ -467,6 +469,19 @@ export const ActivityCard = ({
}
};
const handleReviewManualApproval = async () => {
if (!reviewRecord || !reviewApproveHandler || isReviewSubmitting) {
return;
}
setIsReviewSubmitting(true);
try {
await reviewApproveHandler(reviewRecord.id, reviewRecord, { manualApproval: true });
} finally {
setIsReviewSubmitting(false);
}
};
const canShowInlineReview = Boolean(isRequestDetailsOpen && reviewRecord && reviewApproveHandler);
const rejectConfirmHandler = onRequestRejectConfirm || onRequestReject;
const canShowInlineReject = Boolean(
@@ -704,6 +719,16 @@ export const ActivityCard = ({
>
{isReviewSubmitting ? 'Working...' : approveLabel}
</button>
{canMarkAsApprovedWithoutRelease && (
<button
type="button"
onClick={handleReviewManualApproval}
disabled={isReviewSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
>
{isReviewSubmitting ? 'Working...' : 'Manually Mark as Approved'}
</button>
)}
{canBrowseAlternatives && hasAttachedRelease && (
<button
type="button"
@@ -30,6 +30,7 @@ interface ActivitySidebarProps {
record: RequestRecord,
options?: {
browseOnly?: boolean;
manualApproval?: boolean;
}
) => Promise<void> | void;
onRequestReject?: (requestId: number, adminNote?: string) => Promise<void> | void;
@@ -260,9 +260,9 @@ export const RequestPolicyGrid = ({
if (!isConfigurable) {
return (
<div key={key} className="flex items-center min-h-[36px]">
<div key={key} className="flex items-center justify-center min-h-[36px]">
{mobileLabel}
<span className="text-sm opacity-50">
<span className="text-xs opacity-40">
{REQUEST_POLICY_MODE_LABELS[effectiveMode]}
</span>
</div>
+11 -8
View File
@@ -154,21 +154,24 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
window.location.href = logout_url;
return;
}
try {
// Reload auth settings after session clear so /login has current auth-mode UI config.
applyAuthResponse(await checkAuth());
} catch (error) {
console.error('Post-logout auth refresh failed:', error);
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
setDisplayName(null);
}
refreshSocketSession();
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
setDisplayName(null);
setOidcButtonLabel(null);
setHideLocalAuth(false);
setOidcAutoRedirect(false);
onLogoutSuccess?.();
navigate('/login', { replace: true });
} catch (error) {
console.error('Logout failed:', error);
showToast?.('Logout failed', 'error');
}
}, [navigate, onLogoutSuccess, refreshSocketSession, showToast]);
}, [navigate, onLogoutSuccess, refreshSocketSession, showToast, applyAuthResponse]);
return {
isAuthenticated,
+9 -2
View File
@@ -32,7 +32,8 @@ export interface UseRequestsReturn {
fulfilRequest: (
id: number,
releaseData?: Record<string, unknown>,
adminNote?: string
adminNote?: string,
manualApproval?: boolean
) => Promise<void>;
rejectRequest: (id: number, adminNote?: string) => Promise<void>;
}
@@ -217,7 +218,12 @@ export const useRequests = ({
}, []);
const fulfilRequest = useCallback(
async (id: number, releaseData?: Record<string, unknown>, adminNote?: string) => {
async (
id: number,
releaseData?: Record<string, unknown>,
adminNote?: string,
manualApproval?: boolean
) => {
if (!isAdmin) {
throw new Error('Admin access required');
}
@@ -232,6 +238,7 @@ export const useRequests = ({
const updated = await fulfilAdminRequest(id, {
release_data: releaseData,
admin_note: adminNote,
manual_approval: manualApproval,
});
setRequests((prev) => upsertRequestRecord(prev, updated));
setError(null);
+19 -1
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Book, AppConfig, AdvancedFilterState, ContentType } from '../types';
import { searchBooks, searchMetadata, AuthenticationError } from '../services/api';
@@ -78,6 +78,24 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
sort: string;
fieldValues: SearchFieldValues;
} | null>(null);
const previousContentTypeRef = useRef<ContentType>(contentType);
// Switching media type starts a new search session.
useEffect(() => {
if (previousContentTypeRef.current === contentType) {
return;
}
previousContentTypeRef.current = contentType;
setBooks([]);
setLastSearchQuery('');
setSearchFieldValues({});
setHasMore(false);
setTotalFound(0);
setCurrentPage(1);
lastSearchParamsRef.current = null;
onSearchReset?.();
}, [contentType, onSearchReset]);
const updateAdvancedFilters = useCallback((updates: Partial<AdvancedFilterState>) => {
setAdvancedFilters(prev => ({ ...prev, ...updates }));
+35 -10
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { getSettings, updateSettings, executeSettingsAction } from '../services/api';
import {
SettingsTab,
@@ -12,12 +12,22 @@ import {
setThemePreference,
THEME_FIELD,
} from '../utils/themePreference';
import {
cloneSettingsValues,
mergeFetchedSettingsWithDirtyValues,
type SettingsValues,
} from '../utils/settingsValues';
type ValueBearingField = Exclude<
SettingsField,
{ type: 'ActionButton' } | { type: 'HeadingField' } | { type: 'CustomComponentField' }
>;
interface FetchSettingsOptions {
silent?: boolean;
preserveDirtyValues?: boolean;
}
// Extract value from a field based on its type
function getFieldValue(field: SettingsField): unknown {
// These field types have no value property
@@ -88,11 +98,22 @@ export function useSettings(): UseSettingsReturn {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState<string | null>(null);
const [values, setValues] = useState<Record<string, Record<string, unknown>>>({});
const [originalValues, setOriginalValues] = useState<Record<string, Record<string, unknown>>>({});
const [values, setValues] = useState<SettingsValues>({});
const [originalValues, setOriginalValues] = useState<SettingsValues>({});
const [isSaving, setIsSaving] = useState(false);
const valuesRef = useRef<SettingsValues>({});
const originalValuesRef = useRef<SettingsValues>({});
const fetchSettings = useCallback(async (silent = false) => {
useEffect(() => {
valuesRef.current = values;
}, [values]);
useEffect(() => {
originalValuesRef.current = originalValues;
}, [originalValues]);
const fetchSettings = useCallback(async (options: FetchSettingsOptions = {}) => {
const { silent = false, preserveDirtyValues = false } = options;
if (!silent) {
setIsLoading(true);
}
@@ -114,7 +135,7 @@ export function useSettings(): UseSettingsReturn {
setGroups(response.groups || []);
// Initialize values from fetched data
const initialValues: Record<string, Record<string, unknown>> = {};
const initialValues: SettingsValues = {};
tabsWithTheme.forEach((tab) => {
initialValues[tab.name] = {};
getValueBearingFields(tab.fields).forEach((field) => {
@@ -127,8 +148,12 @@ export function useSettings(): UseSettingsReturn {
});
});
setValues(initialValues);
setOriginalValues(JSON.parse(JSON.stringify(initialValues)));
const nextValues = preserveDirtyValues
? mergeFetchedSettingsWithDirtyValues(initialValues, valuesRef.current, originalValuesRef.current)
: initialValues;
setValues(nextValues);
setOriginalValues(cloneSettingsValues(initialValues));
// Select first tab by default if none selected
if (tabsWithTheme.length > 0) {
@@ -231,7 +256,7 @@ export function useSettings(): UseSettingsReturn {
if (result.success) {
// Refetch all settings silently to pick up any backend-triggered changes
// (e.g., enabling a metadata provider auto-updates METADATA_PROVIDER)
await fetchSettings(true);
await fetchSettings({ silent: true });
}
return result;
@@ -259,7 +284,7 @@ export function useSettings(): UseSettingsReturn {
// Re-fetch settings after successful action to pick up updated options
// (e.g., BookLore "Test Connection" refreshes library/path lists)
if (result.success) {
fetchSettings(true);
await fetchSettings({ silent: true, preserveDirtyValues: true });
}
return result;
@@ -287,6 +312,6 @@ export function useSettings(): UseSettingsReturn {
saveTab,
executeAction,
isSaving,
refetch: fetchSettings,
refetch: () => fetchSettings(),
};
}
+12 -4
View File
@@ -9,6 +9,7 @@ export interface RequestListParams {
export interface FulfilAdminRequestBody {
release_data?: Record<string, unknown>;
admin_note?: string;
manual_approval?: boolean;
}
export interface RejectAdminRequestBody {
@@ -45,10 +46,17 @@ export const buildAdminRequestActionUrl = (
export const buildFulfilAdminRequestBody = (
body: FulfilAdminRequestBody = {}
): FulfilAdminRequestBody => {
return {
release_data: body.release_data,
admin_note: body.admin_note,
};
const payload: FulfilAdminRequestBody = {};
if (body.release_data !== undefined) {
payload.release_data = body.release_data;
}
if (body.admin_note !== undefined) {
payload.admin_note = body.admin_note;
}
if (body.manual_approval !== undefined) {
payload.manual_approval = body.manual_approval;
}
return payload;
};
export const buildRejectAdminRequestBody = (
@@ -36,6 +36,18 @@ describe('admin request API client functions', () => {
});
});
it('builds manual-approval fulfil payload without release data', () => {
const body = buildFulfilAdminRequestBody({
manual_approval: true,
admin_note: 'Handled manually',
});
assert.deepEqual(body, {
manual_approval: true,
admin_note: 'Handled manually',
});
});
it('builds reject endpoint URL and payload shape', () => {
const url = buildAdminRequestActionUrl('/api/admin/requests', 51, 'reject');
const body = buildRejectAdminRequestBody({
@@ -0,0 +1,40 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { getReleaseFormats } from '../utils/releaseFormats.js';
import type { Release } from '../types';
function buildRelease(overrides: Partial<Release>): Release {
return {
source: 'prowlarr',
source_id: 'release-1',
title: 'Test Release',
...overrides,
};
}
describe('releaseFormats.getReleaseFormats', () => {
it('returns primary and extra formats in order, normalized and deduplicated', () => {
const release = buildRelease({
format: 'AZW3',
extra: { formats: ['MOBI', 'EPUB', 'azw3', ' mobi '] },
});
assert.deepEqual(getReleaseFormats(release), ['azw3', 'mobi', 'epub']);
});
it('uses extra formats when primary format is missing', () => {
const release = buildRelease({
extra: { formats: ['EPUB', 'MOBI'] },
});
assert.deepEqual(getReleaseFormats(release), ['epub', 'mobi']);
});
it('supports legacy string value in extra.formats', () => {
const release = buildRelease({
extra: { formats: 'PDF' },
});
assert.deepEqual(getReleaseFormats(release), ['pdf']);
});
});
@@ -0,0 +1,73 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { mergeFetchedSettingsWithDirtyValues } from '../utils/settingsValues.js';
describe('mergeFetchedSettingsWithDirtyValues', () => {
it('preserves unsaved dirty values while applying fresh fetched values', () => {
const fetchedValues = {
general: {
apiKey: 'saved-key',
endpoint: 'https://saved.example',
},
};
const currentValues = {
general: {
apiKey: 'unsaved-key',
endpoint: 'https://saved.example',
},
};
const originalValues = {
general: {
apiKey: 'saved-key',
endpoint: 'https://saved.example',
},
};
assert.deepEqual(
mergeFetchedSettingsWithDirtyValues(fetchedValues, currentValues, originalValues),
{
general: {
apiKey: 'unsaved-key',
endpoint: 'https://saved.example',
},
}
);
});
it('does not preserve values that are not dirty and ignores keys removed by backend', () => {
const fetchedValues = {
provider: {
host: 'new-host',
enabled: true,
},
};
const currentValues = {
provider: {
host: 'old-host',
enabled: false,
removedField: 'local-only',
},
};
const originalValues = {
provider: {
host: 'old-host',
enabled: false,
removedField: 'local-only',
},
};
assert.deepEqual(
mergeFetchedSettingsWithDirtyValues(fetchedValues, currentValues, originalValues),
{
provider: {
host: 'new-host',
enabled: true,
},
}
);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { Release } from '../types';
function normalizeFormatValue(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim().toLowerCase();
return normalized.length > 0 ? normalized : null;
}
export function getReleaseFormats(release: Release): string[] {
const formats: string[] = [];
const seen = new Set<string>();
const addFormat = (value: unknown): void => {
const normalized = normalizeFormatValue(value);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
formats.push(normalized);
};
addFormat(release.format);
const extraFormats = (release.extra as Record<string, unknown> | undefined)?.formats;
if (Array.isArray(extraFormats)) {
extraFormats.forEach(addFormat);
} else {
addFormat(extraFormats);
}
return formats;
}
+32
View File
@@ -0,0 +1,32 @@
export type SettingsValues = Record<string, Record<string, unknown>>;
export function cloneSettingsValues(values: SettingsValues): SettingsValues {
return JSON.parse(JSON.stringify(values)) as SettingsValues;
}
export function mergeFetchedSettingsWithDirtyValues(
fetchedValues: SettingsValues,
currentValues: SettingsValues,
originalValues: SettingsValues
): SettingsValues {
const mergedValues: SettingsValues = {};
for (const [tabName, fetchedTabValues] of Object.entries(fetchedValues)) {
mergedValues[tabName] = { ...fetchedTabValues };
const currentTabValues = currentValues[tabName] ?? {};
const originalTabValues = originalValues[tabName] ?? {};
for (const [key, currentValue] of Object.entries(currentTabValues)) {
if (!(key in fetchedTabValues)) {
continue;
}
const originalValue = originalTabValues[key];
if (JSON.stringify(currentValue) !== JSON.stringify(originalValue)) {
mergedValues[tabName][key] = currentValue;
}
}
}
return mergedValues;
}
+1 -6
View File
@@ -9,10 +9,5 @@
"types": ["node"],
"allowImportingTsExtensions": false
},
"include": [
"src/tests/**/*.node.test.ts",
"src/hooks/requestPolicyCore.ts",
"src/utils/requestPolicyUi.ts",
"src/types/index.ts"
]
"include": ["src"]
}