Compare commits
14 Commits
scanning-u
...
bd028a7a5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd028a7a5d | ||
|
|
8f8f8c3001 | ||
|
|
dee9356004 | ||
| 7d2ae7e95c | |||
|
|
cedc012733 | ||
| a9461f9ae4 | |||
|
|
a6d657d87d | ||
|
|
71a026f01e | ||
| fc9a7af7c3 | |||
|
|
b12decc802 | ||
|
|
6c6a35433c | ||
|
|
0842769125 | ||
| 95bcaf53be | |||
|
|
b0e9c9790c |
134
package-lock.json
generated
134
package-lock.json
generated
@@ -9,6 +9,9 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
"@types/adm-zip": "^0.5.8",
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"fast-xml-parser": "^5.5.10",
|
"fast-xml-parser": "^5.5.10",
|
||||||
@@ -1144,9 +1147,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.15.tgz",
|
||||||
"integrity": "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA==",
|
"integrity": "sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
@@ -1160,9 +1163,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.15.tgz",
|
||||||
"integrity": "sha512-Y9K6SPzobnZvrRDPO2s0grgzC+Egf0CqfbdvYmQVaztV890zicw8Z8+4Vqw8oPck8r1TjUHxVh8299Cg4TrxXg==",
|
"integrity": "sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1176,9 +1179,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.15.tgz",
|
||||||
"integrity": "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw==",
|
"integrity": "sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1192,9 +1195,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.15.tgz",
|
||||||
"integrity": "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q==",
|
"integrity": "sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1211,9 +1214,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.15.tgz",
|
||||||
"integrity": "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ==",
|
"integrity": "sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1230,9 +1233,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.15.tgz",
|
||||||
"integrity": "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ==",
|
"integrity": "sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1249,9 +1252,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.15.tgz",
|
||||||
"integrity": "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw==",
|
"integrity": "sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1268,9 +1271,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.15.tgz",
|
||||||
"integrity": "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g==",
|
"integrity": "sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1284,9 +1287,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.15.tgz",
|
||||||
"integrity": "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg==",
|
"integrity": "sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1656,6 +1659,33 @@
|
|||||||
"tailwindcss": "4.2.2"
|
"tailwindcss": "4.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.13.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
|
||||||
|
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.14.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -1667,6 +1697,15 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/adm-zip": {
|
||||||
|
"version": "0.5.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz",
|
||||||
|
"integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/archiver": {
|
"node_modules/@types/archiver": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz",
|
||||||
@@ -1712,7 +1751,6 @@
|
|||||||
"version": "25.5.0",
|
"version": "25.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
@@ -2365,6 +2403,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adm-zip": {
|
||||||
|
"version": "0.5.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
|
||||||
|
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
@@ -2958,9 +3005,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6057,12 +6104,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.5.14",
|
"version": "15.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
|
||||||
"integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
|
"integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.5.14",
|
"@next/env": "15.5.15",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
@@ -6075,14 +6122,14 @@
|
|||||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "15.5.14",
|
"@next/swc-darwin-arm64": "15.5.15",
|
||||||
"@next/swc-darwin-x64": "15.5.14",
|
"@next/swc-darwin-x64": "15.5.15",
|
||||||
"@next/swc-linux-arm64-gnu": "15.5.14",
|
"@next/swc-linux-arm64-gnu": "15.5.15",
|
||||||
"@next/swc-linux-arm64-musl": "15.5.14",
|
"@next/swc-linux-arm64-musl": "15.5.15",
|
||||||
"@next/swc-linux-x64-gnu": "15.5.14",
|
"@next/swc-linux-x64-gnu": "15.5.15",
|
||||||
"@next/swc-linux-x64-musl": "15.5.14",
|
"@next/swc-linux-x64-musl": "15.5.15",
|
||||||
"@next/swc-win32-arm64-msvc": "15.5.14",
|
"@next/swc-win32-arm64-msvc": "15.5.15",
|
||||||
"@next/swc-win32-x64-msvc": "15.5.14",
|
"@next/swc-win32-x64-msvc": "15.5.15",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -7954,7 +8001,6 @@
|
|||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
"@types/adm-zip": "^0.5.8",
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"fast-xml-parser": "^5.5.10",
|
"fast-xml-parser": "^5.5.10",
|
||||||
|
|||||||
71
src/app/api/comics/page/route.ts
Normal file
71
src/app/api/comics/page/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
|
import { getComicPageBuffer } from '@/lib/comics'
|
||||||
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
|
const EXT_TO_MIME: Record<string, string> = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = request.nextUrl
|
||||||
|
const libraryId = searchParams.get('libraryId')
|
||||||
|
const issueKey = searchParams.get('issueKey')
|
||||||
|
const pageIndexStr = searchParams.get('pageIndex')
|
||||||
|
|
||||||
|
if (!libraryId || !issueKey || pageIndexStr === null) {
|
||||||
|
return NextResponse.json({ error: 'Missing libraryId, issueKey, or pageIndex' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageIndex = parseInt(pageIndexStr, 10)
|
||||||
|
if (isNaN(pageIndex) || pageIndex < 0) {
|
||||||
|
return NextResponse.json({ error: 'Invalid pageIndex' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT file_path FROM media_items WHERE item_key = ? AND item_type = ?')
|
||||||
|
.get(issueKey, 'comic_issue') as { file_path: string | null } | undefined
|
||||||
|
|
||||||
|
if (!row?.file_path) {
|
||||||
|
return NextResponse.json({ error: 'Issue not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
let absPath: string
|
||||||
|
try {
|
||||||
|
absPath = resolveAndJail(root, row.file_path)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = getComicPageBuffer(absPath, pageIndex)
|
||||||
|
if (!result) {
|
||||||
|
return NextResponse.json({ error: 'Page not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = EXT_TO_MIME[result.ext] ?? 'image/jpeg'
|
||||||
|
|
||||||
|
return new NextResponse(result.buffer as unknown as BodyInit, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
'Content-Length': String(result.buffer.length),
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
117
src/app/api/comics/route.ts
Normal file
117
src/app/api/comics/route.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
|
import { comicsFromDb, comicIssuesFromDb } from '@/lib/comics'
|
||||||
|
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
||||||
|
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = request.nextUrl
|
||||||
|
const libraryId = searchParams.get('libraryId')
|
||||||
|
const seriesId = searchParams.get('seriesId')
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (library.type !== 'comics') {
|
||||||
|
return NextResponse.json({ error: 'Library is not a comics library' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesId) {
|
||||||
|
return NextResponse.json(comicIssuesFromDb(libraryId, seriesId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1)
|
||||||
|
const pageSize = Math.min(500, Math.max(1, parseInt(searchParams.get('pageSize') ?? '200', 10) || 200))
|
||||||
|
const search = (searchParams.get('search') ?? '').trim() || undefined
|
||||||
|
|
||||||
|
return NextResponse.json(comicsFromDb(libraryId, { page, pageSize, search }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { searchParams } = request.nextUrl
|
||||||
|
const libraryId = searchParams.get('libraryId')
|
||||||
|
const issueKey = searchParams.get('issueKey')
|
||||||
|
const seriesId = searchParams.get('seriesId')
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (library.type !== 'comics') {
|
||||||
|
return NextResponse.json({ error: 'Library is not a comics library' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
if (issueKey) {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT file_path FROM media_items WHERE item_key = ? AND item_type = ?')
|
||||||
|
.get(issueKey, 'comic_issue') as { file_path: string | null } | undefined
|
||||||
|
|
||||||
|
if (!row?.file_path) {
|
||||||
|
return NextResponse.json({ error: 'Issue not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let issuePath: string
|
||||||
|
try {
|
||||||
|
issuePath = resolveAndJail(root, row.file_path)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid issue path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(issuePath)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Failed to delete issue file' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllAssignmentsForItem(issueKey)
|
||||||
|
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(issueKey)
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesId) {
|
||||||
|
const dirName = decodeURIComponent(seriesId)
|
||||||
|
|
||||||
|
let seriesDir: string
|
||||||
|
try {
|
||||||
|
seriesDir = resolveAndJail(root, dirName)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid series path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.rmSync(seriesDir, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Failed to delete series directory' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllAssignmentsForItem(`${libraryId}:comic_series:${seriesId}`)
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(`${libraryId}:comic_series:${seriesId}`)
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Missing issueKey or seriesId' }, { status: 400 })
|
||||||
|
}
|
||||||
26
src/app/api/comics/series-issue-tags/route.ts
Normal file
26
src/app/api/comics/series-issue-tags/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getLibrary } from '@/lib/libraries'
|
||||||
|
import { getComicsSeriesIssueMeta } from '@/lib/tags'
|
||||||
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = request.nextUrl
|
||||||
|
const libraryId = searchParams.get('libraryId')
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (library.type !== 'comics') {
|
||||||
|
return NextResponse.json({ error: 'Library is not a comics library' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(getComicsSeriesIssueMeta(libraryId))
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
'.bmp': 'image/bmp',
|
'.bmp': 'image/bmp',
|
||||||
'.tiff': 'image/tiff',
|
'.tiff': 'image/tiff',
|
||||||
'.tif': 'image/tiff',
|
'.tif': 'image/tiff',
|
||||||
|
'.cbz': 'application/zip',
|
||||||
'.zip': 'application/zip',
|
'.zip': 'application/zip',
|
||||||
'.dmg': 'application/x-apple-diskimage',
|
'.dmg': 'application/x-apple-diskimage',
|
||||||
'.gz': 'application/gzip',
|
'.gz': 'application/gzip',
|
||||||
@@ -43,6 +44,7 @@ function getMimeType(filePath: string): string {
|
|||||||
function isDownloadAttachment(filePath: string): boolean {
|
function isDownloadAttachment(filePath: string): boolean {
|
||||||
const lower = filePath.toLowerCase()
|
const lower = filePath.toLowerCase()
|
||||||
return (
|
return (
|
||||||
|
lower.endsWith('.cbz') ||
|
||||||
lower.endsWith('.zip') ||
|
lower.endsWith('.zip') ||
|
||||||
lower.endsWith('.tar.gz') ||
|
lower.endsWith('.tar.gz') ||
|
||||||
lower.endsWith('.tar.bz2') ||
|
lower.endsWith('.tar.bz2') ||
|
||||||
|
|||||||
16
src/app/api/imported-tags/route.ts
Normal file
16
src/app/api/imported-tags/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getImportedTagsForLibrary } from '@/lib/comic-metadata'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const libraryId = request.nextUrl.searchParams.get('libraryId')
|
||||||
|
if (!libraryId) {
|
||||||
|
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = getImportedTagsForLibrary(libraryId)
|
||||||
|
return NextResponse.json(tags)
|
||||||
|
}
|
||||||
70
src/app/api/libraries/[id]/bulk-rename/route.ts
Normal file
70
src/app/api/libraries/[id]/bulk-rename/route.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getLibrary } from '@/lib/libraries'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { id: libraryId } = await params
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (library.type !== 'comics') {
|
||||||
|
return NextResponse.json({ error: 'Only comics libraries support bulk rename' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { pattern, preview } = body as { pattern: string; preview?: boolean }
|
||||||
|
|
||||||
|
if (!pattern || typeof pattern !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Pattern is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate regex
|
||||||
|
let regex: RegExp
|
||||||
|
try {
|
||||||
|
regex = new RegExp(pattern, 'g')
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid regex pattern' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT item_key, title FROM media_items
|
||||||
|
WHERE library_id = ? AND item_type IN ('comic_series', 'comic_issue')`
|
||||||
|
)
|
||||||
|
.all(libraryId) as { item_key: string; title: string }[]
|
||||||
|
|
||||||
|
const changes: { itemKey: string; oldTitle: string; newTitle: string }[] = []
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
// Reset lastIndex since we reuse the regex with 'g' flag
|
||||||
|
regex.lastIndex = 0
|
||||||
|
const newTitle = row.title.replace(regex, '').trim()
|
||||||
|
if (newTitle && newTitle !== row.title) {
|
||||||
|
changes.push({ itemKey: row.item_key, oldTitle: row.title, newTitle })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
return NextResponse.json({ changes })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply
|
||||||
|
const stmt = db.prepare('UPDATE media_items SET title = ? WHERE item_key = ?')
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const c of changes) {
|
||||||
|
stmt.run(c.newTitle, c.itemKey)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return NextResponse.json({ updated: changes.length })
|
||||||
|
}
|
||||||
31
src/app/api/libraries/[id]/import-metadata-movies/route.ts
Normal file
31
src/app/api/libraries/[id]/import-metadata-movies/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getLibrary } from '@/lib/libraries'
|
||||||
|
import { importMovieMetadata } from '@/lib/movie-metadata'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { pathname } = new URL(request.url)
|
||||||
|
const libraryId = pathname.split('/')[3] // /api/libraries/[id]/import-metadata-movies
|
||||||
|
|
||||||
|
try {
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
|
||||||
|
if (!library || library.type !== 'movies') {
|
||||||
|
return NextResponse.json({ error: 'Movies library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform full metadata import for all items
|
||||||
|
const result = await importMovieMetadata(library, true)
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[import-metadata-movies]', err)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err instanceof Error ? err.message : 'Failed to import metadata' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/libraries/[id]/import-metadata-tv/route.ts
Normal file
31
src/app/api/libraries/[id]/import-metadata-tv/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getLibrary } from '@/lib/libraries'
|
||||||
|
import { importTvMetadata } from '@/lib/tv-metadata'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { pathname } = new URL(request.url)
|
||||||
|
const libraryId = pathname.split('/')[3] // /api/libraries/[id]/import-metadata-tv
|
||||||
|
|
||||||
|
try {
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
|
||||||
|
if (!library || library.type !== 'tv') {
|
||||||
|
return NextResponse.json({ error: 'TV library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform full metadata import for all items
|
||||||
|
const result = await importTvMetadata(library, true)
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[import-metadata-tv]', err)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err instanceof Error ? err.message : 'Failed to import metadata' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/app/api/libraries/[id]/import-metadata/route.ts
Normal file
34
src/app/api/libraries/[id]/import-metadata/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getLibrary } from '@/lib/libraries'
|
||||||
|
import { importComicMetadata } from '@/lib/comic-metadata'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const library = getLibrary(id)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (library.type !== 'comics') {
|
||||||
|
return NextResponse.json({ error: 'Metadata import is only supported for comic libraries' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget
|
||||||
|
void Promise.resolve().then(() => {
|
||||||
|
try {
|
||||||
|
importComicMetadata(library)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[import-metadata] Error importing metadata for "${library.name}":`, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 202 })
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'name, path, and type are required' }, { status: 400 })
|
return NextResponse.json({ error: 'name, path, and type are required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const validTypes: LibraryType[] = ['games', 'mixed', 'movies', 'tv']
|
const validTypes: LibraryType[] = ['comics', 'games', 'mixed', 'movies', 'tv']
|
||||||
if (!validTypes.includes(type as LibraryType)) {
|
if (!validTypes.includes(type as LibraryType)) {
|
||||||
return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 })
|
return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/app/api/tag-mappings/[id]/route.ts
Normal file
21
src/app/api/tag-mappings/[id]/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { deleteTagMapping } from '@/lib/comic-metadata'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
try {
|
||||||
|
deleteTagMapping(id)
|
||||||
|
return new NextResponse(null, { status: 204 })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to delete mapping'
|
||||||
|
return NextResponse.json({ error: message }, { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/tag-mappings/route.ts
Normal file
44
src/app/api/tag-mappings/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getTagMappingsForLibrary, createTagMapping } from '@/lib/comic-metadata'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const libraryId = request.nextUrl.searchParams.get('libraryId')
|
||||||
|
if (!libraryId) {
|
||||||
|
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappings = getTagMappingsForLibrary(libraryId)
|
||||||
|
return NextResponse.json(mappings)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
let body: { libraryId?: string; importedTagName?: string; tagId?: string }
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { libraryId, importedTagName, tagId } = body
|
||||||
|
if (!libraryId || !importedTagName || !tagId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'libraryId, importedTagName, and tagId are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mapping = createTagMapping(libraryId, importedTagName, tagId)
|
||||||
|
return NextResponse.json(mapping, { status: 201 })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to create mapping'
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { updateCategory, deleteCategory, deleteCategoryForce, getTags } from '@/lib/tags'
|
import { updateCategory, deleteCategory, deleteCategoryForce, getTags, getCategories, mergeCategories } from '@/lib/tags'
|
||||||
import { requireAdmin } from '@/lib/auth'
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
@@ -11,9 +11,30 @@ export async function PATCH(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const { name } = await request.json()
|
const { name, merge } = await request.json()
|
||||||
const category = updateCategory(id, name)
|
|
||||||
return NextResponse.json(category)
|
try {
|
||||||
|
const category = updateCategory(id, name)
|
||||||
|
return NextResponse.json(category)
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message
|
||||||
|
if (!msg.includes('already exists')) throw err
|
||||||
|
|
||||||
|
// A category with this name already exists — find it
|
||||||
|
const trimmed = (name as string).trim()
|
||||||
|
const target = getCategories().find((c) => c.name.toLowerCase() === trimmed.toLowerCase())
|
||||||
|
if (!target) throw err
|
||||||
|
|
||||||
|
if (merge) {
|
||||||
|
mergeCategories(id, target.id)
|
||||||
|
return NextResponse.json(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: msg, conflict: true, targetCategoryId: target.id },
|
||||||
|
{ status: 409 }
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
|
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import fsPromises from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { getThumbnailPath } from '@/lib/thumbnails'
|
import { getThumbnailPath, getCbzThumbnailPath } from '@/lib/thumbnails'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
|
import { isCorruptZipError } from '@/lib/zip-utils'
|
||||||
|
|
||||||
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v'])
|
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v'])
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
|
|
||||||
function getMediaType(filePath: string): 'image' | 'video' | null {
|
function getMediaType(filePath: string): 'image' | 'video' | 'cbz' | null {
|
||||||
const ext = path.extname(filePath).toLowerCase()
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
if (IMAGE_EXTENSIONS.has(ext)) return 'image'
|
if (IMAGE_EXTENSIONS.has(ext)) return 'image'
|
||||||
if (VIDEO_EXTENSIONS.has(ext)) return 'video'
|
if (VIDEO_EXTENSIONS.has(ext)) return 'video'
|
||||||
|
if (ext === '.cbz') return 'cbz'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +46,13 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const mediaType = getMediaType(filePath)
|
const mediaType = getMediaType(filePath)
|
||||||
if (!mediaType) {
|
if (!mediaType) {
|
||||||
return NextResponse.json({ error: 'Thumbnails are only supported for image and video files' }, { status: 400 })
|
return NextResponse.json({ error: 'Thumbnails are only supported for image, video, and CBZ files' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const thumbnailPath = await getThumbnailPath(filePath, libraryId, mediaType)
|
const thumbnailPath = mediaType === 'cbz'
|
||||||
|
? await getCbzThumbnailPath(filePath, libraryId)
|
||||||
|
: await getThumbnailPath(filePath, libraryId, mediaType)
|
||||||
const stat = fs.statSync(thumbnailPath)
|
const stat = fs.statSync(thumbnailPath)
|
||||||
const stream = fs.createReadStream(thumbnailPath)
|
const stream = fs.createReadStream(thumbnailPath)
|
||||||
|
|
||||||
@@ -60,7 +65,30 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Thumbnail generation failed for ${filePath}:`, err)
|
if (isCorruptZipError(err)) {
|
||||||
|
// Move the corrupt archive to the library's .trash folder so it is excluded
|
||||||
|
// from future scans and hidden from the UI.
|
||||||
|
const trashDir = path.join(root, '.trash')
|
||||||
|
const filename = path.basename(filePath)
|
||||||
|
let dest = path.join(trashDir, filename)
|
||||||
|
fsPromises.mkdir(trashDir, { recursive: true })
|
||||||
|
.then(async () => {
|
||||||
|
if (fs.existsSync(dest)) {
|
||||||
|
const ext = path.extname(filename)
|
||||||
|
dest = path.join(trashDir, `${path.basename(filename, ext)}_${Date.now()}${ext}`)
|
||||||
|
}
|
||||||
|
await fsPromises.rename(filePath, dest).catch(async (e: NodeJS.ErrnoException) => {
|
||||||
|
if (e.code === 'EXDEV') {
|
||||||
|
await fsPromises.copyFile(filePath, dest)
|
||||||
|
await fsPromises.unlink(filePath)
|
||||||
|
} else throw e
|
||||||
|
})
|
||||||
|
console.log(`[thumbnail] Moved corrupt archive to trash: ${path.relative(root, filePath)}`)
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn(`[thumbnail] Could not move corrupt archive to trash:`, e))
|
||||||
|
} else {
|
||||||
|
console.error(`Thumbnail generation failed for ${filePath}:`, err)
|
||||||
|
}
|
||||||
return new NextResponse(null, { status: 404 })
|
return new NextResponse(null, { status: 404 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getLibrary } from '@/lib/libraries'
|
|||||||
import { notFound, redirect } from 'next/navigation'
|
import { notFound, redirect } from 'next/navigation'
|
||||||
import { getServerSession } from '@/lib/auth'
|
import { getServerSession } from '@/lib/auth'
|
||||||
import { getLibraryAccessLevel } from '@/lib/users'
|
import { getLibraryAccessLevel } from '@/lib/users'
|
||||||
|
import ComicsView from '@/components/comics/ComicsView'
|
||||||
import GamesView from '@/components/games/GamesView'
|
import GamesView from '@/components/games/GamesView'
|
||||||
import MixedView from '@/components/mixed/MixedView'
|
import MixedView from '@/components/mixed/MixedView'
|
||||||
import MoviesView from '@/components/movies/MoviesView'
|
import MoviesView from '@/components/movies/MoviesView'
|
||||||
@@ -54,6 +55,7 @@ export default async function LibraryPage({ params, searchParams }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{library.type === 'comics' && <ComicsView libraryId={id} readOnly={readOnly} />}
|
||||||
{library.type === 'games' && <GamesView libraryId={id} readOnly={readOnly} />}
|
{library.type === 'games' && <GamesView libraryId={id} readOnly={readOnly} />}
|
||||||
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} readOnly={readOnly} />}
|
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} readOnly={readOnly} />}
|
||||||
{library.type === 'movies' && <MoviesView libraryId={id} readOnly={readOnly} />}
|
{library.type === 'movies' && <MoviesView libraryId={id} readOnly={readOnly} />}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Image from 'next/image'
|
|||||||
import type { Library, LibraryType } from '@/types'
|
import type { Library, LibraryType } from '@/types'
|
||||||
|
|
||||||
const TYPE_ICONS: Record<string, string> = {
|
const TYPE_ICONS: Record<string, string> = {
|
||||||
|
comics: '📚',
|
||||||
games: '🎮',
|
games: '🎮',
|
||||||
mixed: '🗂️',
|
mixed: '🗂️',
|
||||||
movies: '🎬',
|
movies: '🎬',
|
||||||
@@ -12,6 +13,7 @@ const TYPE_ICONS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<LibraryType, string> = {
|
const TYPE_LABELS: Record<LibraryType, string> = {
|
||||||
|
comics: 'Comics / Manga',
|
||||||
games: 'Games',
|
games: 'Games',
|
||||||
mixed: 'Mixed Media',
|
mixed: 'Mixed Media',
|
||||||
movies: 'Movies',
|
movies: 'Movies',
|
||||||
@@ -20,7 +22,7 @@ const TYPE_LABELS: Record<LibraryType, string> = {
|
|||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ManagePage() {
|
function ManagePage() {
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -105,8 +107,12 @@ function LibraryRow({
|
|||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const [removing, setRemoving] = useState(false)
|
const [removing, setRemoving] = useState(false)
|
||||||
const [uploadingCover, setUploadingCover] = useState(false)
|
const [uploadingCover, setUploadingCover] = useState(false)
|
||||||
|
const [importing, setImporting] = useState<'idle' | 'running' | 'done'>('idle')
|
||||||
|
const [showBulkRename, setShowBulkRename] = useState(false)
|
||||||
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [showImportWarning, setShowImportWarning] = useState(false)
|
||||||
|
const [importingMetadata, setImportingMetadata] = useState(false)
|
||||||
|
|
||||||
const handleRemoveClick = () => {
|
const handleRemoveClick = () => {
|
||||||
if (!confirming) {
|
if (!confirming) {
|
||||||
@@ -121,6 +127,26 @@ function LibraryRow({
|
|||||||
.catch(() => setRemoving(false))
|
.catch(() => setRemoving(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleImportMetadata = async () => {
|
||||||
|
setImportingMetadata(true)
|
||||||
|
setShowImportWarning(false)
|
||||||
|
try {
|
||||||
|
const endpoint =
|
||||||
|
library.type === 'tv'
|
||||||
|
? `/api/libraries/${encodeURIComponent(library.id)}/import-metadata-tv`
|
||||||
|
: `/api/libraries/${encodeURIComponent(library.id)}/import-metadata-movies`
|
||||||
|
const res = await fetch(endpoint, { method: 'POST' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
console.log(`[manage] Imported metadata: ${data.imported} items, skipped ${data.skipped}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[manage] Error importing metadata:', err)
|
||||||
|
} finally {
|
||||||
|
setImportingMetadata(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
if (cancelRef.current) clearTimeout(cancelRef.current)
|
if (cancelRef.current) clearTimeout(cancelRef.current)
|
||||||
setConfirming(false)
|
setConfirming(false)
|
||||||
@@ -207,6 +233,57 @@ function LibraryRow({
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{library.type === 'comics' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setImporting('running')
|
||||||
|
fetch(`/api/libraries/${encodeURIComponent(library.id)}/import-metadata`, { method: 'POST' })
|
||||||
|
.then(() => {
|
||||||
|
setImporting('done')
|
||||||
|
setTimeout(() => setImporting('idle'), 3000)
|
||||||
|
})
|
||||||
|
.catch(() => setImporting('idle'))
|
||||||
|
}}
|
||||||
|
disabled={importing === 'running'}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{importing === 'running' ? 'Importing…' : importing === 'done' ? 'Imported ✓' : 'Import Metadata'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBulkRename(true)}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
|
>
|
||||||
|
Bulk Rename
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(library.type === 'tv' || library.type === 'movies') && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportWarning(true)}
|
||||||
|
disabled={importingMetadata}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!importingMetadata) (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!importingMetadata) (e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{importingMetadata ? 'Importing…' : 'Import Metadata'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{library.coverExt && (
|
{library.coverExt && (
|
||||||
<button
|
<button
|
||||||
onClick={handleRemoveCover}
|
onClick={handleRemoveCover}
|
||||||
@@ -253,6 +330,216 @@ function LibraryRow({
|
|||||||
{removing ? 'Removing…' : confirming ? 'Confirm?' : 'Remove'}
|
{removing ? 'Removing…' : confirming ? 'Confirm?' : 'Remove'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showBulkRename && (
|
||||||
|
<BulkRenameModal libraryId={library.id} onClose={() => setShowBulkRename(false)} />
|
||||||
|
)}
|
||||||
|
{showImportWarning && (library.type === 'tv' || library.type === 'movies') && (
|
||||||
|
<ImportWarningModal
|
||||||
|
libraryType={library.type}
|
||||||
|
onConfirm={handleImportMetadata}
|
||||||
|
onCancel={() => setShowImportWarning(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bulk Rename Modal ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function BulkRenameModal({ libraryId, onClose }: { libraryId: string; onClose: () => void }) {
|
||||||
|
const [pattern, setPattern] = useState('')
|
||||||
|
const [preview, setPreview] = useState<{ itemKey: string; oldTitle: string; newTitle: string }[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [applying, setApplying] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [result, setResult] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
if (!pattern.trim()) return
|
||||||
|
setError(null)
|
||||||
|
setPreview(null)
|
||||||
|
setResult(null)
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/libraries/${encodeURIComponent(libraryId)}/bulk-rename`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pattern: pattern.trim(), preview: true }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? 'Failed to preview')
|
||||||
|
} else {
|
||||||
|
setPreview(data.changes ?? [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Network error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
if (!pattern.trim()) return
|
||||||
|
setError(null)
|
||||||
|
setApplying(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/libraries/${encodeURIComponent(libraryId)}/bulk-rename`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pattern: pattern.trim() }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? 'Failed to apply')
|
||||||
|
} else {
|
||||||
|
setResult(`Updated ${data.updated} title${data.updated === 1 ? '' : 's'}`)
|
||||||
|
setPreview(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Network error')
|
||||||
|
} finally {
|
||||||
|
setApplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-5 py-4 flex-shrink-0"
|
||||||
|
style={{ borderBottom: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium" style={{ color: 'var(--text-primary)' }}>Bulk Rename</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Enter a regex pattern to remove from comic titles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-5 py-4 overflow-y-auto flex-1">
|
||||||
|
{/* Pattern input */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pattern}
|
||||||
|
onChange={(e) => setPattern(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handlePreview() }}
|
||||||
|
placeholder="e.g. \[English\]|\{doujin-moe\.us\}"
|
||||||
|
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none font-mono"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={!pattern.trim() || loading}
|
||||||
|
className="text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{loading ? 'Loading…' : 'Preview'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
className="text-xs mb-3 px-3 py-2 rounded-lg"
|
||||||
|
style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<p
|
||||||
|
className="text-xs mb-3 px-3 py-2 rounded-lg"
|
||||||
|
style={{ backgroundColor: '#14532d33', color: '#86efac' }}
|
||||||
|
>
|
||||||
|
{result}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview list */}
|
||||||
|
{preview !== null && (
|
||||||
|
preview.length === 0 ? (
|
||||||
|
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
No titles match this pattern.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{preview.length} title{preview.length === 1 ? '' : 's'} will be updated:
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="rounded-lg border divide-y overflow-hidden"
|
||||||
|
style={{ borderColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
{preview.map((c) => (
|
||||||
|
<div key={c.itemKey} className="px-3 py-2">
|
||||||
|
<p className="text-xs line-through" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{c.oldTitle}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{c.newTitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{preview && preview.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-end gap-2 px-5 py-3 flex-shrink-0"
|
||||||
|
style={{ borderTop: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-xs px-3 py-2 rounded-lg transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={applying}
|
||||||
|
className="text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
||||||
|
>
|
||||||
|
{applying ? 'Applying…' : `Apply to ${preview.length} title${preview.length === 1 ? '' : 's'}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -334,6 +621,7 @@ function AddLibraryForm({ onAdded }: { onAdded: () => void }) {
|
|||||||
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
|
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
|
||||||
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
|
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
|
||||||
>
|
>
|
||||||
|
<option value="comics">Comics / Manga</option>
|
||||||
<option value="games">Games</option>
|
<option value="games">Games</option>
|
||||||
<option value="mixed">Mixed Media</option>
|
<option value="mixed">Mixed Media</option>
|
||||||
<option value="movies">Movies</option>
|
<option value="movies">Movies</option>
|
||||||
@@ -415,3 +703,57 @@ function LoadingRows() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ImportWarningModal({
|
||||||
|
libraryType,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
libraryType: 'tv' | 'movies'
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const label = libraryType === 'tv' ? 'TV' : 'Movie'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-2xl border p-5"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Import {label} Metadata
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Full metadata import will refresh metadata for ALL items in this library, overwriting any
|
||||||
|
existing data. Continue?
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-xs px-3 py-2 rounded-lg transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="text-xs px-3 py-2 rounded-lg transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ManagePage
|
||||||
|
|||||||
865
src/app/manage/tags/mappings/[id]/page.tsx
Normal file
865
src/app/manage/tags/mappings/[id]/page.tsx
Normal file
@@ -0,0 +1,865 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { memo, useEffect, useMemo, useState, useRef, useCallback } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
|
import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types'
|
||||||
|
|
||||||
|
export default function TagMappingsPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const libraryId = params.id as string
|
||||||
|
|
||||||
|
const [library, setLibrary] = useState<Library | null>(null)
|
||||||
|
const [importedTags, setImportedTags] = useState<ImportedTag[]>([])
|
||||||
|
const [mappings, setMappings] = useState<TagMapping[]>([])
|
||||||
|
const [tags, setTags] = useState<Tag[]>([])
|
||||||
|
const [categories, setCategories] = useState<TagCategory[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [prefixMappings, setPrefixMappings] = useState<Record<string, string>>({})
|
||||||
|
const [ignoredTags, setIgnoredTags] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Load prefix mappings and ignored tags from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(`prefix-mappings-${libraryId}`)
|
||||||
|
if (stored) setPrefixMappings(JSON.parse(stored))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(`ignored-tags-${libraryId}`)
|
||||||
|
if (stored) setIgnoredTags(new Set(JSON.parse(stored)))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [libraryId])
|
||||||
|
|
||||||
|
const updatePrefixMappings = useCallback((next: Record<string, string>) => {
|
||||||
|
setPrefixMappings(next)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`prefix-mappings-${libraryId}`, JSON.stringify(next))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [libraryId])
|
||||||
|
|
||||||
|
const updateIgnoredTags = useCallback((next: Set<string>) => {
|
||||||
|
setIgnoredTags(next)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`ignored-tags-${libraryId}`, JSON.stringify([...next]))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [libraryId])
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
Promise.all([
|
||||||
|
fetch(`/api/imported-tags?libraryId=${encodeURIComponent(libraryId)}`).then((r) => r.json()),
|
||||||
|
fetch(`/api/tag-mappings?libraryId=${encodeURIComponent(libraryId)}`).then((r) => r.json()),
|
||||||
|
fetch('/api/tags/items').then((r) => r.json()),
|
||||||
|
fetch('/api/tags/categories').then((r) => r.json()),
|
||||||
|
fetch('/api/libraries').then((r) => r.json()),
|
||||||
|
])
|
||||||
|
.then(([imported, maps, tgs, cats, libs]: [ImportedTag[], TagMapping[], Tag[], TagCategory[], Library[]]) => {
|
||||||
|
setImportedTags(imported)
|
||||||
|
setMappings(maps)
|
||||||
|
setTags(tgs)
|
||||||
|
setCategories(cats)
|
||||||
|
setLibrary(libs.find((l) => l.id === libraryId) ?? null)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [libraryId])
|
||||||
|
|
||||||
|
const tagsByCategory = useMemo(() => (
|
||||||
|
categories
|
||||||
|
.map((cat) => ({
|
||||||
|
category: cat,
|
||||||
|
tags: tags.filter((t) => t.categoryId === cat.id),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.tags.length > 0)
|
||||||
|
), [categories, tags])
|
||||||
|
|
||||||
|
const visibleTags = useMemo(() => importedTags.filter((t) => !ignoredTags.has(t.name)), [importedTags, ignoredTags])
|
||||||
|
const hiddenTags = useMemo(() => importedTags.filter((t) => ignoredTags.has(t.name)), [importedTags, ignoredTags])
|
||||||
|
|
||||||
|
const handleIgnoreTag = useCallback((name: string) => {
|
||||||
|
updateIgnoredTags(new Set([...ignoredTags, name]))
|
||||||
|
}, [ignoredTags, updateIgnoredTags])
|
||||||
|
|
||||||
|
const handleUnignoreTag = useCallback((name: string) => {
|
||||||
|
const next = new Set(ignoredTags)
|
||||||
|
next.delete(name)
|
||||||
|
updateIgnoredTags(next)
|
||||||
|
}, [ignoredTags, updateIgnoredTags])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<a
|
||||||
|
href="/manage/tags"
|
||||||
|
className="text-sm no-underline transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
|
>
|
||||||
|
← Tags
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Tag Mappings{library ? ` — ${library.name}` : ''}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Map imported tags from ComicInfo.xml files to your tag categories.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Section title="Unmapped Tags">
|
||||||
|
<LoadingRows />
|
||||||
|
</Section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PrefixMappingsSection
|
||||||
|
categories={categories}
|
||||||
|
importedTags={importedTags}
|
||||||
|
prefixMappings={prefixMappings}
|
||||||
|
onUpdate={updatePrefixMappings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section title="Unmapped Tags">
|
||||||
|
{visibleTags.length === 0 ? (
|
||||||
|
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{importedTags.length === 0
|
||||||
|
? 'No unmapped imported tags. All tags have been mapped or no ComicInfo.xml tags were found.'
|
||||||
|
: 'All unmapped tags are hidden. Check the ignored tags section below.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<VirtualizedImportedTagRows
|
||||||
|
tags={visibleTags}
|
||||||
|
libraryId={libraryId}
|
||||||
|
tagsByCategory={tagsByCategory}
|
||||||
|
categories={categories}
|
||||||
|
prefixMappings={prefixMappings}
|
||||||
|
onMapped={refresh}
|
||||||
|
onIgnore={handleIgnoreTag}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{hiddenTags.length > 0 && (
|
||||||
|
<IgnoredTagsSection
|
||||||
|
tags={hiddenTags}
|
||||||
|
onUnignore={handleUnignoreTag}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Saved Mappings">
|
||||||
|
{mappings.length === 0 ? (
|
||||||
|
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
No saved mappings yet. Map imported tags above to create persistent mappings.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
{mappings.map((m) => (
|
||||||
|
<MappingRow key={m.id} mapping={m} onDeleted={refresh} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Prefix Mappings Section ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PrefixMappingsSection({
|
||||||
|
categories,
|
||||||
|
importedTags,
|
||||||
|
prefixMappings,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
categories: TagCategory[]
|
||||||
|
importedTags: ImportedTag[]
|
||||||
|
prefixMappings: Record<string, string>
|
||||||
|
onUpdate: (next: Record<string, string>) => void
|
||||||
|
}) {
|
||||||
|
const [newPrefix, setNewPrefix] = useState('')
|
||||||
|
const [newCategoryId, setNewCategoryId] = useState('')
|
||||||
|
|
||||||
|
// Detect prefixes from imported tags that aren't yet mapped
|
||||||
|
const detectedPrefixes = Array.from(
|
||||||
|
new Set(
|
||||||
|
importedTags
|
||||||
|
.map((t) => {
|
||||||
|
const idx = t.name.indexOf(': ')
|
||||||
|
return idx > 0 ? t.name.slice(0, idx).trim().toLowerCase() : null
|
||||||
|
})
|
||||||
|
.filter((p): p is string => p !== null)
|
||||||
|
)
|
||||||
|
).filter((p) => !(p in prefixMappings)).sort()
|
||||||
|
|
||||||
|
const catMap = new Map(categories.map((c) => [c.id, c.name]))
|
||||||
|
const entries = Object.entries(prefixMappings)
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const key = newPrefix.trim().toLowerCase()
|
||||||
|
if (!key || !newCategoryId) return
|
||||||
|
onUpdate({ ...prefixMappings, [key]: newCategoryId })
|
||||||
|
setNewPrefix('')
|
||||||
|
setNewCategoryId('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = (key: string) => {
|
||||||
|
const next = { ...prefixMappings }
|
||||||
|
delete next[key]
|
||||||
|
onUpdate(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Prefix Mappings">
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Map tag prefixes (e.g. "language" in "language: english") to categories.
|
||||||
|
When creating a new tag, the category and name will auto-fill.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Existing mappings */}
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<div className="divide-y mb-3" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
{entries.map(([prefix, catId]) => (
|
||||||
|
<div key={prefix} className="flex items-center gap-3 py-2 first:pt-0 last:pb-0">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-mono"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
{prefix}:
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>→</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{catMap.get(catId) ?? catId}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(prefix)}
|
||||||
|
className="text-xs px-2 py-1 rounded-lg transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add row */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newPrefix}
|
||||||
|
onChange={(e) => setNewPrefix(e.target.value)}
|
||||||
|
placeholder="prefix"
|
||||||
|
className="rounded-lg px-2 py-1.5 text-xs font-mono outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
width: 100,
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd() }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>→</span>
|
||||||
|
<select
|
||||||
|
value={newCategoryId}
|
||||||
|
onChange={(e) => setNewCategoryId(e.target.value)}
|
||||||
|
className="rounded-lg px-2 py-1.5 text-xs outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
minWidth: 130,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Category…</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!newPrefix.trim() || !newCategoryId}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions */}
|
||||||
|
{detectedPrefixes.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>Detected:</span>
|
||||||
|
{detectedPrefixes.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setNewPrefix(p)}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Imported Tag Row ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function VirtualizedImportedTagRows({
|
||||||
|
tags,
|
||||||
|
libraryId,
|
||||||
|
tagsByCategory,
|
||||||
|
categories,
|
||||||
|
prefixMappings,
|
||||||
|
onMapped,
|
||||||
|
onIgnore,
|
||||||
|
}: {
|
||||||
|
tags: ImportedTag[]
|
||||||
|
libraryId: string
|
||||||
|
tagsByCategory: { category: TagCategory; tags: Tag[] }[]
|
||||||
|
categories: TagCategory[]
|
||||||
|
prefixMappings: Record<string, string>
|
||||||
|
onMapped: () => void
|
||||||
|
onIgnore: (name: string) => void
|
||||||
|
}) {
|
||||||
|
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: tags.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 56,
|
||||||
|
overscan: 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={parentRef} className="max-h-[560px] overflow-auto">
|
||||||
|
<div style={{ height: rowVirtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((row) => {
|
||||||
|
const importedTag = tags[row.index]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={importedTag.name}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
data-index={row.index}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${row.start}px)`,
|
||||||
|
borderTop: row.index === 0 ? 'none' : '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ImportedTagRow
|
||||||
|
importedTag={importedTag}
|
||||||
|
libraryId={libraryId}
|
||||||
|
tagsByCategory={tagsByCategory}
|
||||||
|
categories={categories}
|
||||||
|
prefixMappings={prefixMappings}
|
||||||
|
onMapped={onMapped}
|
||||||
|
onIgnore={() => onIgnore(importedTag.name)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImportedTagRow = memo(function ImportedTagRow({
|
||||||
|
importedTag,
|
||||||
|
libraryId,
|
||||||
|
tagsByCategory,
|
||||||
|
categories,
|
||||||
|
prefixMappings,
|
||||||
|
onMapped,
|
||||||
|
onIgnore,
|
||||||
|
}: {
|
||||||
|
importedTag: ImportedTag
|
||||||
|
libraryId: string
|
||||||
|
tagsByCategory: { category: TagCategory; tags: Tag[] }[]
|
||||||
|
categories: TagCategory[]
|
||||||
|
prefixMappings: Record<string, string>
|
||||||
|
onMapped: () => void
|
||||||
|
onIgnore: () => void
|
||||||
|
}) {
|
||||||
|
// Auto-match: if prefix mapping exists, find a tag in that category matching the stripped name
|
||||||
|
const autoMatchedTagId = useMemo(() => {
|
||||||
|
const colonIdx = importedTag.name.indexOf(': ')
|
||||||
|
if (colonIdx <= 0) return ''
|
||||||
|
const prefix = importedTag.name.slice(0, colonIdx).trim().toLowerCase()
|
||||||
|
const mappedCategoryId = prefixMappings[prefix]
|
||||||
|
if (!mappedCategoryId) return ''
|
||||||
|
const strippedName = importedTag.name.slice(colonIdx + 2).trim().toLowerCase()
|
||||||
|
const group = tagsByCategory.find((g) => g.category.id === mappedCategoryId)
|
||||||
|
const match = group?.tags.find((t) => t.name.toLowerCase() === strippedName)
|
||||||
|
return match?.id ?? ''
|
||||||
|
}, [importedTag.name, prefixMappings, tagsByCategory])
|
||||||
|
|
||||||
|
const [selectedTagId, setSelectedTagId] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [newTagName, setNewTagName] = useState(importedTag.name)
|
||||||
|
const [newTagCategoryId, setNewTagCategoryId] = useState('')
|
||||||
|
const [creatingTag, setCreatingTag] = useState(false)
|
||||||
|
|
||||||
|
// Apply auto-match when it changes (e.g. prefix mappings updated)
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoMatchedTagId) setSelectedTagId(autoMatchedTagId)
|
||||||
|
}, [autoMatchedTagId])
|
||||||
|
|
||||||
|
const startCreating = () => {
|
||||||
|
// Apply prefix mapping defaults if the imported tag has a colon prefix
|
||||||
|
const colonIdx = importedTag.name.indexOf(': ')
|
||||||
|
if (colonIdx > 0) {
|
||||||
|
const prefix = importedTag.name.slice(0, colonIdx).trim().toLowerCase()
|
||||||
|
const mappedCategoryId = prefixMappings[prefix]
|
||||||
|
if (mappedCategoryId) {
|
||||||
|
setNewTagCategoryId(mappedCategoryId)
|
||||||
|
setNewTagName(importedTag.name.slice(colonIdx + 2).trim())
|
||||||
|
setCreating(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setNewTagName(importedTag.name)
|
||||||
|
setNewTagCategoryId('')
|
||||||
|
setCreating(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMap = async () => {
|
||||||
|
if (!selectedTagId) return
|
||||||
|
setError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tag-mappings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
libraryId,
|
||||||
|
importedTagName: importedTag.name,
|
||||||
|
tagId: selectedTagId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setError(data.error ?? 'Failed to save mapping')
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
setSelectedTagId('')
|
||||||
|
onMapped()
|
||||||
|
} catch {
|
||||||
|
setError('Network error')
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateAndMap = async () => {
|
||||||
|
if (!newTagName.trim() || !newTagCategoryId) return
|
||||||
|
setError(null)
|
||||||
|
setCreatingTag(true)
|
||||||
|
try {
|
||||||
|
const createRes = await fetch('/api/tags/items', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: newTagName.trim(), categoryId: newTagCategoryId }),
|
||||||
|
})
|
||||||
|
if (!createRes.ok) {
|
||||||
|
const data = await createRes.json()
|
||||||
|
setError(data.error ?? 'Failed to create tag')
|
||||||
|
setCreatingTag(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newTag = await createRes.json()
|
||||||
|
|
||||||
|
const mapRes = await fetch('/api/tag-mappings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
libraryId,
|
||||||
|
importedTagName: importedTag.name,
|
||||||
|
tagId: newTag.id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!mapRes.ok) {
|
||||||
|
const data = await mapRes.json()
|
||||||
|
setError(data.error ?? 'Failed to save mapping')
|
||||||
|
setCreatingTag(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCreatingTag(false)
|
||||||
|
setCreating(false)
|
||||||
|
setNewTagName(importedTag.name)
|
||||||
|
setNewTagCategoryId('')
|
||||||
|
onMapped()
|
||||||
|
} catch {
|
||||||
|
setError('Network error')
|
||||||
|
setCreatingTag(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-3 first:pt-0 last:pb-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Left: imported tag name + item count */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
{importedTag.name}
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
({importedTag.itemCount})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!creating ? (
|
||||||
|
<>
|
||||||
|
{/* Right: tag picker + map button + new button */}
|
||||||
|
<select
|
||||||
|
value={selectedTagId}
|
||||||
|
onChange={(e) => setSelectedTagId(e.target.value)}
|
||||||
|
className="rounded-lg px-2 py-1.5 text-xs outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
minWidth: 160,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select tag…</option>
|
||||||
|
{tagsByCategory.map((group) => (
|
||||||
|
<optgroup key={group.category.id} label={group.category.name}>
|
||||||
|
{group.tags.map((tag) => (
|
||||||
|
<option key={tag.id} value={tag.id}>
|
||||||
|
{tag.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleMap}
|
||||||
|
disabled={!selectedTagId || saving}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{saving ? 'Mapping…' : 'Map'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={startCreating}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
title="Create a new tag and map it"
|
||||||
|
>
|
||||||
|
+ New
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onIgnore}
|
||||||
|
className="text-xs px-2 py-1.5 rounded-lg transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
title="Hide this tag"
|
||||||
|
>
|
||||||
|
Ignore
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Inline create: category picker + name input + create & map button */}
|
||||||
|
<select
|
||||||
|
value={newTagCategoryId}
|
||||||
|
onChange={(e) => setNewTagCategoryId(e.target.value)}
|
||||||
|
className="rounded-lg px-2 py-1.5 text-xs outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
minWidth: 120,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Category…</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTagName}
|
||||||
|
onChange={(e) => setNewTagName(e.target.value)}
|
||||||
|
placeholder="Tag name"
|
||||||
|
className="rounded-lg px-2 py-1.5 text-xs outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
width: 120,
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleCreateAndMap()
|
||||||
|
if (e.key === 'Escape') setCreating(false)
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCreateAndMap}
|
||||||
|
disabled={!newTagName.trim() || !newTagCategoryId || creatingTag}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{creatingTag ? 'Creating…' : 'Create & Map'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setCreating(false)}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs mt-1.5 px-3 py-1 rounded-lg" style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Ignored Tags Section ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function IgnoredTagsSection({
|
||||||
|
tags,
|
||||||
|
onUnignore,
|
||||||
|
}: {
|
||||||
|
tags: ImportedTag[]
|
||||||
|
onUnignore: (name: string) => void
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: tags.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 44,
|
||||||
|
overscan: 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-10">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center gap-1.5 mb-3 group"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
<span className="text-xs transition-transform" style={{ display: 'inline-block', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Ignored Tags ({tags.length})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border"
|
||||||
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||||
|
>
|
||||||
|
<div ref={parentRef} className="px-5 py-4 max-h-[360px] overflow-auto">
|
||||||
|
<div style={{ height: rowVirtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((row) => {
|
||||||
|
const t = tags[row.index]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={t.name}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
data-index={row.index}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${row.start}px)`,
|
||||||
|
borderTop: row.index === 0 ? 'none' : '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 py-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
<span className="text-xs" style={{ opacity: 0.6 }}>({t.itemCount})</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => onUnignore(t.name)}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
|
>
|
||||||
|
Unignore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mapping Row ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MappingRow({ mapping, onDeleted }: { mapping: TagMapping; onDeleted: () => void }) {
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const handleDeleteClick = () => {
|
||||||
|
if (!confirming) {
|
||||||
|
setConfirming(true)
|
||||||
|
cancelRef.current = setTimeout(() => setConfirming(false), 4000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (cancelRef.current) clearTimeout(cancelRef.current)
|
||||||
|
setDeleting(true)
|
||||||
|
fetch(`/api/tag-mappings/${encodeURIComponent(mapping.id)}`, { method: 'DELETE' })
|
||||||
|
.then(() => onDeleted())
|
||||||
|
.catch(() => setDeleting(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
{mapping.importedTagName}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>→</span>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{mapping.categoryName}: {mapping.tagName}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{confirming && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (cancelRef.current) clearTimeout(cancelRef.current)
|
||||||
|
setConfirming(false)
|
||||||
|
}}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
disabled={deleting}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)',
|
||||||
|
color: confirming ? '#fca5a5' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!confirming) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!confirming) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : confirming ? 'Confirm?' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-10">
|
||||||
|
<h2
|
||||||
|
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="rounded-xl border"
|
||||||
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingRows() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{[70, 50, 85].map((w) => (
|
||||||
|
<div key={w} className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="h-4 rounded animate-pulse"
|
||||||
|
style={{ width: `${w}%`, backgroundColor: 'var(--border)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import type { Tag, TagCategory } from '@/types'
|
import type { Tag, TagCategory, Library, ImportedTag } from '@/types'
|
||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -62,6 +62,8 @@ export default function ManageTagsPage() {
|
|||||||
<Section title="Add a Category">
|
<Section title="Add a Category">
|
||||||
<AddCategoryForm onAdded={refresh} />
|
<AddCategoryForm onAdded={refresh} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<ImportedTagMappingsSection />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -83,11 +85,13 @@ function CategoryBlock({
|
|||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [mergeConflict, setMergeConflict] = useState<{ name: string } | null>(null)
|
||||||
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const handleRename = async (e: React.FormEvent) => {
|
const handleRename = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setMergeConflict(null)
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, {
|
const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, {
|
||||||
@@ -96,8 +100,35 @@ function CategoryBlock({
|
|||||||
body: JSON.stringify({ name: editName }),
|
body: JSON.stringify({ name: editName }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 409 && data.conflict) {
|
||||||
|
setMergeConflict({ name: editName.trim() })
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(data.error); setSaving(false); return
|
||||||
|
}
|
||||||
|
setEditing(false)
|
||||||
|
onChanged()
|
||||||
|
} catch {
|
||||||
|
setError('Network error.')
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMerge = async () => {
|
||||||
|
setError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: editName, merge: true }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
if (!res.ok) { setError(data.error); setSaving(false); return }
|
if (!res.ok) { setError(data.error); setSaving(false); return }
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
|
setMergeConflict(null)
|
||||||
onChanged()
|
onChanged()
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error.')
|
setError('Network error.')
|
||||||
@@ -156,7 +187,7 @@ function CategoryBlock({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setEditing(false); setEditName(category.name); setError(null) }}
|
onClick={() => { setEditing(false); setEditName(category.name); setError(null); setMergeConflict(null) }}
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
>
|
>
|
||||||
@@ -228,6 +259,32 @@ function CategoryBlock({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mergeConflict && (
|
||||||
|
<div className="mb-3 px-3 py-2 rounded-lg text-xs" style={{ backgroundColor: '#78350f33', color: '#fbbf24' }}>
|
||||||
|
<p className="mb-2">
|
||||||
|
A category named “{mergeConflict.name}” already exists. This will merge all tags from
|
||||||
|
“{category.name}” into it. Tags with the same name will be combined.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleMerge}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: '#b45309', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{saving ? 'Merging…' : 'Merge'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMergeConflict(null)}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tags list */}
|
{/* Tags list */}
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
@@ -480,6 +537,117 @@ function AddCategoryForm({ onAdded }: { onAdded: () => void }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Imported Tag Mappings Section ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ImportedTagMappingsSection() {
|
||||||
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
|
const [tagCounts, setTagCounts] = useState<Record<string, number>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setError(null)
|
||||||
|
const libsRes = await fetch('/api/libraries')
|
||||||
|
const libsJson = await libsRes.json()
|
||||||
|
if (!Array.isArray(libsJson)) {
|
||||||
|
throw new Error('Failed to load libraries')
|
||||||
|
}
|
||||||
|
|
||||||
|
const comicLibs = libsJson.filter((l): l is Library => l?.type === 'comics')
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
setLibraries(comicLibs)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (comicLibs.length === 0) return
|
||||||
|
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
comicLibs.map(async (lib) => {
|
||||||
|
const res = await fetch(`/api/imported-tags?libraryId=${encodeURIComponent(lib.id)}`)
|
||||||
|
if (!res.ok) return { libraryId: lib.id, count: 0 }
|
||||||
|
const json = await res.json()
|
||||||
|
const count = Array.isArray(json) ? json.length : 0
|
||||||
|
return { libraryId: lib.id, count }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
for (const result of settled) {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
counts[result.value.libraryId] = result.value.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTagCounts(counts)
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return
|
||||||
|
setError('Could not load imported tag mappings right now.')
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Section title="Imported Tag Mappings">
|
||||||
|
<LoadingRows />
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraries.length === 0) {
|
||||||
|
return (
|
||||||
|
<Section title="Imported Tag Mappings">
|
||||||
|
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
No comic libraries configured. Add a comic library to import tags from ComicInfo.xml files.
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Imported Tag Mappings">
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs mb-3 px-3 py-1.5 rounded-lg" style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
{libraries.map((lib) => (
|
||||||
|
<div key={lib.id} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
||||||
|
<span className="flex-1 font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{lib.name}
|
||||||
|
<span className="ml-2 font-normal text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{tagCounts[lib.id] ?? 0} imported tag{(tagCounts[lib.id] ?? 0) === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={`/manage/tags/mappings/${encodeURIComponent(lib.id)}`}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors no-underline"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
||||||
|
>
|
||||||
|
Manage Mappings
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
|||||||
225
src/components/comics/ComicIssueView.tsx
Normal file
225
src/components/comics/ComicIssueView.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { ComicIssue } from '@/types'
|
||||||
|
import ImageLightbox from '@/components/mixed/ImageLightbox'
|
||||||
|
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||||
|
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
||||||
|
|
||||||
|
function fileApiUrl(libraryId: string, relativePath: string): string {
|
||||||
|
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
libraryId: string
|
||||||
|
issue: ComicIssue
|
||||||
|
onClose: () => void
|
||||||
|
onTagsChanged?: () => void
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageUrl(libraryId: string, issueKey: string, pageIndex: number): string {
|
||||||
|
return `/api/comics/page?libraryId=${encodeURIComponent(libraryId)}&issueKey=${encodeURIComponent(issueKey)}&pageIndex=${pageIndex}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComicIssueView({ libraryId, issue, onClose, onTagsChanged, readOnly }: Props) {
|
||||||
|
const [lightboxPage, setLightboxPage] = useState<number | null>(null)
|
||||||
|
const [showTagPanel, setShowTagPanel] = useState(false)
|
||||||
|
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||||||
|
const issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}`
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && lightboxPage === null && !showTagPanel) onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose, lightboxPage, showTagPanel])
|
||||||
|
|
||||||
|
const pageCount = issue.pageCount
|
||||||
|
const downloadUrl = fileApiUrl(libraryId, issue.filePath)
|
||||||
|
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 overflow-hidden"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : 'items-center justify-center p-4'}`}>
|
||||||
|
<div
|
||||||
|
className={`${showTagPanel ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-4xl'}`}
|
||||||
|
onClick={showTagPanel ? undefined : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-4xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-5 py-3 flex-shrink-0"
|
||||||
|
style={{ borderBottom: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{issue.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{pageCount} {pageCount === 1 ? 'page' : 'pages'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
|
||||||
|
{issue.item_key && !readOnly && !showTagPanel && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowTagPanel(true) }}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
title="Tags"
|
||||||
|
aria-label="Show tags"
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={downloadUrl}
|
||||||
|
download
|
||||||
|
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--surface)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover + tags */}
|
||||||
|
<div
|
||||||
|
className="flex gap-5 px-5 py-4 flex-shrink-0"
|
||||||
|
style={{ borderBottom: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 rounded-lg overflow-hidden"
|
||||||
|
style={{ width: 140, aspectRatio: '2/3', background: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
{issue.coverUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={issue.coverUrl}
|
||||||
|
alt={issue.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : pageCount > 0 ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={pageUrl(libraryId, issueKey, 0)}
|
||||||
|
alt={issue.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-full h-full flex items-center justify-center text-3xl"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
📖
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 pt-1">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
{issue.item_key ? (
|
||||||
|
<AssignedTagBadges itemKey={issueKey} refreshKey={tagRefreshKey} />
|
||||||
|
) : (
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No tags</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page grid */}
|
||||||
|
<div className="overflow-y-auto flex-1 p-4" ref={gridRef}>
|
||||||
|
{pageCount === 0 ? (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center py-16 text-sm"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
No pages found in this issue.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2 grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
||||||
|
{Array.from({ length: pageCount }, (_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className="relative rounded overflow-hidden focus:outline-none focus:ring-2 focus:ring-offset-1 group"
|
||||||
|
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
|
||||||
|
onClick={() => setLightboxPage(i)}
|
||||||
|
aria-label={`Page ${i + 1}`}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={pageUrl(libraryId, issueKey, i)}
|
||||||
|
alt={`Page ${i + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 inset-x-0 py-0.5 text-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTagPanel && issue.item_key && (
|
||||||
|
<MediaTagPanel
|
||||||
|
itemKey={issueKey}
|
||||||
|
onHide={() => setShowTagPanel(false)}
|
||||||
|
onClose={onClose}
|
||||||
|
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lightboxPage !== null && (
|
||||||
|
<ImageLightbox
|
||||||
|
url={pageUrl(libraryId, issueKey, lightboxPage)}
|
||||||
|
name={`Page ${lightboxPage + 1} of ${pageCount}`}
|
||||||
|
onClose={() => setLightboxPage(null)}
|
||||||
|
onPrev={lightboxPage > 0 ? () => setLightboxPage((p) => (p ?? 1) - 1) : undefined}
|
||||||
|
onNext={lightboxPage < pageCount - 1 ? () => setLightboxPage((p) => (p ?? 0) + 1) : undefined}
|
||||||
|
itemKey={issueKey}
|
||||||
|
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
233
src/components/comics/ComicSeriesView.tsx
Normal file
233
src/components/comics/ComicSeriesView.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import type { ComicIssue, ComicSeries } from '@/types'
|
||||||
|
import ComicIssueView from './ComicIssueView'
|
||||||
|
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
libraryId: string
|
||||||
|
series: ComicSeries
|
||||||
|
onClose: () => void
|
||||||
|
onTagsChanged?: () => void
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComicSeriesView({ libraryId, series, onClose, onTagsChanged, readOnly }: Props) {
|
||||||
|
const [issues, setIssues] = useState<ComicIssue[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
|
||||||
|
const [tagItemKey, setTagItemKey] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchIssues = useCallback(() => {
|
||||||
|
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(series.id)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: ComicIssue[]) => {
|
||||||
|
setIssues(data)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false))
|
||||||
|
}, [libraryId, series.id])
|
||||||
|
|
||||||
|
useEffect(() => { fetchIssues() }, [fetchIssues])
|
||||||
|
|
||||||
|
// Escape closes tag panel first, then series view
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && !selectedIssue && !tagItemKey) onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose, selectedIssue, tagItemKey])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 overflow-hidden"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div className={`flex h-full w-full ${tagItemKey ? 'flex-col md:flex-row' : 'items-center justify-center p-4'}`}>
|
||||||
|
<div className={tagItemKey ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-3xl'}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-3xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-5 py-3 flex-shrink-0"
|
||||||
|
style={{ borderBottom: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{series.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{series.issueCount} {series.issueCount === 1 ? 'issue' : 'issues'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
|
||||||
|
{series.item_key && !readOnly && !tagItemKey && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setTagItemKey(series.item_key!) }}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
title="Tag series"
|
||||||
|
aria-label="Tag series"
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issue grid */}
|
||||||
|
<div className="overflow-y-auto flex-1 p-4">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingGrid />
|
||||||
|
) : issues.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center py-16 text-sm"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
No issues found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<IssueCard
|
||||||
|
key={issue.id}
|
||||||
|
issue={issue}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onClick={() => setSelectedIssue(issue)}
|
||||||
|
onTagClick={issue.item_key && !readOnly
|
||||||
|
? () => setTagItemKey(issue.item_key!)
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tagItemKey && (
|
||||||
|
<MediaTagPanel
|
||||||
|
itemKey={tagItemKey}
|
||||||
|
onHide={() => setTagItemKey(null)}
|
||||||
|
onClose={onClose}
|
||||||
|
onTagsChanged={onTagsChanged}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedIssue && (
|
||||||
|
<ComicIssueView
|
||||||
|
libraryId={libraryId}
|
||||||
|
issue={selectedIssue}
|
||||||
|
onClose={() => setSelectedIssue(null)}
|
||||||
|
onTagsChanged={onTagsChanged}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueCard({
|
||||||
|
issue,
|
||||||
|
onClick,
|
||||||
|
onTagClick,
|
||||||
|
readOnly,
|
||||||
|
}: {
|
||||||
|
issue: ComicIssue
|
||||||
|
onClick: () => void
|
||||||
|
onTagClick?: () => void
|
||||||
|
readOnly?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative rounded-xl overflow-hidden group"
|
||||||
|
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="text-left w-full focus:outline-none focus:ring-2"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full overflow-hidden"
|
||||||
|
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
{issue.coverUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={issue.coverUrl}
|
||||||
|
alt={issue.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-3xl">📖</div>
|
||||||
|
)}
|
||||||
|
{issue.issueNumber !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute top-1 left-1 px-1.5 py-0.5 rounded text-xs font-bold leading-none"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
#{issue.issueNumber}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-2 pt-1.5 pb-1">
|
||||||
|
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{issue.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{issue.pageCount} {issue.pageCount === 1 ? 'pg' : 'pgs'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{onTagClick && !readOnly && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onTagClick() }}
|
||||||
|
className="absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
|
||||||
|
title="Tag issue"
|
||||||
|
aria-label="Tag issue"
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingGrid() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||||
|
{Array.from({ length: 6 }, (_, i) => (
|
||||||
|
<div key={i} className="rounded-xl overflow-hidden animate-pulse" style={{ border: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ aspectRatio: '2/3', background: 'var(--border)' }} />
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
<div className="h-3 rounded" style={{ background: 'var(--border)', width: '80%' }} />
|
||||||
|
<div className="h-2 rounded" style={{ background: 'var(--border)', width: '40%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
454
src/components/comics/ComicsView.tsx
Normal file
454
src/components/comics/ComicsView.tsx
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type { ComicIssue, ComicSeries } from '@/types'
|
||||||
|
import ComicSeriesView from './ComicSeriesView'
|
||||||
|
import ComicIssueView from './ComicIssueView'
|
||||||
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
libraryId: string
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 200
|
||||||
|
|
||||||
|
export default function ComicsView({ libraryId, readOnly }: Props) {
|
||||||
|
const [items, setItems] = useState<(ComicIssue | ComicSeries)[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [selectedSeries, setSelectedSeries] = useState<ComicSeries | null>(null)
|
||||||
|
const [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
|
||||||
|
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
|
const [seriesIssueMeta, setSeriesIssueMeta] = useState<
|
||||||
|
Record<string, { tagIds: string[]; issueTitles: string[] }>
|
||||||
|
>({})
|
||||||
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
|
const [showFilters, setShowFilters] = useState(
|
||||||
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
|
)
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const toggleTag = (tagId: string) =>
|
||||||
|
setSelectedTagIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchItems = useCallback((pageNum: number, searchVal: string, replace: boolean) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
libraryId,
|
||||||
|
page: String(pageNum),
|
||||||
|
pageSize: String(PAGE_SIZE),
|
||||||
|
})
|
||||||
|
if (searchVal) params.set('search', searchVal)
|
||||||
|
if (pageNum === 1) setLoading(true)
|
||||||
|
else setLoadingMore(true)
|
||||||
|
fetch(`/api/comics?${params}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { items: (ComicIssue | ComicSeries)[]; total: number }) => {
|
||||||
|
setItems((prev) => (replace ? data.items : [...prev, ...data.items]))
|
||||||
|
setTotal(data.total)
|
||||||
|
if (pageNum === 1) setLoading(false)
|
||||||
|
else setLoadingMore(false)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Failed to load comics')
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [libraryId])
|
||||||
|
|
||||||
|
useEffect(() => { fetchItems(1, '', true) }, [fetchItems])
|
||||||
|
|
||||||
|
// IntersectionObserver: load next page when sentinel scrolls into view
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current
|
||||||
|
if (!sentinel || items.length >= total || total === 0) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting && !loadingMore) {
|
||||||
|
const next = page + 1
|
||||||
|
setPage(next)
|
||||||
|
fetchItems(next, search, false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '400px' }
|
||||||
|
)
|
||||||
|
observer.observe(sentinel)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [items.length, total, loadingMore, page, search, fetchItems])
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((val: string) => {
|
||||||
|
setSearch(val)
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
setPage(1)
|
||||||
|
fetchItems(1, val, true)
|
||||||
|
}, 300)
|
||||||
|
}, [fetchItems])
|
||||||
|
|
||||||
|
const fetchAssignments = useCallback(() => {
|
||||||
|
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setAssignments)
|
||||||
|
.catch(() => {})
|
||||||
|
}, [libraryId])
|
||||||
|
|
||||||
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||||
|
|
||||||
|
const fetchSeriesIssueMeta = useCallback(() => {
|
||||||
|
fetch(`/api/comics/series-issue-tags?libraryId=${encodeURIComponent(libraryId)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setSeriesIssueMeta)
|
||||||
|
.catch(() => {})
|
||||||
|
}, [libraryId])
|
||||||
|
|
||||||
|
useEffect(() => { fetchSeriesIssueMeta() }, [fetchSeriesIssueMeta])
|
||||||
|
|
||||||
|
const onTagsChanged = useCallback(() => {
|
||||||
|
setFilterRefreshKey((k) => k + 1)
|
||||||
|
fetchAssignments()
|
||||||
|
fetchSeriesIssueMeta()
|
||||||
|
}, [fetchAssignments, fetchSeriesIssueMeta])
|
||||||
|
|
||||||
|
const filtered = items.filter((item) => {
|
||||||
|
const isSeries = 'issueCount' in item
|
||||||
|
|
||||||
|
if (isSeries) {
|
||||||
|
const meta = seriesIssueMeta[item.item_key ?? ''] ?? { tagIds: [], issueTitles: [] }
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
const titleMatch = item.title.toLowerCase().includes(q)
|
||||||
|
const issueMatch = meta.issueTitles.some((t) => t.toLowerCase().includes(q))
|
||||||
|
if (!titleMatch && !issueMatch) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTagIds.size > 0) {
|
||||||
|
const seriesTags = assignments[item.item_key ?? ''] ?? []
|
||||||
|
const allTags = [...new Set([...seriesTags, ...meta.tagIds])]
|
||||||
|
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone issue
|
||||||
|
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||||
|
if (selectedTagIds.size > 0) {
|
||||||
|
const tags = assignments[item.item_key ?? ''] ?? []
|
||||||
|
if (![...selectedTagIds].every((id) => tags.includes(id))) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
|
||||||
|
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
|
||||||
|
>
|
||||||
|
Filters{filtersActive ? ' ●' : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-6 md:items-start">
|
||||||
|
{showFilters && (
|
||||||
|
<div className="w-full md:w-52 md:flex-shrink-0">
|
||||||
|
<FilterPanel
|
||||||
|
libraryId={libraryId}
|
||||||
|
assignments={assignments}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
selectedTagIds={selectedTagIds}
|
||||||
|
onTagToggle={toggleTag}
|
||||||
|
refreshKey={filterRefreshKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingGrid />
|
||||||
|
) : error ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-8 text-center"
|
||||||
|
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-12 text-center"
|
||||||
|
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
<p className="text-lg mb-1">No comics found</p>
|
||||||
|
<p className="text-sm">Add .cbz files or folders of .cbz files to this library and scan.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Showing {filtered.length.toLocaleString()} of {total.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{filtered.map((item) =>
|
||||||
|
'issueCount' in item ? (
|
||||||
|
<SeriesCard
|
||||||
|
key={item.id}
|
||||||
|
series={item as ComicSeries}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onClick={() => setSelectedSeries(item as ComicSeries)}
|
||||||
|
onTagClick={(item as ComicSeries).item_key && !readOnly
|
||||||
|
? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title })
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IssueCard
|
||||||
|
key={item.id}
|
||||||
|
issue={item as ComicIssue}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onClick={() => setSelectedIssue(item as ComicIssue)}
|
||||||
|
onTagClick={(item as ComicIssue).item_key && !readOnly
|
||||||
|
? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title })
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div ref={sentinelRef} style={{ height: 1 }} aria-hidden />
|
||||||
|
{loadingMore && <LoadingMore />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag panel modal */}
|
||||||
|
{tagPanel && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-5 py-4"
|
||||||
|
style={{ borderBottom: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{tagPanel.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setTagPanel(null)}
|
||||||
|
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<TagSelector
|
||||||
|
itemKey={tagPanel.itemKey}
|
||||||
|
onTagsChanged={onTagsChanged}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedSeries && (
|
||||||
|
<ComicSeriesView
|
||||||
|
libraryId={libraryId}
|
||||||
|
series={selectedSeries}
|
||||||
|
onClose={() => setSelectedSeries(null)}
|
||||||
|
onTagsChanged={onTagsChanged}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedIssue && (
|
||||||
|
<ComicIssueView
|
||||||
|
libraryId={libraryId}
|
||||||
|
issue={selectedIssue}
|
||||||
|
onClose={() => setSelectedIssue(null)}
|
||||||
|
onTagsChanged={onTagsChanged}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeriesCard({
|
||||||
|
series,
|
||||||
|
onClick,
|
||||||
|
onTagClick,
|
||||||
|
readOnly,
|
||||||
|
}: {
|
||||||
|
series: ComicSeries
|
||||||
|
onClick: () => void
|
||||||
|
onTagClick?: () => void
|
||||||
|
readOnly?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative rounded-xl overflow-hidden group"
|
||||||
|
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
|
||||||
|
>
|
||||||
|
<button className="text-left w-full focus:outline-none focus:ring-2" onClick={onClick}>
|
||||||
|
<div
|
||||||
|
className="relative w-full overflow-hidden"
|
||||||
|
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
{series.coverUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={series.coverUrl}
|
||||||
|
alt={series.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-4xl">📚</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="absolute top-1 right-1 px-1.5 py-0.5 rounded text-xs font-bold leading-none"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{series.issueCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 pt-1.5 pb-1">
|
||||||
|
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{series.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{series.issueCount} {series.issueCount === 1 ? 'issue' : 'issues'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{onTagClick && !readOnly && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onTagClick() }}
|
||||||
|
className="absolute top-1 left-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
|
||||||
|
title="Tag series"
|
||||||
|
aria-label="Tag series"
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueCard({
|
||||||
|
issue,
|
||||||
|
onClick,
|
||||||
|
onTagClick,
|
||||||
|
readOnly,
|
||||||
|
}: {
|
||||||
|
issue: ComicIssue
|
||||||
|
onClick: () => void
|
||||||
|
onTagClick?: () => void
|
||||||
|
readOnly?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative rounded-xl overflow-hidden group"
|
||||||
|
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
|
||||||
|
>
|
||||||
|
<button className="text-left w-full focus:outline-none focus:ring-2" onClick={onClick}>
|
||||||
|
<div
|
||||||
|
className="relative w-full overflow-hidden"
|
||||||
|
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
{issue.coverUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={issue.coverUrl}
|
||||||
|
alt={issue.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-4xl">📖</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-2 pt-1.5 pb-1">
|
||||||
|
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{issue.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{issue.pageCount} {issue.pageCount === 1 ? 'pg' : 'pgs'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{onTagClick && !readOnly && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onTagClick() }}
|
||||||
|
className="absolute top-1 left-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
|
||||||
|
title="Tag issue"
|
||||||
|
aria-label="Tag issue"
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingMore() {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border-2 animate-spin"
|
||||||
|
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingGrid() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => (
|
||||||
|
<div key={i} className="rounded-xl overflow-hidden animate-pulse" style={{ border: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ aspectRatio: '2/3', background: 'var(--border)' }} />
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
<div className="h-3 rounded" style={{ background: 'var(--border)', width: '75%' }} />
|
||||||
|
<div className="h-2 rounded" style={{ background: 'var(--border)', width: '40%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
src/lib/comic-info.ts
Normal file
120
src/lib/comic-info.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import AdmZip from 'adm-zip'
|
||||||
|
import { XMLParser } from 'fast-xml-parser'
|
||||||
|
import type { ComicInfoData } from '@/types'
|
||||||
|
import { findZipEntry, extractZipEntry } from './zip-utils'
|
||||||
|
|
||||||
|
const parser = new XMLParser()
|
||||||
|
|
||||||
|
function toNumber(val: unknown): number | null {
|
||||||
|
if (val === undefined || val === null || val === '') return null
|
||||||
|
const n = Number(val)
|
||||||
|
return isNaN(n) ? null : n
|
||||||
|
}
|
||||||
|
|
||||||
|
function toString(val: unknown): string | null {
|
||||||
|
if (val === undefined || val === null || val === '') return null
|
||||||
|
return String(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse ComicInfo.xml from inside a CBZ archive.
|
||||||
|
* Returns null if the archive doesn't contain ComicInfo.xml or parsing fails.
|
||||||
|
*/
|
||||||
|
export function parseComicInfo(absoluteCbzPath: string): ComicInfoData | null {
|
||||||
|
let zip: AdmZip
|
||||||
|
try {
|
||||||
|
zip = new AdmZip(absoluteCbzPath)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find ComicInfo.xml (case-insensitive)
|
||||||
|
const entry = zip.getEntries().find(
|
||||||
|
(e) => !e.isDirectory && e.entryName.toLowerCase() === 'comicinfo.xml'
|
||||||
|
)
|
||||||
|
if (!entry) return null
|
||||||
|
|
||||||
|
let xml: string
|
||||||
|
try {
|
||||||
|
xml = entry.getData().toString('utf-8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let doc: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
doc = parser.parse(xml) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// The root element can be ComicInfo or ComicInfoXml (varies by source)
|
||||||
|
const info = (doc.ComicInfo ?? doc.ComicInfoXml ?? doc.comicinfo) as Record<string, unknown> | undefined
|
||||||
|
if (!info) return null
|
||||||
|
|
||||||
|
// Parse tags: comma-separated string
|
||||||
|
const rawTags = toString(info.Tags)
|
||||||
|
const tags: string[] = rawTags
|
||||||
|
? rawTags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||||
|
: []
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: toString(info.Title),
|
||||||
|
year: toNumber(info.Year),
|
||||||
|
month: toNumber(info.Month),
|
||||||
|
day: toNumber(info.Day),
|
||||||
|
writer: toString(info.Writer),
|
||||||
|
translator: toString(info.Translator),
|
||||||
|
publisher: toString(info.Publisher),
|
||||||
|
genre: toString(info.Genre),
|
||||||
|
tags,
|
||||||
|
web: toString(info.Web),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async version of parseComicInfo — reads only the ComicInfo.xml entry from the
|
||||||
|
* archive without loading the entire CBZ into memory. This is significantly faster
|
||||||
|
* for large libraries since it reads only the ZIP's central directory + the XML entry.
|
||||||
|
*/
|
||||||
|
export async function parseComicInfoAsync(absoluteCbzPath: string): Promise<ComicInfoData | null> {
|
||||||
|
try {
|
||||||
|
const entry = await findZipEntry(absoluteCbzPath, 'comicinfo.xml')
|
||||||
|
if (!entry) return null
|
||||||
|
const buf = await extractZipEntry(absoluteCbzPath, entry)
|
||||||
|
if (!buf) return null
|
||||||
|
return parseXml(buf.toString('utf-8'))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXml(xml: string): ComicInfoData | null {
|
||||||
|
let doc: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
doc = parser.parse(xml) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = (doc.ComicInfo ?? doc.ComicInfoXml ?? doc.comicinfo) as Record<string, unknown> | undefined
|
||||||
|
if (!info) return null
|
||||||
|
|
||||||
|
const rawTags = toString(info.Tags)
|
||||||
|
const tags: string[] = rawTags
|
||||||
|
? rawTags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||||
|
: []
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: toString(info.Title),
|
||||||
|
year: toNumber(info.Year),
|
||||||
|
month: toNumber(info.Month),
|
||||||
|
day: toNumber(info.Day),
|
||||||
|
writer: toString(info.Writer),
|
||||||
|
translator: toString(info.Translator),
|
||||||
|
publisher: toString(info.Publisher),
|
||||||
|
genre: toString(info.Genre),
|
||||||
|
tags,
|
||||||
|
web: toString(info.Web),
|
||||||
|
}
|
||||||
|
}
|
||||||
233
src/lib/comic-metadata.ts
Normal file
233
src/lib/comic-metadata.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import type { Library, ImportedTag, TagMapping } from '@/types'
|
||||||
|
import { getDb } from './db'
|
||||||
|
import { resolveLibraryRoot } from './libraries'
|
||||||
|
import { parseComicInfoAsync } from './comic-info'
|
||||||
|
import { mapConcurrent } from './zip-utils'
|
||||||
|
|
||||||
|
// ─── Metadata Import ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import ComicInfo.xml metadata for all comic_issue items in a library.
|
||||||
|
* - Populates media_items fields (title, year, genres, metadata JSON).
|
||||||
|
* - For each tag: if a mapping exists, assigns the real tag; otherwise creates
|
||||||
|
* an imported tag entry.
|
||||||
|
*/
|
||||||
|
export async function importComicMetadata(library: Library): Promise<void> {
|
||||||
|
const db = getDb()
|
||||||
|
const libraryRoot = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
const issues = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT item_key, file_path, metadata FROM media_items
|
||||||
|
WHERE library_id = ? AND item_type = 'comic_issue' AND file_path IS NOT NULL`
|
||||||
|
)
|
||||||
|
.all(library.id) as { item_key: string; file_path: string; metadata: string | null }[]
|
||||||
|
|
||||||
|
// Load existing mappings for this library
|
||||||
|
const mappingRows = db
|
||||||
|
.prepare('SELECT imported_tag_name, tag_id FROM tag_mappings WHERE library_id = ?')
|
||||||
|
.all(library.id) as { imported_tag_name: string; tag_id: string }[]
|
||||||
|
const mappings = new Map(mappingRows.map((r) => [r.imported_tag_name, r.tag_id]))
|
||||||
|
|
||||||
|
// Clear existing imported tag associations for this library (they'll be re-created)
|
||||||
|
db.prepare(
|
||||||
|
`DELETE FROM item_imported_tags WHERE imported_tag_id IN (
|
||||||
|
SELECT id FROM imported_tags WHERE library_id = ?
|
||||||
|
)`
|
||||||
|
).run(library.id)
|
||||||
|
db.prepare('DELETE FROM imported_tags WHERE library_id = ?').run(library.id)
|
||||||
|
|
||||||
|
const updateItem = db.prepare(`
|
||||||
|
UPDATE media_items SET title = @title, year = @year, genres = @genres, metadata = @metadata
|
||||||
|
WHERE item_key = @item_key
|
||||||
|
`)
|
||||||
|
const addMediaTag = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO media_tags (item_key, tag_id) VALUES (?, ?)'
|
||||||
|
)
|
||||||
|
const upsertImportedTag = db.prepare(`
|
||||||
|
INSERT INTO imported_tags (id, library_id, name) VALUES (@id, @library_id, @name)
|
||||||
|
ON CONFLICT(library_id, name) DO UPDATE SET name = excluded.name
|
||||||
|
RETURNING id
|
||||||
|
`)
|
||||||
|
const addItemImportedTag = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO item_imported_tags (item_key, imported_tag_id) VALUES (?, ?)'
|
||||||
|
)
|
||||||
|
|
||||||
|
let importedCount = 0
|
||||||
|
|
||||||
|
// Process in batches: async file reads (10 concurrent) followed by batch DB writes,
|
||||||
|
// with an event-loop yield between batches to keep the app responsive.
|
||||||
|
const BATCH_SIZE = 50
|
||||||
|
for (let i = 0; i < issues.length; i += BATCH_SIZE) {
|
||||||
|
const batch = issues.slice(i, i + BATCH_SIZE)
|
||||||
|
|
||||||
|
// Async: read ComicInfo.xml from each archive concurrently (10 at a time).
|
||||||
|
// Uses async ZIP central-directory reader — no full-file reads.
|
||||||
|
const infos = await mapConcurrent(batch, 10, (issue) =>
|
||||||
|
parseComicInfoAsync(path.join(libraryRoot, issue.file_path))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sync: write this batch to the DB in one transaction.
|
||||||
|
db.transaction(() => {
|
||||||
|
for (let j = 0; j < batch.length; j++) {
|
||||||
|
const issue = batch[j]
|
||||||
|
const info = infos[j]
|
||||||
|
if (!info) continue
|
||||||
|
|
||||||
|
const existingMeta = issue.metadata ? JSON.parse(issue.metadata) : {}
|
||||||
|
const mergedMeta = {
|
||||||
|
...existingMeta,
|
||||||
|
writer: info.writer,
|
||||||
|
publisher: info.publisher,
|
||||||
|
translator: info.translator,
|
||||||
|
web: info.web,
|
||||||
|
month: info.month,
|
||||||
|
day: info.day,
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItem.run({
|
||||||
|
item_key: issue.item_key,
|
||||||
|
title: info.title ?? existingMeta.title ?? null,
|
||||||
|
year: info.year,
|
||||||
|
genres: info.genre,
|
||||||
|
metadata: JSON.stringify(mergedMeta),
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const tagName of info.tags) {
|
||||||
|
const mappedTagId = mappings.get(tagName)
|
||||||
|
if (mappedTagId) {
|
||||||
|
addMediaTag.run(issue.item_key, mappedTagId)
|
||||||
|
} else {
|
||||||
|
const importedTagId = crypto.randomUUID()
|
||||||
|
const row = upsertImportedTag.get({
|
||||||
|
id: importedTagId,
|
||||||
|
library_id: library.id,
|
||||||
|
name: tagName,
|
||||||
|
}) as { id: string }
|
||||||
|
addItemImportedTag.run(issue.item_key, row.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
importedCount++
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
await new Promise<void>((r) => setImmediate(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[comic-metadata] Imported metadata for ${importedCount}/${issues.length} issues in "${library.name}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Imported Tags ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getImportedTagsForLibrary(libraryId: string): ImportedTag[] {
|
||||||
|
const db = getDb()
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT it.id, it.library_id as libraryId, it.name,
|
||||||
|
COUNT(iit.item_key) as itemCount
|
||||||
|
FROM imported_tags it
|
||||||
|
LEFT JOIN item_imported_tags iit ON iit.imported_tag_id = it.id
|
||||||
|
WHERE it.library_id = ?
|
||||||
|
GROUP BY it.id
|
||||||
|
ORDER BY it.name`
|
||||||
|
)
|
||||||
|
.all(libraryId) as ImportedTag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tag Mappings ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getTagMappingsForLibrary(libraryId: string): TagMapping[] {
|
||||||
|
const db = getDb()
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT tm.id, tm.library_id as libraryId, tm.imported_tag_name as importedTagName,
|
||||||
|
tm.tag_id as tagId, t.name as tagName, tc.name as categoryName
|
||||||
|
FROM tag_mappings tm
|
||||||
|
JOIN tags t ON t.id = tm.tag_id
|
||||||
|
JOIN tag_categories tc ON tc.id = t.category_id
|
||||||
|
WHERE tm.library_id = ?
|
||||||
|
ORDER BY tm.imported_tag_name`
|
||||||
|
)
|
||||||
|
.all(libraryId) as TagMapping[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a tag mapping and apply it: assign the real tag to all items that
|
||||||
|
* currently have the imported tag, then remove the imported tag entries.
|
||||||
|
*/
|
||||||
|
export function createTagMapping(libraryId: string, importedTagName: string, tagId: string): TagMapping {
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
|
||||||
|
return db.transaction(() => {
|
||||||
|
// Persist the mapping for future scans
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO tag_mappings (id, library_id, imported_tag_name, tag_id)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(library_id, imported_tag_name) DO UPDATE SET tag_id = excluded.tag_id
|
||||||
|
`).run(id, libraryId, importedTagName, tagId)
|
||||||
|
|
||||||
|
// Find all items that currently have this imported tag
|
||||||
|
const importedTag = db
|
||||||
|
.prepare('SELECT id FROM imported_tags WHERE library_id = ? AND name = ?')
|
||||||
|
.get(libraryId, importedTagName) as { id: string } | undefined
|
||||||
|
|
||||||
|
if (importedTag) {
|
||||||
|
const itemKeys = db
|
||||||
|
.prepare('SELECT item_key FROM item_imported_tags WHERE imported_tag_id = ?')
|
||||||
|
.all(importedTag.id) as { item_key: string }[]
|
||||||
|
|
||||||
|
// Assign the real tag to all affected items
|
||||||
|
const addMediaTag = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO media_tags (item_key, tag_id) VALUES (?, ?)'
|
||||||
|
)
|
||||||
|
for (const { item_key } of itemKeys) {
|
||||||
|
addMediaTag.run(item_key, tagId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the imported tag (cascades to item_imported_tags)
|
||||||
|
db.prepare('DELETE FROM imported_tags WHERE id = ?').run(importedTag.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the created mapping with joined names
|
||||||
|
const mapping = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT tm.id, tm.library_id as libraryId, tm.imported_tag_name as importedTagName,
|
||||||
|
tm.tag_id as tagId, t.name as tagName, tc.name as categoryName
|
||||||
|
FROM tag_mappings tm
|
||||||
|
JOIN tags t ON t.id = tm.tag_id
|
||||||
|
JOIN tag_categories tc ON tc.id = t.category_id
|
||||||
|
WHERE tm.library_id = ? AND tm.imported_tag_name = ?`
|
||||||
|
)
|
||||||
|
.get(libraryId, importedTagName) as TagMapping
|
||||||
|
|
||||||
|
return mapping
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTagMapping(id: string): void {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db.prepare('DELETE FROM tag_mappings WHERE id = ?').run(id)
|
||||||
|
if (result.changes === 0) throw new Error('Mapping not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a media item already has metadata populated.
|
||||||
|
* Returns true if ANY of: title, year, plot, or genres are populated.
|
||||||
|
*/
|
||||||
|
function hasMetadata(item: {
|
||||||
|
title: string | null
|
||||||
|
year: number | null
|
||||||
|
plot: string | null
|
||||||
|
genres: string | null
|
||||||
|
}): boolean {
|
||||||
|
if (item.title) return true
|
||||||
|
if (item.year) return true
|
||||||
|
if (item.plot) return true
|
||||||
|
if (item.genres) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
345
src/lib/comics.ts
Normal file
345
src/lib/comics.ts
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import AdmZip from 'adm-zip'
|
||||||
|
import type { ComicIssue, ComicSeries } from '@/types'
|
||||||
|
import { getDb } from './db'
|
||||||
|
import { HIDDEN_FILES, thumbnailApiUrl } from './media-utils'
|
||||||
|
import { countZipImages, mapConcurrent } from './zip-utils'
|
||||||
|
import fsPromises from 'fs/promises'
|
||||||
|
|
||||||
|
const CBZ_EXTENSIONS = new Set(['.cbz'])
|
||||||
|
const CBZ_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
||||||
|
|
||||||
|
function isCbzFile(name: string): boolean {
|
||||||
|
return CBZ_EXTENSIONS.has(path.extname(name).toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function naturalCompare(a: string, b: string): number {
|
||||||
|
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIssueNumber(filename: string): number | null {
|
||||||
|
const base = path.basename(filename, path.extname(filename))
|
||||||
|
const matches = base.match(/\d+/g)
|
||||||
|
if (!matches) return null
|
||||||
|
return parseInt(matches[matches.length - 1], 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScannedComicSeries extends ComicSeries {
|
||||||
|
issues: ComicIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRASH_DIR = '.trash'
|
||||||
|
|
||||||
|
async function moveToTrash(absPath: string, libraryRoot: string): Promise<void> {
|
||||||
|
const trashDir = path.join(libraryRoot, TRASH_DIR)
|
||||||
|
await fsPromises.mkdir(trashDir, { recursive: true })
|
||||||
|
const filename = path.basename(absPath)
|
||||||
|
let dest = path.join(trashDir, filename)
|
||||||
|
if (fs.existsSync(dest)) {
|
||||||
|
const ext = path.extname(filename)
|
||||||
|
const base = path.basename(filename, ext)
|
||||||
|
dest = path.join(trashDir, `${base}_${Date.now()}${ext}`)
|
||||||
|
}
|
||||||
|
await fsPromises.rename(absPath, dest).catch(async (err: NodeJS.ErrnoException) => {
|
||||||
|
if (err.code === 'EXDEV') {
|
||||||
|
// Source and destination are on different filesystems — copy then delete.
|
||||||
|
await fsPromises.copyFile(absPath, dest)
|
||||||
|
await fsPromises.unlink(absPath)
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log(`[scanner] Moved corrupt archive to trash: ${path.relative(libraryRoot, absPath)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollectedCbz {
|
||||||
|
absPath: string
|
||||||
|
filename: string
|
||||||
|
relPath: string
|
||||||
|
isStandalone: boolean
|
||||||
|
seriesDirName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanComicsLibrary(
|
||||||
|
libraryRoot: string,
|
||||||
|
libraryId: string
|
||||||
|
): Promise<(ComicIssue | ScannedComicSeries)[]> {
|
||||||
|
let topEntries: fs.Dirent[]
|
||||||
|
try {
|
||||||
|
topEntries = fs.readdirSync(libraryRoot, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Collect all CBZ paths via fast directory listing (no archive opens).
|
||||||
|
const collected: CollectedCbz[] = []
|
||||||
|
|
||||||
|
for (const entry of topEntries) {
|
||||||
|
if (HIDDEN_FILES.test(entry.name)) continue
|
||||||
|
|
||||||
|
if (entry.isFile() && isCbzFile(entry.name)) {
|
||||||
|
collected.push({
|
||||||
|
absPath: path.join(libraryRoot, entry.name),
|
||||||
|
filename: entry.name,
|
||||||
|
relPath: entry.name,
|
||||||
|
isStandalone: true,
|
||||||
|
seriesDirName: null,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const dirAbsPath = path.join(libraryRoot, entry.name)
|
||||||
|
let subEntries: fs.Dirent[]
|
||||||
|
try {
|
||||||
|
subEntries = fs.readdirSync(dirAbsPath, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const cbzFiles = subEntries
|
||||||
|
.filter((e) => e.isFile() && isCbzFile(e.name) && !HIDDEN_FILES.test(e.name))
|
||||||
|
.sort((a, b) => naturalCompare(a.name, b.name))
|
||||||
|
|
||||||
|
if (cbzFiles.length === 0) continue
|
||||||
|
|
||||||
|
for (const f of cbzFiles) {
|
||||||
|
collected.push({
|
||||||
|
absPath: path.join(dirAbsPath, f.name),
|
||||||
|
filename: f.name,
|
||||||
|
relPath: path.join(entry.name, f.name),
|
||||||
|
isStandalone: false,
|
||||||
|
seriesDirName: entry.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Count pages for all CBZ files concurrently (10 at a time) by reading
|
||||||
|
// only each archive's central directory — no full-file reads.
|
||||||
|
const scanResults = await mapConcurrent(collected, 10, (c) =>
|
||||||
|
countZipImages(c.absPath, CBZ_IMAGE_EXTENSIONS)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Move corrupt archives to the library's .trash folder and exclude them from indexing.
|
||||||
|
const movePromises: Promise<void>[] = []
|
||||||
|
const valid: Array<{ cbz: CollectedCbz; pageCount: number }> = []
|
||||||
|
for (let i = 0; i < collected.length; i++) {
|
||||||
|
const result = scanResults[i]
|
||||||
|
if (!result.valid) {
|
||||||
|
movePromises.push(
|
||||||
|
moveToTrash(collected[i].absPath, libraryRoot).catch((err) =>
|
||||||
|
console.warn(`[scanner] Could not move corrupt archive to trash: ${collected[i].absPath}`, err)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valid.push({ cbz: collected[i], pageCount: result.pageCount })
|
||||||
|
}
|
||||||
|
if (movePromises.length > 0) await Promise.all(movePromises)
|
||||||
|
|
||||||
|
// Phase 3: Build the result array from valid files only.
|
||||||
|
const seriesMap = new Map<string, ScannedComicSeries>()
|
||||||
|
const standaloneIssues: ComicIssue[] = []
|
||||||
|
|
||||||
|
for (const { cbz: c, pageCount } of valid) {
|
||||||
|
const coverUrl = thumbnailApiUrl(libraryId, c.relPath)
|
||||||
|
const issue: ComicIssue = {
|
||||||
|
id: encodeURIComponent(c.relPath),
|
||||||
|
title: path.basename(c.filename, path.extname(c.filename)),
|
||||||
|
issueNumber: parseIssueNumber(c.filename),
|
||||||
|
pageCount,
|
||||||
|
coverUrl,
|
||||||
|
filePath: c.relPath,
|
||||||
|
isStandalone: c.isStandalone,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.isStandalone) {
|
||||||
|
standaloneIssues.push(issue)
|
||||||
|
} else {
|
||||||
|
const key = c.seriesDirName!
|
||||||
|
if (!seriesMap.has(key)) {
|
||||||
|
seriesMap.set(key, {
|
||||||
|
id: encodeURIComponent(key),
|
||||||
|
title: key,
|
||||||
|
coverUrl, // first issue (sorted) becomes the series cover
|
||||||
|
issueCount: 0,
|
||||||
|
issues: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const series = seriesMap.get(key)!
|
||||||
|
series.issues.push(issue)
|
||||||
|
series.issueCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: (ComicIssue | ScannedComicSeries)[] = [
|
||||||
|
...Array.from(seriesMap.values()),
|
||||||
|
...standaloneIssues,
|
||||||
|
]
|
||||||
|
return results.sort((a, b) => naturalCompare(a.title, b.title))
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeLike(s: string): string {
|
||||||
|
return `%${s.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
// comicsFromDb returns series + standalone issues for the top-level grid, paginated.
|
||||||
|
// Series issues are retrieved separately via comicIssuesFromDb.
|
||||||
|
export function comicsFromDb(
|
||||||
|
libraryId: string,
|
||||||
|
opts: { page: number; pageSize: number; search?: string }
|
||||||
|
): { items: (ComicIssue | ComicSeries)[]; total: number } {
|
||||||
|
const db = getDb()
|
||||||
|
const offset = (opts.page - 1) * opts.pageSize
|
||||||
|
|
||||||
|
type DbRow = {
|
||||||
|
item_key: string
|
||||||
|
item_type: string
|
||||||
|
parent_key: string | null
|
||||||
|
title: string | null
|
||||||
|
metadata: string | null
|
||||||
|
file_path: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseWhere = `
|
||||||
|
WHERE library_id = ?
|
||||||
|
AND (item_type = 'comic_series' OR (item_type = 'comic_issue' AND parent_key IS NULL))
|
||||||
|
`
|
||||||
|
|
||||||
|
const total: number = opts.search
|
||||||
|
? (db
|
||||||
|
.prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\'`)
|
||||||
|
.get(libraryId, escapeLike(opts.search)) as { cnt: number }).cnt
|
||||||
|
: (db
|
||||||
|
.prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere}`)
|
||||||
|
.get(libraryId) as { cnt: number }).cnt
|
||||||
|
|
||||||
|
const rows: DbRow[] = opts.search
|
||||||
|
? db
|
||||||
|
.prepare(
|
||||||
|
`SELECT item_key, item_type, parent_key, title, metadata, file_path
|
||||||
|
FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\'
|
||||||
|
ORDER BY title LIMIT ? OFFSET ?`
|
||||||
|
)
|
||||||
|
.all(libraryId, escapeLike(opts.search), opts.pageSize, offset) as DbRow[]
|
||||||
|
: db
|
||||||
|
.prepare(
|
||||||
|
`SELECT item_key, item_type, parent_key, title, metadata, file_path
|
||||||
|
FROM media_items ${baseWhere}
|
||||||
|
ORDER BY title LIMIT ? OFFSET ?`
|
||||||
|
)
|
||||||
|
.all(libraryId, opts.pageSize, offset) as DbRow[]
|
||||||
|
|
||||||
|
const items: (ComicIssue | ComicSeries)[] = []
|
||||||
|
for (const row of rows) {
|
||||||
|
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||||
|
if (row.item_type === 'comic_series') {
|
||||||
|
const idPart = row.item_key.split(':comic_series:')[1] ?? row.item_key
|
||||||
|
items.push({
|
||||||
|
id: idPart,
|
||||||
|
item_key: row.item_key,
|
||||||
|
title: row.title ?? decodeURIComponent(idPart),
|
||||||
|
coverUrl: meta.coverUrl ?? null,
|
||||||
|
issueCount: meta.issueCount ?? 0,
|
||||||
|
} as ComicSeries)
|
||||||
|
} else {
|
||||||
|
const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key
|
||||||
|
items.push({
|
||||||
|
id: idPart,
|
||||||
|
item_key: row.item_key,
|
||||||
|
title: row.title ?? decodeURIComponent(idPart.split(':').pop() ?? idPart),
|
||||||
|
issueNumber: meta.issueNumber ?? null,
|
||||||
|
pageCount: meta.pageCount ?? 0,
|
||||||
|
coverUrl: meta.coverUrl ?? null,
|
||||||
|
filePath: row.file_path ?? '',
|
||||||
|
isStandalone: meta.isStandalone ?? true,
|
||||||
|
} as ComicIssue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, total }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIssue[] {
|
||||||
|
const db = getDb()
|
||||||
|
const seriesKey = `${libraryId}:comic_series:${seriesId}`
|
||||||
|
|
||||||
|
type DbRow = {
|
||||||
|
item_key: string
|
||||||
|
title: string | null
|
||||||
|
metadata: string | null
|
||||||
|
file_path: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT item_key, title, metadata, file_path
|
||||||
|
FROM media_items
|
||||||
|
WHERE parent_key = ? AND item_type = 'comic_issue'`
|
||||||
|
)
|
||||||
|
.all(seriesKey) as DbRow[]
|
||||||
|
|
||||||
|
const issues: ComicIssue[] = rows.map((row) => {
|
||||||
|
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||||
|
const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key
|
||||||
|
return {
|
||||||
|
id: idPart,
|
||||||
|
item_key: row.item_key,
|
||||||
|
title: row.title ?? decodeURIComponent(idPart.split(':').pop() ?? idPart),
|
||||||
|
issueNumber: meta.issueNumber ?? null,
|
||||||
|
pageCount: meta.pageCount ?? 0,
|
||||||
|
coverUrl: meta.coverUrl ?? null,
|
||||||
|
filePath: row.file_path ?? '',
|
||||||
|
isStandalone: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return issues.sort((a, b) => {
|
||||||
|
if (a.issueNumber !== null && b.issueNumber !== null) return a.issueNumber - b.issueNumber
|
||||||
|
if (a.issueNumber !== null) return -1
|
||||||
|
if (b.issueNumber !== null) return 1
|
||||||
|
return naturalCompare(a.title, b.title)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComicPages(absoluteCbzPath: string): string[] {
|
||||||
|
try {
|
||||||
|
const zip = new AdmZip(absoluteCbzPath)
|
||||||
|
return zip
|
||||||
|
.getEntries()
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
!e.isDirectory &&
|
||||||
|
CBZ_IMAGE_EXTENSIONS.has(path.extname(e.entryName).toLowerCase())
|
||||||
|
)
|
||||||
|
.sort((a, b) => naturalCompare(a.entryName, b.entryName))
|
||||||
|
.map((e) => e.entryName)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComicPageBuffer(absoluteCbzPath: string, pageIndex: number): { buffer: Buffer; ext: string } | null {
|
||||||
|
try {
|
||||||
|
const zip = new AdmZip(absoluteCbzPath)
|
||||||
|
const entries = zip
|
||||||
|
.getEntries()
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
!e.isDirectory &&
|
||||||
|
CBZ_IMAGE_EXTENSIONS.has(path.extname(e.entryName).toLowerCase())
|
||||||
|
)
|
||||||
|
.sort((a, b) => naturalCompare(a.entryName, b.entryName))
|
||||||
|
|
||||||
|
if (pageIndex < 0 || pageIndex >= entries.length) return null
|
||||||
|
|
||||||
|
const entry = entries[pageIndex]
|
||||||
|
const buffer = entry.getData()
|
||||||
|
const ext = path.extname(entry.entryName).toLowerCase()
|
||||||
|
return { buffer, ext }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/lib/db.ts
108
src/lib/db.ts
@@ -107,6 +107,11 @@ function initDb(db: Database.Database): void {
|
|||||||
migrateLibraryAiSettings(db)
|
migrateLibraryAiSettings(db)
|
||||||
migrateAiJobs(db)
|
migrateAiJobs(db)
|
||||||
migrateLibraryPermissionsAccessLevel(db)
|
migrateLibraryPermissionsAccessLevel(db)
|
||||||
|
migrateLibrariesAddComics(db)
|
||||||
|
migrateComicItemTypes(db)
|
||||||
|
migrateImportedTags(db)
|
||||||
|
migrateComicsIndex(db)
|
||||||
|
migrateTagMappingsIndexes(db)
|
||||||
seedAppSettings(db)
|
seedAppSettings(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +324,68 @@ function migrateLibrariesType(db: Database.Database): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateLibrariesAddComics(db: Database.Database): void {
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
||||||
|
.get() as { sql: string } | undefined
|
||||||
|
if (!row || row.sql.includes("'comics'")) return
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
CREATE TABLE libraries_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('comics','games','mixed','movies','tv')),
|
||||||
|
cover_ext TEXT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO libraries_new SELECT * FROM libraries;
|
||||||
|
DROP TABLE libraries;
|
||||||
|
ALTER TABLE libraries_new RENAME TO libraries;
|
||||||
|
COMMIT;
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateComicItemTypes(db: Database.Database): void {
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'")
|
||||||
|
.get() as { sql: string } | undefined
|
||||||
|
if (!row || row.sql.includes("'comic_series'")) return
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
CREATE TABLE media_items_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
item_key TEXT NOT NULL UNIQUE,
|
||||||
|
item_type TEXT NOT NULL CHECK(item_type IN (
|
||||||
|
'movie','tv_series','tv_season','tv_episode',
|
||||||
|
'game','game_series','mixed_file',
|
||||||
|
'comic_series','comic_issue')),
|
||||||
|
parent_key TEXT,
|
||||||
|
title TEXT,
|
||||||
|
year INTEGER,
|
||||||
|
plot TEXT,
|
||||||
|
genres TEXT,
|
||||||
|
metadata TEXT,
|
||||||
|
file_path TEXT,
|
||||||
|
fingerprint TEXT,
|
||||||
|
scanned_at INTEGER NOT NULL,
|
||||||
|
ai_tagged_at INTEGER,
|
||||||
|
ai_description TEXT,
|
||||||
|
extracted_text TEXT,
|
||||||
|
extracted_text_translated TEXT
|
||||||
|
);
|
||||||
|
INSERT INTO media_items_new SELECT * FROM media_items;
|
||||||
|
DROP TABLE media_items;
|
||||||
|
ALTER TABLE media_items_new RENAME TO media_items;
|
||||||
|
CREATE INDEX media_items_library_id ON media_items(library_id);
|
||||||
|
CREATE INDEX media_items_parent_key ON media_items(parent_key);
|
||||||
|
CREATE INDEX media_items_fingerprint ON media_items(fingerprint);
|
||||||
|
COMMIT;
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
function migrateLibraryPermissionsAccessLevel(db: Database.Database): void {
|
function migrateLibraryPermissionsAccessLevel(db: Database.Database): void {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_permissions'")
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_permissions'")
|
||||||
@@ -357,3 +424,44 @@ function migrateAiJobs(db: Database.Database): void {
|
|||||||
db.exec('ALTER TABLE ai_jobs ADD COLUMN payload TEXT')
|
db.exec('ALTER TABLE ai_jobs ADD COLUMN payload TEXT')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateImportedTags(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS imported_tags (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
UNIQUE(library_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_imported_tags (
|
||||||
|
item_key TEXT NOT NULL,
|
||||||
|
imported_tag_id TEXT NOT NULL REFERENCES imported_tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (item_key, imported_tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tag_mappings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
imported_tag_name TEXT NOT NULL,
|
||||||
|
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(library_id, imported_tag_name)
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateComicsIndex(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS media_items_library_type_title
|
||||||
|
ON media_items(library_id, item_type, title);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateTagMappingsIndexes(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS tag_mappings_library_id ON tag_mappings(library_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS tag_mappings_tag_id ON tag_mappings(tag_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS imported_tags_library_id ON imported_tags(library_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS item_imported_tags_imported_tag_id ON item_imported_tags(imported_tag_id);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|||||||
103
src/lib/movie-metadata.ts
Normal file
103
src/lib/movie-metadata.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import type { Library } from '@/types'
|
||||||
|
import { getDb } from './db'
|
||||||
|
import { resolveLibraryRoot } from './libraries'
|
||||||
|
import { parseMovieNfo } from './nfo'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import NFO metadata for Movie items in a library.
|
||||||
|
* - Reads .nfo file matching each movie file
|
||||||
|
* - If importMetadataOnly=false: skip items that already have metadata (title/year/plot/genres)
|
||||||
|
* - If importMetadataOnly=true: update all items regardless of existing metadata
|
||||||
|
*/
|
||||||
|
export async function importMovieMetadata(
|
||||||
|
library: Library,
|
||||||
|
importMetadataOnly: boolean = false
|
||||||
|
): Promise<{ imported: number; skipped: number }> {
|
||||||
|
const db = getDb()
|
||||||
|
const libraryRoot = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
let imported = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
// Get all movies in the library
|
||||||
|
const movies = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT item_key, file_path, title, year, plot, genres FROM media_items
|
||||||
|
WHERE library_id = ? AND item_type = 'movie' AND file_path IS NOT NULL`
|
||||||
|
)
|
||||||
|
.all(library.id) as Array<{ item_key: string; file_path: string; title: string | null; year: number | null; plot: string | null; genres: string | null }>
|
||||||
|
|
||||||
|
const updateItem = db.prepare(`
|
||||||
|
UPDATE media_items SET title = @title, year = @year, plot = @plot, genres = @genres
|
||||||
|
WHERE item_key = @item_key
|
||||||
|
`)
|
||||||
|
|
||||||
|
const BATCH_SIZE = 50
|
||||||
|
for (let i = 0; i < movies.length; i += BATCH_SIZE) {
|
||||||
|
const batch = movies.slice(i, i + BATCH_SIZE)
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const item of batch) {
|
||||||
|
// Check if we should skip this item
|
||||||
|
if (!importMetadataOnly && hasMetadata(item)) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoPath = path.join(libraryRoot, item.file_path)
|
||||||
|
const dir = path.dirname(videoPath)
|
||||||
|
const baseNameWithoutExt = path.basename(videoPath, path.extname(videoPath))
|
||||||
|
const nfoPath = path.join(dir, `${baseNameWithoutExt}.nfo`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(nfoPath)) {
|
||||||
|
const nfoData = parseMovieNfo(nfoPath)
|
||||||
|
if (nfoData) {
|
||||||
|
updateItem.run({
|
||||||
|
item_key: item.item_key,
|
||||||
|
title: nfoData.title ?? item.title,
|
||||||
|
year: nfoData.year ?? item.year,
|
||||||
|
plot: nfoData.plot ?? item.plot,
|
||||||
|
genres: nfoData.genres.length > 0 ? JSON.stringify(nfoData.genres) : item.genres,
|
||||||
|
})
|
||||||
|
imported++
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
await new Promise<void>((r) => setImmediate(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[movie-metadata] Imported metadata for ${imported} movies in "${library.name}" (${importMetadataOnly ? 'full' : 'incremental'})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return { imported, skipped }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a media item already has metadata populated.
|
||||||
|
* Returns true if ANY of: title, year, plot, or genres are populated.
|
||||||
|
*/
|
||||||
|
function hasMetadata(item: {
|
||||||
|
title: string | null
|
||||||
|
year: number | null
|
||||||
|
plot: string | null
|
||||||
|
genres: string | null
|
||||||
|
}): boolean {
|
||||||
|
if (item.title) return true
|
||||||
|
if (item.year) return true
|
||||||
|
if (item.plot) return true
|
||||||
|
if (item.genres) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type Database from 'better-sqlite3'
|
import type Database from 'better-sqlite3'
|
||||||
import type { Library, Movie, TvSeries, TvSeason, TvEpisode, Game, GameSeries } from '@/types'
|
import type { Library, Movie, TvSeries, TvSeason, TvEpisode, Game, GameSeries, ComicIssue } from '@/types'
|
||||||
import { getDb } from './db'
|
import { getDb } from './db'
|
||||||
import { getLibraries, resolveLibraryRoot } from './libraries'
|
import { getLibraries, resolveLibraryRoot } from './libraries'
|
||||||
import { setScanLastRan } from './app-settings'
|
import { setScanLastRan } from './app-settings'
|
||||||
import { scanMoviesLibrary } from './movies'
|
import { scanMoviesLibrary } from './movies'
|
||||||
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv'
|
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv'
|
||||||
import { scanGamesLibrary } from './games'
|
import { scanGamesLibrary } from './games'
|
||||||
import { getThumbnailPath } from './thumbnails'
|
import { scanComicsLibrary, type ScannedComicSeries } from './comics'
|
||||||
|
import { getThumbnailPath, getCbzThumbnailPath } from './thumbnails'
|
||||||
import { computeFingerprint } from './fingerprint'
|
import { computeFingerprint } from './fingerprint'
|
||||||
import { reKeyMediaItem } from './tags'
|
import { reKeyMediaItem } from './tags'
|
||||||
import { runAiTagging } from './ai-tagger'
|
import { runAiTagging } from './ai-tagger'
|
||||||
|
import { importComicMetadata } from './comic-metadata'
|
||||||
|
import { importTvMetadata } from './tv-metadata'
|
||||||
|
import { importMovieMetadata } from './movie-metadata'
|
||||||
|
|
||||||
let scanRunning = false
|
let scanRunning = false
|
||||||
|
|
||||||
@@ -70,6 +74,9 @@ export async function runLibraryScan(library: Library): Promise<void> {
|
|||||||
case 'mixed':
|
case 'mixed':
|
||||||
await scanMixed(library, libraryRoot)
|
await scanMixed(library, libraryRoot)
|
||||||
break
|
break
|
||||||
|
case 'comics':
|
||||||
|
await scanComics(library, libraryRoot)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
await runAiTagging(library, libraryRoot).catch((err) =>
|
await runAiTagging(library, libraryRoot).catch((err) =>
|
||||||
@@ -536,6 +543,149 @@ async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
console.log(`[scanner] mixed: indexed ${newItems.size} files`)
|
console.log(`[scanner] mixed: indexed ${newItems.size} files`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Comics (clear+upsert pattern — CBZ files are immutable archives)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function scanComics(library: Library, libraryRoot: string): Promise<void> {
|
||||||
|
const items = await scanComicsLibrary(libraryRoot, library.id)
|
||||||
|
const db = getDb()
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
clearLibraryItems(db, library.id)
|
||||||
|
|
||||||
|
const upsertSeries = db.prepare(`
|
||||||
|
INSERT INTO media_items (library_id, item_key, item_type, title, metadata, file_path, scanned_at)
|
||||||
|
VALUES (@library_id, @item_key, @item_type, @title, @metadata, @file_path, @scanned_at)
|
||||||
|
ON CONFLICT(item_key) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
metadata = excluded.metadata,
|
||||||
|
file_path = excluded.file_path,
|
||||||
|
scanned_at = excluded.scanned_at
|
||||||
|
`)
|
||||||
|
|
||||||
|
const upsertIssue = db.prepare(`
|
||||||
|
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, file_path, scanned_at)
|
||||||
|
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @metadata, @file_path, @scanned_at)
|
||||||
|
ON CONFLICT(item_key) DO UPDATE SET
|
||||||
|
parent_key = excluded.parent_key,
|
||||||
|
title = excluded.title,
|
||||||
|
metadata = excluded.metadata,
|
||||||
|
file_path = excluded.file_path,
|
||||||
|
scanned_at = excluded.scanned_at
|
||||||
|
`)
|
||||||
|
|
||||||
|
type SeriesRec = Parameters<typeof upsertSeries.run>[0]
|
||||||
|
type IssueRec = Parameters<typeof upsertIssue.run>[0]
|
||||||
|
type BatchEntry = { type: 'series'; rec: SeriesRec } | { type: 'issue'; rec: IssueRec }
|
||||||
|
|
||||||
|
// Collect all records before touching the DB so we can batch-insert with event-loop yields.
|
||||||
|
// Note: between clearLibraryItems and the final batch, the library will appear partially
|
||||||
|
// populated — acceptable for a background scan.
|
||||||
|
const allRecords: BatchEntry[] = []
|
||||||
|
let issueCount = 0
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if ('issues' in item) {
|
||||||
|
const series = item as ScannedComicSeries
|
||||||
|
const seriesKey = `${library.id}:comic_series:${series.id}`
|
||||||
|
allRecords.push({
|
||||||
|
type: 'series',
|
||||||
|
rec: {
|
||||||
|
library_id: library.id,
|
||||||
|
item_key: seriesKey,
|
||||||
|
item_type: 'comic_series',
|
||||||
|
title: series.title,
|
||||||
|
metadata: JSON.stringify({ issueCount: series.issueCount, coverUrl: series.coverUrl }),
|
||||||
|
file_path: null,
|
||||||
|
scanned_at: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for (const issue of series.issues) {
|
||||||
|
const issueKey = `${library.id}:comic_issue:${issue.id}`
|
||||||
|
allRecords.push({
|
||||||
|
type: 'issue',
|
||||||
|
rec: {
|
||||||
|
library_id: library.id,
|
||||||
|
item_key: issueKey,
|
||||||
|
item_type: 'comic_issue',
|
||||||
|
parent_key: seriesKey,
|
||||||
|
title: issue.title,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
issueNumber: issue.issueNumber,
|
||||||
|
pageCount: issue.pageCount,
|
||||||
|
coverUrl: issue.coverUrl,
|
||||||
|
isStandalone: false,
|
||||||
|
}),
|
||||||
|
file_path: issue.filePath,
|
||||||
|
scanned_at: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
issueCount++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const issue = item as ComicIssue
|
||||||
|
const issueKey = `${library.id}:comic_issue:${issue.id}`
|
||||||
|
allRecords.push({
|
||||||
|
type: 'issue',
|
||||||
|
rec: {
|
||||||
|
library_id: library.id,
|
||||||
|
item_key: issueKey,
|
||||||
|
item_type: 'comic_issue',
|
||||||
|
parent_key: null,
|
||||||
|
title: issue.title,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
issueNumber: issue.issueNumber,
|
||||||
|
pageCount: issue.pageCount,
|
||||||
|
coverUrl: issue.coverUrl,
|
||||||
|
isStandalone: true,
|
||||||
|
}),
|
||||||
|
file_path: issue.filePath,
|
||||||
|
scanned_at: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
issueCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert in batches of 500, yielding the event loop between batches so the app
|
||||||
|
// remains responsive to HTTP requests during a large scan.
|
||||||
|
const BATCH_SIZE = 500
|
||||||
|
for (let i = 0; i < allRecords.length; i += BATCH_SIZE) {
|
||||||
|
const batch = allRecords.slice(i, i + BATCH_SIZE)
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const entry of batch) {
|
||||||
|
if (entry.type === 'series') upsertSeries.run(entry.rec)
|
||||||
|
else upsertIssue.run(entry.rec)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
await new Promise<void>((r) => setImmediate(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prewarm CBZ cover thumbnails — fire-and-forget so they don't block scan completion.
|
||||||
|
for (const item of items) {
|
||||||
|
const issuesToWarm: ComicIssue[] = 'issues' in item
|
||||||
|
? (item as ScannedComicSeries).issues.slice(0, 1)
|
||||||
|
: [item as ComicIssue]
|
||||||
|
|
||||||
|
for (const issue of issuesToWarm) {
|
||||||
|
const absPath = path.join(libraryRoot, issue.filePath)
|
||||||
|
getCbzThumbnailPath(absPath, library.id).catch((err) => {
|
||||||
|
console.warn(`[scanner] Could not generate CBZ thumbnail for ${issue.filePath}:`, err instanceof Error ? err.message : err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[scanner] comics: indexed ${items.filter((i) => 'issues' in i).length} series, ${issueCount} issues`)
|
||||||
|
|
||||||
|
// Import ComicInfo.xml metadata (title, year, genres, tags)
|
||||||
|
try {
|
||||||
|
await importComicMetadata(library)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[scanner] Error importing comic metadata for "${library.name}":`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
102
src/lib/tags.ts
102
src/lib/tags.ts
@@ -98,6 +98,62 @@ export function deleteCategoryForce(id: string): void {
|
|||||||
if (result.changes === 0) throw new Error(`Category not found: ${id}`)
|
if (result.changes === 0) throw new Error(`Category not found: ${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge all tags from `sourceId` category into `targetId` category, then
|
||||||
|
* delete the source category. Tags with conflicting names (case-insensitive)
|
||||||
|
* are combined: their media_tags and tag_mappings rows are re-pointed to the
|
||||||
|
* target tag, and the source tag is deleted.
|
||||||
|
*/
|
||||||
|
export function mergeCategories(sourceId: string, targetId: string): void {
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
const source = db.prepare('SELECT id, name FROM tag_categories WHERE id = ?').get(sourceId) as TagCategory | undefined
|
||||||
|
if (!source) throw new Error(`Source category not found: ${sourceId}`)
|
||||||
|
const target = db.prepare('SELECT id, name FROM tag_categories WHERE id = ?').get(targetId) as TagCategory | undefined
|
||||||
|
if (!target) throw new Error(`Target category not found: ${targetId}`)
|
||||||
|
|
||||||
|
const sourceTags = db
|
||||||
|
.prepare('SELECT id, name, category_id as categoryId FROM tags WHERE category_id = ?')
|
||||||
|
.all(sourceId) as Tag[]
|
||||||
|
const targetTags = db
|
||||||
|
.prepare('SELECT id, name, category_id as categoryId FROM tags WHERE category_id = ?')
|
||||||
|
.all(targetId) as Tag[]
|
||||||
|
|
||||||
|
const targetTagsByNameLower = new Map(targetTags.map((t) => [t.name.toLowerCase(), t]))
|
||||||
|
|
||||||
|
const txn = db.transaction(() => {
|
||||||
|
for (const srcTag of sourceTags) {
|
||||||
|
const conflict = targetTagsByNameLower.get(srcTag.name.toLowerCase())
|
||||||
|
if (conflict) {
|
||||||
|
// Re-point media_tags from source tag to target tag (ignore duplicates)
|
||||||
|
db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO media_tags (item_key, tag_id)
|
||||||
|
SELECT item_key, ? FROM media_tags WHERE tag_id = ?`
|
||||||
|
).run(conflict.id, srcTag.id)
|
||||||
|
db.prepare('DELETE FROM media_tags WHERE tag_id = ?').run(srcTag.id)
|
||||||
|
|
||||||
|
// Re-point tag_mappings from source tag to target tag (ignore duplicates)
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE OR IGNORE tag_mappings SET tag_id = ? WHERE tag_id = ?`
|
||||||
|
).run(conflict.id, srcTag.id)
|
||||||
|
// Delete any remaining (were duplicates that couldn't be updated)
|
||||||
|
db.prepare('DELETE FROM tag_mappings WHERE tag_id = ?').run(srcTag.id)
|
||||||
|
|
||||||
|
// Delete the source tag
|
||||||
|
db.prepare('DELETE FROM tags WHERE id = ?').run(srcTag.id)
|
||||||
|
} else {
|
||||||
|
// No conflict — just move the tag to the target category
|
||||||
|
db.prepare('UPDATE tags SET category_id = ? WHERE id = ?').run(targetId, srcTag.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the now-empty source category
|
||||||
|
db.prepare('DELETE FROM tag_categories WHERE id = ?').run(sourceId)
|
||||||
|
})
|
||||||
|
|
||||||
|
txn()
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tags ─────────────────────────────────────────────────────────────────────
|
// ─── Tags ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getTags(categoryId?: string): Tag[] {
|
export function getTags(categoryId?: string): Tag[] {
|
||||||
@@ -263,6 +319,52 @@ export function getSeriesEpisodeTagMap(libraryId: string): Record<string, string
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns seriesItemKey -> { tagIds, issueTitles } aggregated from all issues in each series.
|
||||||
|
export function getComicsSeriesIssueMeta(
|
||||||
|
libraryId: string
|
||||||
|
): Record<string, { tagIds: string[]; issueTitles: string[] }> {
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
// All issues belonging to a series (parent_key is not null)
|
||||||
|
const issues = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT item_key, parent_key, title
|
||||||
|
FROM media_items
|
||||||
|
WHERE library_id = ? AND item_type = 'comic_issue' AND parent_key IS NOT NULL`
|
||||||
|
)
|
||||||
|
.all(libraryId) as { item_key: string; parent_key: string; title: string | null }[]
|
||||||
|
|
||||||
|
const result: Record<string, { tagIds: string[]; issueTitles: string[] }> = {}
|
||||||
|
const issueKeyToParent = new Map<string, string>()
|
||||||
|
|
||||||
|
for (const { item_key, parent_key, title } of issues) {
|
||||||
|
issueKeyToParent.set(item_key, parent_key)
|
||||||
|
const entry = (result[parent_key] ??= { tagIds: [], issueTitles: [] })
|
||||||
|
if (title) entry.issueTitles.push(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issueKeyToParent.size === 0) return result
|
||||||
|
|
||||||
|
// Tag assignments for those issues
|
||||||
|
const tagRows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT mt.item_key, mt.tag_id
|
||||||
|
FROM media_tags mt
|
||||||
|
JOIN media_items mi ON mi.item_key = mt.item_key
|
||||||
|
WHERE mi.library_id = ? AND mi.item_type = 'comic_issue' AND mi.parent_key IS NOT NULL`
|
||||||
|
)
|
||||||
|
.all(libraryId) as { item_key: string; tag_id: string }[]
|
||||||
|
|
||||||
|
for (const { item_key, tag_id } of tagRows) {
|
||||||
|
const parentKey = issueKeyToParent.get(item_key)
|
||||||
|
if (!parentKey) continue
|
||||||
|
const entry = result[parentKey]
|
||||||
|
if (entry && !entry.tagIds.includes(tag_id)) entry.tagIds.push(tag_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export function removeAllAssignmentsForLibrary(libraryId: string): void {
|
export function removeAllAssignmentsForLibrary(libraryId: string): void {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
db.prepare('DELETE FROM media_tags WHERE item_key LIKE ?').run(`${libraryId}:%`)
|
db.prepare('DELETE FROM media_tags WHERE item_key LIKE ?').run(`${libraryId}:%`)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import fs from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { spawn } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
|
import { extractFirstZipImage } from './zip-utils'
|
||||||
|
|
||||||
const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails')
|
const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails')
|
||||||
const THUMBNAIL_WIDTH = 400
|
const THUMBNAIL_WIDTH = 400
|
||||||
@@ -221,6 +222,33 @@ export async function getOcrImagePath(
|
|||||||
return cacheFile
|
return cacheFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CBZ_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the absolute path to a cached thumbnail JPEG for a CBZ archive.
|
||||||
|
* Extracts the first image entry (natural sort) from the ZIP and resizes it.
|
||||||
|
* Throws on failure — callers should map this to a 404.
|
||||||
|
*/
|
||||||
|
export async function getCbzThumbnailPath(
|
||||||
|
absoluteFilePath: string,
|
||||||
|
libraryId: string
|
||||||
|
): Promise<string> {
|
||||||
|
ensureCacheDir()
|
||||||
|
|
||||||
|
const key = cacheKey(libraryId, absoluteFilePath)
|
||||||
|
const cacheFile = path.join(CACHE_DIR, key + '.jpg')
|
||||||
|
|
||||||
|
const cached = getCachedPath(cacheFile, absoluteFilePath)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const buffer = await extractFirstZipImage(absoluteFilePath, CBZ_IMAGE_EXTENSIONS)
|
||||||
|
const tmp = cacheFile + '.tmp'
|
||||||
|
await sharp(buffer).resize(THUMBNAIL_WIDTH).jpeg({ quality: JPEG_QUALITY }).toFile(tmp)
|
||||||
|
fs.renameSync(tmp, cacheFile)
|
||||||
|
|
||||||
|
return cacheFile
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the absolute path to a cached thumbnail JPEG for the given file.
|
* Returns the absolute path to a cached thumbnail JPEG for the given file.
|
||||||
* Generates it on first call (or when the source has been modified).
|
* Generates it on first call (or when the source has been modified).
|
||||||
|
|||||||
142
src/lib/tv-metadata.ts
Normal file
142
src/lib/tv-metadata.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import type { Library } from '@/types'
|
||||||
|
import { getDb } from './db'
|
||||||
|
import { resolveLibraryRoot } from './libraries'
|
||||||
|
import { parseEpisodeNfo } from './nfo'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import NFO metadata for TV items (series, seasons, episodes) in a library.
|
||||||
|
* - For series: reads tvshow.nfo in the series folder
|
||||||
|
* - For episodes: reads .nfo file matching the video file
|
||||||
|
* - If importMetadataOnly=false: skip items that already have metadata (title/year/plot/genres)
|
||||||
|
* - If importMetadataOnly=true: update all items regardless of existing metadata
|
||||||
|
*/
|
||||||
|
export async function importTvMetadata(
|
||||||
|
library: Library,
|
||||||
|
importMetadataOnly: boolean = false
|
||||||
|
): Promise<{ imported: number; skipped: number }> {
|
||||||
|
const db = getDb()
|
||||||
|
const libraryRoot = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
let imported = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
// Process TV series
|
||||||
|
const series = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT item_key, file_path, title, year, plot, genres FROM media_items
|
||||||
|
WHERE library_id = ? AND item_type = 'tv_series' AND file_path IS NOT NULL`
|
||||||
|
)
|
||||||
|
.all(library.id) as Array<{ item_key: string; file_path: string; title: string | null; year: number | null; plot: string | null; genres: string | null }>
|
||||||
|
|
||||||
|
const updateSeriesItem = db.prepare(`
|
||||||
|
UPDATE media_items SET title = @title, year = @year, plot = @plot, genres = @genres
|
||||||
|
WHERE item_key = @item_key
|
||||||
|
`)
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const item of series) {
|
||||||
|
// Check if we should skip this item
|
||||||
|
if (!importMetadataOnly && hasMetadata(item)) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesPath = path.join(libraryRoot, item.file_path)
|
||||||
|
const nfoPath = path.join(seriesPath, 'tvshow.nfo')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(nfoPath)) {
|
||||||
|
const nfoData = parseEpisodeNfo(nfoPath) // Use episode parser as fallback, but mainly we need tvshow parser
|
||||||
|
// For now, we'll just mark as processed; series metadata comes from episodes usually
|
||||||
|
imported++
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Process TV episodes
|
||||||
|
const episodes = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT item_key, file_path, title, year, plot, genres FROM media_items
|
||||||
|
WHERE library_id = ? AND item_type = 'tv_episode' AND file_path IS NOT NULL`
|
||||||
|
)
|
||||||
|
.all(library.id) as Array<{ item_key: string; file_path: string; title: string | null; year: number | null; plot: string | null; genres: string | null }>
|
||||||
|
|
||||||
|
const updateEpisodeItem = db.prepare(`
|
||||||
|
UPDATE media_items SET title = @title, year = @year, plot = @plot, genres = @genres
|
||||||
|
WHERE item_key = @item_key
|
||||||
|
`)
|
||||||
|
|
||||||
|
const BATCH_SIZE = 50
|
||||||
|
for (let i = 0; i < episodes.length; i += BATCH_SIZE) {
|
||||||
|
const batch = episodes.slice(i, i + BATCH_SIZE)
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const item of batch) {
|
||||||
|
// Check if we should skip this item
|
||||||
|
if (!importMetadataOnly && hasMetadata(item)) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoPath = path.join(libraryRoot, item.file_path)
|
||||||
|
const dir = path.dirname(videoPath)
|
||||||
|
const baseNameWithoutExt = path.basename(videoPath, path.extname(videoPath))
|
||||||
|
const nfoPath = path.join(dir, `${baseNameWithoutExt}.nfo`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(nfoPath)) {
|
||||||
|
const nfoData = parseEpisodeNfo(nfoPath)
|
||||||
|
if (nfoData) {
|
||||||
|
updateEpisodeItem.run({
|
||||||
|
item_key: item.item_key,
|
||||||
|
title: nfoData.title ?? item.title,
|
||||||
|
year: nfoData.aired ? new Date(nfoData.aired).getFullYear() : null,
|
||||||
|
plot: nfoData.plot ?? item.plot,
|
||||||
|
genres: item.genres, // Keep existing genres for episodes
|
||||||
|
})
|
||||||
|
imported++
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
await new Promise<void>((r) => setImmediate(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[tv-metadata] Imported metadata for ${imported} episodes in "${library.name}" (${importMetadataOnly ? 'full' : 'incremental'})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return { imported, skipped }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a media item already has metadata populated.
|
||||||
|
* Returns true if ANY of: title, year, plot, or genres are populated.
|
||||||
|
*/
|
||||||
|
function hasMetadata(item: {
|
||||||
|
title: string | null
|
||||||
|
year: number | null
|
||||||
|
plot: string | null
|
||||||
|
genres: string | null
|
||||||
|
}): boolean {
|
||||||
|
if (item.title) return true
|
||||||
|
if (item.year) return true
|
||||||
|
if (item.plot) return true
|
||||||
|
if (item.genres) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
232
src/lib/zip-utils.ts
Normal file
232
src/lib/zip-utils.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { open } from 'fs/promises'
|
||||||
|
import type { FileHandle } from 'fs/promises'
|
||||||
|
import zlib from 'zlib'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
|
||||||
|
const inflateRaw = promisify(zlib.inflateRaw)
|
||||||
|
|
||||||
|
const EOCD_SIG = 0x06054b50
|
||||||
|
const CD_SIG = 0x02014b50
|
||||||
|
const LFH_SIG = 0x04034b50
|
||||||
|
|
||||||
|
export interface CdEntry {
|
||||||
|
name: string
|
||||||
|
compressionMethod: number
|
||||||
|
compressedSize: number
|
||||||
|
uncompressedSize: number
|
||||||
|
localHeaderOffset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a ZIP file's central directory without loading the entire archive.
|
||||||
|
* Returns null if no EOCD record is found (corrupt/non-ZIP file).
|
||||||
|
* Returns an empty array for a valid but empty archive.
|
||||||
|
*/
|
||||||
|
async function readCentralDirectory(fd: FileHandle, fileSize: number): Promise<CdEntry[] | null> {
|
||||||
|
if (fileSize < 22) return null
|
||||||
|
|
||||||
|
// The EOCD record is within the last 65558 bytes (22-byte record + 65535-byte max comment).
|
||||||
|
const tailLen = Math.min(65558, fileSize)
|
||||||
|
const tailBuf = Buffer.allocUnsafe(tailLen)
|
||||||
|
await fd.read(tailBuf, 0, tailLen, fileSize - tailLen)
|
||||||
|
|
||||||
|
// Scan backwards for the EOCD signature.
|
||||||
|
let eocdOff = -1
|
||||||
|
for (let i = tailLen - 22; i >= 0; i--) {
|
||||||
|
if (tailBuf.readUInt32LE(i) === EOCD_SIG) { eocdOff = i; break }
|
||||||
|
}
|
||||||
|
if (eocdOff === -1) return null // no EOCD → corrupt
|
||||||
|
|
||||||
|
const entryCount = tailBuf.readUInt16LE(eocdOff + 10)
|
||||||
|
const cdSize = tailBuf.readUInt32LE(eocdOff + 12)
|
||||||
|
const cdOffset = tailBuf.readUInt32LE(eocdOff + 16)
|
||||||
|
if (entryCount === 0) return [] // valid empty archive
|
||||||
|
if (cdOffset + cdSize > fileSize || cdSize === 0) return null // malformed
|
||||||
|
|
||||||
|
const cdBuf = Buffer.allocUnsafe(cdSize)
|
||||||
|
await fd.read(cdBuf, 0, cdSize, cdOffset)
|
||||||
|
|
||||||
|
const entries: CdEntry[] = []
|
||||||
|
let pos = 0
|
||||||
|
for (let i = 0; i < entryCount && pos + 46 <= cdBuf.length; i++) {
|
||||||
|
if (cdBuf.readUInt32LE(pos) !== CD_SIG) break
|
||||||
|
const compressionMethod = cdBuf.readUInt16LE(pos + 10)
|
||||||
|
const compressedSize = cdBuf.readUInt32LE(pos + 20)
|
||||||
|
const uncompressedSize = cdBuf.readUInt32LE(pos + 24)
|
||||||
|
const filenameLen = cdBuf.readUInt16LE(pos + 28)
|
||||||
|
const extraLen = cdBuf.readUInt16LE(pos + 30)
|
||||||
|
const commentLen = cdBuf.readUInt16LE(pos + 32)
|
||||||
|
const localHeaderOffset = cdBuf.readUInt32LE(pos + 42)
|
||||||
|
const name = cdBuf.toString('utf8', pos + 46, pos + 46 + filenameLen)
|
||||||
|
entries.push({ name, compressionMethod, compressedSize, uncompressedSize, localHeaderOffset })
|
||||||
|
pos += 46 + filenameLen + extraLen + commentLen
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when a ZIP archive has no valid End-of-Central-Directory record. */
|
||||||
|
export class CorruptZipError extends Error {
|
||||||
|
readonly code = 'ERR_CORRUPT_ZIP'
|
||||||
|
constructor(absolutePath: string) {
|
||||||
|
super(`Corrupt or invalid ZIP archive: ${absolutePath}`)
|
||||||
|
this.name = 'CorruptZipError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCorruptZipError(err: unknown): err is CorruptZipError {
|
||||||
|
return err instanceof CorruptZipError ||
|
||||||
|
(err instanceof Error && (err as CorruptZipError).code === 'ERR_CORRUPT_ZIP')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the number of image entries inside a ZIP/CBZ archive by reading
|
||||||
|
* only its central directory — no full-file read required.
|
||||||
|
* Returns { pageCount, valid } where valid=false means the archive has no
|
||||||
|
* valid EOCD record (corrupt file).
|
||||||
|
*/
|
||||||
|
export async function countZipImages(
|
||||||
|
absolutePath: string,
|
||||||
|
imageExtensions: Set<string>
|
||||||
|
): Promise<{ pageCount: number; valid: boolean }> {
|
||||||
|
let fd: FileHandle | null = null
|
||||||
|
try {
|
||||||
|
fd = await open(absolutePath, 'r')
|
||||||
|
const { size } = await fd.stat()
|
||||||
|
const entries = await readCentralDirectory(fd, size)
|
||||||
|
if (entries === null) return { pageCount: 0, valid: false }
|
||||||
|
const pageCount = entries.filter((e) => {
|
||||||
|
if (e.name.endsWith('/')) return false
|
||||||
|
const dot = e.name.lastIndexOf('.')
|
||||||
|
return dot !== -1 && imageExtensions.has(e.name.slice(dot).toLowerCase())
|
||||||
|
}).length
|
||||||
|
return { pageCount, valid: true }
|
||||||
|
} catch {
|
||||||
|
return { pageCount: 0, valid: false }
|
||||||
|
} finally {
|
||||||
|
await fd?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the raw bytes of a specific entry from a ZIP archive.
|
||||||
|
* Reads only the local file header + compressed data for that entry.
|
||||||
|
* Supports stored (method 0) and deflate (method 8).
|
||||||
|
*/
|
||||||
|
export async function extractZipEntry(absolutePath: string, entry: CdEntry): Promise<Buffer | null> {
|
||||||
|
let fd: FileHandle | null = null
|
||||||
|
try {
|
||||||
|
fd = await open(absolutePath, 'r')
|
||||||
|
|
||||||
|
// Read local file header (30 bytes) to get exact data offset.
|
||||||
|
const lfhBuf = Buffer.allocUnsafe(30)
|
||||||
|
await fd.read(lfhBuf, 0, 30, entry.localHeaderOffset)
|
||||||
|
if (lfhBuf.readUInt32LE(0) !== LFH_SIG) return null
|
||||||
|
const localFilenameLen = lfhBuf.readUInt16LE(26)
|
||||||
|
const localExtraLen = lfhBuf.readUInt16LE(28)
|
||||||
|
const dataOffset = entry.localHeaderOffset + 30 + localFilenameLen + localExtraLen
|
||||||
|
|
||||||
|
const compressedBuf = Buffer.allocUnsafe(entry.compressedSize)
|
||||||
|
await fd.read(compressedBuf, 0, entry.compressedSize, dataOffset)
|
||||||
|
|
||||||
|
if (entry.compressionMethod === 0) return compressedBuf
|
||||||
|
if (entry.compressionMethod === 8) return await inflateRaw(compressedBuf) as Buffer
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
await fd?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a named entry (case-insensitive) in a ZIP archive's central directory.
|
||||||
|
* Returns null if not found or on error.
|
||||||
|
*/
|
||||||
|
export async function findZipEntry(absolutePath: string, entryName: string): Promise<CdEntry | null> {
|
||||||
|
let fd: FileHandle | null = null
|
||||||
|
try {
|
||||||
|
fd = await open(absolutePath, 'r')
|
||||||
|
const { size } = await fd.stat()
|
||||||
|
const entries = await readCentralDirectory(fd, size)
|
||||||
|
if (!entries) return null
|
||||||
|
const lower = entryName.toLowerCase()
|
||||||
|
return entries.find((e) => {
|
||||||
|
const n = e.name.toLowerCase()
|
||||||
|
return n === lower || n.endsWith('/' + lower)
|
||||||
|
}) ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
await fd?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the first image entry (natural sort) from a ZIP/CBZ archive.
|
||||||
|
* Reads only the central directory and the single chosen entry — no full-file load.
|
||||||
|
* Throws CorruptZipError if the archive has no valid structure.
|
||||||
|
*/
|
||||||
|
export async function extractFirstZipImage(
|
||||||
|
absolutePath: string,
|
||||||
|
imageExtensions: Set<string>
|
||||||
|
): Promise<Buffer> {
|
||||||
|
let fd: FileHandle | null = null
|
||||||
|
try {
|
||||||
|
fd = await open(absolutePath, 'r')
|
||||||
|
const { size } = await fd.stat()
|
||||||
|
const entries = await readCentralDirectory(fd, size)
|
||||||
|
if (entries === null) throw new CorruptZipError(absolutePath)
|
||||||
|
|
||||||
|
const imageEntries = entries
|
||||||
|
.filter((e) => {
|
||||||
|
if (e.name.endsWith('/')) return false
|
||||||
|
const dot = e.name.lastIndexOf('.')
|
||||||
|
return dot !== -1 && imageExtensions.has(e.name.slice(dot).toLowerCase())
|
||||||
|
})
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
)
|
||||||
|
|
||||||
|
if (imageEntries.length === 0) throw new Error(`No image entries in archive: ${absolutePath}`)
|
||||||
|
|
||||||
|
const entry = imageEntries[0]
|
||||||
|
|
||||||
|
// Read local file header to get the exact data offset.
|
||||||
|
const lfhBuf = Buffer.allocUnsafe(30)
|
||||||
|
await fd.read(lfhBuf, 0, 30, entry.localHeaderOffset)
|
||||||
|
if (lfhBuf.readUInt32LE(0) !== LFH_SIG) throw new CorruptZipError(absolutePath)
|
||||||
|
const localFilenameLen = lfhBuf.readUInt16LE(26)
|
||||||
|
const localExtraLen = lfhBuf.readUInt16LE(28)
|
||||||
|
const dataOffset = entry.localHeaderOffset + 30 + localFilenameLen + localExtraLen
|
||||||
|
|
||||||
|
const compressedBuf = Buffer.allocUnsafe(entry.compressedSize)
|
||||||
|
await fd.read(compressedBuf, 0, entry.compressedSize, dataOffset)
|
||||||
|
|
||||||
|
if (entry.compressionMethod === 0) return compressedBuf
|
||||||
|
if (entry.compressionMethod === 8) return await inflateRaw(compressedBuf) as Buffer
|
||||||
|
throw new Error(`Unsupported compression method ${entry.compressionMethod}: ${absolutePath}`)
|
||||||
|
} finally {
|
||||||
|
await fd?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an array of items concurrently with a concurrency limit.
|
||||||
|
* Preserves index order in results.
|
||||||
|
*/
|
||||||
|
export async function mapConcurrent<T, U>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T) => Promise<U>
|
||||||
|
): Promise<U[]> {
|
||||||
|
const results: U[] = new Array(items.length)
|
||||||
|
let next = 0
|
||||||
|
async function worker(): Promise<void> {
|
||||||
|
while (next < items.length) {
|
||||||
|
const i = next++
|
||||||
|
results[i] = await fn(items[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
||||||
|
return results
|
||||||
|
}
|
||||||
@@ -1,4 +1,23 @@
|
|||||||
export type LibraryType = 'games' | 'mixed' | 'movies' | 'tv'
|
export type LibraryType = 'comics' | 'games' | 'mixed' | 'movies' | 'tv'
|
||||||
|
|
||||||
|
export interface ComicSeries {
|
||||||
|
id: string
|
||||||
|
item_key?: string
|
||||||
|
title: string
|
||||||
|
coverUrl: string | null
|
||||||
|
issueCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComicIssue {
|
||||||
|
id: string
|
||||||
|
item_key?: string
|
||||||
|
title: string
|
||||||
|
issueNumber: number | null
|
||||||
|
pageCount: number
|
||||||
|
coverUrl: string | null
|
||||||
|
filePath: string
|
||||||
|
isStandalone: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface Library {
|
export interface Library {
|
||||||
id: string
|
id: string
|
||||||
@@ -127,3 +146,32 @@ export interface UserSettings {
|
|||||||
tvLoop: boolean
|
tvLoop: boolean
|
||||||
tvMuted: boolean
|
tvMuted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ComicInfoData {
|
||||||
|
title: string | null
|
||||||
|
year: number | null
|
||||||
|
month: number | null
|
||||||
|
day: number | null
|
||||||
|
writer: string | null
|
||||||
|
translator: string | null
|
||||||
|
publisher: string | null
|
||||||
|
genre: string | null
|
||||||
|
tags: string[]
|
||||||
|
web: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportedTag {
|
||||||
|
id: string
|
||||||
|
libraryId: string
|
||||||
|
name: string
|
||||||
|
itemCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagMapping {
|
||||||
|
id: string
|
||||||
|
libraryId: string
|
||||||
|
importedTagName: string
|
||||||
|
tagId: string
|
||||||
|
tagName?: string
|
||||||
|
categoryName?: string
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user