initial commit
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@meta-fuse/core",
|
||||
"version": "1.0.0",
|
||||
"description": "Core FUSE API server for meta-fuse",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/index.js",
|
||||
"test": "mocha",
|
||||
"lint": "eslint src --ext ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"dotenv": "^16.4.1",
|
||||
"fastify": "^5.6.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"tslog": "^4.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.16",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
Generated
+856
@@ -0,0 +1,856 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@fastify/cors':
|
||||
specifier: ^11.1.0
|
||||
version: 11.2.0
|
||||
'@fastify/static':
|
||||
specifier: ^8.0.0
|
||||
version: 8.3.0
|
||||
dotenv:
|
||||
specifier: ^16.4.1
|
||||
version: 16.6.1
|
||||
fastify:
|
||||
specifier: ^5.6.1
|
||||
version: 5.6.2
|
||||
ioredis:
|
||||
specifier: ^5.4.1
|
||||
version: 5.8.2
|
||||
tslog:
|
||||
specifier: ^4.9.2
|
||||
version: 4.10.2
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.11.16
|
||||
version: 20.19.27
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@fastify/[email protected]':
|
||||
resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
|
||||
|
||||
'@fastify/[email protected]':
|
||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
||||
|
||||
'@fastify/[email protected]':
|
||||
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
||||
|
||||
'@fastify/[email protected]':
|
||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||
|
||||
'@fastify/[email protected]':
|
||||
resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==}
|
||||
|
||||
'@fastify/[email protected]':
|
||||
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
|
||||
|
||||
'@fastify/[email protected]':
|
||||
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
||||
|
||||
'@fastify/[email protected]':
|
||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||
|
||||
'@fastify/[email protected]':
|
||||
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
|
||||
|
||||
'@fastify/[email protected]':
|
||||
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
|
||||
|
||||
'@ioredis/[email protected]':
|
||||
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
|
||||
|
||||
'@isaacs/[email protected]':
|
||||
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
'@isaacs/[email protected]':
|
||||
resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
'@isaacs/[email protected]':
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@lukeed/[email protected]':
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@pinojs/[email protected]':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
ajv: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
ajv:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
|
||||
engines: {node: 20 || >=22}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@fastify/[email protected]': {}
|
||||
|
||||
'@fastify/[email protected]':
|
||||
dependencies:
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1([email protected])
|
||||
fast-uri: 3.1.0
|
||||
|
||||
'@fastify/[email protected]':
|
||||
dependencies:
|
||||
fastify-plugin: 5.1.0
|
||||
toad-cache: 3.7.0
|
||||
|
||||
'@fastify/[email protected]': {}
|
||||
|
||||
'@fastify/[email protected]':
|
||||
dependencies:
|
||||
fast-json-stringify: 6.1.1
|
||||
|
||||
'@fastify/[email protected]': {}
|
||||
|
||||
'@fastify/[email protected]':
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
'@fastify/[email protected]':
|
||||
dependencies:
|
||||
'@fastify/forwarded': 3.0.1
|
||||
ipaddr.js: 2.3.0
|
||||
|
||||
'@fastify/[email protected]':
|
||||
dependencies:
|
||||
'@lukeed/ms': 2.0.2
|
||||
escape-html: 1.0.3
|
||||
fast-decode-uri-component: 1.0.1
|
||||
http-errors: 2.0.1
|
||||
mime: 3.0.0
|
||||
|
||||
'@fastify/[email protected]':
|
||||
dependencies:
|
||||
'@fastify/accept-negotiator': 2.0.1
|
||||
'@fastify/send': 4.1.0
|
||||
content-disposition: 0.5.4
|
||||
fastify-plugin: 5.1.0
|
||||
fastq: 1.20.1
|
||||
glob: 11.1.0
|
||||
|
||||
'@ioredis/[email protected]': {}
|
||||
|
||||
'@isaacs/[email protected]': {}
|
||||
|
||||
'@isaacs/[email protected]':
|
||||
dependencies:
|
||||
'@isaacs/balanced-match': 4.0.1
|
||||
|
||||
'@isaacs/[email protected]':
|
||||
dependencies:
|
||||
string-width: 5.1.2
|
||||
string-width-cjs: [email protected]
|
||||
strip-ansi: 7.1.2
|
||||
strip-ansi-cjs: [email protected]
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: [email protected]
|
||||
|
||||
'@lukeed/[email protected]': {}
|
||||
|
||||
'@pinojs/[email protected]': {}
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
optionalDependencies:
|
||||
ajv: 8.17.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.0
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@fastify/error': 4.2.0
|
||||
fastq: 1.20.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@fastify/merge-json-schemas': 0.2.1
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1([email protected])
|
||||
fast-uri: 3.1.0
|
||||
json-schema-ref-resolver: 3.0.0
|
||||
rfdc: 1.4.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
fast-decode-uri-component: 1.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.5
|
||||
'@fastify/error': 4.2.0
|
||||
'@fastify/fast-json-stringify-compiler': 5.0.3
|
||||
'@fastify/proxy-addr': 5.1.0
|
||||
abstract-logging: 2.0.1
|
||||
avvio: 9.1.0
|
||||
fast-json-stringify: 6.1.1
|
||||
find-my-way: 9.3.0
|
||||
light-my-request: 6.6.0
|
||||
pino: 10.1.0
|
||||
process-warning: 5.0.0
|
||||
rfdc: 1.4.1
|
||||
secure-json-parse: 4.1.0
|
||||
semver: 7.7.3
|
||||
toad-cache: 3.7.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-querystring: 1.1.2
|
||||
safe-regex2: 5.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
foreground-child: 3.3.1
|
||||
jackspeak: 4.1.1
|
||||
minimatch: 10.1.1
|
||||
minipass: 7.1.2
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 2.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
inherits: 2.0.4
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.4.0
|
||||
cluster-key-slot: 1.1.2
|
||||
debug: 4.4.3
|
||||
denque: 2.1.0
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.isarguments: 3.1.0
|
||||
redis-errors: 1.2.0
|
||||
redis-parser: 3.0.0
|
||||
standard-as-callback: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
cookie: 1.1.1
|
||||
process-warning: 4.0.1
|
||||
set-cookie-parser: 2.7.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@isaacs/brace-expansion': 5.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
lru-cache: 11.2.4
|
||||
minipass: 7.1.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@pinojs/redact': 0.4.0
|
||||
atomic-sleep: 1.0.0
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 2.0.0
|
||||
pino-std-serializers: 7.0.0
|
||||
process-warning: 5.0.0
|
||||
quick-format-unescaped: 4.0.4
|
||||
real-require: 0.2.0
|
||||
safe-stable-stringify: 2.5.0
|
||||
sonic-boom: 4.2.0
|
||||
thread-stream: 3.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
redis-errors: 1.2.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ret: 0.5.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
atomic-sleep: 1.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
eastasianwidth: 0.2.0
|
||||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
real-require: 0.2.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 5.1.2
|
||||
strip-ansi: 7.1.2
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Fastify API Server for FUSE operations
|
||||
*
|
||||
* Exposes the VirtualFileSystem over HTTP for consumption by the FUSE driver
|
||||
*/
|
||||
|
||||
import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { Logger } from 'tslog';
|
||||
import { VirtualFileSystem } from '../vfs/VirtualFileSystem.js';
|
||||
import type { KVManager } from '../kv/KVManager.js';
|
||||
import type { RenamingConfig, RenamingRule, PreviewResponse } from '../vfs/types/RenamingRuleTypes.js';
|
||||
import { TEMPLATE_VARIABLES } from '../vfs/types/RenamingRuleTypes.js';
|
||||
import { TemplateEngine } from '../vfs/template/TemplateEngine.js';
|
||||
import { ConditionEvaluator } from '../vfs/template/ConditionEvaluator.js';
|
||||
|
||||
const logger = new Logger({ name: 'APIServer' });
|
||||
|
||||
export interface APIServerConfig {
|
||||
port?: number;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
interface PathBody {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export class APIServer {
|
||||
private app: FastifyInstance;
|
||||
private vfs: VirtualFileSystem;
|
||||
private kvManager: KVManager | null = null;
|
||||
private config: Required<APIServerConfig>;
|
||||
|
||||
constructor(vfs: VirtualFileSystem, config: APIServerConfig = {}, kvManager?: KVManager) {
|
||||
this.vfs = vfs;
|
||||
this.kvManager = kvManager || null;
|
||||
this.config = {
|
||||
port: config.port ?? parseInt(process.env.API_PORT ?? '3000', 10),
|
||||
host: config.host ?? process.env.API_HOST ?? '0.0.0.0',
|
||||
};
|
||||
|
||||
this.app = Fastify({
|
||||
logger: false,
|
||||
});
|
||||
|
||||
this.setupRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup API routes
|
||||
*/
|
||||
private setupRoutes(): void {
|
||||
// CORS
|
||||
this.app.register(cors, {
|
||||
origin: true,
|
||||
methods: ['GET', 'POST', 'OPTIONS'],
|
||||
});
|
||||
|
||||
// Health check
|
||||
this.app.get('/health', this.handleHealth.bind(this));
|
||||
this.app.get('/api/health', this.handleHealth.bind(this));
|
||||
this.app.get('/api/fuse/health', this.handleHealth.bind(this));
|
||||
|
||||
// Stats
|
||||
this.app.get('/api/fuse/stats', this.handleStats.bind(this));
|
||||
|
||||
// FUSE operations
|
||||
this.app.post('/api/fuse/readdir', this.handleReaddir.bind(this));
|
||||
this.app.post('/api/fuse/getattr', this.handleGetattr.bind(this));
|
||||
this.app.post('/api/fuse/exists', this.handleExists.bind(this));
|
||||
this.app.post('/api/fuse/read', this.handleRead.bind(this));
|
||||
this.app.post('/api/fuse/metadata', this.handleMetadata.bind(this));
|
||||
|
||||
// Directory listing endpoints
|
||||
this.app.get('/api/fuse/files', this.handleFiles.bind(this));
|
||||
this.app.get('/api/fuse/directories', this.handleDirectories.bind(this));
|
||||
|
||||
// Refresh
|
||||
this.app.post('/api/fuse/refresh', this.handleRefresh.bind(this));
|
||||
|
||||
// Renaming rules configuration
|
||||
this.app.get('/api/fuse/rules', this.handleGetRules.bind(this));
|
||||
this.app.put('/api/fuse/rules', this.handleUpdateRules.bind(this));
|
||||
this.app.post('/api/fuse/rules/preview', this.handlePreviewRules.bind(this));
|
||||
this.app.post('/api/fuse/rules/validate', this.handleValidateRule.bind(this));
|
||||
this.app.get('/api/fuse/rules/variables', this.handleGetVariables.bind(this));
|
||||
|
||||
// Service discovery (for inter-service navigation)
|
||||
if (this.kvManager) {
|
||||
this.app.get('/api/services', this.handleServices.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the server
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
await this.app.listen({
|
||||
port: this.config.port,
|
||||
host: this.config.host,
|
||||
});
|
||||
logger.info(`API Server listening on http://${this.config.host}:${this.config.port}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start API server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
await this.app.close();
|
||||
logger.info('API Server stopped');
|
||||
}
|
||||
|
||||
// Route handlers
|
||||
|
||||
private async handleHealth(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
reply.send({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'meta-fuse',
|
||||
});
|
||||
}
|
||||
|
||||
private async handleStats(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const stats = this.vfs.getStats();
|
||||
reply.send(stats);
|
||||
}
|
||||
|
||||
private async handleReaddir(
|
||||
req: FastifyRequest<{ Body: PathBody }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const { path } = req.body;
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = this.vfs.readdir(path);
|
||||
|
||||
if (entries === null) {
|
||||
reply.status(404).send({ error: 'Directory not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
reply.send({ entries });
|
||||
}
|
||||
|
||||
private async handleGetattr(
|
||||
req: FastifyRequest<{ Body: PathBody }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const { path } = req.body;
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const attrs = this.vfs.getattr(path);
|
||||
|
||||
if (attrs === null) {
|
||||
reply.status(404).send({ error: 'Path not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
reply.send(attrs);
|
||||
}
|
||||
|
||||
private async handleExists(
|
||||
req: FastifyRequest<{ Body: PathBody }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const { path } = req.body;
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = this.vfs.exists(path);
|
||||
reply.send({ exists });
|
||||
}
|
||||
|
||||
private async handleRead(
|
||||
req: FastifyRequest<{ Body: PathBody }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const { path } = req.body;
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = this.vfs.read(path);
|
||||
|
||||
if (result === null) {
|
||||
reply.status(404).send({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Return source path for FUSE driver to read from
|
||||
const response: Record<string, unknown> = {
|
||||
sourcePath: result.sourcePath,
|
||||
size: result.size,
|
||||
};
|
||||
|
||||
// If content is provided (virtual file), encode as base64
|
||||
if (result.content !== null) {
|
||||
response.content = result.content.toString('base64');
|
||||
response.contentEncoding = 'base64';
|
||||
}
|
||||
|
||||
reply.send(response);
|
||||
}
|
||||
|
||||
private async handleMetadata(
|
||||
req: FastifyRequest<{ Body: PathBody }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const { path } = req.body;
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = this.vfs.getMetadata(path);
|
||||
|
||||
if (metadata === null) {
|
||||
reply.status(404).send({ error: 'Metadata not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
reply.send(metadata);
|
||||
}
|
||||
|
||||
private async handleFiles(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const files = this.vfs.getAllFiles();
|
||||
reply.send({ files });
|
||||
}
|
||||
|
||||
private async handleDirectories(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const directories = this.vfs.getAllDirectories();
|
||||
reply.send({ directories });
|
||||
}
|
||||
|
||||
private async handleRefresh(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
await this.vfs.refresh();
|
||||
reply.send({ status: 'ok' });
|
||||
}
|
||||
|
||||
private async handleServices(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
interface ServiceResponse {
|
||||
name: string;
|
||||
url: string;
|
||||
api: string;
|
||||
status: string;
|
||||
capabilities: string[];
|
||||
version: string;
|
||||
}
|
||||
|
||||
const services: ServiceResponse[] = [];
|
||||
let leaderInfo: { host: string; api: string; http: string } | null = null;
|
||||
|
||||
try {
|
||||
const serviceDiscovery = this.kvManager?.getServiceDiscovery();
|
||||
if (serviceDiscovery) {
|
||||
const allServices = await serviceDiscovery.discoverAllServices();
|
||||
|
||||
for (const svc of allServices) {
|
||||
// Build dashboard URL from API URL
|
||||
const apiUrl = svc.api || '';
|
||||
const dashboardPath = svc.endpoints?.dashboard || '/';
|
||||
|
||||
services.push({
|
||||
name: svc.name || 'Unknown',
|
||||
url: apiUrl + dashboardPath,
|
||||
api: apiUrl,
|
||||
status: svc.status || 'unknown',
|
||||
capabilities: svc.capabilities || [],
|
||||
version: svc.version || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error discovering services:', error);
|
||||
}
|
||||
|
||||
// Get leader info
|
||||
if (this.kvManager) {
|
||||
const info = this.kvManager.getLeaderInfo();
|
||||
if (info) {
|
||||
leaderInfo = {
|
||||
host: info.host,
|
||||
api: info.api,
|
||||
http: info.http,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
reply.send({
|
||||
services,
|
||||
current: 'meta-fuse',
|
||||
leader: leaderInfo,
|
||||
isLeader: false, // meta-fuse is never the leader
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Renaming Rules API Handlers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get current renaming rules configuration
|
||||
*/
|
||||
private async handleGetRules(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const config = this.vfs.getRulesConfig();
|
||||
reply.send({
|
||||
config,
|
||||
lastModified: config.lastModified || new Date().toISOString(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get rules:', error);
|
||||
reply.status(500).send({ error: 'Failed to get rules configuration' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update renaming rules configuration
|
||||
*/
|
||||
private async handleUpdateRules(
|
||||
req: FastifyRequest<{ Body: { config: RenamingConfig } }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { config } = req.body;
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
reply.status(400).send({ error: 'Missing or invalid config in request body' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate rules
|
||||
const errors: string[] = [];
|
||||
const templateEngine = new TemplateEngine();
|
||||
const conditionEvaluator = new ConditionEvaluator();
|
||||
|
||||
for (const rule of config.rules) {
|
||||
// Validate template
|
||||
const templateValidation = templateEngine.validate(rule.template);
|
||||
if (!templateValidation.valid) {
|
||||
errors.push(`Rule "${rule.name}": ${templateValidation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate conditions
|
||||
const conditionValidation = conditionEvaluator.validate(rule.conditions);
|
||||
if (!conditionValidation.valid) {
|
||||
errors.push(`Rule "${rule.name}": ${conditionValidation.errors.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
reply.status(400).send({ success: false, errors });
|
||||
return;
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
this.vfs.saveRulesConfig(config);
|
||||
|
||||
// Trigger immediate VFS refresh
|
||||
await this.vfs.refresh();
|
||||
|
||||
reply.send({ success: true, refreshed: true });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to update rules:', error);
|
||||
reply.status(500).send({ error: 'Failed to update rules configuration' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview how files would be renamed with given rules
|
||||
*/
|
||||
private async handlePreviewRules(
|
||||
req: FastifyRequest<{ Body: { rules?: RenamingRule[]; limit?: number } }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { rules, limit = 100 } = req.body || {};
|
||||
const preview = await this.vfs.previewRules(rules, limit);
|
||||
reply.send(preview);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to preview rules:', error);
|
||||
reply.status(500).send({ error: 'Failed to generate preview' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single rule
|
||||
*/
|
||||
private async handleValidateRule(
|
||||
req: FastifyRequest<{ Body: { rule: RenamingRule; sampleMetadata?: Record<string, unknown> } }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { rule, sampleMetadata } = req.body;
|
||||
|
||||
if (!rule || typeof rule !== 'object') {
|
||||
reply.status(400).send({ error: 'Missing or invalid rule in request body' });
|
||||
return;
|
||||
}
|
||||
|
||||
const templateEngine = new TemplateEngine();
|
||||
const conditionEvaluator = new ConditionEvaluator();
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate template
|
||||
const templateValidation = templateEngine.validate(rule.template);
|
||||
errors.push(...templateValidation.errors);
|
||||
warnings.push(...templateValidation.warnings);
|
||||
|
||||
// Validate conditions
|
||||
const conditionValidation = conditionEvaluator.validate(rule.conditions);
|
||||
errors.push(...conditionValidation.errors);
|
||||
|
||||
// Generate sample output if metadata provided
|
||||
let sampleOutput: string | undefined;
|
||||
if (sampleMetadata && errors.length === 0) {
|
||||
sampleOutput = templateEngine.interpolate(rule.template, sampleMetadata) || undefined;
|
||||
}
|
||||
|
||||
reply.send({
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
sampleOutput,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to validate rule:', error);
|
||||
reply.status(500).send({ error: 'Failed to validate rule' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available template variables
|
||||
*/
|
||||
private async handleGetVariables(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
reply.send({ variables: TEMPLATE_VARIABLES });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* ConfigStorage - File-based configuration storage
|
||||
*
|
||||
* Stores configuration (like renaming rules) in JSON files
|
||||
* instead of Redis, which should only be used for metadata.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Logger } from 'tslog';
|
||||
import type { RenamingConfig } from '../vfs/types/RenamingRuleTypes.js';
|
||||
import { getDefaultRulesConfig } from '../vfs/defaults/defaultRules.js';
|
||||
|
||||
const logger = new Logger({ name: 'ConfigStorage' });
|
||||
|
||||
export interface ConfigStorageOptions {
|
||||
configDir: string; // Directory to store config files
|
||||
}
|
||||
|
||||
export class ConfigStorage {
|
||||
private configDir: string;
|
||||
private rulesFile: string;
|
||||
|
||||
constructor(options: ConfigStorageOptions) {
|
||||
this.configDir = options.configDir;
|
||||
this.rulesFile = path.join(this.configDir, 'renaming-rules.json');
|
||||
|
||||
// Ensure config directory exists
|
||||
this.ensureConfigDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the config directory exists
|
||||
*/
|
||||
private ensureConfigDir(): void {
|
||||
try {
|
||||
if (!fs.existsSync(this.configDir)) {
|
||||
fs.mkdirSync(this.configDir, { recursive: true });
|
||||
logger.info(`Created config directory: ${this.configDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create config directory: ${this.configDir}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get renaming rules configuration from file
|
||||
* If no config file exists, creates one with default rules
|
||||
*
|
||||
* @returns The stored RenamingConfig
|
||||
*/
|
||||
getRulesConfig(): RenamingConfig {
|
||||
try {
|
||||
if (!fs.existsSync(this.rulesFile)) {
|
||||
logger.info('No renaming rules config file found, creating default');
|
||||
const defaultConfig = this.createDefaultConfig();
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(this.rulesFile, 'utf-8');
|
||||
const config = JSON.parse(data) as RenamingConfig;
|
||||
logger.debug(`Loaded renaming rules config with ${config.rules.length} rules`);
|
||||
return config;
|
||||
} catch (error) {
|
||||
logger.error('Failed to read rules config, using defaults:', error);
|
||||
return this.createDefaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and save the default configuration file
|
||||
*/
|
||||
private createDefaultConfig(): RenamingConfig {
|
||||
const defaultConfig = getDefaultRulesConfig();
|
||||
|
||||
// Mark as default configuration
|
||||
const configWithDefault: RenamingConfig = {
|
||||
...defaultConfig,
|
||||
isDefault: true,
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Save to file so user can see/edit it
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
this.rulesFile,
|
||||
JSON.stringify(configWithDefault, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
logger.info('Created default renaming rules config file');
|
||||
} catch (error) {
|
||||
logger.error('Failed to create default config file:', error);
|
||||
}
|
||||
|
||||
return configWithDefault;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save renaming rules configuration to file
|
||||
* Removes the isDefault field since this is now user-customized
|
||||
*
|
||||
* @param config The RenamingConfig to save
|
||||
*/
|
||||
saveRulesConfig(config: RenamingConfig): void {
|
||||
try {
|
||||
// Create backup of current config if it exists
|
||||
if (fs.existsSync(this.rulesFile)) {
|
||||
const backupFile = path.join(
|
||||
this.configDir,
|
||||
`renaming-rules.backup.${Date.now()}.json`
|
||||
);
|
||||
fs.copyFileSync(this.rulesFile, backupFile);
|
||||
logger.debug(`Created backup: ${backupFile}`);
|
||||
|
||||
// Clean up old backups (keep last 5)
|
||||
this.cleanupBackups();
|
||||
}
|
||||
|
||||
// Remove isDefault flag (user is customizing) and update timestamp
|
||||
const { isDefault, ...configWithoutDefault } = config;
|
||||
const configToSave: RenamingConfig = {
|
||||
...configWithoutDefault,
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Save new config with pretty formatting
|
||||
fs.writeFileSync(
|
||||
this.rulesFile,
|
||||
JSON.stringify(configToSave, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
logger.info(`Saved renaming rules config with ${config.rules.length} rules`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to save rules config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old backup files, keeping only the most recent ones
|
||||
*/
|
||||
private cleanupBackups(keepCount: number = 5): void {
|
||||
try {
|
||||
const files = fs.readdirSync(this.configDir);
|
||||
const backups = files
|
||||
.filter(f => f.startsWith('renaming-rules.backup.') && f.endsWith('.json'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: path.join(this.configDir, f),
|
||||
time: parseInt(f.replace('renaming-rules.backup.', '').replace('.json', ''), 10),
|
||||
}))
|
||||
.sort((a, b) => b.time - a.time);
|
||||
|
||||
// Remove old backups beyond keepCount
|
||||
for (let i = keepCount; i < backups.length; i++) {
|
||||
fs.unlinkSync(backups[i].path);
|
||||
logger.debug(`Removed old backup: ${backups[i].name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to cleanup backups:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the config directory path
|
||||
*/
|
||||
getConfigDir(): string {
|
||||
return this.configDir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* meta-fuse-core entry point
|
||||
*
|
||||
* Standalone FUSE API server that reads metadata from Redis,
|
||||
* computes virtual paths, and exposes organized folder structure.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { Logger } from 'tslog';
|
||||
import { KVManager } from './kv/KVManager.js';
|
||||
import { RedisClient } from './kv/RedisClient.js';
|
||||
import { VirtualFileSystem } from './vfs/VirtualFileSystem.js';
|
||||
import { APIServer } from './api/APIServer.js';
|
||||
|
||||
const logger = new Logger({ name: 'meta-fuse' });
|
||||
|
||||
// Pub/sub channel for batched updates from meta-sort
|
||||
const UPDATE_CHANNEL = 'meta-sort:file:batch';
|
||||
|
||||
interface BatchUpdateMessage {
|
||||
timestamp: number;
|
||||
changes: Array<{
|
||||
action: 'add' | 'update' | 'remove';
|
||||
hashId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
logger.info('Starting meta-fuse...');
|
||||
logger.info(`Node.js ${process.version}`);
|
||||
|
||||
// Configuration
|
||||
const config = {
|
||||
metaCorePath: process.env.META_CORE_PATH ?? '/meta-core',
|
||||
filesPath: process.env.FILES_VOLUME ?? '/files',
|
||||
redisUrl: process.env.REDIS_URL,
|
||||
redisPrefix: process.env.REDIS_PREFIX ?? 'meta-sort:',
|
||||
apiPort: parseInt(process.env.API_PORT ?? '3000', 10),
|
||||
apiHost: process.env.API_HOST ?? '0.0.0.0',
|
||||
vfsRefreshInterval: parseInt(process.env.VFS_REFRESH_INTERVAL ?? '30000', 10),
|
||||
baseUrl: process.env.BASE_URL,
|
||||
serviceVersion: process.env.SERVICE_VERSION ?? '1.0.0',
|
||||
};
|
||||
|
||||
logger.info(`Config: META_CORE_PATH=${config.metaCorePath}, FILES_VOLUME=${config.filesPath}`);
|
||||
|
||||
// Initialize KV Manager (handles leader discovery)
|
||||
const kvManager = new KVManager({
|
||||
metaCorePath: config.metaCorePath,
|
||||
filesPath: config.filesPath,
|
||||
serviceName: 'meta-fuse',
|
||||
version: config.serviceVersion,
|
||||
apiPort: config.apiPort,
|
||||
redisUrl: config.redisUrl,
|
||||
redisPrefix: config.redisPrefix,
|
||||
baseUrl: config.baseUrl,
|
||||
capabilities: ['read', 'vfs'],
|
||||
});
|
||||
|
||||
let redisClient: RedisClient | null = null;
|
||||
let vfs: VirtualFileSystem | null = null;
|
||||
let apiServer: APIServer | null = null;
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
logger.info(`Received ${signal}, shutting down...`);
|
||||
|
||||
try {
|
||||
if (apiServer) await apiServer.stop();
|
||||
if (vfs) await vfs.stop();
|
||||
await kvManager.stop();
|
||||
logger.info('Shutdown complete');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('Error during shutdown:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
// Handle ready event from KV manager
|
||||
kvManager.onReady(async () => {
|
||||
logger.info('KV Manager ready, initializing VFS...');
|
||||
|
||||
try {
|
||||
// Get Redis client from KV manager
|
||||
redisClient = kvManager.getClient();
|
||||
|
||||
if (!redisClient) {
|
||||
logger.error('Redis client not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize VFS
|
||||
vfs = new VirtualFileSystem(redisClient, {
|
||||
fileMode: parseInt(process.env.FUSE_FILE_MODE ?? '644', 8),
|
||||
directoryMode: parseInt(process.env.FUSE_DIR_MODE ?? '755', 8),
|
||||
uid: parseInt(process.env.PUID ?? '1000', 10),
|
||||
gid: parseInt(process.env.PGID ?? '1000', 10),
|
||||
refreshInterval: config.vfsRefreshInterval,
|
||||
configDir: process.env.CONFIG_DIR ?? '/meta-fuse/config',
|
||||
});
|
||||
|
||||
// Start VFS
|
||||
logger.info('Starting VirtualFileSystem...');
|
||||
await vfs.start();
|
||||
|
||||
// Subscribe to batch updates from meta-sort
|
||||
logger.info(`Subscribing to ${UPDATE_CHANNEL}...`);
|
||||
await redisClient.subscribe(UPDATE_CHANNEL, async (message: string) => {
|
||||
try {
|
||||
const batch: BatchUpdateMessage = JSON.parse(message);
|
||||
logger.debug(`Received batch update: ${batch.changes.length} changes`);
|
||||
|
||||
// Process each change incrementally
|
||||
for (const change of batch.changes) {
|
||||
await vfs!.onFileUpdate(change.hashId, change.action);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`Error processing batch update: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize API server (with kvManager for service discovery)
|
||||
apiServer = new APIServer(vfs, {
|
||||
port: config.apiPort,
|
||||
host: config.apiHost,
|
||||
}, kvManager);
|
||||
|
||||
// Start API server
|
||||
logger.info('Starting API server...');
|
||||
await apiServer.start();
|
||||
|
||||
logger.info('meta-fuse is ready!');
|
||||
logger.info(`API: http://${config.apiHost}:${config.apiPort}`);
|
||||
|
||||
// Log initial stats
|
||||
const stats = vfs.getStats();
|
||||
logger.info(`VFS: ${stats.fileCount} files, ${stats.directoryCount} directories`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize after KV ready:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle disconnect event
|
||||
kvManager.onDisconnect(() => {
|
||||
logger.warn('Redis disconnected, VFS will use cached data');
|
||||
});
|
||||
|
||||
// Start KV Manager (initiates leader discovery)
|
||||
try {
|
||||
logger.info('Starting KV Manager...');
|
||||
await kvManager.start();
|
||||
|
||||
// Wait for ready with timeout
|
||||
await kvManager.waitForReady(60000);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start meta-fuse:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
logger.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* KV Client Interface - Read-only abstraction for meta-fuse
|
||||
*
|
||||
* meta-fuse is a FOLLOWER service that only reads metadata from Redis.
|
||||
* This interface provides the subset of operations needed for reading.
|
||||
*/
|
||||
|
||||
export interface KeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface IKVClient {
|
||||
/**
|
||||
* Get a value by key (value is JSON deserialized)
|
||||
*/
|
||||
get(key: string): Promise<any | null>;
|
||||
|
||||
/**
|
||||
* Count keys with a given prefix
|
||||
*/
|
||||
countKeysWithPrefix(prefix: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Health check - verifies KV store is accessible
|
||||
*/
|
||||
health(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get all key-value pairs with a given prefix
|
||||
*/
|
||||
getRange(prefix: string): Promise<KeyValuePair[]>;
|
||||
|
||||
/**
|
||||
* Retrieve file metadata - reconstructs from individual property keys
|
||||
*/
|
||||
getMetadataFlat(hashId: string): Promise<any | null>;
|
||||
|
||||
/**
|
||||
* Get all unique hash IDs (files) stored in KV
|
||||
*/
|
||||
getAllHashIds(): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Subscribe to Redis pub/sub channel
|
||||
*/
|
||||
subscribe(channel: string, callback: (message: string) => void): Promise<void>;
|
||||
|
||||
/**
|
||||
* Unsubscribe from Redis pub/sub channel
|
||||
*/
|
||||
unsubscribe(channel: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Close connections and cleanup resources
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leader lock file content format
|
||||
*/
|
||||
export interface LeaderLockInfo {
|
||||
/** Hostname of the leader */
|
||||
host: string;
|
||||
|
||||
/** Redis connection URL (e.g., redis://10.0.1.50:6379) */
|
||||
api: string;
|
||||
|
||||
/** HTTP API URL for the leader service */
|
||||
http: string;
|
||||
|
||||
/** Timestamp when leadership was acquired */
|
||||
timestamp: number;
|
||||
|
||||
/** Process ID of the leader */
|
||||
pid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service registration info stored in JSON file
|
||||
*/
|
||||
export interface ServiceInfo {
|
||||
/** Service name */
|
||||
name: string;
|
||||
|
||||
/** Service version */
|
||||
version: string;
|
||||
|
||||
/** HTTP API URL (e.g., http://10.0.1.50:3000) */
|
||||
api: string;
|
||||
|
||||
/** Service status */
|
||||
status: 'starting' | 'running' | 'stopping' | 'stopped';
|
||||
|
||||
/** Process ID */
|
||||
pid: number;
|
||||
|
||||
/** Hostname */
|
||||
hostname: string;
|
||||
|
||||
/** Service start time (ISO string) */
|
||||
startedAt: string;
|
||||
|
||||
/** Last heartbeat time (ISO string) */
|
||||
lastHeartbeat: string;
|
||||
|
||||
/** Service capabilities */
|
||||
capabilities: string[];
|
||||
|
||||
/** Named endpoints */
|
||||
endpoints: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* KV Manager for meta-fuse (FOLLOWER-only)
|
||||
*
|
||||
* Simplified KV manager that:
|
||||
* 1. Discovers the leader via lock file
|
||||
* 2. Creates Redis client connection
|
||||
* 3. Handles reconnection on leader failure
|
||||
*
|
||||
* Unlike meta-sort's KVManager, this version never spawns Redis -
|
||||
* it only connects as a client.
|
||||
*/
|
||||
|
||||
import { Logger } from 'tslog';
|
||||
import { LeaderDiscovery } from './LeaderDiscovery.js';
|
||||
import { ServiceDiscovery } from './ServiceDiscovery.js';
|
||||
import { RedisClient } from './RedisClient.js';
|
||||
import type { LeaderLockInfo } from './IKVClient.js';
|
||||
|
||||
const logger = new Logger({ name: 'KVManager' });
|
||||
|
||||
interface KVManagerConfig {
|
||||
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
|
||||
metaCorePath: string;
|
||||
|
||||
/** Path to FILES_VOLUME (e.g., /files) */
|
||||
filesPath: string;
|
||||
|
||||
/** Service name (for logging) */
|
||||
serviceName?: string;
|
||||
|
||||
/** Service version */
|
||||
version?: string;
|
||||
|
||||
/** HTTP API port */
|
||||
apiPort?: number;
|
||||
|
||||
/** Override Redis URL (skip leader discovery if provided) */
|
||||
redisUrl?: string;
|
||||
|
||||
/** Redis key prefix (default: 'meta-sort:') */
|
||||
redisPrefix?: string;
|
||||
|
||||
/** Base URL for service discovery (overrides auto-detected URL) */
|
||||
baseUrl?: string;
|
||||
|
||||
/** Service capabilities */
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
export class KVManager {
|
||||
private config: Required<KVManagerConfig>;
|
||||
private leaderDiscovery: LeaderDiscovery | null = null;
|
||||
private serviceDiscovery: ServiceDiscovery | null = null;
|
||||
private redisClient: RedisClient | null = null;
|
||||
private isStarted = false;
|
||||
private isShuttingDown = false;
|
||||
|
||||
// Event callbacks
|
||||
private onReadyCallbacks: (() => void)[] = [];
|
||||
private onDisconnectCallbacks: (() => void)[] = [];
|
||||
|
||||
constructor(config: KVManagerConfig) {
|
||||
this.config = {
|
||||
serviceName: 'meta-fuse',
|
||||
version: '1.0.0',
|
||||
apiPort: 3000,
|
||||
redisUrl: '',
|
||||
redisPrefix: 'meta-sort:',
|
||||
baseUrl: '',
|
||||
capabilities: ['read', 'vfs'],
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the KV manager
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.isStarted) {
|
||||
logger.warn('KVManager already started');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Starting KVManager for ${this.config.serviceName}...`);
|
||||
|
||||
// Initialize service discovery
|
||||
const apiUrl = this.config.baseUrl || `http://localhost:${this.config.apiPort}`;
|
||||
this.serviceDiscovery = new ServiceDiscovery({
|
||||
metaCorePath: this.config.metaCorePath,
|
||||
serviceName: this.config.serviceName,
|
||||
version: this.config.version,
|
||||
apiUrl,
|
||||
baseUrl: this.config.baseUrl || undefined,
|
||||
capabilities: this.config.capabilities,
|
||||
endpoints: {
|
||||
health: '/api/fuse/health',
|
||||
dashboard: '/',
|
||||
stats: '/api/fuse/stats',
|
||||
}
|
||||
});
|
||||
|
||||
// Start service discovery
|
||||
await this.serviceDiscovery.start();
|
||||
|
||||
// If direct Redis URL provided, skip leader discovery
|
||||
if (this.config.redisUrl) {
|
||||
logger.info(`Using direct Redis URL: ${this.config.redisUrl}`);
|
||||
await this.connectToRedis(this.config.redisUrl);
|
||||
this.isStarted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use leader discovery
|
||||
this.leaderDiscovery = new LeaderDiscovery({
|
||||
metaCorePath: this.config.metaCorePath
|
||||
});
|
||||
|
||||
// Set up leader discovery callbacks
|
||||
this.leaderDiscovery.onLeaderFound(async (info: LeaderLockInfo) => {
|
||||
logger.info(`Connecting to leader at ${info.api}...`);
|
||||
await this.connectToRedis(info.api);
|
||||
});
|
||||
|
||||
this.leaderDiscovery.onLeaderLost(async () => {
|
||||
logger.warn('Leader lost, disconnecting...');
|
||||
await this.disconnectRedis();
|
||||
this.notifyDisconnect();
|
||||
});
|
||||
|
||||
// Start leader discovery
|
||||
await this.leaderDiscovery.start();
|
||||
|
||||
this.isStarted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the KV manager
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.isStarted) return;
|
||||
|
||||
logger.info('Stopping KVManager...');
|
||||
this.isShuttingDown = true;
|
||||
|
||||
// Stop service discovery
|
||||
if (this.serviceDiscovery) {
|
||||
await this.serviceDiscovery.stop();
|
||||
this.serviceDiscovery = null;
|
||||
}
|
||||
|
||||
// Stop leader discovery
|
||||
if (this.leaderDiscovery) {
|
||||
await this.leaderDiscovery.stop();
|
||||
this.leaderDiscovery = null;
|
||||
}
|
||||
|
||||
// Disconnect Redis
|
||||
await this.disconnectRedis();
|
||||
|
||||
this.isStarted = false;
|
||||
logger.info('KVManager stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Redis
|
||||
*/
|
||||
private async connectToRedis(url: string): Promise<void> {
|
||||
try {
|
||||
// Disconnect existing client if any
|
||||
await this.disconnectRedis();
|
||||
|
||||
// Create new client
|
||||
this.redisClient = new RedisClient({
|
||||
url,
|
||||
prefix: this.config.redisPrefix,
|
||||
filesVolume: this.config.filesPath
|
||||
});
|
||||
|
||||
await this.redisClient.connect();
|
||||
logger.info('Connected to Redis');
|
||||
|
||||
// Notify ready
|
||||
this.notifyReady();
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to connect to Redis: ${error.message}`);
|
||||
this.redisClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from Redis
|
||||
*/
|
||||
private async disconnectRedis(): Promise<void> {
|
||||
if (this.redisClient) {
|
||||
try {
|
||||
await this.redisClient.disconnect();
|
||||
} catch (error: any) {
|
||||
logger.warn(`Error disconnecting Redis: ${error.message}`);
|
||||
}
|
||||
this.redisClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify ready callbacks
|
||||
*/
|
||||
private notifyReady(): void {
|
||||
for (const callback of this.onReadyCallbacks) {
|
||||
try {
|
||||
callback();
|
||||
} catch (error: any) {
|
||||
logger.error(`Error in ready callback: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify disconnect callbacks
|
||||
*/
|
||||
private notifyDisconnect(): void {
|
||||
for (const callback of this.onDisconnectCallbacks) {
|
||||
try {
|
||||
callback();
|
||||
} catch (error: any) {
|
||||
logger.error(`Error in disconnect callback: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Event Registration
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Register callback for when KV client is ready
|
||||
*/
|
||||
onReady(callback: () => void): this {
|
||||
this.onReadyCallbacks.push(callback);
|
||||
|
||||
// If already ready, call immediately
|
||||
if (this.redisClient?.connected) {
|
||||
callback();
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for disconnection
|
||||
*/
|
||||
onDisconnect(callback: () => void): this {
|
||||
this.onDisconnectCallbacks.push(callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for KV client to be ready
|
||||
*/
|
||||
async waitForReady(timeoutMs: number = 30000): Promise<void> {
|
||||
if (this.redisClient?.connected) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`KV client not ready after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.onReady(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Getters
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Get the Redis client (may be null if not connected)
|
||||
*/
|
||||
getClient(): RedisClient | null {
|
||||
return this.redisClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Redis client (throws if not connected)
|
||||
*/
|
||||
requireClient(): RedisClient {
|
||||
if (!this.redisClient) {
|
||||
throw new Error('Redis client not available. Ensure start() was called and leader is available.');
|
||||
}
|
||||
return this.redisClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.redisClient?.connected ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
async isHealthy(): Promise<boolean> {
|
||||
if (!this.redisClient) return false;
|
||||
try {
|
||||
const stats = await this.redisClient.getStats();
|
||||
return stats.connected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get leader info (if using leader discovery)
|
||||
*/
|
||||
getLeaderInfo(): LeaderLockInfo | null {
|
||||
return this.leaderDiscovery?.getLeaderInfo() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service discovery (for inter-service navigation)
|
||||
*/
|
||||
getServiceDiscovery(): ServiceDiscovery | null {
|
||||
return this.serviceDiscovery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured paths
|
||||
*/
|
||||
getPaths() {
|
||||
return {
|
||||
metaCorePath: this.config.metaCorePath,
|
||||
filesPath: this.config.filesPath
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Leader Discovery for meta-fuse (FOLLOWER-only)
|
||||
*
|
||||
* This is a simplified version of leader election that only discovers
|
||||
* the existing leader. meta-fuse never spawns Redis - it only connects
|
||||
* to the leader's Redis instance.
|
||||
*
|
||||
* Architecture:
|
||||
* - Leader holds flock on META_CORE_PATH/locks/kv-leader.lock
|
||||
* - Leader writes info to META_CORE_PATH/locks/kv-leader.info
|
||||
* - This class reads the info file to discover the leader
|
||||
* - Watches for leader changes via health checks
|
||||
* - Triggers reconnection callbacks on leader failure/change
|
||||
*/
|
||||
|
||||
import { promises as fs, watch } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { Logger } from 'tslog';
|
||||
import type { LeaderLockInfo } from './IKVClient.js';
|
||||
|
||||
const logger = new Logger({ name: 'LeaderDiscovery' });
|
||||
|
||||
interface LeaderDiscoveryConfig {
|
||||
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
|
||||
metaCorePath: string;
|
||||
|
||||
/** Health check interval in ms (default: 5000) */
|
||||
healthCheckInterval?: number;
|
||||
|
||||
/** Max consecutive failures before marking leader as lost (default: 3) */
|
||||
maxFailures?: number;
|
||||
}
|
||||
|
||||
export class LeaderDiscovery {
|
||||
private config: Required<LeaderDiscoveryConfig>;
|
||||
private lockFilePath: string;
|
||||
private leaderInfo: LeaderLockInfo | null = null;
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
private fileWatcher: ReturnType<typeof watch> | null = null;
|
||||
private isStarted = false;
|
||||
private consecutiveFailures = 0;
|
||||
|
||||
// Event callbacks
|
||||
private onLeaderFoundCallback?: (info: LeaderLockInfo) => void;
|
||||
private onLeaderLostCallback?: () => void;
|
||||
|
||||
constructor(config: LeaderDiscoveryConfig) {
|
||||
this.config = {
|
||||
healthCheckInterval: 5000,
|
||||
maxFailures: 3,
|
||||
...config
|
||||
};
|
||||
|
||||
this.lockFilePath = `${this.config.metaCorePath}/locks/kv-leader.info`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching for leader
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.isStarted) {
|
||||
logger.warn('LeaderDiscovery already started');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Starting leader discovery, lock file: ${this.lockFilePath}`);
|
||||
|
||||
// Ensure lock directory exists (for file watching)
|
||||
await this.ensureLockDir();
|
||||
|
||||
// Try to read existing leader info
|
||||
await this.checkForLeader();
|
||||
|
||||
// Start file watcher for immediate detection of leader changes
|
||||
this.startFileWatcher();
|
||||
|
||||
// Start health check for ongoing monitoring
|
||||
this.startHealthCheck();
|
||||
|
||||
this.isStarted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.isStarted) return;
|
||||
|
||||
logger.info('Stopping leader discovery');
|
||||
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
|
||||
if (this.fileWatcher) {
|
||||
this.fileWatcher.close();
|
||||
this.fileWatcher = null;
|
||||
}
|
||||
|
||||
this.isStarted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure lock directory exists
|
||||
*/
|
||||
private async ensureLockDir(): Promise<void> {
|
||||
const lockDir = dirname(this.lockFilePath);
|
||||
try {
|
||||
await fs.mkdir(lockDir, { recursive: true });
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'EEXIST') {
|
||||
logger.warn(`Could not create lock directory: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for leader by reading lock file
|
||||
*/
|
||||
private async checkForLeader(): Promise<void> {
|
||||
try {
|
||||
const content = await fs.readFile(this.lockFilePath, 'utf-8');
|
||||
const info = JSON.parse(content) as LeaderLockInfo;
|
||||
|
||||
// Validate leader info
|
||||
if (info && info.api && info.http) {
|
||||
const isNew = !this.leaderInfo || this.leaderInfo.api !== info.api;
|
||||
this.leaderInfo = info;
|
||||
this.consecutiveFailures = 0;
|
||||
|
||||
if (isNew) {
|
||||
logger.info(`Leader found: ${info.host} at ${info.api}`);
|
||||
this.onLeaderFoundCallback?.(info);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Lock file doesn't exist - no leader yet
|
||||
if (this.leaderInfo) {
|
||||
logger.warn('Lock file removed, leader lost');
|
||||
this.handleLeaderLost();
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Error reading lock file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current leader is still healthy
|
||||
*/
|
||||
private async isLeaderHealthy(): Promise<boolean> {
|
||||
if (!this.leaderInfo) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.leaderInfo.http}/health`, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle leader being lost
|
||||
*/
|
||||
private handleLeaderLost(): void {
|
||||
this.leaderInfo = null;
|
||||
this.onLeaderLostCallback?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching lock file for changes
|
||||
*/
|
||||
private startFileWatcher(): void {
|
||||
try {
|
||||
const lockDir = dirname(this.lockFilePath);
|
||||
this.fileWatcher = watch(lockDir, async (eventType, filename) => {
|
||||
if (filename === 'kv-leader.info') {
|
||||
logger.debug(`Lock file ${eventType}, checking for leader...`);
|
||||
await this.checkForLeader();
|
||||
}
|
||||
});
|
||||
|
||||
this.fileWatcher.on('error', (error) => {
|
||||
logger.warn(`File watcher error: ${error.message}`);
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.warn(`Could not start file watcher: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start health check loop
|
||||
*/
|
||||
private startHealthCheck(): void {
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
if (!this.leaderInfo) {
|
||||
// No leader known, try to find one
|
||||
await this.checkForLeader();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if current leader is healthy
|
||||
const healthy = await this.isLeaderHealthy();
|
||||
|
||||
if (healthy) {
|
||||
this.consecutiveFailures = 0;
|
||||
} else {
|
||||
this.consecutiveFailures++;
|
||||
logger.warn(`Leader health check failed (${this.consecutiveFailures}/${this.config.maxFailures})`);
|
||||
|
||||
if (this.consecutiveFailures >= this.config.maxFailures) {
|
||||
logger.warn('Leader appears dead, marking as lost');
|
||||
this.handleLeaderLost();
|
||||
|
||||
// Try to find new leader
|
||||
await this.checkForLeader();
|
||||
}
|
||||
}
|
||||
}, this.config.healthCheckInterval);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Event Registration
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Register callback for when leader is found/changed
|
||||
*/
|
||||
onLeaderFound(callback: (info: LeaderLockInfo) => void): this {
|
||||
this.onLeaderFoundCallback = callback;
|
||||
|
||||
// If we already have leader info, call immediately
|
||||
if (this.leaderInfo) {
|
||||
callback(this.leaderInfo);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for when leader is lost
|
||||
*/
|
||||
onLeaderLost(callback: () => void): this {
|
||||
this.onLeaderLostCallback = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Getters
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Get current leader info
|
||||
*/
|
||||
getLeaderInfo(): LeaderLockInfo | null {
|
||||
return this.leaderInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Redis URL of current leader
|
||||
*/
|
||||
getRedisUrl(): string | null {
|
||||
return this.leaderInfo?.api ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a leader is known
|
||||
*/
|
||||
hasLeader(): boolean {
|
||||
return this.leaderInfo !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* Redis Client for meta-fuse
|
||||
* Reads metadata from Redis (written by meta-sort)
|
||||
*
|
||||
* Key features:
|
||||
* - FILES_VOLUME path resolution (prepends /files/ to relative paths)
|
||||
* - Parses meta-sort hash format into structured metadata
|
||||
* - Redis pub/sub support for real-time updates
|
||||
*/
|
||||
|
||||
import { Redis } from 'ioredis';
|
||||
import { Logger } from 'tslog';
|
||||
import type { IKVClient, KeyValuePair } from './IKVClient.js';
|
||||
|
||||
const logger = new Logger({ name: 'RedisClient' });
|
||||
|
||||
/**
|
||||
* Full metadata structure including fields needed for virtual path computation
|
||||
*/
|
||||
export interface FileMetadata {
|
||||
// Path information
|
||||
sourcePath: string; // Full path: /files/media1/Movies/...
|
||||
originalPath: string; // Relative path from meta-sort: media1/Movies/...
|
||||
|
||||
// File stats
|
||||
size: number;
|
||||
mtime: number;
|
||||
ctime: number;
|
||||
|
||||
// Identification
|
||||
hashId?: string; // midhash256 hash ID
|
||||
|
||||
// Title information (for folder organization)
|
||||
title?: string;
|
||||
titles?: { eng?: string; [key: string]: string | undefined };
|
||||
originalTitle?: string;
|
||||
fileName?: string;
|
||||
|
||||
// Series metadata
|
||||
season?: number;
|
||||
episode?: number;
|
||||
extra?: boolean;
|
||||
|
||||
// Movie metadata
|
||||
movieYear?: number;
|
||||
year?: number;
|
||||
|
||||
// File type
|
||||
fileType?: string; // 'video', 'subtitle', 'torrent', etc.
|
||||
extension?: string;
|
||||
|
||||
// Version/variant info
|
||||
version?: string;
|
||||
subtitleLanguage?: string;
|
||||
|
||||
// Generic metadata container
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface RedisClientConfig {
|
||||
url?: string;
|
||||
prefix?: string;
|
||||
filesVolume?: string;
|
||||
reconnectInterval?: number;
|
||||
}
|
||||
|
||||
export class RedisClient implements Partial<IKVClient> {
|
||||
private client: Redis | null = null;
|
||||
private subscriber: Redis | null = null;
|
||||
private config: Required<RedisClientConfig>;
|
||||
private isConnected = false;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private subscriptions: Map<string, (message: string) => void> = new Map();
|
||||
|
||||
constructor(config: RedisClientConfig = {}) {
|
||||
this.config = {
|
||||
url: config.url ?? process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
|
||||
prefix: config.prefix ?? process.env.REDIS_PREFIX ?? 'meta-sort:',
|
||||
filesVolume: config.filesVolume ?? process.env.FILES_VOLUME ?? '/files',
|
||||
reconnectInterval: config.reconnectInterval ?? 5000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Redis
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.client && this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.client = new Redis(this.config.url, {
|
||||
retryStrategy: (times: number) => {
|
||||
if (times > 10) {
|
||||
logger.warn('Max reconnection attempts reached');
|
||||
return null;
|
||||
}
|
||||
return Math.min(times * 500, 5000);
|
||||
},
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
logger.info('Connected to Redis');
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.client.on('error', (err: Error) => {
|
||||
logger.error('Redis error:', err.message);
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.client.on('close', () => {
|
||||
logger.warn('Redis connection closed');
|
||||
this.isConnected = false;
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
|
||||
// Wait for connection
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Redis connection timeout'));
|
||||
}, 10000);
|
||||
|
||||
this.client!.once('ready', () => {
|
||||
clearTimeout(timeout);
|
||||
this.isConnected = true;
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.client!.once('error', (err: Error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect to Redis:', error);
|
||||
this.scheduleReconnect();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection attempt
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(async () => {
|
||||
this.reconnectTimer = null;
|
||||
try {
|
||||
await this.connect();
|
||||
} catch {
|
||||
// Will retry via scheduleReconnect
|
||||
}
|
||||
}, this.config.reconnectInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from Redis
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.subscriber) {
|
||||
await this.subscriber.quit();
|
||||
this.subscriber = null;
|
||||
}
|
||||
|
||||
if (this.client) {
|
||||
await this.client.quit();
|
||||
this.client = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
get connected(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all file metadata from Redis
|
||||
*/
|
||||
async getAllFiles(): Promise<Map<string, FileMetadata>> {
|
||||
const files = new Map<string, FileMetadata>();
|
||||
|
||||
if (!this.client || !this.isConnected) {
|
||||
logger.warn('Redis not connected, returning empty file list');
|
||||
return files;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all file keys using the index
|
||||
const indexKey = `${this.config.prefix}file:__index__`;
|
||||
const hashIds = await this.client.smembers(indexKey);
|
||||
|
||||
if (hashIds.length === 0) {
|
||||
// Fallback: scan for file keys directly
|
||||
const pattern = `${this.config.prefix}file:*`;
|
||||
const keys = await this.scanKeys(pattern);
|
||||
logger.debug(`Found ${keys.length} file keys in Redis (via scan)`);
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.includes('__index__')) continue;
|
||||
|
||||
try {
|
||||
const data = await this.client.hgetall(key);
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const hashId = key.replace(`${this.config.prefix}file:`, '');
|
||||
const metadata = this.parseMetadata(data, hashId);
|
||||
files.set(metadata.sourcePath, metadata);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to get metadata for key ${key}:`, err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Found ${hashIds.length} files in index`);
|
||||
|
||||
// Fetch metadata for each hash ID
|
||||
for (const hashId of hashIds) {
|
||||
try {
|
||||
const key = `${this.config.prefix}file:${hashId}`;
|
||||
const data = await this.client.hgetall(key);
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const metadata = this.parseMetadata(data, hashId);
|
||||
files.set(metadata.sourcePath, metadata);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to get metadata for hashId ${hashId}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Loaded ${files.size} files from Redis`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get files from Redis:', error);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file metadata by hash ID
|
||||
*/
|
||||
async getFileByHashId(hashId: string): Promise<FileMetadata | null> {
|
||||
if (!this.client || !this.isConnected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = `${this.config.prefix}file:${hashId}`;
|
||||
const data = await this.client.hgetall(key);
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
return this.parseMetadata(data, hashId);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get file ${hashId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hash IDs from Redis index
|
||||
*/
|
||||
async getAllHashIds(): Promise<string[]> {
|
||||
if (!this.client || !this.isConnected) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const indexKey = `${this.config.prefix}file:__index__`;
|
||||
return await this.client.smembers(indexKey);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get hash IDs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan Redis keys matching pattern
|
||||
*/
|
||||
private async scanKeys(pattern: string): Promise<string[]> {
|
||||
if (!this.client) return [];
|
||||
|
||||
const keys: string[] = [];
|
||||
let cursor = '0';
|
||||
|
||||
do {
|
||||
const [newCursor, batch] = await this.client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000);
|
||||
cursor = newCursor;
|
||||
keys.push(...batch);
|
||||
} while (cursor !== '0');
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse metadata from Redis hash
|
||||
* Handles meta-sort format and resolves paths with FILES_VOLUME
|
||||
*/
|
||||
private parseMetadata(data: Record<string, string>, hashId?: string): FileMetadata {
|
||||
// Get the original path (relative to FILES_VOLUME)
|
||||
const originalPath = data.filePath ?? data.sourcePath ?? '';
|
||||
|
||||
// Resolve full source path by prepending FILES_VOLUME
|
||||
let sourcePath = originalPath;
|
||||
if (originalPath && !originalPath.startsWith('/')) {
|
||||
// Relative path - prepend FILES_VOLUME
|
||||
sourcePath = `${this.config.filesVolume}/${originalPath}`;
|
||||
} else if (!originalPath.startsWith(this.config.filesVolume) && originalPath.startsWith('/')) {
|
||||
// Absolute path but not in FILES_VOLUME - still prepend
|
||||
sourcePath = `${this.config.filesVolume}${originalPath}`;
|
||||
}
|
||||
|
||||
// Parse size
|
||||
const size = parseInt(data.fileSize ?? data.size ?? data.sizeByte ?? '0', 10);
|
||||
|
||||
// Parse timestamps
|
||||
let mtime = 0;
|
||||
if (data.mtime) {
|
||||
const parsed = Date.parse(data.mtime);
|
||||
mtime = isNaN(parsed) ? parseFloat(data.mtime) : parsed;
|
||||
}
|
||||
|
||||
// Parse titles - can be nested object or simple string
|
||||
let titles: { eng?: string; [key: string]: string | undefined } | undefined;
|
||||
if (data['titles/eng']) {
|
||||
titles = { eng: data['titles/eng'] };
|
||||
}
|
||||
|
||||
// Parse season/episode (can be 0 for specials)
|
||||
const season = data.season !== undefined ? parseInt(data.season, 10) : undefined;
|
||||
const episode = data.episode !== undefined ? parseInt(data.episode, 10) : undefined;
|
||||
|
||||
// Parse year
|
||||
const movieYear = data.movieYear ? parseInt(data.movieYear, 10) : undefined;
|
||||
const year = data.year ? parseInt(data.year, 10) : movieYear;
|
||||
|
||||
return {
|
||||
// Path information
|
||||
sourcePath,
|
||||
originalPath,
|
||||
|
||||
// File stats
|
||||
size,
|
||||
mtime,
|
||||
ctime: data.ctime ? parseFloat(data.ctime) : mtime,
|
||||
|
||||
// Identification
|
||||
hashId: hashId ?? data.cid_midhash256 ?? data.hashId,
|
||||
|
||||
// Title information
|
||||
title: data.title,
|
||||
titles,
|
||||
originalTitle: data.originalTitle,
|
||||
fileName: data.fileName,
|
||||
|
||||
// Series metadata
|
||||
season,
|
||||
episode,
|
||||
extra: data.extra === 'true',
|
||||
|
||||
// Movie metadata
|
||||
movieYear,
|
||||
year,
|
||||
|
||||
// File type
|
||||
fileType: data.fileType,
|
||||
extension: data.extension,
|
||||
|
||||
// Version/variant
|
||||
version: data.version,
|
||||
subtitleLanguage: data.subtitleLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to Redis pub/sub channel
|
||||
*/
|
||||
async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Redis not connected');
|
||||
}
|
||||
|
||||
// Create subscriber connection if not exists
|
||||
if (!this.subscriber) {
|
||||
this.subscriber = this.client.duplicate();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Subscriber connection timeout')), 10000);
|
||||
this.subscriber!.once('ready', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
this.subscriber!.once('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
// Set up message handler
|
||||
this.subscriber.on('message', (ch: string, message: string) => {
|
||||
const handler = this.subscriptions.get(ch);
|
||||
if (handler) {
|
||||
try {
|
||||
handler(message);
|
||||
} catch (error: any) {
|
||||
logger.error(`Error in subscription handler for ${ch}:`, error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Store callback and subscribe
|
||||
this.subscriptions.set(channel, callback);
|
||||
await this.subscriber.subscribe(channel);
|
||||
logger.info(`Subscribed to channel: ${channel}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from Redis pub/sub channel
|
||||
*/
|
||||
async unsubscribe(channel: string): Promise<void> {
|
||||
if (this.subscriber) {
|
||||
await this.subscriber.unsubscribe(channel);
|
||||
this.subscriptions.delete(channel);
|
||||
logger.info(`Unsubscribed from channel: ${channel}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats about Redis data
|
||||
*/
|
||||
async getStats(): Promise<{ totalKeys: number; fileCount: number; connected: boolean }> {
|
||||
if (!this.client || !this.isConnected) {
|
||||
return { totalKeys: 0, fileCount: 0, connected: false };
|
||||
}
|
||||
|
||||
try {
|
||||
// Try index first
|
||||
const indexKey = `${this.config.prefix}file:__index__`;
|
||||
const count = await this.client.scard(indexKey);
|
||||
|
||||
if (count > 0) {
|
||||
return {
|
||||
totalKeys: count,
|
||||
fileCount: count,
|
||||
connected: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to scanning
|
||||
const filePattern = `${this.config.prefix}file:*`;
|
||||
const fileKeys = await this.scanKeys(filePattern);
|
||||
const actualFileKeys = fileKeys.filter(k => !k.includes('__index__'));
|
||||
|
||||
return {
|
||||
totalKeys: actualFileKeys.length,
|
||||
fileCount: actualFileKeys.length,
|
||||
connected: true,
|
||||
};
|
||||
} catch {
|
||||
return { totalKeys: 0, fileCount: 0, connected: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
async health(): Promise<boolean> {
|
||||
if (!this.client || !this.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.client.ping();
|
||||
return result === 'PONG';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close connections
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
await this.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw ioredis client for direct access if needed
|
||||
* Use with caution - prefer using the typed methods above
|
||||
*/
|
||||
getRawClient(): Redis | null {
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Service Discovery using shared filesystem
|
||||
*
|
||||
* Each service registers itself in /meta-core/services/{service-name}.json
|
||||
* with its API endpoint, status, and heartbeat timestamp.
|
||||
*
|
||||
* Services can discover each other by reading these JSON files.
|
||||
* Stale detection: if lastHeartbeat > 60 seconds ago, service is considered dead.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { hostname } from 'os';
|
||||
import { join } from 'path';
|
||||
import { Logger } from 'tslog';
|
||||
import type { ServiceInfo } from './IKVClient.js';
|
||||
|
||||
const logger = new Logger({ name: 'ServiceDiscovery' });
|
||||
|
||||
interface ServiceDiscoveryConfig {
|
||||
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
|
||||
metaCorePath: string;
|
||||
|
||||
/** Service name */
|
||||
serviceName: string;
|
||||
|
||||
/** Service version */
|
||||
version: string;
|
||||
|
||||
/** HTTP API URL (can be overridden by baseUrl) */
|
||||
apiUrl?: string;
|
||||
|
||||
/** Base URL for service discovery (overrides auto-detected URL) */
|
||||
baseUrl?: string;
|
||||
|
||||
/** Service capabilities */
|
||||
capabilities?: string[];
|
||||
|
||||
/** Named endpoints */
|
||||
endpoints?: Record<string, string>;
|
||||
|
||||
/** Heartbeat interval in ms (default: 30000) */
|
||||
heartbeatInterval?: number;
|
||||
|
||||
/** Stale threshold in ms (default: 60000) */
|
||||
staleThreshold?: number;
|
||||
}
|
||||
|
||||
export class ServiceDiscovery {
|
||||
private config: ServiceDiscoveryConfig;
|
||||
private servicesDir: string;
|
||||
private serviceFilePath: string;
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
private isShuttingDown = false;
|
||||
private isStarted = false;
|
||||
|
||||
constructor(config: ServiceDiscoveryConfig) {
|
||||
this.config = {
|
||||
heartbeatInterval: 30000,
|
||||
staleThreshold: 60000,
|
||||
capabilities: [],
|
||||
endpoints: {},
|
||||
...config
|
||||
};
|
||||
|
||||
this.servicesDir = `${this.config.metaCorePath}/services`;
|
||||
this.serviceFilePath = `${this.servicesDir}/${this.config.serviceName}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure services directory exists
|
||||
*/
|
||||
private async ensureServicesDir(): Promise<void> {
|
||||
await fs.mkdir(this.servicesDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build service info for this service
|
||||
*/
|
||||
private buildServiceInfo(status: ServiceInfo['status']): ServiceInfo {
|
||||
return {
|
||||
name: this.config.serviceName,
|
||||
version: this.config.version,
|
||||
api: this.config.baseUrl || this.config.apiUrl || '',
|
||||
status,
|
||||
pid: process.pid,
|
||||
hostname: hostname(),
|
||||
startedAt: new Date().toISOString(),
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
capabilities: this.config.capabilities || [],
|
||||
endpoints: this.config.endpoints || {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this service
|
||||
*/
|
||||
async register(): Promise<void> {
|
||||
await this.ensureServicesDir();
|
||||
|
||||
const info = this.buildServiceInfo('starting');
|
||||
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
|
||||
|
||||
logger.info(`Registered ${this.config.serviceName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update service status
|
||||
*/
|
||||
async updateStatus(status: ServiceInfo['status']): Promise<void> {
|
||||
try {
|
||||
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
|
||||
const info = JSON.parse(content) as ServiceInfo;
|
||||
|
||||
info.status = status;
|
||||
info.lastHeartbeat = new Date().toISOString();
|
||||
|
||||
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
|
||||
} catch (error) {
|
||||
logger.error('Failed to update status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send heartbeat (update lastHeartbeat timestamp)
|
||||
*/
|
||||
async heartbeat(): Promise<void> {
|
||||
try {
|
||||
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
|
||||
const info = JSON.parse(content) as ServiceInfo;
|
||||
|
||||
info.lastHeartbeat = new Date().toISOString();
|
||||
|
||||
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
|
||||
} catch (error: any) {
|
||||
// If file was deleted, re-register
|
||||
if (error.code === 'ENOENT') {
|
||||
await this.register();
|
||||
await this.updateStatus('running');
|
||||
} else {
|
||||
logger.error('Heartbeat failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat loop
|
||||
*/
|
||||
startHeartbeat(): void {
|
||||
this.heartbeatTimer = setInterval(async () => {
|
||||
if (this.isShuttingDown) return;
|
||||
await this.heartbeat();
|
||||
}, this.config.heartbeatInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat loop
|
||||
*/
|
||||
stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister this service (on shutdown)
|
||||
*/
|
||||
async unregister(): Promise<void> {
|
||||
this.isShuttingDown = true;
|
||||
this.stopHeartbeat();
|
||||
|
||||
try {
|
||||
// Update status to stopped before removing
|
||||
await this.updateStatus('stopped');
|
||||
|
||||
logger.info(`Unregistered ${this.config.serviceName}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to unregister:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Discovery Methods
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Discover a service by name
|
||||
*/
|
||||
async discoverService(name: string): Promise<ServiceInfo | null> {
|
||||
const servicePath = join(this.servicesDir, `${name}.json`);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(servicePath, 'utf-8');
|
||||
const service = JSON.parse(content) as ServiceInfo;
|
||||
|
||||
// Check if service is alive (heartbeat within threshold)
|
||||
const lastHeartbeat = new Date(service.lastHeartbeat).getTime();
|
||||
const isAlive = Date.now() - lastHeartbeat < this.config.staleThreshold!;
|
||||
|
||||
if (!isAlive) {
|
||||
logger.warn(`Service ${name} appears stale (last heartbeat: ${service.lastHeartbeat})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return service;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null; // Service not registered
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all registered services
|
||||
*/
|
||||
async discoverAllServices(): Promise<ServiceInfo[]> {
|
||||
try {
|
||||
const files = await fs.readdir(this.servicesDir);
|
||||
const services: ServiceInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
|
||||
const name = file.replace('.json', '');
|
||||
const service = await this.discoverService(name);
|
||||
|
||||
if (service && service.status === 'running') {
|
||||
services.push(service);
|
||||
}
|
||||
}
|
||||
|
||||
return services;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return []; // Services directory doesn't exist yet
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a service is healthy
|
||||
*/
|
||||
async isServiceHealthy(name: string): Promise<boolean> {
|
||||
const service = await this.discoverService(name);
|
||||
if (!service) return false;
|
||||
|
||||
// Try to ping the service's health endpoint
|
||||
const healthEndpoint = service.endpoints.health || '/health';
|
||||
const url = `${service.api}${healthEndpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Lifecycle
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Full startup sequence
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.isStarted) return;
|
||||
|
||||
await this.register();
|
||||
await this.updateStatus('running');
|
||||
this.startHeartbeat();
|
||||
this.isStarted = true;
|
||||
|
||||
logger.info(`Started ${this.config.serviceName} at ${this.config.baseUrl || this.config.apiUrl}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full shutdown sequence
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.isStarted) return;
|
||||
|
||||
await this.unregister();
|
||||
this.isStarted = false;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Getters
|
||||
// ========================================================================
|
||||
|
||||
getServiceFilePath(): string {
|
||||
return this.serviceFilePath;
|
||||
}
|
||||
|
||||
getServicesDir(): string {
|
||||
return this.servicesDir;
|
||||
}
|
||||
|
||||
getIsStarted(): boolean {
|
||||
return this.isStarted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* MetaDataToFolderStruct - Generates virtual filesystem structure from metadata
|
||||
*
|
||||
* Migrated from meta-mesh to meta-fuse.
|
||||
* Takes a collection of file metadata and generates organized virtual paths.
|
||||
*/
|
||||
|
||||
import { renamingRule, sanitizePath } from './RenamingRule.js';
|
||||
import { Logger } from 'tslog';
|
||||
|
||||
const logger = new Logger({ name: 'MetaDataToFolderStruct' });
|
||||
|
||||
type RenamingRuleFunction = (metadata: any, filepath: string) => string | null;
|
||||
|
||||
export class MetaDataToFolderStruct {
|
||||
public renamingRule: RenamingRuleFunction;
|
||||
|
||||
constructor(customRenamingRule?: RenamingRuleFunction) {
|
||||
this.renamingRule = customRenamingRule ?? renamingRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute virtual filesystem structure for FUSE/WebDAV
|
||||
* Generates organized paths based on metadata without creating physical files
|
||||
*
|
||||
* @param data Map of source paths to metadata
|
||||
* @returns Map of source paths to virtual paths
|
||||
*/
|
||||
private computeVirtualStructure(data: Map<string, any>): Map<string, string> {
|
||||
const virtualMap = new Map<string, string>();
|
||||
|
||||
for (const [filepath, metadata] of data) {
|
||||
// Compute the virtual path for the file
|
||||
let virtualPath: string | null = null;
|
||||
|
||||
try {
|
||||
virtualPath = this.renamingRule(metadata, filepath);
|
||||
} catch (e: any) {
|
||||
logger.warn(`Failed to compute virtual path for ${filepath}: ${e.message || e}`);
|
||||
}
|
||||
|
||||
if (!virtualPath) {
|
||||
// Fallback: use filename directly if renaming rule fails
|
||||
const filename = filepath.split('/').pop() || filepath.split('\\').pop() || filepath;
|
||||
virtualPath = `Unsorted/${filename}`;
|
||||
}
|
||||
|
||||
virtualPath = sanitizePath(virtualPath);
|
||||
virtualMap.set(filepath, virtualPath);
|
||||
}
|
||||
|
||||
logger.debug(`Computed virtual structure for ${virtualMap.size} files`);
|
||||
return virtualMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate virtual filesystem structure for FUSE/WebDAV
|
||||
* Returns only the computed virtual paths without checking physical filesystem
|
||||
*
|
||||
* @param data Map of source paths to metadata
|
||||
* @returns Map of source paths to virtual paths
|
||||
*/
|
||||
public generateVirtualStructure(data: Map<string, any>): Map<string, string> {
|
||||
const start = performance.now();
|
||||
const computed = this.computeVirtualStructure(data);
|
||||
logger.debug(`Compute virtual structure took ${Math.ceil(performance.now() - start)}ms`);
|
||||
return computed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate virtual path for a single file
|
||||
*
|
||||
* @param sourcePath The source file path
|
||||
* @param metadata The file metadata
|
||||
* @returns Virtual path or fallback path
|
||||
*/
|
||||
public generateSingleVirtualPath(sourcePath: string, metadata: any): string {
|
||||
let virtualPath: string | null = null;
|
||||
|
||||
try {
|
||||
virtualPath = this.renamingRule(metadata, sourcePath);
|
||||
} catch (e: any) {
|
||||
logger.warn(`Failed to compute virtual path for ${sourcePath}: ${e.message || e}`);
|
||||
}
|
||||
|
||||
if (!virtualPath) {
|
||||
// Fallback: use filename directly if renaming rule fails
|
||||
const filename = sourcePath.split('/').pop() || sourcePath.split('\\').pop() || sourcePath;
|
||||
virtualPath = `Unsorted/${filename}`;
|
||||
}
|
||||
|
||||
return sanitizePath(virtualPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Renaming Rule - Defines folder organization logic
|
||||
*
|
||||
* Migrated from meta-mesh to meta-fuse for virtual path computation.
|
||||
* Organizes files into structured folders based on metadata:
|
||||
* - TV Shows/{title}/S01/{filename} for series
|
||||
* - Movies/{title} (year)/{filename} for movies
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal metadata interface for folder organization
|
||||
* Contains only the fields needed for virtual path computation
|
||||
*/
|
||||
export interface MediaMeta {
|
||||
// Title information
|
||||
titles?: { eng?: string; [key: string]: string | undefined };
|
||||
originalTitle?: string;
|
||||
title?: string;
|
||||
|
||||
// Series metadata
|
||||
season?: number;
|
||||
episode?: number;
|
||||
extra?: boolean;
|
||||
|
||||
// Movie metadata
|
||||
movieYear?: number;
|
||||
year?: number;
|
||||
|
||||
// File type info
|
||||
fileType?: string; // 'video', 'subtitle', 'torrent'
|
||||
extension?: string;
|
||||
|
||||
// Version/variant
|
||||
version?: string;
|
||||
subtitleLanguage?: string;
|
||||
|
||||
// Allow additional fields
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the virtual path for a file based on its metadata
|
||||
* Returns the path relative to the VFS root
|
||||
*
|
||||
* @param metadata File metadata with title, season, episode, year, etc.
|
||||
* @param filepath Original file path (used for warnings)
|
||||
* @returns Virtual path (e.g., "TV Shows/Show Name/S01/Episode.mkv") or null if unsupported
|
||||
*/
|
||||
export function renamingRule(metadata: Partial<MediaMeta>, filepath?: string): string | null {
|
||||
const supported = ['video', 'subtitle', 'torrent'];
|
||||
|
||||
// Check if filetype is one of the supported ones
|
||||
if (!metadata.fileType || !supported.includes(metadata.fileType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the file extension
|
||||
const extension = metadata.extension;
|
||||
if (!extension) {
|
||||
console.warn(`[RenamingRule] No extension found for file: ${filepath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get title - try various fields
|
||||
const title = metadata.titles?.eng || metadata.originalTitle;
|
||||
if (!title) {
|
||||
if (metadata.fileType !== 'torrent') {
|
||||
// Don't warn for torrent files because they don't always have a title
|
||||
console.warn(`[RenamingRule] No title found for file: ${filepath}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let newPath = '';
|
||||
|
||||
// Build filename components
|
||||
const season = metadata.season ? ('S' + String(metadata.season).padStart(2, '0')) : '';
|
||||
const seasonSpace = metadata.season ? ` ${season}` : '';
|
||||
const episode = metadata.episode ? ('E' + String(metadata.episode).padStart(2, '0')) : '';
|
||||
const version = metadata.version ? ` ${metadata.version}` : '';
|
||||
const year = metadata.movieYear ? ` (${metadata.movieYear})` : '';
|
||||
const subtitle = metadata.fileType === 'subtitle' && metadata.subtitleLanguage
|
||||
? `.${metadata.subtitleLanguage}`
|
||||
: '';
|
||||
|
||||
const fileName = `${title}${seasonSpace}${episode}${year}${subtitle}${version}.${extension}`;
|
||||
|
||||
// Determine structure: TV Shows for series, Movies for standalone content
|
||||
// Note: season can be 0 (special episodes), so check for null/undefined explicitly
|
||||
const hasSeason = metadata.season !== null && metadata.season !== undefined;
|
||||
const hasEpisode = metadata.episode !== null && metadata.episode !== undefined;
|
||||
|
||||
if (metadata.extra) {
|
||||
newPath = `TV Shows/${title}/extra/${fileName}`;
|
||||
} else if (hasSeason && hasEpisode) {
|
||||
newPath = `TV Shows/${title}/${season}/${fileName}`;
|
||||
} else {
|
||||
newPath = `Movies/${title}${year}/${fileName}`;
|
||||
}
|
||||
|
||||
return newPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a path to remove illegal characters
|
||||
* Works for both Windows and Unix-like systems
|
||||
*/
|
||||
export function sanitizePath(newPath: string): string {
|
||||
// Isolate any potential Windows drive letter at the start
|
||||
const driveMatch = newPath.match(/^[a-zA-Z]:/);
|
||||
const drive = driveMatch ? driveMatch[0] : '';
|
||||
|
||||
// Define illegal characters for Windows and potentially problematic ones for Unix
|
||||
const illegalChars = /[<>:"|?*]/g;
|
||||
|
||||
// Replace illegal characters in the path, excluding the drive part
|
||||
const pathWithoutDrive = newPath.slice(drive.length);
|
||||
const sanitizedPath = pathWithoutDrive.replace(illegalChars, '');
|
||||
|
||||
// Combine the drive (if any) and the sanitized path
|
||||
const cleanPath = drive + sanitizedPath;
|
||||
|
||||
// Normalize to forward slashes (works cross-platform)
|
||||
return cleanPath.replace(/\\/g, '/');
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
/**
|
||||
* VirtualFileSystem - In-memory representation of the virtual filesystem
|
||||
*
|
||||
* Reads metadata from Redis and computes organized virtual paths
|
||||
* using MetaDataToFolderStruct for folder structure organization.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Logger } from 'tslog';
|
||||
import { RedisClient, FileMetadata } from '../kv/RedisClient.js';
|
||||
import { MetaDataToFolderStruct } from './MetaDataToFolderStruct.js';
|
||||
import { sanitizePath } from './RenamingRule.js';
|
||||
import { TemplateEngine } from './template/TemplateEngine.js';
|
||||
import { ConditionEvaluator } from './template/ConditionEvaluator.js';
|
||||
import { ConfigStorage } from '../config/ConfigStorage.js';
|
||||
import type { RenamingConfig, RenamingRule, PreviewItem, PreviewResponse } from './types/RenamingRuleTypes.js';
|
||||
|
||||
const logger = new Logger({ name: 'VirtualFileSystem' });
|
||||
|
||||
export interface FileAttributes {
|
||||
size: number;
|
||||
mode: number;
|
||||
mtime: number;
|
||||
atime: number;
|
||||
ctime: number;
|
||||
nlink: number;
|
||||
uid: number;
|
||||
gid: number;
|
||||
}
|
||||
|
||||
export interface ReadResult {
|
||||
sourcePath: string | null;
|
||||
content: Buffer | null;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface VFSNode {
|
||||
type: 'file' | 'directory';
|
||||
name: string;
|
||||
parent: string | null;
|
||||
sourcePath?: string;
|
||||
size?: number;
|
||||
mtime?: Date;
|
||||
ctime?: Date;
|
||||
metadata?: FileMetadata;
|
||||
children?: Set<string>;
|
||||
}
|
||||
|
||||
export interface VFSConfig {
|
||||
fileMode?: number;
|
||||
directoryMode?: number;
|
||||
uid?: number;
|
||||
gid?: number;
|
||||
refreshInterval?: number;
|
||||
configDir?: string; // Directory for config files (renaming rules, etc.)
|
||||
}
|
||||
|
||||
export class VirtualFileSystem {
|
||||
private nodes: Map<string, VFSNode> = new Map();
|
||||
private sourcePathToVirtualPath: Map<string, string> = new Map();
|
||||
private redisClient: RedisClient;
|
||||
private configStorage: ConfigStorage;
|
||||
private metaDataToFolderStruct: MetaDataToFolderStruct;
|
||||
private templateEngine: TemplateEngine;
|
||||
private conditionEvaluator: ConditionEvaluator;
|
||||
private rulesConfig: RenamingConfig | null = null;
|
||||
private config: Required<VFSConfig>;
|
||||
private refreshTimer: NodeJS.Timeout | null = null;
|
||||
private lastRefresh: Date | null = null;
|
||||
|
||||
private cachedStats = {
|
||||
fileCount: 0,
|
||||
directoryCount: 0,
|
||||
totalSize: 0,
|
||||
};
|
||||
|
||||
constructor(redisClient: RedisClient, config: VFSConfig = {}) {
|
||||
this.redisClient = redisClient;
|
||||
this.metaDataToFolderStruct = new MetaDataToFolderStruct();
|
||||
this.templateEngine = new TemplateEngine();
|
||||
this.conditionEvaluator = new ConditionEvaluator();
|
||||
|
||||
// Default config directory
|
||||
const configDir = config.configDir ?? '/meta-fuse/config';
|
||||
this.configStorage = new ConfigStorage({ configDir });
|
||||
|
||||
this.config = {
|
||||
fileMode: config.fileMode ?? 0o644,
|
||||
directoryMode: config.directoryMode ?? 0o755,
|
||||
uid: config.uid ?? 1000,
|
||||
gid: config.gid ?? 1000,
|
||||
refreshInterval: config.refreshInterval ?? 30000, // 30 seconds
|
||||
configDir,
|
||||
};
|
||||
|
||||
// Initialize root directory
|
||||
this.initRoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize root directory
|
||||
*/
|
||||
private initRoot(): void {
|
||||
this.nodes.clear();
|
||||
this.sourcePathToVirtualPath.clear();
|
||||
this.nodes.set('/', {
|
||||
type: 'directory',
|
||||
name: '',
|
||||
parent: null,
|
||||
children: new Set(),
|
||||
});
|
||||
this.cachedStats = { fileCount: 0, directoryCount: 1, totalSize: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the VFS (connect to Redis and load data)
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
logger.info('Starting VirtualFileSystem');
|
||||
|
||||
// Initial load
|
||||
await this.refresh();
|
||||
|
||||
// Start periodic refresh
|
||||
this.refreshTimer = setInterval(async () => {
|
||||
try {
|
||||
await this.refresh();
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh VFS:', error);
|
||||
}
|
||||
}, this.config.refreshInterval);
|
||||
|
||||
logger.info('VirtualFileSystem started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the VFS
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
logger.info('VirtualFileSystem stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the VFS from Redis
|
||||
* Uses template-based rules if configured, otherwise falls back to MetaDataToFolderStruct
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
logger.debug('Refreshing VFS from Redis');
|
||||
|
||||
try {
|
||||
// Load rules configuration
|
||||
this.loadRulesConfig();
|
||||
|
||||
// Get all files from Redis (keyed by sourcePath)
|
||||
const files = await this.redisClient.getAllFiles();
|
||||
|
||||
if (files.size === 0) {
|
||||
logger.info('No files found in Redis');
|
||||
this.initRoot();
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild VFS with computed paths
|
||||
this.initRoot();
|
||||
|
||||
for (const [sourcePath, metadata] of files) {
|
||||
// Compute virtual path using rules or fallback
|
||||
const virtualPath = this.computeVirtualPath(metadata, sourcePath);
|
||||
if (virtualPath) {
|
||||
this.addFile(virtualPath, metadata);
|
||||
this.sourcePathToVirtualPath.set(sourcePath, virtualPath);
|
||||
}
|
||||
}
|
||||
|
||||
this.lastRefresh = new Date();
|
||||
logger.info(`VFS refreshed: ${this.cachedStats.fileCount} files, ${this.cachedStats.directoryCount} directories`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh VFS:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load rules configuration from file
|
||||
* ConfigStorage creates the default config file if it doesn't exist
|
||||
*/
|
||||
private loadRulesConfig(): void {
|
||||
this.rulesConfig = this.configStorage.getRulesConfig();
|
||||
const isDefault = this.rulesConfig.isDefault ? ' (default)' : '';
|
||||
logger.debug(`Loaded ${this.rulesConfig.rules.length} renaming rules${isDefault}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute virtual path for a file using template rules
|
||||
*/
|
||||
private computeVirtualPath(metadata: FileMetadata, sourcePath: string): string {
|
||||
if (!this.rulesConfig) {
|
||||
// Fallback to old behavior
|
||||
return this.metaDataToFolderStruct.generateSingleVirtualPath(sourcePath, metadata);
|
||||
}
|
||||
|
||||
// Sort rules by priority (descending)
|
||||
const sortedRules = [...this.rulesConfig.rules]
|
||||
.filter(r => r.enabled)
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
// Find first matching rule
|
||||
for (const rule of sortedRules) {
|
||||
if (this.conditionEvaluator.evaluateGroup(rule.conditions, metadata as Record<string, unknown>)) {
|
||||
const result = this.templateEngine.interpolate(rule.template, metadata as Record<string, unknown>);
|
||||
if (result) {
|
||||
return sanitizePath(result);
|
||||
}
|
||||
if (rule.fallbackToUnsorted) {
|
||||
const filename = metadata.fileName || sourcePath.split('/').pop() || 'unknown';
|
||||
return `Unsorted/${filename}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply default rule if no rules matched
|
||||
if (this.rulesConfig.defaultRule) {
|
||||
const result = this.templateEngine.interpolate(
|
||||
this.rulesConfig.defaultRule.template,
|
||||
metadata as Record<string, unknown>
|
||||
);
|
||||
if (result) {
|
||||
return sanitizePath(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Ultimate fallback
|
||||
const filename = metadata.fileName || sourcePath.split('/').pop() || 'unknown';
|
||||
return `Unsorted/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incremental file update from pub/sub
|
||||
* Adds or updates a single file without full rebuild
|
||||
*/
|
||||
async onFileUpdate(hashId: string, action: 'add' | 'update' | 'remove'): Promise<void> {
|
||||
if (action === 'remove') {
|
||||
// Find and remove file by hashId
|
||||
for (const [vPath, node] of this.nodes) {
|
||||
if (node.type === 'file' && node.metadata?.hashId === hashId) {
|
||||
this.removeFile(vPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add or update: fetch metadata and compute virtual path
|
||||
const metadata = await this.redisClient.getFileByHashId(hashId);
|
||||
if (!metadata) {
|
||||
logger.warn(`File not found in Redis: ${hashId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute virtual path for this single file
|
||||
const virtualPath = this.metaDataToFolderStruct.generateSingleVirtualPath(
|
||||
metadata.sourcePath,
|
||||
metadata
|
||||
);
|
||||
|
||||
// If updating, remove old entry first
|
||||
if (action === 'update') {
|
||||
const oldVirtualPath = this.sourcePathToVirtualPath.get(metadata.sourcePath);
|
||||
if (oldVirtualPath && oldVirtualPath !== virtualPath) {
|
||||
this.removeFile(oldVirtualPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Add/update file
|
||||
this.addFile(virtualPath, metadata);
|
||||
this.sourcePathToVirtualPath.set(metadata.sourcePath, virtualPath);
|
||||
|
||||
logger.debug(`File ${action}ed: ${virtualPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a file to the VFS
|
||||
*/
|
||||
private addFile(virtualPath: string, metadata: FileMetadata): void {
|
||||
virtualPath = this.normalizePath(virtualPath);
|
||||
|
||||
// Skip if file already exists at this path
|
||||
if (this.nodes.has(virtualPath) && this.nodes.get(virtualPath)!.type === 'file') {
|
||||
// Update existing file
|
||||
const node = this.nodes.get(virtualPath)!;
|
||||
const oldSize = node.size ?? 0;
|
||||
this.cachedStats.totalSize -= oldSize;
|
||||
this.cachedStats.fileCount--;
|
||||
}
|
||||
|
||||
// Create parent directories
|
||||
const dirname = path.dirname(virtualPath);
|
||||
this.ensureDirectory(dirname);
|
||||
|
||||
// Get file stats
|
||||
let size = metadata.size;
|
||||
let mtime = new Date(metadata.mtime || Date.now());
|
||||
let ctime = new Date(metadata.ctime || metadata.mtime || Date.now());
|
||||
|
||||
// Fallback: stat the source file if metadata is incomplete
|
||||
if (!size && metadata.sourcePath) {
|
||||
try {
|
||||
const stats = fs.statSync(metadata.sourcePath);
|
||||
size = stats.size;
|
||||
mtime = stats.mtime;
|
||||
ctime = stats.ctime;
|
||||
} catch {
|
||||
logger.warn(`Cannot stat source file: ${metadata.sourcePath}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add file node
|
||||
const filename = path.basename(virtualPath);
|
||||
this.nodes.set(virtualPath, {
|
||||
type: 'file',
|
||||
name: filename,
|
||||
parent: dirname,
|
||||
sourcePath: metadata.sourcePath,
|
||||
size,
|
||||
mtime,
|
||||
ctime,
|
||||
metadata,
|
||||
});
|
||||
|
||||
// Add to parent's children
|
||||
const parentNode = this.nodes.get(dirname);
|
||||
if (parentNode && parentNode.type === 'directory') {
|
||||
parentNode.children!.add(filename);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
this.cachedStats.fileCount++;
|
||||
this.cachedStats.totalSize += size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from the VFS
|
||||
*/
|
||||
private removeFile(virtualPath: string): void {
|
||||
virtualPath = this.normalizePath(virtualPath);
|
||||
const node = this.nodes.get(virtualPath);
|
||||
|
||||
if (!node || node.type !== 'file') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from parent's children
|
||||
if (node.parent) {
|
||||
const parentNode = this.nodes.get(node.parent);
|
||||
if (parentNode && parentNode.type === 'directory') {
|
||||
parentNode.children!.delete(node.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stats
|
||||
this.cachedStats.fileCount--;
|
||||
this.cachedStats.totalSize -= node.size ?? 0;
|
||||
|
||||
// Remove node
|
||||
this.nodes.delete(virtualPath);
|
||||
|
||||
// Remove from source path mapping
|
||||
if (node.sourcePath) {
|
||||
this.sourcePathToVirtualPath.delete(node.sourcePath);
|
||||
}
|
||||
|
||||
// Clean up empty parent directories
|
||||
this.cleanEmptyDirectories(node.parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up empty directories
|
||||
*/
|
||||
private cleanEmptyDirectories(dirPath: string | null): void {
|
||||
if (!dirPath || dirPath === '/') {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = this.nodes.get(dirPath);
|
||||
if (!node || node.type !== 'directory') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only remove if empty
|
||||
if (node.children!.size === 0) {
|
||||
// Remove from parent
|
||||
if (node.parent) {
|
||||
const parentNode = this.nodes.get(node.parent);
|
||||
if (parentNode && parentNode.type === 'directory') {
|
||||
parentNode.children!.delete(node.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove this directory
|
||||
this.nodes.delete(dirPath);
|
||||
this.cachedStats.directoryCount--;
|
||||
|
||||
// Recursively clean parent
|
||||
this.cleanEmptyDirectories(node.parent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a directory exists in the VFS
|
||||
*/
|
||||
private ensureDirectory(dirPath: string): void {
|
||||
dirPath = this.normalizePath(dirPath);
|
||||
|
||||
if (dirPath === '/' || this.nodes.has(dirPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recursively create parent
|
||||
const parentPath = path.dirname(dirPath);
|
||||
this.ensureDirectory(parentPath);
|
||||
|
||||
// Create this directory
|
||||
const dirName = path.basename(dirPath);
|
||||
this.nodes.set(dirPath, {
|
||||
type: 'directory',
|
||||
name: dirName,
|
||||
parent: parentPath,
|
||||
children: new Set(),
|
||||
});
|
||||
|
||||
// Add to parent's children
|
||||
const parentNode = this.nodes.get(parentPath);
|
||||
if (parentNode && parentNode.type === 'directory') {
|
||||
parentNode.children!.add(dirName);
|
||||
}
|
||||
|
||||
this.cachedStats.directoryCount++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path
|
||||
*/
|
||||
private normalizePath(p: string): string {
|
||||
p = p.split(path.sep).join('/');
|
||||
if (!p.startsWith('/')) {
|
||||
p = '/' + p;
|
||||
}
|
||||
if (p !== '/' && p.endsWith('/')) {
|
||||
p = p.slice(0, -1);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// FUSE API Methods
|
||||
|
||||
/**
|
||||
* List directory contents
|
||||
*/
|
||||
readdir(dirPath: string): string[] | null {
|
||||
dirPath = this.normalizePath(dirPath);
|
||||
const node = this.nodes.get(dirPath);
|
||||
|
||||
if (!node || node.type !== 'directory') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Array.from(node.children!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file attributes
|
||||
*/
|
||||
getattr(filepath: string): FileAttributes | null {
|
||||
filepath = this.normalizePath(filepath);
|
||||
const node = this.nodes.get(filepath);
|
||||
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.type === 'directory') {
|
||||
return {
|
||||
size: 4096,
|
||||
mode: 0o040000 | this.config.directoryMode,
|
||||
mtime: Date.now() / 1000,
|
||||
atime: Date.now() / 1000,
|
||||
ctime: Date.now() / 1000,
|
||||
nlink: 2,
|
||||
uid: this.config.uid,
|
||||
gid: this.config.gid,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
size: node.size!,
|
||||
mode: 0o100000 | this.config.fileMode,
|
||||
mtime: node.mtime!.getTime() / 1000,
|
||||
atime: node.mtime!.getTime() / 1000,
|
||||
ctime: node.ctime!.getTime() / 1000,
|
||||
nlink: 1,
|
||||
uid: this.config.uid,
|
||||
gid: this.config.gid,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path exists
|
||||
*/
|
||||
exists(filepath: string): boolean {
|
||||
filepath = this.normalizePath(filepath);
|
||||
return this.nodes.has(filepath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file (returns source path for actual reading)
|
||||
*/
|
||||
read(filepath: string): ReadResult | null {
|
||||
filepath = this.normalizePath(filepath);
|
||||
const node = this.nodes.get(filepath);
|
||||
|
||||
if (!node || node.type !== 'file') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sourcePath: node.sourcePath!,
|
||||
content: null,
|
||||
size: node.size!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file metadata
|
||||
*/
|
||||
getMetadata(filepath: string): FileMetadata | null {
|
||||
filepath = this.normalizePath(filepath);
|
||||
const node = this.nodes.get(filepath);
|
||||
|
||||
if (!node || node.type !== 'file') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return node.metadata ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all file paths
|
||||
*/
|
||||
getAllFiles(): string[] {
|
||||
const files: string[] = [];
|
||||
for (const [filepath, node] of this.nodes.entries()) {
|
||||
if (node.type === 'file') {
|
||||
files.push(filepath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all directory paths
|
||||
*/
|
||||
getAllDirectories(): string[] {
|
||||
const dirs: string[] = [];
|
||||
for (const [dirpath, node] of this.nodes.entries()) {
|
||||
if (node.type === 'directory') {
|
||||
dirs.push(dirpath);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VFS statistics
|
||||
*/
|
||||
getStats(): {
|
||||
fileCount: number;
|
||||
directoryCount: number;
|
||||
totalSize: number;
|
||||
lastRefresh: string | null;
|
||||
redisConnected: boolean;
|
||||
} {
|
||||
return {
|
||||
...this.cachedStats,
|
||||
lastRefresh: this.lastRefresh?.toISOString() ?? null,
|
||||
redisConnected: this.redisClient.connected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get virtual path for a source path
|
||||
*/
|
||||
getVirtualPath(sourcePath: string): string | null {
|
||||
return this.sourcePathToVirtualPath.get(sourcePath) ?? null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Rules Configuration API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get current rules configuration
|
||||
*/
|
||||
getRulesConfig(): RenamingConfig {
|
||||
if (!this.rulesConfig) {
|
||||
this.loadRulesConfig();
|
||||
}
|
||||
return this.rulesConfig!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save rules configuration to file
|
||||
*/
|
||||
saveRulesConfig(config: RenamingConfig): void {
|
||||
this.configStorage.saveRulesConfig(config);
|
||||
this.rulesConfig = config;
|
||||
logger.info(`Saved rules configuration with ${config.rules.length} rules`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview how files would be renamed with given rules
|
||||
*/
|
||||
async previewRules(testRules?: RenamingRule[], limit: number = 100): Promise<PreviewResponse> {
|
||||
const files = await this.redisClient.getAllFiles();
|
||||
const previews: PreviewItem[] = [];
|
||||
|
||||
// Use test rules or current config
|
||||
const rulesToTest = testRules || this.rulesConfig?.rules || [];
|
||||
const defaultRule = this.rulesConfig?.defaultRule;
|
||||
|
||||
let count = 0;
|
||||
for (const [sourcePath, metadata] of files) {
|
||||
if (count >= limit) break;
|
||||
|
||||
const currentVirtualPath = this.sourcePathToVirtualPath.get(sourcePath) || 'Unknown';
|
||||
|
||||
try {
|
||||
// Compute new path with test rules
|
||||
const newVirtualPath = this.computeVirtualPathWithRules(
|
||||
metadata,
|
||||
sourcePath,
|
||||
rulesToTest,
|
||||
defaultRule
|
||||
);
|
||||
const matchedRule = this.findMatchingRule(metadata, rulesToTest);
|
||||
|
||||
previews.push({
|
||||
sourcePath,
|
||||
currentVirtualPath,
|
||||
newVirtualPath,
|
||||
matchedRule: matchedRule?.id || null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
previews.push({
|
||||
sourcePath,
|
||||
currentVirtualPath,
|
||||
newVirtualPath: null,
|
||||
matchedRule: null,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
return {
|
||||
previews,
|
||||
total: files.size,
|
||||
limited: files.size > limit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute virtual path with specific rules (for preview)
|
||||
*/
|
||||
private computeVirtualPathWithRules(
|
||||
metadata: FileMetadata,
|
||||
sourcePath: string,
|
||||
rules: RenamingRule[],
|
||||
defaultRule?: RenamingRule
|
||||
): string {
|
||||
// Sort rules by priority (descending)
|
||||
const sortedRules = [...rules]
|
||||
.filter(r => r.enabled)
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
// Find first matching rule
|
||||
for (const rule of sortedRules) {
|
||||
if (this.conditionEvaluator.evaluateGroup(rule.conditions, metadata as Record<string, unknown>)) {
|
||||
const result = this.templateEngine.interpolate(rule.template, metadata as Record<string, unknown>);
|
||||
if (result) {
|
||||
return sanitizePath(result);
|
||||
}
|
||||
if (rule.fallbackToUnsorted) {
|
||||
const filename = metadata.fileName || sourcePath.split('/').pop() || 'unknown';
|
||||
return `Unsorted/${filename}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply default rule if no rules matched
|
||||
if (defaultRule) {
|
||||
const result = this.templateEngine.interpolate(
|
||||
defaultRule.template,
|
||||
metadata as Record<string, unknown>
|
||||
);
|
||||
if (result) {
|
||||
return sanitizePath(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Ultimate fallback
|
||||
const filename = metadata.fileName || sourcePath.split('/').pop() || 'unknown';
|
||||
return `Unsorted/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find which rule matches a file
|
||||
*/
|
||||
private findMatchingRule(metadata: FileMetadata, rules: RenamingRule[]): RenamingRule | null {
|
||||
const sortedRules = [...rules]
|
||||
.filter(r => r.enabled)
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
for (const rule of sortedRules) {
|
||||
if (this.conditionEvaluator.evaluateGroup(rule.conditions, metadata as Record<string, unknown>)) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Default Renaming Rules
|
||||
*
|
||||
* These rules match the current hardcoded behavior in RenamingRule.ts.
|
||||
* They serve as the initial configuration when no custom rules are set.
|
||||
*
|
||||
* Old behavior:
|
||||
* - Only processes video, subtitle, torrent files
|
||||
* - Requires extension
|
||||
* - Title from titles.eng || originalTitle
|
||||
* - Files without title/extension are hidden (not in Unsorted)
|
||||
*/
|
||||
|
||||
import type { RenamingConfig, RenamingRule } from '../types/RenamingRuleTypes.js';
|
||||
|
||||
/**
|
||||
* Supported file types condition (video, subtitle, torrent)
|
||||
* Used by all rules to filter out unsupported file types
|
||||
*/
|
||||
const supportedFileTypeCondition = {
|
||||
operator: 'OR' as const,
|
||||
conditions: [
|
||||
{ type: 'EQUALS' as const, field: 'fileType', value: 'video' },
|
||||
{ type: 'EQUALS' as const, field: 'fileType', value: 'subtitle' },
|
||||
{ type: 'EQUALS' as const, field: 'fileType', value: 'torrent' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* TV Series - Extras rule
|
||||
* Matches files marked as extra/special content
|
||||
*/
|
||||
const tvExtrasRule: RenamingRule = {
|
||||
id: 'tv-extras',
|
||||
name: 'TV Series - Extras',
|
||||
description: 'Special episodes, behind the scenes, extras',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
conditions: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
supportedFileTypeCondition,
|
||||
{ type: 'EXISTS', field: 'extension' },
|
||||
{ type: 'EQUALS', field: 'extra', value: true },
|
||||
],
|
||||
},
|
||||
// Old format: TV Shows/{title}/extra/{title}{seasonSpace}{episode}{subtitle}{version}.{extension}
|
||||
template: 'TV Shows/{title|originalTitle}/extra/{title|originalTitle}{season?( S{season:pad2})}{episode?(E{episode:pad2})}{subtitleLanguage?(.{subtitleLanguage})}{version?( {version})}.{extension}',
|
||||
fallbackToUnsorted: true, // If template fails (no title), put in Unsorted
|
||||
};
|
||||
|
||||
/**
|
||||
* TV Series rule
|
||||
* Matches files with both season and episode numbers
|
||||
*/
|
||||
const tvSeriesRule: RenamingRule = {
|
||||
id: 'tv-series',
|
||||
name: 'TV Series',
|
||||
description: 'Regular TV show episodes with season and episode numbers',
|
||||
enabled: true,
|
||||
priority: 90,
|
||||
conditions: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
supportedFileTypeCondition,
|
||||
{ type: 'EXISTS', field: 'extension' },
|
||||
{ type: 'EXISTS', field: 'season' },
|
||||
{ type: 'EXISTS', field: 'episode' },
|
||||
],
|
||||
},
|
||||
// Old format: TV Shows/{title}/S{season}/{title} S{season}E{episode}{subtitle}{version}.{extension}
|
||||
template: 'TV Shows/{title|originalTitle}/S{season:pad2}/{title|originalTitle} S{season:pad2}E{episode:pad2}{subtitleLanguage?(.{subtitleLanguage})}{version?( {version})}.{extension}',
|
||||
fallbackToUnsorted: true, // If template fails (no title), put in Unsorted
|
||||
};
|
||||
|
||||
/**
|
||||
* Movies rule
|
||||
* Matches files with a title but no season number (assumed to be movies)
|
||||
*/
|
||||
const moviesRule: RenamingRule = {
|
||||
id: 'movies',
|
||||
name: 'Movies',
|
||||
description: 'Standalone movies without season/episode numbers',
|
||||
enabled: true,
|
||||
priority: 80,
|
||||
conditions: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
supportedFileTypeCondition,
|
||||
{ type: 'EXISTS', field: 'extension' },
|
||||
{
|
||||
operator: 'OR',
|
||||
conditions: [
|
||||
{ type: 'EXISTS', field: 'title' },
|
||||
{ type: 'EXISTS', field: 'originalTitle' },
|
||||
],
|
||||
},
|
||||
{ type: 'NOT_EXISTS', field: 'season' },
|
||||
],
|
||||
},
|
||||
// Old format: Movies/{title}{year}/{title}{year}{subtitle}{version}.{extension}
|
||||
template: 'Movies/{title|originalTitle}{movieYear?( ({movieYear}))}/{title|originalTitle}{movieYear?( ({movieYear}))}{subtitleLanguage?(.{subtitleLanguage})}{version?( {version})}.{extension}',
|
||||
fallbackToUnsorted: true, // If template fails, put in Unsorted
|
||||
};
|
||||
|
||||
/**
|
||||
* Images rule
|
||||
* Matches image files (posters, backdrops, etc.)
|
||||
* Note: Poster images are classified as 'document' type
|
||||
*/
|
||||
const imagesRule: RenamingRule = {
|
||||
id: 'images',
|
||||
name: 'Images',
|
||||
description: 'Image files like posters and backdrops',
|
||||
enabled: true,
|
||||
priority: 70,
|
||||
conditions: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{
|
||||
operator: 'OR',
|
||||
conditions: [
|
||||
{ type: 'EQUALS', field: 'fileType', value: 'image' },
|
||||
{ type: 'EQUALS', field: 'fileType', value: 'document' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
template: 'Images/{fileName}',
|
||||
fallbackToUnsorted: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default fallback rule
|
||||
* Used when no other rules match - places files in Unsorted folder
|
||||
* Note: In old behavior, unsupported files were hidden. This rule provides visibility.
|
||||
*/
|
||||
const fallbackRule: RenamingRule = {
|
||||
id: 'fallback-unsorted',
|
||||
name: 'Unsorted',
|
||||
description: 'Files that do not match any other rule',
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
conditions: {
|
||||
operator: 'AND',
|
||||
conditions: [], // Always matches
|
||||
},
|
||||
template: 'Unsorted/{fileName}',
|
||||
fallbackToUnsorted: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the default renaming configuration
|
||||
*
|
||||
* @returns The default RenamingConfig matching current hardcoded behavior
|
||||
*/
|
||||
export function getDefaultRulesConfig(): RenamingConfig {
|
||||
return {
|
||||
version: 1,
|
||||
rules: [
|
||||
tvExtrasRule,
|
||||
tvSeriesRule,
|
||||
moviesRule,
|
||||
imagesRule,
|
||||
],
|
||||
defaultRule: fallbackRule,
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plex-style naming preset
|
||||
* Uses Plex's preferred naming conventions
|
||||
*/
|
||||
export function getPlexPreset(): RenamingConfig {
|
||||
return {
|
||||
version: 1,
|
||||
rules: [
|
||||
{
|
||||
...tvSeriesRule,
|
||||
id: 'plex-tv',
|
||||
name: 'TV Series (Plex)',
|
||||
template: 'TV Shows/{title|originalTitle}/Season {season:pad2}/{title|originalTitle} - S{season:pad2}E{episode:pad2}{subtitleLanguage?(.{subtitleLanguage})}{version?( - {version})}.{extension}',
|
||||
},
|
||||
{
|
||||
...moviesRule,
|
||||
id: 'plex-movies',
|
||||
name: 'Movies (Plex)',
|
||||
template: 'Movies/{title|originalTitle} ({movieYear|Unknown})/{title|originalTitle} ({movieYear|Unknown}){subtitleLanguage?(.{subtitleLanguage})}{version?( - {version})}.{extension}',
|
||||
},
|
||||
],
|
||||
defaultRule: fallbackRule,
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Jellyfin-style naming preset
|
||||
* Uses Jellyfin's preferred naming conventions
|
||||
*/
|
||||
export function getJellyfinPreset(): RenamingConfig {
|
||||
return {
|
||||
version: 1,
|
||||
rules: [
|
||||
{
|
||||
...tvSeriesRule,
|
||||
id: 'jellyfin-tv',
|
||||
name: 'TV Series (Jellyfin)',
|
||||
template: 'Shows/{title|originalTitle}{year?( ({year}))}/Season {season:pad2}/S{season:pad2}E{episode:pad2}{subtitleLanguage?(.{subtitleLanguage})}.{extension}',
|
||||
},
|
||||
{
|
||||
...moviesRule,
|
||||
id: 'jellyfin-movies',
|
||||
name: 'Movies (Jellyfin)',
|
||||
template: 'Movies/{title|originalTitle} ({movieYear})/{title|originalTitle} ({movieYear}){subtitleLanguage?(.{subtitleLanguage})}.{extension}',
|
||||
},
|
||||
],
|
||||
defaultRule: fallbackRule,
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Available presets
|
||||
*/
|
||||
export const PRESETS = {
|
||||
default: getDefaultRulesConfig,
|
||||
plex: getPlexPreset,
|
||||
jellyfin: getJellyfinPreset,
|
||||
} as const;
|
||||
|
||||
export type PresetName = keyof typeof PRESETS;
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Condition Evaluator
|
||||
*
|
||||
* Evaluates conditions and condition groups against metadata
|
||||
* to determine which renaming rule should be applied.
|
||||
*/
|
||||
|
||||
import { Logger } from 'tslog';
|
||||
import type { Condition, ConditionGroup } from '../types/RenamingRuleTypes.js';
|
||||
import { isConditionGroup } from '../types/RenamingRuleTypes.js';
|
||||
import { getNestedValue } from './TemplateEngine.js';
|
||||
|
||||
const logger = new Logger({ name: 'ConditionEvaluator' });
|
||||
|
||||
/**
|
||||
* Condition Evaluator class
|
||||
*/
|
||||
export class ConditionEvaluator {
|
||||
/**
|
||||
* Evaluate a single condition against metadata
|
||||
*
|
||||
* @param condition The condition to evaluate
|
||||
* @param metadata The metadata object to check against
|
||||
* @returns true if the condition is satisfied
|
||||
*/
|
||||
evaluateCondition(condition: Condition, metadata: Record<string, unknown>): boolean {
|
||||
const value = getNestedValue(metadata, condition.field);
|
||||
|
||||
switch (condition.type) {
|
||||
case 'EXISTS':
|
||||
return value !== null && value !== undefined;
|
||||
|
||||
case 'NOT_EXISTS':
|
||||
return value === null || value === undefined;
|
||||
|
||||
case 'EQUALS':
|
||||
// Handle type coercion for common cases
|
||||
if (typeof condition.value === 'boolean') {
|
||||
return value === condition.value;
|
||||
}
|
||||
if (typeof condition.value === 'number') {
|
||||
return Number(value) === condition.value;
|
||||
}
|
||||
return String(value) === String(condition.value);
|
||||
|
||||
case 'NOT_EQUALS':
|
||||
if (typeof condition.value === 'boolean') {
|
||||
return value !== condition.value;
|
||||
}
|
||||
if (typeof condition.value === 'number') {
|
||||
return Number(value) !== condition.value;
|
||||
}
|
||||
return String(value) !== String(condition.value);
|
||||
|
||||
case 'CONTAINS':
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
return String(value).includes(String(condition.value));
|
||||
|
||||
case 'MATCHES':
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const regex = new RegExp(String(condition.value));
|
||||
return regex.test(String(value));
|
||||
} catch (error) {
|
||||
logger.warn(`Invalid regex in condition: ${condition.value}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn(`Unknown condition type: ${(condition as Condition).type}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a condition group (AND/OR of multiple conditions)
|
||||
*
|
||||
* @param group The condition group to evaluate
|
||||
* @param metadata The metadata object to check against
|
||||
* @returns true if the group condition is satisfied
|
||||
*/
|
||||
evaluateGroup(group: ConditionGroup, metadata: Record<string, unknown>): boolean {
|
||||
// Empty conditions means always match
|
||||
if (group.conditions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const results = group.conditions.map(condition => {
|
||||
if (isConditionGroup(condition)) {
|
||||
return this.evaluateGroup(condition, metadata);
|
||||
}
|
||||
return this.evaluateCondition(condition, metadata);
|
||||
});
|
||||
|
||||
if (group.operator === 'AND') {
|
||||
return results.every(r => r);
|
||||
} else {
|
||||
return results.some(r => r);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate conditions - convenience method that handles both single conditions and groups
|
||||
*
|
||||
* @param conditions A single condition or condition group
|
||||
* @param metadata The metadata object to check against
|
||||
* @returns true if the conditions are satisfied
|
||||
*/
|
||||
evaluate(conditions: Condition | ConditionGroup, metadata: Record<string, unknown>): boolean {
|
||||
if (isConditionGroup(conditions)) {
|
||||
return this.evaluateGroup(conditions, metadata);
|
||||
}
|
||||
return this.evaluateCondition(conditions, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a condition or condition group
|
||||
*
|
||||
* @param conditions The conditions to validate
|
||||
* @returns Validation result with any errors
|
||||
*/
|
||||
validate(conditions: Condition | ConditionGroup): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (isConditionGroup(conditions)) {
|
||||
// Validate group
|
||||
if (!['AND', 'OR'].includes(conditions.operator)) {
|
||||
errors.push(`Invalid operator: ${conditions.operator}`);
|
||||
}
|
||||
|
||||
for (const condition of conditions.conditions) {
|
||||
const result = this.validate(condition);
|
||||
errors.push(...result.errors);
|
||||
}
|
||||
} else {
|
||||
// Validate single condition
|
||||
const validTypes = ['EXISTS', 'NOT_EXISTS', 'EQUALS', 'NOT_EQUALS', 'CONTAINS', 'MATCHES'];
|
||||
if (!validTypes.includes(conditions.type)) {
|
||||
errors.push(`Invalid condition type: ${conditions.type}`);
|
||||
}
|
||||
|
||||
if (!conditions.field || typeof conditions.field !== 'string') {
|
||||
errors.push('Condition must have a valid field');
|
||||
}
|
||||
|
||||
// Check if value is required
|
||||
const requiresValue = ['EQUALS', 'NOT_EQUALS', 'CONTAINS', 'MATCHES'];
|
||||
if (requiresValue.includes(conditions.type) && conditions.value === undefined) {
|
||||
errors.push(`Condition type ${conditions.type} requires a value`);
|
||||
}
|
||||
|
||||
// Validate regex for MATCHES
|
||||
if (conditions.type === 'MATCHES' && conditions.value !== undefined) {
|
||||
try {
|
||||
new RegExp(String(conditions.value));
|
||||
} catch (error) {
|
||||
errors.push(`Invalid regex: ${conditions.value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Template Engine
|
||||
*
|
||||
* Parses and interpolates template strings for renaming rules.
|
||||
*
|
||||
* Supported syntax:
|
||||
* - {var} - Basic variable interpolation
|
||||
* - {var:pad2} - Zero-pad to 2 digits
|
||||
* - {var:pad3} - Zero-pad to 3 digits
|
||||
* - {var?} - Optional: omit if missing
|
||||
* - {var?(text)} - Conditional section: include text only if var exists
|
||||
* - {var|default} - Use default value if var is missing
|
||||
* - {a.b} - Nested object access
|
||||
*/
|
||||
|
||||
import { Logger } from 'tslog';
|
||||
|
||||
const logger = new Logger({ name: 'TemplateEngine' });
|
||||
|
||||
/**
|
||||
* Token types for template parsing
|
||||
*/
|
||||
type TokenType = 'literal' | 'variable' | 'optional' | 'conditional' | 'default';
|
||||
|
||||
interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
field?: string;
|
||||
format?: string;
|
||||
defaultValue?: string;
|
||||
innerTemplate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nested value from an object using dot notation
|
||||
* e.g., getNestedValue({titles: {eng: 'Test'}}, 'titles.eng') => 'Test'
|
||||
*/
|
||||
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a format to a value
|
||||
*/
|
||||
function applyFormat(value: unknown, format: string): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const strValue = String(value);
|
||||
|
||||
switch (format) {
|
||||
case 'pad2':
|
||||
return strValue.padStart(2, '0');
|
||||
case 'pad3':
|
||||
return strValue.padStart(3, '0');
|
||||
case 'pad4':
|
||||
return strValue.padStart(4, '0');
|
||||
case 'upper':
|
||||
case 'uppercase':
|
||||
return strValue.toUpperCase();
|
||||
case 'lower':
|
||||
case 'lowercase':
|
||||
return strValue.toLowerCase();
|
||||
default:
|
||||
// Check for custom padding: padN where N is a number
|
||||
const padMatch = format.match(/^pad(\d+)$/);
|
||||
if (padMatch) {
|
||||
return strValue.padStart(parseInt(padMatch[1], 10), '0');
|
||||
}
|
||||
return strValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a template string into tokens
|
||||
*/
|
||||
function tokenize(template: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
let literalStart = 0;
|
||||
|
||||
while (i < template.length) {
|
||||
if (template[i] === '{') {
|
||||
// Save any literal text before this
|
||||
if (i > literalStart) {
|
||||
tokens.push({
|
||||
type: 'literal',
|
||||
value: template.slice(literalStart, i),
|
||||
});
|
||||
}
|
||||
|
||||
// Find the matching closing brace
|
||||
const start = i + 1;
|
||||
let depth = 1;
|
||||
let end = start;
|
||||
|
||||
while (end < template.length && depth > 0) {
|
||||
if (template[end] === '{') depth++;
|
||||
if (template[end] === '}') depth--;
|
||||
if (depth > 0) end++;
|
||||
}
|
||||
|
||||
if (depth !== 0) {
|
||||
// Unclosed brace, treat as literal
|
||||
tokens.push({
|
||||
type: 'literal',
|
||||
value: template.slice(i, end),
|
||||
});
|
||||
} else {
|
||||
const content = template.slice(start, end);
|
||||
tokens.push(parseVariable(content));
|
||||
}
|
||||
|
||||
i = end + 1;
|
||||
literalStart = i;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining literal text
|
||||
if (literalStart < template.length) {
|
||||
tokens.push({
|
||||
type: 'literal',
|
||||
value: template.slice(literalStart),
|
||||
});
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a variable expression inside braces
|
||||
*/
|
||||
function parseVariable(content: string): Token {
|
||||
// Check for conditional section: var?(inner template)
|
||||
const conditionalMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)\?\((.*)\)$/s);
|
||||
if (conditionalMatch) {
|
||||
return {
|
||||
type: 'conditional',
|
||||
value: content,
|
||||
field: conditionalMatch[1],
|
||||
innerTemplate: conditionalMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for optional variable: var?
|
||||
if (content.endsWith('?')) {
|
||||
const field = content.slice(0, -1);
|
||||
// Check for format: var:format?
|
||||
const formatMatch = field.match(/^([a-zA-Z_][a-zA-Z0-9_.]*):([a-z0-9]+)$/);
|
||||
if (formatMatch) {
|
||||
return {
|
||||
type: 'optional',
|
||||
value: content,
|
||||
field: formatMatch[1],
|
||||
format: formatMatch[2],
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'optional',
|
||||
value: content,
|
||||
field: field,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for default value: var|default
|
||||
const defaultMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)\|(.+)$/);
|
||||
if (defaultMatch) {
|
||||
return {
|
||||
type: 'default',
|
||||
value: content,
|
||||
field: defaultMatch[1],
|
||||
defaultValue: defaultMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for format: var:format
|
||||
const formatMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_.]*):([a-z0-9]+)$/);
|
||||
if (formatMatch) {
|
||||
return {
|
||||
type: 'variable',
|
||||
value: content,
|
||||
field: formatMatch[1],
|
||||
format: formatMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Simple variable
|
||||
return {
|
||||
type: 'variable',
|
||||
value: content,
|
||||
field: content,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Template Engine class for interpolating templates
|
||||
*/
|
||||
export class TemplateEngine {
|
||||
/**
|
||||
* Interpolate a template string with metadata
|
||||
*
|
||||
* @param template The template string with variables
|
||||
* @param metadata The metadata object to get values from
|
||||
* @returns The interpolated string, or null if a required variable is missing
|
||||
*/
|
||||
interpolate(template: string, metadata: Record<string, unknown>): string | null {
|
||||
const tokens = tokenize(template);
|
||||
const result: string[] = [];
|
||||
|
||||
for (const token of tokens) {
|
||||
switch (token.type) {
|
||||
case 'literal':
|
||||
result.push(token.value);
|
||||
break;
|
||||
|
||||
case 'variable': {
|
||||
const value = getNestedValue(metadata, token.field!);
|
||||
if (value === null || value === undefined) {
|
||||
// Required variable is missing
|
||||
logger.debug(`Missing required variable: ${token.field}`);
|
||||
return null;
|
||||
}
|
||||
const formatted = token.format ? applyFormat(value, token.format) : String(value);
|
||||
result.push(formatted);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'optional': {
|
||||
const value = getNestedValue(metadata, token.field!);
|
||||
if (value !== null && value !== undefined) {
|
||||
const formatted = token.format ? applyFormat(value, token.format) : String(value);
|
||||
result.push(formatted);
|
||||
}
|
||||
// If missing, just skip (don't add anything)
|
||||
break;
|
||||
}
|
||||
|
||||
case 'conditional': {
|
||||
const value = getNestedValue(metadata, token.field!);
|
||||
if (value !== null && value !== undefined) {
|
||||
// Variable exists, interpolate the inner template
|
||||
const innerResult = this.interpolate(token.innerTemplate!, metadata);
|
||||
if (innerResult !== null) {
|
||||
result.push(innerResult);
|
||||
}
|
||||
}
|
||||
// If missing, skip the entire conditional section
|
||||
break;
|
||||
}
|
||||
|
||||
case 'default': {
|
||||
const value = getNestedValue(metadata, token.field!);
|
||||
if (value !== null && value !== undefined) {
|
||||
result.push(String(value));
|
||||
} else {
|
||||
// Check if defaultValue looks like a field name (for field fallback)
|
||||
// Field names: start with letter/underscore, contain alphanumeric/dot/underscore
|
||||
const fieldPattern = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
|
||||
if (fieldPattern.test(token.defaultValue!)) {
|
||||
// Try to get value from fallback field
|
||||
const fallbackValue = getNestedValue(metadata, token.defaultValue!);
|
||||
if (fallbackValue !== null && fallbackValue !== undefined) {
|
||||
result.push(String(fallbackValue));
|
||||
} else {
|
||||
// Neither field exists - treat as missing required variable
|
||||
// Don't use the field name as a literal string
|
||||
logger.debug(`Missing both primary field '${token.field}' and fallback field '${token.defaultValue}'`);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// defaultValue is not a field name, use as literal
|
||||
result.push(token.defaultValue!);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a template string for syntax errors
|
||||
*
|
||||
* @param template The template to validate
|
||||
* @returns An object with valid flag and any errors/warnings
|
||||
*/
|
||||
validate(template: string): { valid: boolean; errors: string[]; warnings: string[] } {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
const tokens = tokenize(template);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type !== 'literal' && !token.field) {
|
||||
errors.push(`Invalid variable syntax: {${token.value}}`);
|
||||
}
|
||||
|
||||
// Check for nested conditional templates
|
||||
if (token.type === 'conditional' && token.innerTemplate) {
|
||||
const innerValidation = this.validate(token.innerTemplate);
|
||||
errors.push(...innerValidation.errors.map(e => `In conditional {${token.field}?()}: ${e}`));
|
||||
warnings.push(...innerValidation.warnings.map(w => `In conditional {${token.field}?()}: ${w}`));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`Template parsing error: ${error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all variable names from a template
|
||||
*
|
||||
* @param template The template to analyze
|
||||
* @returns Array of variable field paths used in the template
|
||||
*/
|
||||
extractVariables(template: string): string[] {
|
||||
const variables: Set<string> = new Set();
|
||||
const tokens = tokenize(template);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.field) {
|
||||
variables.add(token.field);
|
||||
}
|
||||
// Recursively extract from conditional templates
|
||||
if (token.type === 'conditional' && token.innerTemplate) {
|
||||
const innerVars = this.extractVariables(token.innerTemplate);
|
||||
innerVars.forEach(v => variables.add(v));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(variables);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Template module exports
|
||||
*/
|
||||
|
||||
export { TemplateEngine, getNestedValue } from './TemplateEngine.js';
|
||||
export { ConditionEvaluator } from './ConditionEvaluator.js';
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Renaming Rule Types
|
||||
*
|
||||
* Defines the schema for configurable renaming rules that replace
|
||||
* the hardcoded renamingRule() function.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Condition operators for evaluating metadata fields
|
||||
*/
|
||||
export type ConditionOperator =
|
||||
| 'EXISTS' // Field exists and is not null/undefined
|
||||
| 'NOT_EXISTS' // Field is null/undefined
|
||||
| 'EQUALS' // Field equals specified value
|
||||
| 'NOT_EQUALS' // Field does not equal specified value
|
||||
| 'CONTAINS' // String field contains specified substring
|
||||
| 'MATCHES'; // String field matches specified regex
|
||||
|
||||
/**
|
||||
* A single condition that evaluates a metadata field
|
||||
*/
|
||||
export interface Condition {
|
||||
type: ConditionOperator;
|
||||
field: string; // Metadata field path, e.g., "titles.eng", "season", "fileType"
|
||||
value?: string | number | boolean; // Required for EQUALS, NOT_EQUALS, CONTAINS, MATCHES
|
||||
}
|
||||
|
||||
/**
|
||||
* A group of conditions combined with AND or OR logic
|
||||
*/
|
||||
export interface ConditionGroup {
|
||||
operator: 'AND' | 'OR';
|
||||
conditions: (Condition | ConditionGroup)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A single renaming rule with conditions and a template
|
||||
*/
|
||||
export interface RenamingRule {
|
||||
id: string; // Unique identifier (UUID)
|
||||
name: string; // Human-readable name
|
||||
description?: string; // Optional description
|
||||
enabled: boolean; // Can disable without deleting
|
||||
priority: number; // Higher = evaluated first (default: 0)
|
||||
conditions: ConditionGroup;
|
||||
template: string; // Template string with variables
|
||||
fallbackToUnsorted: boolean; // If template fails, put in Unsorted/
|
||||
}
|
||||
|
||||
/**
|
||||
* Full renaming configuration with all rules
|
||||
*/
|
||||
export interface RenamingConfig {
|
||||
version: number; // Schema version for migrations
|
||||
rules: RenamingRule[];
|
||||
defaultRule?: RenamingRule; // Catch-all rule when no conditions match
|
||||
lastModified?: string; // ISO timestamp of last modification
|
||||
isDefault?: boolean; // True if this is the default config (not user-customized)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview item showing how a file would be renamed
|
||||
*/
|
||||
export interface PreviewItem {
|
||||
sourcePath: string;
|
||||
currentVirtualPath: string;
|
||||
newVirtualPath: string | null;
|
||||
matchedRule: string | null; // Rule ID that matched
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for preview endpoint
|
||||
*/
|
||||
export interface PreviewResponse {
|
||||
previews: PreviewItem[];
|
||||
total: number;
|
||||
limited: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available template variable metadata for UI
|
||||
*/
|
||||
export interface TemplateVariable {
|
||||
name: string; // Display name
|
||||
path: string; // Field path (e.g., "titles.eng")
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
description: string;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* List of all available template variables
|
||||
*/
|
||||
export const TEMPLATE_VARIABLES: TemplateVariable[] = [
|
||||
{ name: 'English Title', path: 'titles.eng', type: 'string', description: 'English title from metadata', example: 'Breaking Bad' },
|
||||
{ name: 'Original Title', path: 'originalTitle', type: 'string', description: 'Original/native title', example: 'Breaking Bad' },
|
||||
{ name: 'Title', path: 'title', type: 'string', description: 'Simple title field', example: 'Breaking Bad' },
|
||||
{ name: 'Season', path: 'season', type: 'number', description: 'Season number', example: '1' },
|
||||
{ name: 'Episode', path: 'episode', type: 'number', description: 'Episode number', example: '1' },
|
||||
{ name: 'Is Extra', path: 'extra', type: 'boolean', description: 'Whether this is a special/extra', example: 'false' },
|
||||
{ name: 'Movie Year', path: 'movieYear', type: 'number', description: 'Movie release year', example: '2010' },
|
||||
{ name: 'Year', path: 'year', type: 'number', description: 'General year field', example: '2010' },
|
||||
{ name: 'File Type', path: 'fileType', type: 'string', description: 'Type: video, subtitle, torrent', example: 'video' },
|
||||
{ name: 'Extension', path: 'extension', type: 'string', description: 'File extension', example: 'mkv' },
|
||||
{ name: 'Version', path: 'version', type: 'string', description: 'Quality/release version', example: '1080p BluRay' },
|
||||
{ name: 'Subtitle Language', path: 'subtitleLanguage', type: 'string', description: 'Language code for subtitles', example: 'eng' },
|
||||
{ name: 'File Name', path: 'fileName', type: 'string', description: 'Original filename', example: 'Breaking.Bad.S01E01.mkv' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Type guard to check if a condition is a ConditionGroup
|
||||
*/
|
||||
export function isConditionGroup(condition: Condition | ConditionGroup): condition is ConditionGroup {
|
||||
return 'operator' in condition && 'conditions' in condition;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Generated
+1790
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "meta-fuse-driver"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
description = "Rust FUSE driver for meta-fuse virtual filesystem"
|
||||
|
||||
[dependencies]
|
||||
fuser = "0.14"
|
||||
libc = "0.2"
|
||||
env_logger = "0.11"
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
log = "0.4"
|
||||
base64 = "0.22"
|
||||
|
||||
[[bin]]
|
||||
name = "meta-fuse-driver"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,121 @@
|
||||
use reqwest::blocking::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiClient {
|
||||
base_url: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FileAttributes {
|
||||
pub size: u64,
|
||||
pub mode: u32,
|
||||
pub mtime: f64,
|
||||
pub atime: f64,
|
||||
pub ctime: f64,
|
||||
pub nlink: u32,
|
||||
pub uid: u32,
|
||||
pub gid: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReadResult {
|
||||
#[serde(rename = "sourcePath")]
|
||||
pub source_path: Option<String>,
|
||||
pub content: Option<String>,
|
||||
#[serde(rename = "contentEncoding")]
|
||||
pub content_encoding: Option<String>,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReaddirResponse {
|
||||
pub entries: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PathRequest {
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new(base_url: String) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
Ok(ApiClient { base_url, client })
|
||||
}
|
||||
|
||||
pub fn readdir(&self, path: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/api/fuse/readdir", self.base_url);
|
||||
let request = PathRequest {
|
||||
path: path.to_string(),
|
||||
};
|
||||
|
||||
let response = self.client.post(&url).json(&request).send()?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let result: ReaddirResponse = response.json()?;
|
||||
Ok(result.entries)
|
||||
} else {
|
||||
Err(format!("API error: {}", response.status()).into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getattr(&self, path: &str) -> Result<FileAttributes, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/api/fuse/getattr", self.base_url);
|
||||
let request = PathRequest {
|
||||
path: path.to_string(),
|
||||
};
|
||||
|
||||
let response = self.client.post(&url).json(&request).send()?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let attrs: FileAttributes = response.json()?;
|
||||
Ok(attrs)
|
||||
} else {
|
||||
Err(format!("API error: {}", response.status()).into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exists(&self, path: &str) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/api/fuse/exists", self.base_url);
|
||||
let request = PathRequest {
|
||||
path: path.to_string(),
|
||||
};
|
||||
|
||||
let response = self.client.post(&url).json(&request).send()?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let result: HashMap<String, bool> = response.json()?;
|
||||
Ok(result.get("exists").copied().unwrap_or(false))
|
||||
} else {
|
||||
Err(format!("API error: {}", response.status()).into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self, path: &str) -> Result<ReadResult, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/api/fuse/read", self.base_url);
|
||||
let request = PathRequest {
|
||||
path: path.to_string(),
|
||||
};
|
||||
|
||||
let response = self.client.post(&url).json(&request).send()?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let result: ReadResult = response.json()?;
|
||||
Ok(result)
|
||||
} else {
|
||||
Err(format!("API error: {}", response.status()).into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn health_check(&self) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/api/fuse/health", self.base_url);
|
||||
let response = self.client.get(&url).send()?;
|
||||
Ok(response.status().is_success())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
mod api_client;
|
||||
|
||||
use api_client::ApiClient;
|
||||
use base64::Engine;
|
||||
use fuser::{
|
||||
FileAttr, FileType, Filesystem, MountOption, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry,
|
||||
Request,
|
||||
};
|
||||
use libc::ENOENT;
|
||||
use log::{debug, error, info};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
const TTL: Duration = Duration::from_secs(1);
|
||||
const ROOT_INO: u64 = 1;
|
||||
const CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
const ERROR_FILE_INO: u64 = 2;
|
||||
const API_ERROR_THRESHOLD: usize = 3;
|
||||
|
||||
/// Cached directory entry
|
||||
#[derive(Clone)]
|
||||
struct CachedDirEntry {
|
||||
entries: Vec<String>,
|
||||
timestamp: SystemTime,
|
||||
}
|
||||
|
||||
/// Cached file attributes
|
||||
#[derive(Clone)]
|
||||
struct CachedAttrs {
|
||||
attrs: api_client::FileAttributes,
|
||||
timestamp: SystemTime,
|
||||
}
|
||||
|
||||
/// API health tracker
|
||||
struct ApiHealth {
|
||||
consecutive_errors: usize,
|
||||
last_error_message: String,
|
||||
last_error_time: Option<SystemTime>,
|
||||
}
|
||||
|
||||
impl ApiHealth {
|
||||
fn new() -> Self {
|
||||
ApiHealth {
|
||||
consecutive_errors: 0,
|
||||
last_error_message: String::new(),
|
||||
last_error_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_success(&mut self) {
|
||||
self.consecutive_errors = 0;
|
||||
}
|
||||
|
||||
fn record_error(&mut self, message: String) {
|
||||
self.consecutive_errors += 1;
|
||||
self.last_error_message = message;
|
||||
self.last_error_time = Some(SystemTime::now());
|
||||
if self.consecutive_errors >= API_ERROR_THRESHOLD {
|
||||
error!("API has failed {} consecutive times. ERROR.txt will be displayed.", self.consecutive_errors);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unhealthy(&self) -> bool {
|
||||
self.consecutive_errors >= API_ERROR_THRESHOLD
|
||||
}
|
||||
|
||||
fn get_error_content(&self) -> String {
|
||||
let timestamp = self.last_error_time
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
format!(
|
||||
"Meta-Fuse FUSE Driver - API Connection Error\n\
|
||||
=============================================\n\n\
|
||||
The FUSE driver is mounted, but the meta-fuse API is not responding.\n\n\
|
||||
Error Details:\n\
|
||||
- Consecutive failures: {}\n\
|
||||
- Last error: {}\n\
|
||||
- Timestamp: {}\n\n\
|
||||
Possible causes:\n\
|
||||
1. meta-fuse-core service is not running\n\
|
||||
2. API (port 3000) is not accessible\n\
|
||||
3. Network connectivity issues\n\n\
|
||||
To resolve:\n\
|
||||
1. Check if meta-fuse is running: docker ps | grep meta-fuse\n\
|
||||
2. Check API health: curl http://localhost:3000/api/fuse/health\n\
|
||||
3. Restart the container: docker restart meta-fuse\n\n\
|
||||
This ERROR.txt file will disappear once the API is responding again.\n",
|
||||
self.consecutive_errors,
|
||||
self.last_error_message,
|
||||
timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps virtual paths to inode numbers
|
||||
struct InodeMapper {
|
||||
path_to_ino: HashMap<String, u64>,
|
||||
ino_to_path: HashMap<u64, String>,
|
||||
next_ino: u64,
|
||||
}
|
||||
|
||||
impl InodeMapper {
|
||||
fn new() -> Self {
|
||||
let mut mapper = InodeMapper {
|
||||
path_to_ino: HashMap::new(),
|
||||
ino_to_path: HashMap::new(),
|
||||
next_ino: ROOT_INO + 1,
|
||||
};
|
||||
mapper.path_to_ino.insert("/".to_string(), ROOT_INO);
|
||||
mapper.ino_to_path.insert(ROOT_INO, "/".to_string());
|
||||
mapper
|
||||
}
|
||||
|
||||
fn get_or_create_ino(&mut self, path: &str) -> u64 {
|
||||
if let Some(&ino) = self.path_to_ino.get(path) {
|
||||
return ino;
|
||||
}
|
||||
|
||||
let ino = self.next_ino;
|
||||
self.next_ino += 1;
|
||||
self.path_to_ino.insert(path.to_string(), ino);
|
||||
self.ino_to_path.insert(ino, path.to_string());
|
||||
ino
|
||||
}
|
||||
|
||||
fn get_path(&self, ino: u64) -> Option<&String> {
|
||||
self.ino_to_path.get(&ino)
|
||||
}
|
||||
}
|
||||
|
||||
struct ApiFS {
|
||||
api: ApiClient,
|
||||
inode_mapper: Arc<Mutex<InodeMapper>>,
|
||||
dir_cache: Arc<Mutex<HashMap<String, CachedDirEntry>>>,
|
||||
attr_cache: Arc<Mutex<HashMap<String, CachedAttrs>>>,
|
||||
api_health: Arc<Mutex<ApiHealth>>,
|
||||
default_uid: u32,
|
||||
default_gid: u32,
|
||||
file_perm: u16,
|
||||
dir_perm: u16,
|
||||
}
|
||||
|
||||
impl ApiFS {
|
||||
fn new(api_url: String, uid: u32, gid: u32, file_perm: u16, dir_perm: u16) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let api = ApiClient::new(api_url)?;
|
||||
|
||||
if !api.health_check()? {
|
||||
return Err("API health check failed".into());
|
||||
}
|
||||
|
||||
Ok(ApiFS {
|
||||
api,
|
||||
inode_mapper: Arc::new(Mutex::new(InodeMapper::new())),
|
||||
dir_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
attr_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_health: Arc::new(Mutex::new(ApiHealth::new())),
|
||||
default_uid: uid,
|
||||
default_gid: gid,
|
||||
file_perm,
|
||||
dir_perm,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_cache_valid(timestamp: SystemTime) -> bool {
|
||||
SystemTime::now()
|
||||
.duration_since(timestamp)
|
||||
.map(|d| d < CACHE_TTL)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn get_cached_readdir(&self, path: &str) -> Option<Vec<String>> {
|
||||
let cache = self.dir_cache.lock().unwrap();
|
||||
if let Some(cached) = cache.get(path) {
|
||||
if Self::is_cache_valid(cached.timestamp) {
|
||||
debug!("Cache hit for readdir: {}", path);
|
||||
return Some(cached.entries.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn cache_readdir(&self, path: &str, entries: Vec<String>) {
|
||||
let mut cache = self.dir_cache.lock().unwrap();
|
||||
cache.insert(
|
||||
path.to_string(),
|
||||
CachedDirEntry {
|
||||
entries,
|
||||
timestamp: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn get_cached_attrs(&self, path: &str) -> Option<api_client::FileAttributes> {
|
||||
let cache = self.attr_cache.lock().unwrap();
|
||||
if let Some(cached) = cache.get(path) {
|
||||
if Self::is_cache_valid(cached.timestamp) {
|
||||
debug!("Cache hit for getattr: {}", path);
|
||||
return Some(cached.attrs.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn cache_attrs(&self, path: &str, attrs: api_client::FileAttributes) {
|
||||
let mut cache = self.attr_cache.lock().unwrap();
|
||||
cache.insert(
|
||||
path.to_string(),
|
||||
CachedAttrs {
|
||||
attrs,
|
||||
timestamp: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn get_error_file_attrs(&self) -> FileAttr {
|
||||
let content = self.api_health.lock().unwrap().get_error_content();
|
||||
let size = content.len() as u64;
|
||||
|
||||
FileAttr {
|
||||
ino: ERROR_FILE_INO,
|
||||
size,
|
||||
blocks: (size + 511) / 512,
|
||||
atime: UNIX_EPOCH + Duration::from_secs(0),
|
||||
mtime: UNIX_EPOCH + Duration::from_secs(0),
|
||||
ctime: UNIX_EPOCH + Duration::from_secs(0),
|
||||
crtime: UNIX_EPOCH,
|
||||
kind: FileType::RegularFile,
|
||||
perm: 0o444,
|
||||
nlink: 1,
|
||||
uid: self.default_uid,
|
||||
gid: self.default_gid,
|
||||
rdev: 0,
|
||||
blksize: 512,
|
||||
flags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_attrs(&self, path: &str, api_attrs: api_client::FileAttributes) -> FileAttr {
|
||||
let ino = {
|
||||
let mut mapper = self.inode_mapper.lock().unwrap();
|
||||
mapper.get_or_create_ino(path)
|
||||
};
|
||||
|
||||
let kind = if api_attrs.mode & 0o040000 != 0 {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::RegularFile
|
||||
};
|
||||
|
||||
let perm = if kind == FileType::Directory {
|
||||
self.dir_perm
|
||||
} else {
|
||||
self.file_perm
|
||||
};
|
||||
|
||||
FileAttr {
|
||||
ino,
|
||||
size: api_attrs.size,
|
||||
blocks: (api_attrs.size + 511) / 512,
|
||||
atime: UNIX_EPOCH + Duration::from_secs_f64(api_attrs.atime),
|
||||
mtime: UNIX_EPOCH + Duration::from_secs_f64(api_attrs.mtime),
|
||||
ctime: UNIX_EPOCH + Duration::from_secs_f64(api_attrs.ctime),
|
||||
crtime: UNIX_EPOCH,
|
||||
kind,
|
||||
perm,
|
||||
nlink: api_attrs.nlink,
|
||||
uid: self.default_uid,
|
||||
gid: self.default_gid,
|
||||
rdev: 0,
|
||||
blksize: 512,
|
||||
flags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_file_content(
|
||||
&self,
|
||||
read_result: &api_client::ReadResult,
|
||||
offset: usize,
|
||||
size: usize,
|
||||
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
if let Some(ref content_b64) = read_result.content {
|
||||
let content = base64::prelude::BASE64_STANDARD.decode(content_b64)?;
|
||||
let end = std::cmp::min(offset + size, content.len());
|
||||
if offset >= content.len() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
return Ok(content[offset..end].to_vec());
|
||||
}
|
||||
|
||||
if let Some(ref source_path) = read_result.source_path {
|
||||
let mut file = File::open(source_path)?;
|
||||
|
||||
if offset > 0 {
|
||||
use std::io::Seek;
|
||||
file.seek(std::io::SeekFrom::Start(offset as u64))?;
|
||||
}
|
||||
|
||||
let mut buffer = vec![0u8; size];
|
||||
let bytes_read = file.read(&mut buffer)?;
|
||||
buffer.truncate(bytes_read);
|
||||
|
||||
return Ok(buffer);
|
||||
}
|
||||
|
||||
Err("No content or source path available".into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Filesystem for ApiFS {
|
||||
fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
|
||||
let name_str = match name.to_str() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
error!("Invalid UTF-8 in filename");
|
||||
reply.error(ENOENT);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if parent == ROOT_INO && name_str == "ERROR.txt" {
|
||||
let is_unhealthy = self.api_health.lock().unwrap().is_unhealthy();
|
||||
if is_unhealthy {
|
||||
let attr = self.get_error_file_attrs();
|
||||
reply.entry(&TTL, &attr, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let parent_path = {
|
||||
let mapper = self.inode_mapper.lock().unwrap();
|
||||
match mapper.get_path(parent) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
error!("Parent inode {} not found", parent);
|
||||
reply.error(ENOENT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let child_path = if parent_path == "/" {
|
||||
format!("/{}", name_str)
|
||||
} else {
|
||||
format!("{}/{}", parent_path, name_str)
|
||||
};
|
||||
|
||||
debug!("lookup: parent={} name={} -> {}", parent, name_str, child_path);
|
||||
|
||||
if let Some(cached_attrs) = self.get_cached_attrs(&child_path) {
|
||||
let attr = self.convert_attrs(&child_path, cached_attrs);
|
||||
reply.entry(&TTL, &attr, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
match self.api.getattr(&child_path) {
|
||||
Ok(api_attrs) => {
|
||||
self.api_health.lock().unwrap().record_success();
|
||||
self.cache_attrs(&child_path, api_attrs.clone());
|
||||
let attr = self.convert_attrs(&child_path, api_attrs);
|
||||
reply.entry(&TTL, &attr, 0);
|
||||
}
|
||||
Err(e) => {
|
||||
self.api_health.lock().unwrap().record_error(format!("lookup failed for {}: {}", child_path, e));
|
||||
debug!("lookup failed for {}: {}", child_path, e);
|
||||
reply.error(ENOENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn getattr(&mut self, _req: &Request, ino: u64, reply: ReplyAttr) {
|
||||
if ino == ERROR_FILE_INO {
|
||||
let is_unhealthy = self.api_health.lock().unwrap().is_unhealthy();
|
||||
if is_unhealthy {
|
||||
let attr = self.get_error_file_attrs();
|
||||
reply.attr(&TTL, &attr);
|
||||
return;
|
||||
} else {
|
||||
reply.error(ENOENT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let path = {
|
||||
let mapper = self.inode_mapper.lock().unwrap();
|
||||
match mapper.get_path(ino) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
error!("Inode {} not found", ino);
|
||||
reply.error(ENOENT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
debug!("getattr: ino={} path={}", ino, path);
|
||||
|
||||
if let Some(cached_attrs) = self.get_cached_attrs(&path) {
|
||||
let attr = self.convert_attrs(&path, cached_attrs);
|
||||
reply.attr(&TTL, &attr);
|
||||
return;
|
||||
}
|
||||
|
||||
match self.api.getattr(&path) {
|
||||
Ok(api_attrs) => {
|
||||
self.api_health.lock().unwrap().record_success();
|
||||
self.cache_attrs(&path, api_attrs.clone());
|
||||
let attr = self.convert_attrs(&path, api_attrs);
|
||||
reply.attr(&TTL, &attr);
|
||||
}
|
||||
Err(e) => {
|
||||
self.api_health.lock().unwrap().record_error(format!("getattr failed for {}: {}", path, e));
|
||||
error!("getattr failed for {}: {}", path, e);
|
||||
reply.error(ENOENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read(
|
||||
&mut self,
|
||||
_req: &Request,
|
||||
ino: u64,
|
||||
_fh: u64,
|
||||
offset: i64,
|
||||
size: u32,
|
||||
_flags: i32,
|
||||
_lock: Option<u64>,
|
||||
reply: ReplyData,
|
||||
) {
|
||||
if ino == ERROR_FILE_INO {
|
||||
let content = self.api_health.lock().unwrap().get_error_content();
|
||||
let content_bytes = content.as_bytes();
|
||||
let offset = offset as usize;
|
||||
let size = size as usize;
|
||||
|
||||
if offset >= content_bytes.len() {
|
||||
reply.data(&[]);
|
||||
return;
|
||||
}
|
||||
|
||||
let end = std::cmp::min(offset + size, content_bytes.len());
|
||||
reply.data(&content_bytes[offset..end]);
|
||||
return;
|
||||
}
|
||||
|
||||
let path = {
|
||||
let mapper = self.inode_mapper.lock().unwrap();
|
||||
match mapper.get_path(ino) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
error!("Inode {} not found", ino);
|
||||
reply.error(ENOENT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
debug!("read: ino={} path={} offset={} size={}", ino, path, offset, size);
|
||||
|
||||
match self.api.read(&path) {
|
||||
Ok(read_result) => {
|
||||
self.api_health.lock().unwrap().record_success();
|
||||
match self.read_file_content(&read_result, offset as usize, size as usize) {
|
||||
Ok(data) => reply.data(&data),
|
||||
Err(e) => {
|
||||
error!("Failed to read file content for {}: {}", path, e);
|
||||
reply.error(libc::EIO);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.api_health.lock().unwrap().record_error(format!("read API call failed for {}: {}", path, e));
|
||||
error!("read API call failed for {}: {}", path, e);
|
||||
reply.error(ENOENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn readdir(
|
||||
&mut self,
|
||||
_req: &Request,
|
||||
ino: u64,
|
||||
_fh: u64,
|
||||
offset: i64,
|
||||
mut reply: ReplyDirectory,
|
||||
) {
|
||||
let path = {
|
||||
let mapper = self.inode_mapper.lock().unwrap();
|
||||
match mapper.get_path(ino) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
error!("Inode {} not found", ino);
|
||||
reply.error(ENOENT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
debug!("readdir: ino={} path={} offset={}", ino, path, offset);
|
||||
|
||||
let entries = if let Some(cached_entries) = self.get_cached_readdir(&path) {
|
||||
cached_entries
|
||||
} else {
|
||||
match self.api.readdir(&path) {
|
||||
Ok(entries) => {
|
||||
self.api_health.lock().unwrap().record_success();
|
||||
self.cache_readdir(&path, entries.clone());
|
||||
entries
|
||||
}
|
||||
Err(e) => {
|
||||
self.api_health.lock().unwrap().record_error(format!("readdir failed for {}: {}", path, e));
|
||||
error!("readdir failed for {}: {}", path, e);
|
||||
reply.error(ENOENT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut full_entries = vec![
|
||||
(ino, FileType::Directory, ".".to_string()),
|
||||
(ino, FileType::Directory, "..".to_string()),
|
||||
];
|
||||
|
||||
if ino == ROOT_INO {
|
||||
let is_unhealthy = self.api_health.lock().unwrap().is_unhealthy();
|
||||
if is_unhealthy {
|
||||
full_entries.push((ERROR_FILE_INO, FileType::RegularFile, "ERROR.txt".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
for entry_name in entries {
|
||||
let entry_path = if path == "/" {
|
||||
format!("/{}", entry_name)
|
||||
} else {
|
||||
format!("{}/{}", path, entry_name)
|
||||
};
|
||||
|
||||
let entry_ino = {
|
||||
let mut mapper = self.inode_mapper.lock().unwrap();
|
||||
mapper.get_or_create_ino(&entry_path)
|
||||
};
|
||||
|
||||
let file_type = if let Some(cached_attrs) = self.get_cached_attrs(&entry_path) {
|
||||
if cached_attrs.mode & 0o040000 != 0 {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::RegularFile
|
||||
}
|
||||
} else {
|
||||
match self.api.getattr(&entry_path) {
|
||||
Ok(attrs) => {
|
||||
self.cache_attrs(&entry_path, attrs.clone());
|
||||
if attrs.mode & 0o040000 != 0 {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::RegularFile
|
||||
}
|
||||
}
|
||||
Err(_) => FileType::RegularFile,
|
||||
}
|
||||
};
|
||||
|
||||
full_entries.push((entry_ino, file_type, entry_name));
|
||||
}
|
||||
|
||||
for (i, entry) in full_entries.into_iter().enumerate().skip(offset as usize) {
|
||||
if reply.add(entry.0, (i + 1) as i64, entry.1, entry.2) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
reply.ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
if args.len() < 2 || args.len() > 5 {
|
||||
eprintln!("Usage: {} <mountpoint> [api-url] [uid] [gid]", args[0]);
|
||||
eprintln!("\nExample: {} /mnt/virtual http://localhost:3000 1000 1000", args[0]);
|
||||
eprintln!(" {} /mnt/virtual (defaults: http://localhost:3000, uid=1000, gid=1000)", args[0]);
|
||||
eprintln!("\nEnvironment variables:");
|
||||
eprintln!(" PUID - User ID for file ownership (default: 1000)");
|
||||
eprintln!(" PGID - Group ID for file ownership (default: 1000)");
|
||||
eprintln!(" FUSE_FILE_PERM - File permissions in octal (default: 755)");
|
||||
eprintln!(" FUSE_DIR_PERM - Directory permissions in octal (default: 755)");
|
||||
eprintln!(" FUSE_API_URL - API URL (default: http://localhost:3000)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let mountpoint = &args[1];
|
||||
let api_url = if args.len() >= 3 {
|
||||
args[2].clone()
|
||||
} else {
|
||||
std::env::var("FUSE_API_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||||
};
|
||||
|
||||
let uid: u32 = if args.len() >= 4 {
|
||||
args[3].parse().unwrap_or_else(|_| {
|
||||
eprintln!("Error: Invalid UID value");
|
||||
std::process::exit(1);
|
||||
})
|
||||
} else {
|
||||
std::env::var("PUID")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1000)
|
||||
};
|
||||
|
||||
let gid: u32 = if args.len() >= 5 {
|
||||
args[4].parse().unwrap_or_else(|_| {
|
||||
eprintln!("Error: Invalid GID value");
|
||||
std::process::exit(1);
|
||||
})
|
||||
} else {
|
||||
std::env::var("PGID")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1000)
|
||||
};
|
||||
|
||||
let file_perm: u16 = std::env::var("FUSE_FILE_PERM")
|
||||
.ok()
|
||||
.and_then(|v| u16::from_str_radix(&v, 8).ok())
|
||||
.unwrap_or(0o755);
|
||||
|
||||
let dir_perm: u16 = std::env::var("FUSE_DIR_PERM")
|
||||
.ok()
|
||||
.and_then(|v| u16::from_str_radix(&v, 8).ok())
|
||||
.unwrap_or(0o755);
|
||||
|
||||
info!("Connecting to API at: {}", api_url);
|
||||
info!("File ownership: uid={}, gid={}", uid, gid);
|
||||
info!("File permissions: {:o} (files), {:o} (directories)", file_perm, dir_perm);
|
||||
|
||||
let fs = match ApiFS::new(api_url.clone(), uid, gid, file_perm, dir_perm) {
|
||||
Ok(fs) => {
|
||||
info!("Successfully connected to meta-fuse API");
|
||||
fs
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to connect to API at {}: {}", api_url, e);
|
||||
eprintln!("Error: Failed to connect to API at {}: {}", api_url, e);
|
||||
eprintln!("\nMake sure meta-fuse-core is running.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
info!("Mounting filesystem at: {}", mountpoint);
|
||||
|
||||
let options = vec![
|
||||
MountOption::RO,
|
||||
MountOption::FSName("meta-fuse".to_string()),
|
||||
MountOption::AutoUnmount,
|
||||
MountOption::AllowOther,
|
||||
];
|
||||
|
||||
match fuser::mount2(fs, mountpoint, &options) {
|
||||
Ok(()) => {
|
||||
info!("Filesystem unmounted successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Mount failed: {}", e);
|
||||
eprintln!("Error: Failed to mount filesystem: {}", e);
|
||||
eprintln!("\nPossible causes:");
|
||||
eprintln!("1. Mount point does not exist or is not accessible");
|
||||
eprintln!("2. FUSE module is not loaded (try: modprobe fuse)");
|
||||
eprintln!("3. /etc/fuse.conf missing 'user_allow_other' option");
|
||||
eprintln!(" To fix: echo 'user_allow_other' | sudo tee -a /etc/fuse.conf");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>meta-fuse Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@meta-fuse/ui",
|
||||
"version": "1.0.2",
|
||||
"description": "Dashboard UI for meta-fuse",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext ts,tsx"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { RulesEditor } from './components/RulesEditor';
|
||||
|
||||
interface Stats {
|
||||
fileCount: number;
|
||||
directoryCount: number;
|
||||
totalSize: number;
|
||||
lastRefresh: string | null;
|
||||
redisConnected: boolean;
|
||||
}
|
||||
|
||||
interface HealthStatus {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
service: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatTime(isoString: string | null): string {
|
||||
if (!isoString) return 'Never';
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||
const [directories, setDirectories] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [statsRes, healthRes, dirsRes] = await Promise.all([
|
||||
fetch('/api/fuse/stats'),
|
||||
fetch('/api/fuse/health'),
|
||||
fetch('/api/fuse/directories'),
|
||||
]);
|
||||
|
||||
if (!statsRes.ok || !healthRes.ok) {
|
||||
throw new Error('Failed to fetch data');
|
||||
}
|
||||
|
||||
const statsData = await statsRes.json();
|
||||
const healthData = await healthRes.json();
|
||||
const dirsData = await dirsRes.json();
|
||||
|
||||
setStats(statsData);
|
||||
setHealth(healthData);
|
||||
setDirectories(dirsData.directories || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await fetch('/api/fuse/refresh', { method: 'POST' });
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Refresh failed');
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const dataInterval = setInterval(fetchData, 5000);
|
||||
return () => {
|
||||
clearInterval(dataInterval);
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="loading">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isConnected = health?.status === 'ok' && stats?.redisConnected;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="header">
|
||||
<div>
|
||||
<h1>meta-fuse</h1>
|
||||
<p className="header-subtitle">Virtual filesystem service</p>
|
||||
</div>
|
||||
<div className={`status-badge ${isConnected ? 'connected' : 'disconnected'}`}>
|
||||
<span className={`status-dot ${isConnected ? 'connected' : 'disconnected'}`} />
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<div className="section">
|
||||
<h2 className="section-title">VFS Control</h2>
|
||||
|
||||
<div className="grid">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">Files</span>
|
||||
</div>
|
||||
<div className="card-value">{stats?.fileCount ?? 0}</div>
|
||||
<div className="card-subtitle">Total files in VFS</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">Directories</span>
|
||||
</div>
|
||||
<div className="card-value">{stats?.directoryCount ?? 0}</div>
|
||||
<div className="card-subtitle">Total directories</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">Total Size</span>
|
||||
</div>
|
||||
<div className="card-value">{formatBytes(stats?.totalSize ?? 0)}</div>
|
||||
<div className="card-subtitle">Combined file size</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">Last Refresh</span>
|
||||
<button
|
||||
className="refresh-btn"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
>
|
||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-value" style={{ fontSize: '1.5rem' }}>
|
||||
{formatTime(stats?.lastRefresh ?? null)}
|
||||
</div>
|
||||
<div className="card-subtitle">From Redis metadata</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '1.5rem' }}>
|
||||
<div className="card-header">
|
||||
<span className="card-title">Connection Status</span>
|
||||
</div>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<div className="info-label">API Status</div>
|
||||
<div className="info-value">{health?.status ?? 'unknown'}</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="info-label">Redis</div>
|
||||
<div className="info-value">
|
||||
{stats?.redisConnected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="info-label">Service</div>
|
||||
<div className="info-value">{health?.service ?? 'unknown'}</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="info-label">Timestamp</div>
|
||||
<div className="info-value">{formatTime(health?.timestamp ?? null)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="subsection">
|
||||
<div
|
||||
className="section-header-collapsible"
|
||||
onClick={() => setShowRules(!showRules)}
|
||||
>
|
||||
<h3 className="subsection-title">Renaming Rules</h3>
|
||||
<span className="collapse-icon">{showRules ? '-' : '+'}</span>
|
||||
</div>
|
||||
{showRules && (
|
||||
<RulesEditor onSave={fetchData} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h2 className="section-title">Virtual Directories</h2>
|
||||
<div className="file-list">
|
||||
{directories.length === 0 ? (
|
||||
<div className="file-item">
|
||||
<span className="file-name" style={{ color: '#64748b' }}>
|
||||
No directories found. Waiting for metadata from meta-sort...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
directories.slice(0, 20).map((dir) => (
|
||||
<div key={dir} className="file-item">
|
||||
<span className="file-icon">📁</span>
|
||||
<span className="file-name">{dir}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{directories.length > 20 && (
|
||||
<div className="file-item">
|
||||
<span className="file-name" style={{ color: '#64748b' }}>
|
||||
... and {directories.length - 20} more directories
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,626 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type {
|
||||
RenamingConfig,
|
||||
RenamingRule,
|
||||
ConditionGroup,
|
||||
Condition,
|
||||
PreviewItem,
|
||||
TemplateVariable,
|
||||
} from '../types/rules';
|
||||
import { isConditionGroup, generateId } from '../types/rules';
|
||||
|
||||
interface RulesEditorProps {
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
// Available metadata fields for conditions
|
||||
const AVAILABLE_FIELDS = [
|
||||
{ value: 'fileType', label: 'File Type', type: 'string' },
|
||||
{ value: 'titles.eng', label: 'English Title', type: 'string' },
|
||||
{ value: 'originalTitle', label: 'Original Title', type: 'string' },
|
||||
{ value: 'season', label: 'Season', type: 'number' },
|
||||
{ value: 'episode', label: 'Episode', type: 'number' },
|
||||
{ value: 'extra', label: 'Is Extra', type: 'boolean' },
|
||||
{ value: 'movieYear', label: 'Movie Year', type: 'number' },
|
||||
{ value: 'year', label: 'Year', type: 'number' },
|
||||
{ value: 'extension', label: 'Extension', type: 'string' },
|
||||
{ value: 'version', label: 'Version', type: 'string' },
|
||||
{ value: 'subtitleLanguage', label: 'Subtitle Language', type: 'string' },
|
||||
];
|
||||
|
||||
const CONDITION_TYPES = [
|
||||
{ value: 'EXISTS', label: 'exists', needsValue: false },
|
||||
{ value: 'NOT_EXISTS', label: 'does not exist', needsValue: false },
|
||||
{ value: 'EQUALS', label: 'equals', needsValue: true },
|
||||
{ value: 'NOT_EQUALS', label: 'does not equal', needsValue: true },
|
||||
{ value: 'CONTAINS', label: 'contains', needsValue: true },
|
||||
{ value: 'MATCHES', label: 'matches regex', needsValue: true },
|
||||
];
|
||||
|
||||
function createEmptyRule(): RenamingRule {
|
||||
return {
|
||||
id: generateId(),
|
||||
name: 'New Rule',
|
||||
description: '',
|
||||
enabled: true,
|
||||
priority: 50,
|
||||
conditions: { operator: 'AND', conditions: [] },
|
||||
template: '',
|
||||
fallbackToUnsorted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyCondition(): Condition {
|
||||
return {
|
||||
type: 'EXISTS',
|
||||
field: 'titles.eng',
|
||||
};
|
||||
}
|
||||
|
||||
// ConditionRow component
|
||||
interface ConditionRowProps {
|
||||
condition: Condition;
|
||||
onChange: (condition: Condition) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function ConditionRow({ condition, onChange, onDelete }: ConditionRowProps) {
|
||||
const conditionType = CONDITION_TYPES.find(t => t.value === condition.type);
|
||||
const needsValue = conditionType?.needsValue ?? false;
|
||||
|
||||
return (
|
||||
<div className="condition-row">
|
||||
<select
|
||||
value={condition.field}
|
||||
onChange={e => onChange({ ...condition, field: e.target.value })}
|
||||
className="condition-field"
|
||||
>
|
||||
{AVAILABLE_FIELDS.map(f => (
|
||||
<option key={f.value} value={f.value}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={condition.type}
|
||||
onChange={e => onChange({ ...condition, type: e.target.value as Condition['type'] })}
|
||||
className="condition-type"
|
||||
>
|
||||
{CONDITION_TYPES.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{needsValue && (
|
||||
<input
|
||||
type="text"
|
||||
value={String(condition.value ?? '')}
|
||||
onChange={e => onChange({ ...condition, value: e.target.value })}
|
||||
placeholder="Value"
|
||||
className="condition-value"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button onClick={onDelete} className="btn-icon btn-delete" title="Remove condition">
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ConditionBuilder component
|
||||
interface ConditionBuilderProps {
|
||||
conditions: ConditionGroup;
|
||||
onChange: (conditions: ConditionGroup) => void;
|
||||
}
|
||||
|
||||
function ConditionBuilder({ conditions, onChange }: ConditionBuilderProps) {
|
||||
const addCondition = () => {
|
||||
onChange({
|
||||
...conditions,
|
||||
conditions: [...conditions.conditions, createEmptyCondition()],
|
||||
});
|
||||
};
|
||||
|
||||
const updateCondition = (index: number, updated: Condition | ConditionGroup) => {
|
||||
const newConditions = [...conditions.conditions];
|
||||
newConditions[index] = updated;
|
||||
onChange({ ...conditions, conditions: newConditions });
|
||||
};
|
||||
|
||||
const deleteCondition = (index: number) => {
|
||||
const newConditions = conditions.conditions.filter((_, i) => i !== index);
|
||||
onChange({ ...conditions, conditions: newConditions });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="condition-builder">
|
||||
<div className="condition-operator">
|
||||
<label>Match</label>
|
||||
<select
|
||||
value={conditions.operator}
|
||||
onChange={e => onChange({ ...conditions, operator: e.target.value as 'AND' | 'OR' })}
|
||||
>
|
||||
<option value="AND">ALL conditions (AND)</option>
|
||||
<option value="OR">ANY condition (OR)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="condition-list">
|
||||
{conditions.conditions.length === 0 && (
|
||||
<div className="condition-empty">No conditions - rule will match all files</div>
|
||||
)}
|
||||
{conditions.conditions.map((cond, index) => (
|
||||
!isConditionGroup(cond) && (
|
||||
<ConditionRow
|
||||
key={index}
|
||||
condition={cond}
|
||||
onChange={updated => updateCondition(index, updated)}
|
||||
onDelete={() => deleteCondition(index)}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button onClick={addCondition} className="btn-add-condition">
|
||||
+ Add Condition
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TemplateEditor component
|
||||
interface TemplateEditorProps {
|
||||
template: string;
|
||||
onChange: (template: string) => void;
|
||||
variables: TemplateVariable[];
|
||||
}
|
||||
|
||||
function TemplateEditor({ template, onChange, variables }: TemplateEditorProps) {
|
||||
const [showVars, setShowVars] = useState(false);
|
||||
|
||||
const insertVariable = (path: string) => {
|
||||
onChange(template + `{${path}}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="template-editor">
|
||||
<div className="template-header">
|
||||
<label>Path Template</label>
|
||||
<button
|
||||
onClick={() => setShowVars(!showVars)}
|
||||
className="btn-small"
|
||||
>
|
||||
{showVars ? 'Hide Variables' : 'Show Variables'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={template}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="e.g., Movies/{titles.eng|originalTitle}{movieYear? ({movieYear})}/{fileName}.{extension}"
|
||||
rows={3}
|
||||
className="template-input"
|
||||
/>
|
||||
|
||||
{showVars && (
|
||||
<div className="variable-palette">
|
||||
{variables.map(v => (
|
||||
<button
|
||||
key={v.path}
|
||||
onClick={() => insertVariable(v.path)}
|
||||
className="variable-chip"
|
||||
title={`${v.description} (${v.type})`}
|
||||
>
|
||||
{v.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="template-help">
|
||||
<span><code>{'{var}'}</code> basic</span>
|
||||
<span><code>{'{var:pad2}'}</code> zero-pad</span>
|
||||
<span><code>{'{var?}'}</code> optional</span>
|
||||
<span><code>{'{var?(text)}'}</code> conditional</span>
|
||||
<span><code>{'{var|default}'}</code> default</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// RuleCard component
|
||||
interface RuleCardProps {
|
||||
rule: RenamingRule;
|
||||
variables: TemplateVariable[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onUpdate: (rule: RenamingRule) => void;
|
||||
onDelete: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
function RuleCard({
|
||||
rule,
|
||||
variables,
|
||||
expanded,
|
||||
onToggle,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
isFirst,
|
||||
isLast,
|
||||
}: RuleCardProps) {
|
||||
const conditionCount = rule.conditions.conditions.length;
|
||||
|
||||
return (
|
||||
<div className={`rule-card ${expanded ? 'expanded' : ''} ${!rule.enabled ? 'disabled' : ''}`}>
|
||||
<div className="rule-header" onClick={onToggle}>
|
||||
<div className="rule-header-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.enabled}
|
||||
onChange={e => {
|
||||
e.stopPropagation();
|
||||
onUpdate({ ...rule, enabled: e.target.checked });
|
||||
}}
|
||||
title="Enable/disable rule"
|
||||
/>
|
||||
<span className="rule-name">{rule.name}</span>
|
||||
<span className="rule-meta">
|
||||
Priority: {rule.priority} | {conditionCount} condition{conditionCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rule-header-right">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onMoveUp(); }}
|
||||
disabled={isFirst}
|
||||
className="btn-icon"
|
||||
title="Move up"
|
||||
>
|
||||
^
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onMoveDown(); }}
|
||||
disabled={isLast}
|
||||
className="btn-icon"
|
||||
title="Move down"
|
||||
>
|
||||
v
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onDelete(); }}
|
||||
className="btn-icon btn-delete"
|
||||
title="Delete rule"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
<span className="expand-icon">{expanded ? '-' : '+'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="rule-body">
|
||||
<div className="rule-field">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.name}
|
||||
onChange={e => onUpdate({ ...rule, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rule-field">
|
||||
<label>Priority (higher = evaluated first)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.priority}
|
||||
onChange={e => onUpdate({ ...rule, priority: parseInt(e.target.value, 10) || 0 })}
|
||||
min={0}
|
||||
max={1000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rule-field">
|
||||
<label>Conditions</label>
|
||||
<ConditionBuilder
|
||||
conditions={rule.conditions}
|
||||
onChange={conditions => onUpdate({ ...rule, conditions })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rule-field">
|
||||
<TemplateEditor
|
||||
template={rule.template}
|
||||
onChange={template => onUpdate({ ...rule, template })}
|
||||
variables={variables}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rule-field rule-field-inline">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.fallbackToUnsorted}
|
||||
onChange={e => onUpdate({ ...rule, fallbackToUnsorted: e.target.checked })}
|
||||
/>
|
||||
Fall back to Unsorted folder if template fails
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PreviewPanel component
|
||||
interface PreviewPanelProps {
|
||||
previews: PreviewItem[];
|
||||
loading: boolean;
|
||||
total: number;
|
||||
limited: boolean;
|
||||
}
|
||||
|
||||
function PreviewPanel({ previews, loading, total, limited }: PreviewPanelProps) {
|
||||
if (loading) {
|
||||
return <div className="preview-loading">Loading preview...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="preview-panel">
|
||||
<div className="preview-header">
|
||||
<h3>Preview</h3>
|
||||
<span className="preview-count">
|
||||
Showing {previews.length} of {total} files
|
||||
{limited && ' (limited)'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{previews.length === 0 ? (
|
||||
<div className="preview-empty">No files to preview</div>
|
||||
) : (
|
||||
<div className="preview-table-wrapper">
|
||||
<table className="preview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Current Path</th>
|
||||
<th>New Path</th>
|
||||
<th>Rule</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previews.map((p, i) => {
|
||||
const changed = p.newVirtualPath !== p.currentVirtualPath;
|
||||
return (
|
||||
<tr key={i} className={p.error ? 'error' : changed ? 'changed' : ''}>
|
||||
<td title={p.sourcePath}>{p.sourcePath.split('/').pop()}</td>
|
||||
<td title={p.currentVirtualPath}>{truncatePath(p.currentVirtualPath)}</td>
|
||||
<td className={changed ? 'highlight' : ''} title={p.newVirtualPath || ''}>
|
||||
{p.error ? (
|
||||
<span className="error-text">{p.error}</span>
|
||||
) : (
|
||||
truncatePath(p.newVirtualPath || '')
|
||||
)}
|
||||
</td>
|
||||
<td>{p.matchedRule || '-'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function truncatePath(path: string, maxLen = 40): string {
|
||||
if (path.length <= maxLen) return path;
|
||||
return '...' + path.slice(-maxLen + 3);
|
||||
}
|
||||
|
||||
// Main RulesEditor component
|
||||
export function RulesEditor({ onSave }: RulesEditorProps) {
|
||||
const [config, setConfig] = useState<RenamingConfig | null>(null);
|
||||
const [variables, setVariables] = useState<TemplateVariable[]>([]);
|
||||
const [expandedRule, setExpandedRule] = useState<string | null>(null);
|
||||
const [previews, setPreviews] = useState<PreviewItem[]>([]);
|
||||
const [previewTotal, setPreviewTotal] = useState(0);
|
||||
const [previewLimited, setPreviewLimited] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch rules and variables on mount
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/api/fuse/rules').then(r => r.json()),
|
||||
fetch('/api/fuse/rules/variables').then(r => r.json()),
|
||||
])
|
||||
.then(([rulesData, varsData]) => {
|
||||
setConfig(rulesData.config);
|
||||
setVariables(varsData.variables);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Fetch preview when config changes
|
||||
const fetchPreview = useCallback(async () => {
|
||||
if (!config) return;
|
||||
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/fuse/rules/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rules: config.rules, limit: 20 }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setPreviews(data.previews || []);
|
||||
setPreviewTotal(data.total || 0);
|
||||
setPreviewLimited(data.limited || false);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch preview:', err);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Debounced preview fetch
|
||||
useEffect(() => {
|
||||
if (!dirty || !config) return;
|
||||
const timeout = setTimeout(fetchPreview, 500);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [config, dirty, fetchPreview]);
|
||||
|
||||
// Initial preview
|
||||
useEffect(() => {
|
||||
if (config && !dirty) {
|
||||
fetchPreview();
|
||||
}
|
||||
}, [config, dirty, fetchPreview]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/fuse/rules', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.errors?.join(', ') || data.error || 'Failed to save');
|
||||
}
|
||||
|
||||
setDirty(false);
|
||||
onSave?.();
|
||||
// Refetch preview after save
|
||||
await fetchPreview();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRules = (rules: RenamingRule[]) => {
|
||||
if (!config) return;
|
||||
setConfig({ ...config, rules });
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const addRule = () => {
|
||||
if (!config) return;
|
||||
const newRule = createEmptyRule();
|
||||
updateRules([...config.rules, newRule]);
|
||||
setExpandedRule(newRule.id);
|
||||
};
|
||||
|
||||
const updateRule = (id: string, updated: RenamingRule) => {
|
||||
if (!config) return;
|
||||
updateRules(config.rules.map(r => r.id === id ? updated : r));
|
||||
};
|
||||
|
||||
const deleteRule = (id: string) => {
|
||||
if (!config) return;
|
||||
if (confirm('Are you sure you want to delete this rule?')) {
|
||||
updateRules(config.rules.filter(r => r.id !== id));
|
||||
if (expandedRule === id) setExpandedRule(null);
|
||||
}
|
||||
};
|
||||
|
||||
const moveRule = (index: number, direction: -1 | 1) => {
|
||||
if (!config) return;
|
||||
const newIndex = index + direction;
|
||||
if (newIndex < 0 || newIndex >= config.rules.length) return;
|
||||
|
||||
const rules = [...config.rules];
|
||||
[rules[index], rules[newIndex]] = [rules[newIndex], rules[index]];
|
||||
updateRules(rules);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="rules-loading">Loading rules configuration...</div>;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return <div className="rules-error">Failed to load rules configuration</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rules-editor">
|
||||
<div className="rules-header">
|
||||
<h2>Renaming Rules</h2>
|
||||
<div className="rules-actions">
|
||||
{dirty && <span className="unsaved-badge">Unsaved changes</span>}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className={`btn-save ${dirty ? 'btn-primary' : ''}`}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save & Apply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rules-error-message">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="rules-content">
|
||||
<div className="rules-list">
|
||||
{config.rules.length === 0 && (
|
||||
<div className="rules-empty">
|
||||
No rules configured. Click "Add Rule" to create one.
|
||||
</div>
|
||||
)}
|
||||
{config.rules.map((rule, index) => (
|
||||
<RuleCard
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
variables={variables}
|
||||
expanded={expandedRule === rule.id}
|
||||
onToggle={() => setExpandedRule(expandedRule === rule.id ? null : rule.id)}
|
||||
onUpdate={updated => updateRule(rule.id, updated)}
|
||||
onDelete={() => deleteRule(rule.id)}
|
||||
onMoveUp={() => moveRule(index, -1)}
|
||||
onMoveDown={() => moveRule(index, 1)}
|
||||
isFirst={index === 0}
|
||||
isLast={index === config.rules.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button onClick={addRule} className="btn-add-rule">
|
||||
+ Add Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PreviewPanel
|
||||
previews={previews}
|
||||
loading={previewLoading}
|
||||
total={previewTotal}
|
||||
limited={previewLimited}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RulesEditor;
|
||||
@@ -0,0 +1,828 @@
|
||||
:root {
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--bg-tertiary: #1a1a2e;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--accent-primary: #4ecdc4;
|
||||
--accent-secondary: #ff6b6b;
|
||||
--accent-warning: #ffd93d;
|
||||
--border-color: #2a2a3e;
|
||||
--success: #4ecdc4;
|
||||
--error: #ff6b6b;
|
||||
--warning: #ffd93d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.connected {
|
||||
background: rgba(78, 205, 196, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-badge.disconnected {
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 8px var(--success);
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: rgba(78, 205, 196, 0.3);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.subsection {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.subsection-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
color: var(--error);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: #3db8b0;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Collapsible section header */
|
||||
.section-header-collapsible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.section-header-collapsible:hover .section-title {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Rules Editor */
|
||||
.rules-editor {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.rules-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.rules-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rules-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.unsaved-badge {
|
||||
background: rgba(255, 217, 61, 0.15);
|
||||
color: var(--warning);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-save.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.btn-save:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-save:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.rules-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.rules-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.rules-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btn-add-rule {
|
||||
background: transparent;
|
||||
border: 2px dashed var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-add-rule:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Rule Card */
|
||||
.rule-card {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rule-card.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.rule-card.expanded {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.rule-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.rule-header:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.rule-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.rule-header-left input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rule-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rule-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.rule-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.btn-icon:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-icon.btn-delete:hover {
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.rule-body {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.rule-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rule-field > label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.rule-field input[type="text"],
|
||||
.rule-field input[type="number"] {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.rule-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.rule-field-inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rule-field-inline label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-transform: none;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Condition Builder */
|
||||
.condition-builder {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.condition-operator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.condition-operator label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.condition-operator select {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.condition-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.condition-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.condition-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.condition-field,
|
||||
.condition-type {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.condition-value {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.condition-value:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.btn-add-condition {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-add-condition:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Template Editor */
|
||||
.template-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.template-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.template-header label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-small:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.template-input {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-family: monospace;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.template-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.variable-palette {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.variable-chip {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.variable-chip:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.template-help {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.template-help code {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Preview Panel */
|
||||
.preview-panel {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-header h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.preview-loading,
|
||||
.preview-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.preview-table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.preview-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preview-table th,
|
||||
.preview-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-table th {
|
||||
background: var(--bg-secondary);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.preview-table td {
|
||||
font-family: monospace;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.preview-table tr.changed td.highlight {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.preview-table tr.error td {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--error);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rules-loading,
|
||||
.rules-error {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.rules-error-message {
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
color: var(--error);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Types for renaming rules UI
|
||||
* These mirror the backend types for API communication
|
||||
*/
|
||||
|
||||
export type ConditionOperator =
|
||||
| 'EXISTS'
|
||||
| 'NOT_EXISTS'
|
||||
| 'EQUALS'
|
||||
| 'NOT_EQUALS'
|
||||
| 'CONTAINS'
|
||||
| 'MATCHES';
|
||||
|
||||
export interface Condition {
|
||||
type: ConditionOperator;
|
||||
field: string;
|
||||
value?: string | number | boolean;
|
||||
}
|
||||
|
||||
export interface ConditionGroup {
|
||||
operator: 'AND' | 'OR';
|
||||
conditions: (Condition | ConditionGroup)[];
|
||||
}
|
||||
|
||||
export interface RenamingRule {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
conditions: ConditionGroup;
|
||||
template: string;
|
||||
fallbackToUnsorted: boolean;
|
||||
}
|
||||
|
||||
export interface RenamingConfig {
|
||||
version: number;
|
||||
rules: RenamingRule[];
|
||||
defaultRule?: RenamingRule;
|
||||
lastModified?: string;
|
||||
isDefault?: boolean; // True if this is the default config (not user-customized)
|
||||
}
|
||||
|
||||
export interface PreviewItem {
|
||||
sourcePath: string;
|
||||
currentVirtualPath: string;
|
||||
newVirtualPath: string | null;
|
||||
matchedRule: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PreviewResponse {
|
||||
previews: PreviewItem[];
|
||||
total: number;
|
||||
limited: boolean;
|
||||
}
|
||||
|
||||
export interface TemplateVariable {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
description: string;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
// Helper function to check if a condition is a group
|
||||
export function isConditionGroup(condition: Condition | ConditionGroup): condition is ConditionGroup {
|
||||
return 'operator' in condition && 'conditions' in condition;
|
||||
}
|
||||
|
||||
// Generate a unique ID
|
||||
export function generateId(): string {
|
||||
return `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user