diff --git a/BillNote_frontend/package-lock.json b/BillNote_frontend/package-lock.json index 76b9320b..2b677c53 100644 --- a/BillNote_frontend/package-lock.json +++ b/BillNote_frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "bili_note", "version": "0.0.0", "dependencies": { + "@ant-design/x": "^2.4.0", "@hookform/resolvers": "^5.0.1", "@lobehub/icons": "^1.97.1", "@lobehub/icons-static-svg": "^1.45.0", @@ -192,6 +193,182 @@ "react": ">=16.9.0" } }, + "node_modules/@ant-design/x": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@ant-design/x/-/x-2.5.0.tgz", + "integrity": "sha512-B4FGlYz++MHelu5+PHbdKCXASAz7n+W8bzpIDzFbK45Dx9mL6uR3jOpCN2UcuWE0w2hQ8wtuKbRb/AfGS+KNeA==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.0", + "@ant-design/cssinjs": "^2.0.1", + "@ant-design/cssinjs-utils": "^2.0.2", + "@ant-design/fast-color": "^3.0.0", + "@ant-design/icons": "^6.0.0", + "@babel/runtime": "^7.25.6", + "@rc-component/motion": "^1.1.6", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "lodash.throttle": "^4.1.1", + "mermaid": "^11.12.1", + "react-syntax-highlighter": "^16.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "antd": "^6.1.1", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@ant-design/x/node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/x/node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/x/node_modules/@ant-design/cssinjs-utils": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@babel/runtime": "^7.23.2", + "@rc-component/util": "^1.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@ant-design/x/node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/x/node_modules/@ant-design/icons": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.2.2.tgz", + "integrity": "sha512-zlJtE7AMbG12TeYVPhtBXwNpFInNy8mjLzcIm+0BPw16/b8ODG87YJ1G37VIF5VFscdgfsf6EweAFPTobu/3iQ==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/icons-svg": "^4.4.2", + "@rc-component/util": "^1.10.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/x/node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@ant-design/x/node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@ant-design/x/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@ant-design/x/node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, + "node_modules/@ant-design/x/node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", @@ -232,8 +409,8 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -333,6 +510,7 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", @@ -386,6 +564,7 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", @@ -590,7 +769,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -826,6 +1004,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -842,6 +1021,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -858,6 +1038,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -874,6 +1055,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -890,6 +1072,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -906,6 +1089,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -922,6 +1106,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -938,6 +1123,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -954,6 +1140,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -970,6 +1157,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -986,6 +1174,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1002,6 +1191,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1018,6 +1208,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1034,6 +1225,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1050,6 +1242,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1066,6 +1259,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1082,6 +1276,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1098,6 +1293,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1114,6 +1310,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1130,6 +1327,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1146,6 +1344,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1162,6 +1361,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1178,6 +1378,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1194,6 +1395,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1210,6 +1412,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1226,6 +1429,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3292,6 +3496,20 @@ "node": ">=8.x" } }, + "node_modules/@rc-component/motion": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.3.2.tgz", + "integrity": "sha512-itfd+GztzJYAb04Z4RkEub1TbJAfZc2Iuy8p44U44xD1F5+fNYFKI3897ijlbIyfvXkTmMm+KGcjkQQGMHywEQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@rc-component/mutate-observer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", @@ -3344,6 +3562,19 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz", + "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@rc-component/tour": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", @@ -3385,6 +3616,26 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@rc-component/util": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.10.1.tgz", + "integrity": "sha512-q++9S6rUa5Idb/xIBNz6jtvumw5+O5YV5V0g4iK9mn9jWs4oGJheE3ZN1kAnE723AXyaD8v95yeOASmdk8Jnng==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3399,6 +3650,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3412,6 +3664,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3425,6 +3678,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3438,6 +3692,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3451,6 +3706,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3464,6 +3720,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3477,6 +3734,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3490,6 +3748,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3503,6 +3762,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3516,6 +3776,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3529,6 +3790,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3542,6 +3804,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3555,6 +3818,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3568,6 +3832,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3581,6 +3846,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3594,6 +3860,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3607,6 +3874,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3620,6 +3888,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3633,6 +3902,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3646,6 +3916,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3659,6 +3930,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3672,6 +3944,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3685,6 +3958,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3698,6 +3972,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3711,6 +3986,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4760,9 +5036,8 @@ "version": "22.19.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz", "integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4789,8 +5064,8 @@ "version": "19.2.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4799,9 +5074,8 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4863,7 +5137,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -5450,7 +5723,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5527,7 +5799,6 @@ "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", "license": "MIT", - "peer": true, "dependencies": { "@ant-design/colors": "^7.2.1", "@ant-design/cssinjs": "^1.23.0", @@ -5833,7 +6104,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6154,6 +6424,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -6305,15 +6576,13 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cytoscape": { "version": "3.33.1", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -6723,7 +6992,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7035,8 +7303,7 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/emoji-regex": { "version": "10.6.0", @@ -7172,6 +7439,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -7236,7 +7504,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7563,6 +7830,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -7755,6 +8023,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7787,6 +8056,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -8950,6 +9220,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -9085,6 +9361,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -9566,6 +9843,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -9762,7 +10045,6 @@ "resolved": "https://registry.npmjs.org/markmap-common/-/markmap-common-0.18.9.tgz", "integrity": "sha512-MV2HQO7IGIm3jWEJXSG8vmdpqf4WIDXcEyAEN52lrWR1qD53Zg5l81JwjXoZ2l0rY5mofKYqUFlmdM2fqTGMVg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.22.6", "@gera2ld/jsx-dom": "^2.2.2", @@ -14062,6 +14344,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -14393,8 +14676,8 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14451,6 +14734,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -14466,7 +14750,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14499,7 +14782,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -15316,7 +15598,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -15336,7 +15617,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -15407,7 +15687,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17354,6 +17633,7 @@ "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -17804,6 +18084,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -17907,7 +18188,6 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17965,7 +18245,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unified": { @@ -18432,8 +18712,8 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/BillNote_frontend/src/hooks/useTaskPolling.ts b/BillNote_frontend/src/hooks/useTaskPolling.ts index 58e406f4..393702b5 100644 --- a/BillNote_frontend/src/hooks/useTaskPolling.ts +++ b/BillNote_frontend/src/hooks/useTaskPolling.ts @@ -6,8 +6,6 @@ import toast from 'react-hot-toast' export const useTaskPolling = (interval = 3000) => { const tasks = useTaskStore(state => state.tasks) const updateTaskContent = useTaskStore(state => state.updateTaskContent) - const updateTaskStatus = useTaskStore(state => state.updateTaskStatus) - const removeTask = useTaskStore(state => state.removeTask) const tasksRef = useRef(tasks) @@ -48,8 +46,7 @@ export const useTaskPolling = (interval = 3000) => { } } } catch (e) { - console.error('❌ 任务轮询失败:', e) - updateTaskContent(task.id, { status: 'FAILED' }) + console.error('❌ 任务轮询失败,保留当前状态等待下次轮询:', e) } } }, interval) diff --git a/BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx b/BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx index ac95cd3e..f654709d 100644 --- a/BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx @@ -39,10 +39,12 @@ interface MarkdownViewerProps { } const steps = [ + { label: '排队中', key: 'PENDING' }, { label: '解析链接', key: 'PARSING' }, { label: '下载音频', key: 'DOWNLOADING' }, { label: '转写文字', key: 'TRANSCRIBING' }, { label: '总结内容', key: 'SUMMARIZING' }, + { label: '保存结果', key: 'SAVING' }, { label: '保存完成', key: 'SUCCESS' }, ] diff --git a/BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx b/BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx index 1a1f6e59..1784ee8c 100644 --- a/BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx @@ -8,7 +8,7 @@ import { FormMessage, } from '@/components/ui/form.tsx' import { useEffect,useState } from 'react' -import { useForm, useWatch } from 'react-hook-form' +import { FieldErrors, useForm, useWatch } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -162,9 +162,15 @@ const NoteForm = () => { /* ---- 副作用 ---- */ useEffect(() => { loadEnabledModels() - - return }, []) + + useEffect(() => { + if (currentTask || modelList.length === 0) return + const currentModel = form.getValues('model_name') + if (!currentModel || !modelList.some(m => m.model_name === currentModel)) { + form.setValue('model_name', modelList[0].model_name, { shouldValidate: true }) + } + }, [currentTask, form, modelList]) useEffect(() => { if (!currentTask) return const { formData } = currentTask @@ -217,10 +223,15 @@ const NoteForm = () => { } const onSubmit = async (values: NoteFormValues) => { - console.log('Not even go here') + const provider = modelList.find(m => m.model_name === values.model_name) + if (!provider) { + form.setError('model_name', { type: 'manual', message: '请选择模型' }) + return + } + const payload: NoteFormValues = { ...values, - provider_id: modelList.find(m => m.model_name === values.model_name)!.provider_id, + provider_id: provider.provider_id, task_id: currentTaskId || '', } if (currentTaskId) { diff --git a/BillNote_frontend/src/pages/HomePage/components/StepBar.tsx b/BillNote_frontend/src/pages/HomePage/components/StepBar.tsx index 8ad3622a..d7c09f01 100644 --- a/BillNote_frontend/src/pages/HomePage/components/StepBar.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/StepBar.tsx @@ -12,7 +12,8 @@ interface StepBarProps { } const StepBar: FC = ({ steps, currentStep }) => { - const currentIndex = steps.findIndex(step => step.key === currentStep) + const rawIndex = steps.findIndex(step => step.key === currentStep) + const currentIndex = rawIndex >= 0 ? rawIndex : 0 return (
diff --git a/BillNote_frontend/src/services/note.ts b/BillNote_frontend/src/services/note.ts index 722bd92b..ff00b787 100644 --- a/BillNote_frontend/src/services/note.ts +++ b/BillNote_frontend/src/services/note.ts @@ -67,9 +67,6 @@ export const get_task_status = async (task_id: string) => { } catch (e) { console.error('❌ 请求出错', e) - // 错误提示 - toast.error('笔记生成失败,请稍后重试') - throw e // 抛出错误以便调用方处理 } } diff --git a/BillNote_frontend/src/store/taskStore/index.ts b/BillNote_frontend/src/store/taskStore/index.ts index f2730937..f15b4f4f 100644 --- a/BillNote_frontend/src/store/taskStore/index.ts +++ b/BillNote_frontend/src/store/taskStore/index.ts @@ -5,7 +5,16 @@ import { v4 as uuidv4 } from 'uuid' import toast from 'react-hot-toast' -export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD' +export type TaskStatus = + | 'PENDING' + | 'PARSING' + | 'DOWNLOADING' + | 'TRANSCRIBING' + | 'SUMMARIZING' + | 'FORMATTING' + | 'SAVING' + | 'SUCCESS' + | 'FAILED' export interface AudioMeta { cover_url: string diff --git a/backend/app/downloaders/bilibili_downloader.py b/backend/app/downloaders/bilibili_downloader.py index 2c23dc50..dc772b9f 100644 --- a/backend/app/downloaders/bilibili_downloader.py +++ b/backend/app/downloaders/bilibili_downloader.py @@ -1,11 +1,20 @@ -import os +import asyncio import json import logging +import os +import re +import subprocess +import time +import urllib.parse +import urllib.request from abc import ABC -from typing import Union, Optional, List from pathlib import Path +from typing import List, Optional, Union +import requests +import websockets import yt_dlp +from yt_dlp.utils import DownloadError, ExtractorError from app.downloaders.base import Downloader, DownloadQuality, QUALITY_MAP from app.models.notes_model import AudioDownloadResult @@ -15,50 +24,580 @@ logger = logging.getLogger(__name__) -# B站 cookies 文件路径 -BILIBILI_COOKIES_FILE = os.getenv("BILIBILI_COOKIES_FILE", "cookies.txt") +# B? cookies ??????????????????? +BILIBILI_COOKIES_FILE = os.getenv("BILIBILI_COOKIES_FILE") or os.getenv("BILIBILI_COOKIE_FILE", "cookies.txt") class BilibiliDownloader(Downloader, ABC): def __init__(self): super().__init__() + @staticmethod + def _bilibili_headers() -> dict: + return { + "Referer": "https://www.bilibili.com/", + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + } + + def _ydl_base_opts(self, output_path: str) -> dict: + opts = { + "format": "bestaudio[ext=m4a]/bestaudio/best", + "outtmpl": output_path, + "postprocessors": [ + {"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "64"} + ], + "noplaylist": True, + "quiet": False, + "http_headers": self._bilibili_headers(), + } + cookie_file = os.getenv("BILIBILI_COOKIE_FILE") or os.getenv("BILIBILI_COOKIES_FILE") + if cookie_file: + opts["cookiefile"] = cookie_file + proxy = os.getenv("BILIBILI_PROXY") + if proxy: + opts["proxy"] = proxy + return opts + + @staticmethod + def _extract_bvid(video_url: str) -> str: + match = re.search(r"BV[0-9A-Za-z]+", video_url) + if not match: + raise ValueError(f"??? Bilibili ???? BV ?: {video_url}") + return match.group(0) + + @staticmethod + def _http_json(url: str, method: str = "GET") -> dict: + req = urllib.request.Request(url, method=method) + with urllib.request.urlopen(req, timeout=10) as response: + return json.loads(response.read().decode("utf-8")) + + @staticmethod + def _is_cdp_available(cdp_base: str) -> bool: + try: + BilibiliDownloader._http_json(f"{cdp_base}/json/version") + return True + except Exception: + return False + + @staticmethod + def _chrome_candidates() -> list[str]: + return [ + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"), + r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", + r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", + ] + + def _ensure_cdp(self) -> str: + cdp_base = os.getenv("BILIBILI_CDP_BASE", "http://127.0.0.1:9223").rstrip("/") + if self._is_cdp_available(cdp_base): + return cdp_base + + port_match = re.search(r":(\d+)$", urllib.parse.urlparse(cdp_base).netloc) + port = port_match.group(1) if port_match else "9223" + profile_dir = os.path.abspath(os.getenv("BILIBILI_CDP_PROFILE", ".bilinote-cdp-profile")) + chrome_path = os.getenv("BILIBILI_CHROME_PATH") + if not chrome_path: + chrome_path = next((path for path in self._chrome_candidates() if os.path.exists(path)), None) + if not chrome_path: + raise RuntimeError("??? Chrome/Edge????? Bilibili CDP ????") + + os.makedirs(profile_dir, exist_ok=True) + subprocess.Popen( + [ + chrome_path, + f"--remote-debugging-port={port}", + f"--user-data-dir={profile_dir}", + "--no-first-run", + "--no-default-browser-check", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + for _ in range(20): + if self._is_cdp_available(cdp_base): + return cdp_base + time.sleep(0.5) + raise RuntimeError(f"Chrome CDP ?????: {cdp_base}") + + async def _cdp_send(self, ws, message_id: int, method: str, params: Optional[dict] = None) -> tuple[int, dict]: + await ws.send(json.dumps({"id": message_id, "method": method, "params": params or {}})) + while True: + response = json.loads(await ws.recv()) + if response.get("id") == message_id: + return message_id + 1, response + + async def _get_playinfo_from_cdp(self, video_url: str) -> dict: + cdp_base = self._ensure_cdp() + clean_url = self._canonical_bilibili_url(video_url) + target = self._http_json( + f"{cdp_base}/json/new?{urllib.parse.quote(clean_url, safe=':/?=&')}", + method="PUT", + ) + ws_url = target["webSocketDebuggerUrl"] + async with websockets.connect(ws_url, max_size=20_000_000) as ws: + message_id = 1 + message_id, _ = await self._cdp_send(ws, message_id, "Page.enable") + message_id, _ = await self._cdp_send(ws, message_id, "Runtime.enable") + message_id, _ = await self._cdp_send(ws, message_id, "Network.enable") + + deadline = time.time() + int(os.getenv("BILIBILI_CDP_WAIT_SECONDS", "25")) + last_state = {} + while time.time() < deadline: + expr = """ + (() => { + const play = window.__playinfo__; + const state = window.__INITIAL_STATE__; + const video = state?.videoData || {}; + return { + title: document.title || video.title || '', + bvid: video.bvid || video.bv_id || '', + duration: video.duration || 0, + pic: video.pic || '', + desc: video.desc || '', + owner: video.owner?.name || '', + tags: (state?.tags || []).map((tag) => tag?.tag_name || tag?.tagName || tag?.name).filter(Boolean), + playinfo: play || null, + }; + })() + """ + message_id, result = await self._cdp_send( + ws, message_id, "Runtime.evaluate", {"expression": expr, "returnByValue": True} + ) + value = result.get("result", {}).get("result", {}).get("value") or {} + last_state = value + audios = (((value.get("playinfo") or {}).get("data") or {}).get("dash") or {}).get("audio") or [] + if audios: + return value + await asyncio.sleep(0.8) + raise RuntimeError(f"CDP ??? Bilibili ?????????????: {last_state}") + + @staticmethod + def _canonical_bilibili_url(video_url: str) -> str: + bvid = BilibiliDownloader._extract_bvid(video_url) + return f"https://www.bilibili.com/video/{bvid}/" + + @staticmethod + def _download_url(url: str, output_path: str, referer: str) -> None: + """Download a Bilibili media URL with retries and resume support. + + Bilibili CDN connections often close early. requests then raises + ChunkedEncodingError / IncompleteRead. Retrying with Range keeps the + already downloaded bytes and avoids failing the whole note task. + """ + headers = BilibiliDownloader._bilibili_headers() + headers["Referer"] = referer + max_attempts = max(1, int(os.getenv("BILIBILI_MEDIA_RETRY_ATTEMPTS", "8"))) + chunk_size = int(os.getenv("BILIBILI_MEDIA_CHUNK_SIZE", str(256 * 1024))) + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + + last_error = None + expected_total = None + for attempt in range(1, max_attempts + 1): + resume_from = os.path.getsize(output_path) if os.path.exists(output_path) else 0 + req_headers = dict(headers) + if resume_from > 0: + req_headers["Range"] = f"bytes={resume_from}-" + + try: + with requests.get(url, headers=req_headers, stream=True, timeout=(10, 90)) as response: + if resume_from > 0 and response.status_code == 416: + # Range Not Satisfiable commonly means our local partial is already complete + # or the CDN rejected a stale range. Try to validate completion, otherwise restart. + content_range = response.headers.get("Content-Range") or "" + total_match = re.search(r"/(\d+)$", content_range) + if total_match and resume_from >= int(total_match.group(1)): + return + Path(output_path).unlink(missing_ok=True) + resume_from = 0 + req_headers.pop("Range", None) + response.close() + with requests.get(url, headers=req_headers, stream=True, timeout=(10, 90)) as fresh_response: + fresh_response.raise_for_status() + with open(output_path, "wb") as file: + for chunk in fresh_response.iter_content(chunk_size=chunk_size): + if chunk: + file.write(chunk) + return + + if resume_from > 0 and response.status_code == 200: + # Server ignored Range, restart cleanly. + resume_from = 0 + Path(output_path).unlink(missing_ok=True) + response.raise_for_status() + + content_range = response.headers.get("Content-Range") or "" + content_length = response.headers.get("Content-Length") + if content_range and "/" in content_range: + try: + expected_total = int(content_range.rsplit("/", 1)[1]) + except ValueError: + expected_total = None + elif content_length and resume_from == 0: + expected_total = int(content_length) + + mode = "ab" if resume_from > 0 and response.status_code == 206 else "wb" + with open(output_path, mode) as file: + for chunk in response.iter_content(chunk_size=chunk_size): + if chunk: + file.write(chunk) + + final_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0 + if expected_total is None or final_size >= expected_total: + return + last_error = RuntimeError(f"incomplete media download: {final_size}/{expected_total} bytes") + except Exception as exc: + last_error = exc + logger.warning( + "Bilibili media download interrupted (attempt %s/%s, downloaded=%s): %s", + attempt, + max_attempts, + os.path.getsize(output_path) if os.path.exists(output_path) else 0, + exc, + ) + + time.sleep(min(2 * attempt, 10)) + + raise RuntimeError(f"Bilibili media download failed after {max_attempts} attempts: {last_error}") + + @staticmethod + def _find_ffmpeg() -> str: + binary = os.getenv("FFMPEG_BINARY") + if binary and os.path.exists(binary): + return binary + bin_dir = os.getenv("FFMPEG_BIN_PATH") + if bin_dir: + candidate = os.path.join(bin_dir, "ffmpeg.exe") + if os.path.exists(candidate): + return candidate + return "ffmpeg" + + @staticmethod + def _find_ffprobe() -> str: + binary = os.getenv("FFPROBE_BINARY") + if binary and os.path.exists(binary): + return binary + bin_dir = os.getenv("FFMPEG_BIN_PATH") + if bin_dir: + candidate = os.path.join(bin_dir, "ffprobe.exe") + if os.path.exists(candidate): + return candidate + return "ffprobe" + + @staticmethod + def _existing_file(path: Optional[str]) -> bool: + return bool(path) and os.path.exists(path) and os.path.getsize(path) > 0 + + @classmethod + def _probe_duration(cls, media_path: Optional[str]) -> float: + if not cls._existing_file(media_path): + return 0 + try: + command = [ + cls._find_ffprobe(), + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(media_path), + ] + output = subprocess.check_output(command, stderr=subprocess.DEVNULL, text=True).strip() + return float(output) if output else 0 + except Exception: + return 0 + + @staticmethod + def _note_result_dirs() -> list[Path]: + configured = Path(os.getenv("NOTE_OUTPUT_DIR", "note_results")) + backend_root = Path(__file__).resolve().parents[2] + candidates = [] + if configured.is_absolute(): + candidates.append(configured) + else: + candidates.extend([ + Path.cwd() / configured, + backend_root / configured, + backend_root.parent / configured, + ]) + + result = [] + seen = set() + for candidate in candidates: + try: + resolved = candidate.resolve() + except Exception: + resolved = candidate.absolute() + if resolved not in seen and resolved.exists(): + result.append(resolved) + seen.add(resolved) + return result + + def _prior_audio_cache_records(self, video_id: str) -> list[tuple[float, Path, dict]]: + records: list[tuple[float, Path, dict]] = [] + for note_dir in self._note_result_dirs(): + for audio_json in note_dir.glob("*_audio.json"): + try: + data = json.loads(audio_json.read_text(encoding="utf-8-sig")) + except Exception: + continue + if data.get("video_id") != video_id: + continue + records.append((audio_json.stat().st_mtime, audio_json, data)) + records.sort(key=lambda item: item[0], reverse=True) + return records + + def _cached_audio_result( + self, + video_url: str, + output_dir: str, + source: str, + allow_metadata_only: bool = False, + ) -> Optional[AudioDownloadResult]: + """Return a usable local audio result when Bilibili blocks fresh CDP/yt-dlp. + + Bilibili sometimes serves an "error" page to the controlled browser even + though the same video's audio/video was already cached locally by a + previous successful run. In that case there is no reason to hit the site + or bcut again just to recreate metadata. + """ + video_id = self._extract_bvid(video_url) + preferred_audio_path = os.path.join(output_dir, f"{video_id}.mp3") + records = self._prior_audio_cache_records(video_id) + + audio_path = preferred_audio_path if self._existing_file(preferred_audio_path) else None + if audio_path is None: + for _, _, data in records: + candidate = data.get("file_path") + if self._existing_file(candidate): + audio_path = candidate + break + + if audio_path is None and not allow_metadata_only: + return None + + prior = records[0][2] if records else {} + raw_info = prior.get("raw_info") if isinstance(prior.get("raw_info"), dict) else {} + raw_info = dict(raw_info) + original_source = raw_info.get("source") + raw_info["source"] = source + if original_source: + raw_info["original_source"] = original_source + if records: + raw_info["prior_audio_cache"] = str(records[0][1]) + raw_info["cached_audio_path"] = audio_path or preferred_audio_path + + duration = prior.get("duration") or self._probe_duration(audio_path) + try: + duration = float(duration or 0) + except (TypeError, ValueError): + duration = 0 + + return AudioDownloadResult( + file_path=audio_path or preferred_audio_path, + title=prior.get("title") or video_id, + duration=duration, + cover_url=prior.get("cover_url") or "", + platform="bilibili", + video_id=video_id, + raw_info=raw_info, + video_path=prior.get("video_path"), + ) + + @classmethod + def _run_ffmpeg_to_mp3(cls, input_path: str, output_path: str) -> None: + command = [cls._find_ffmpeg(), "-y", "-i", input_path, "-vn", "-acodec", "libmp3lame", "-b:a", "64k", output_path] + subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + @classmethod + def _run_ffmpeg_to_mp4(cls, input_path: str, output_path: str) -> None: + # First try a fast remux; if the stream/container is not directly mp4-compatible, + # fall back to a conservative H.264 encode for VideoReader frame extraction. + remux = [cls._find_ffmpeg(), "-y", "-i", input_path, "-an", "-c:v", "copy", "-movflags", "+faststart", output_path] + result = subprocess.run(remux, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if result.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0: + return + encode = [ + cls._find_ffmpeg(), "-y", "-i", input_path, "-an", "-c:v", "libx264", + "-preset", "veryfast", "-crf", "28", "-pix_fmt", "yuv420p", "-movflags", "+faststart", output_path, + ] + subprocess.run(encode, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + @staticmethod + def _select_dash_stream(streams: list[dict], prefer_lowest: bool = True) -> dict: + if not streams: + raise RuntimeError("Bilibili DASH stream list is empty") + def score(item: dict) -> int: + return int(item.get("bandwidth") or item.get("size") or 0) + return min(streams, key=score) if prefer_lowest else max(streams, key=score) + + def _download_audio_via_cdp(self, video_url: str, output_dir: str) -> AudioDownloadResult: + logger.info("yt-dlp ? B ?????? Chrome CDP ????") + state = asyncio.run(self._get_playinfo_from_cdp(video_url)) + playinfo = state["playinfo"] + data = playinfo["data"] + audio_streams = data["dash"]["audio"] + audio = self._select_dash_stream( + audio_streams, + prefer_lowest=self._env_enabled("BILIBILI_LOWEST_AUDIO_FIRST", True), + ) + + video_id = state.get("bvid") or self._extract_bvid(video_url) + title = (state.get("title") or video_id).replace("_????_bilibili", "") + duration = int(state.get("duration") or data.get("duration") or 0) + cover_url = state.get("pic") or "" + raw_audio_path = os.path.join(output_dir, f"{video_id}.m4s") + audio_path = os.path.join(output_dir, f"{video_id}.mp3") + + self._download_url(audio.get("baseUrl") or audio.get("base_url"), raw_audio_path, video_url) + self._run_ffmpeg_to_mp3(raw_audio_path, audio_path) + + return AudioDownloadResult( + file_path=audio_path, + title=title, + duration=duration, + cover_url=cover_url, + platform="bilibili", + video_id=video_id, + raw_info={ + "path": raw_audio_path, + "playinfo": playinfo, + "description": state.get("desc") or "", + "owner": state.get("owner") or "", + "tags": state.get("tags") or [], + "source": "chrome-cdp-playinfo", + "audio_id": audio.get("id"), + }, + video_path=None, + ) + + def _download_video_via_cdp(self, video_url: str, output_dir: str) -> str: + logger.info("Downloading Bilibili video through Chrome CDP playinfo") + state = asyncio.run(self._get_playinfo_from_cdp(video_url)) + playinfo = state["playinfo"] + data = playinfo["data"] + video_streams = ((data.get("dash") or {}).get("video") or []) + video = self._select_dash_stream( + video_streams, + prefer_lowest=self._env_enabled("BILIBILI_LOWEST_VIDEO_FIRST", True), + ) + + video_id = state.get("bvid") or self._extract_bvid(video_url) + raw_video_path = os.path.join(output_dir, f"{video_id}.video.m4s") + video_path = os.path.join(output_dir, f"{video_id}.mp4") + url = video.get("baseUrl") or video.get("base_url") + if not url: + raise RuntimeError("Bilibili CDP playinfo did not contain a video baseUrl") + + self._download_url(url, raw_video_path, video_url) + self._run_ffmpeg_to_mp4(raw_video_path, video_path) + if not os.path.exists(video_path): + raise FileNotFoundError(f"Bilibili CDP video file not found: {video_path}") + return video_path + + @staticmethod + def _should_fallback_to_cdp(error: Exception) -> bool: + message = str(error) + return "HTTP Error 412" in message or "Precondition Failed" in message + + @staticmethod + def _env_enabled(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() not in {"0", "false", "no", "off"} + + def _prefer_cdp_first(self) -> bool: + # Bilibili frequently returns HTTP 412 to yt-dlp metadata probes. + # Prefer the real-browser CDP path first, matching the old-version fix. + return self._env_enabled("BILIBILI_CDP_FIRST", True) + + def _reuse_existing_media(self) -> bool: + return self._env_enabled("BILIBILI_REUSE_EXISTING_MEDIA", True) + def download( self, video_url: str, output_dir: Union[str, None] = None, quality: DownloadQuality = "fast", - need_video:Optional[bool]=False + need_video: Optional[bool] = False, + skip_download: bool = False, ) -> AudioDownloadResult: if output_dir is None: output_dir = get_data_dir() if not output_dir: - output_dir=self.cache_data + output_dir = self.cache_data os.makedirs(output_dir, exist_ok=True) - output_path = os.path.join(output_dir, "%(id)s.%(ext)s") + ydl_opts = self._ydl_base_opts(output_path) + + if self._reuse_existing_media(): + cached = self._cached_audio_result( + str(video_url), + output_dir, + source="local-media-cache-before-network", + allow_metadata_only=skip_download, + ) + if cached is not None: + logger.info("Reusing cached Bilibili audio for %s: %s", cached.video_id, cached.file_path) + return cached + + cdp_first_error = None + if self._prefer_cdp_first(): + try: + return self._download_audio_via_cdp(video_url, output_dir) + except Exception as cdp_error: + cdp_first_error = cdp_error + if self._reuse_existing_media(): + cached = self._cached_audio_result( + str(video_url), + output_dir, + source="local-media-cache-after-cdp-failure", + allow_metadata_only=skip_download, + ) + if cached is not None: + logger.warning( + "Bilibili CDP failed, using cached audio for %s instead: %s", + cached.video_id, + cdp_error, + ) + return cached + logger.warning("Bilibili CDP-first download failed, falling back to yt-dlp: %s", cdp_error) - ydl_opts = { - 'format': 'bestaudio[ext=m4a]/bestaudio/best', - 'outtmpl': output_path, - 'postprocessors': [ - { - 'key': 'FFmpegExtractAudio', - 'preferredcodec': 'mp3', - 'preferredquality': '64', - } - ], - 'noplaylist': True, - 'quiet': False, - } - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(video_url, download=True) - video_id = info.get("id") - title = info.get("title") - duration = info.get("duration", 0) - cover_url = info.get("thumbnail") - audio_path = os.path.join(output_dir, f"{video_id}.mp3") + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(video_url, download=not skip_download) + video_id = info.get("id") + title = info.get("title") + duration = info.get("duration", 0) + cover_url = info.get("thumbnail") + audio_path = os.path.join(output_dir, f"{video_id}.mp3") + except Exception as error: + if self._should_fallback_to_cdp(error): + if cdp_first_error is not None: + if self._reuse_existing_media(): + cached = self._cached_audio_result( + str(video_url), + output_dir, + source="local-media-cache-after-cdp-and-ytdlp-failure", + allow_metadata_only=skip_download, + ) + if cached is not None: + logger.warning( + "Bilibili CDP and yt-dlp failed, using cached audio for %s", + cached.video_id, + ) + return cached + logger.error("yt-dlp also returned 412 after CDP-first failure; raising original CDP error") + raise cdp_first_error + return self._download_audio_via_cdp(video_url, output_dir) + raise return AudioDownloadResult( file_path=audio_path, @@ -68,59 +607,58 @@ def download( platform="bilibili", video_id=video_id, raw_info=info, - video_path=None # ❗音频下载不包含视频路径 + video_path=None, ) - def download_video( - self, - video_url: str, - output_dir: Union[str, None] = None, - ) -> str: - """ - 下载视频,返回视频文件路径 - """ - + def download_video(self, video_url: str, output_dir: Union[str, None] = None) -> str: if output_dir is None: output_dir = get_data_dir() + if not output_dir: + output_dir = self.cache_data os.makedirs(output_dir, exist_ok=True) - print("video_url",video_url) - video_id=extract_video_id(video_url, "bilibili") + video_id = extract_video_id(video_url, "bilibili") video_path = os.path.join(output_dir, f"{video_id}.mp4") - if os.path.exists(video_path): + if os.path.exists(video_path) and os.path.getsize(video_path) > 0: return video_path - # 检查是否已经存在 - + cdp_first_error = None + if self._prefer_cdp_first(): + try: + return self._download_video_via_cdp(video_url, output_dir) + except Exception as cdp_error: + cdp_first_error = cdp_error + logger.warning("Bilibili CDP-first video download failed, falling back to yt-dlp: %s", cdp_error) output_path = os.path.join(output_dir, "%(id)s.%(ext)s") - ydl_opts = { - 'format': 'bv*[ext=mp4]/bestvideo+bestaudio/best', - 'outtmpl': output_path, - 'noplaylist': True, - 'quiet': False, - 'merge_output_format': 'mp4', # 确保合并成 mp4 + "format": "bv*[ext=mp4]/bestvideo+bestaudio/best", + "outtmpl": output_path, + "noplaylist": True, + "quiet": False, + "merge_output_format": "mp4", + "http_headers": self._bilibili_headers(), } - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(video_url, download=True) - video_id = info.get("id") - video_path = os.path.join(output_dir, f"{video_id}.mp4") - + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(video_url, download=True) + video_id = info.get("id") + video_path = os.path.join(output_dir, f"{video_id}.mp4") + except Exception as error: + if self._should_fallback_to_cdp(error): + if cdp_first_error is not None: + logger.error("yt-dlp video path also returned 412 after CDP-first failure; raising original CDP error") + raise cdp_first_error + return self._download_video_via_cdp(video_url, output_dir) + raise if not os.path.exists(video_path): - raise FileNotFoundError(f"视频文件未找到: {video_path}") - + raise FileNotFoundError(f"Bilibili video file not found: {video_path}") return video_path def delete_video(self, video_path: str) -> str: - """ - 删除视频文件 - """ if os.path.exists(video_path): os.remove(video_path) - return f"视频文件已删除: {video_path}" - else: - return f"视频文件未找到: {video_path}" + return f"???????: {video_path}" + return f"???????: {video_path}" def download_subtitles(self, video_url: str, output_dir: str = None, langs: List[str] = None) -> Optional[TranscriptResult]: @@ -138,6 +676,10 @@ def download_subtitles(self, video_url: str, output_dir: str = None, output_dir = self.cache_data os.makedirs(output_dir, exist_ok=True) + if self._env_enabled("BILIBILI_SKIP_YTDLP_SUBTITLES", True): + logger.info("Skipping yt-dlp Bilibili subtitle probe; CDP/audio fallback will be used to avoid HTTP 412") + return None + if langs is None: langs = ['zh-Hans', 'zh', 'zh-CN', 'ai-zh', 'en', 'en-US'] @@ -314,4 +856,4 @@ def _parse_json3_subtitle(self, subtitle_file: str, language: str) -> Optional[T except Exception as e: logger.warning(f"解析字幕文件失败: {e}") - return None \ No newline at end of file + return None diff --git a/backend/app/routers/note.py b/backend/app/routers/note.py index a9e2d4c3..2376ebeb 100644 --- a/backend/app/routers/note.py +++ b/backend/app/routers/note.py @@ -6,11 +6,10 @@ from typing import Optional from urllib.parse import urlparse -from fastapi import APIRouter, HTTPException, BackgroundTasks, UploadFile, File -from pydantic import BaseModel, validator, field_validator +from fastapi import APIRouter, HTTPException, UploadFile, File +from pydantic import BaseModel, field_validator from dataclasses import asdict -from app.db.video_task_dao import get_task_by_video from app.enmus.exception import NoteErrorEnum from app.enmus.note_enums import DownloadQuality from app.exceptions.note import NoteError @@ -19,7 +18,7 @@ from app.utils.response import ResponseWrapper as R from app.utils.url_parser import extract_video_id from app.validators.video_url_validator import is_supported_video_url -from fastapi import APIRouter, Request, HTTPException +from fastapi import Request from fastapi.responses import StreamingResponse import httpx from app.enmus.task_status_enums import TaskStatus @@ -70,8 +69,23 @@ def validate_supported_url(cls, v): def save_note_to_file(task_id: str, note): os.makedirs(NOTE_OUTPUT_DIR, exist_ok=True) - with open(os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.json"), "w", encoding="utf-8") as f: + result_path = Path(NOTE_OUTPUT_DIR) / f"{task_id}.json" + temp_path = result_path.with_suffix(".json.tmp") + with open(temp_path, "w", encoding="utf-8") as f: json.dump(asdict(note), f, ensure_ascii=False, indent=2) + os.replace(temp_path, result_path) + + +def _read_result_file(task_id: str) -> Optional[dict]: + result_path = os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.json") + if not os.path.exists(result_path): + return None + try: + with open(result_path, "r", encoding="utf-8-sig") as f: + return json.load(f) + except json.JSONDecodeError as exc: + logger.warning("结果文件尚未写完整: task_id=%s, error=%s", task_id, exc) + return None def run_note_task(task_id: str, video_url: str, platform: str, quality: DownloadQuality, @@ -81,6 +95,7 @@ def run_note_task(task_id: str, video_url: str, platform: str, quality: Download ): if not model_name or not provider_id: + NoteGenerator()._update_status(task_id, TaskStatus.FAILED, message="请选择模型和提供者") raise HTTPException(status_code=400, detail="请选择模型和提供者") def _execute_note_task(): @@ -101,20 +116,26 @@ def _execute_note_task(): grid_size=grid_size, ) - logger.info(f"任务进入执行队列 (task_id={task_id})") - note = task_serial_executor.run(_execute_note_task) - logger.info(f"Note generated: {task_id}") - if not note or not note.markdown: - logger.warning(f"任务 {task_id} 执行失败,跳过保存") - return - save_note_to_file(task_id, note) - - # 自动建立向量索引(用于 AI 问答),失败不影响笔记生成 try: - from app.services.vector_store import VectorStoreManager - VectorStoreManager().index_task(task_id) - except Exception as e: - logger.warning(f"向量索引失败(不影响笔记): {e}") + logger.info(f"任务进入执行队列 (task_id={task_id})") + note = task_serial_executor.run(_execute_note_task) + logger.info(f"Note generated: {task_id}") + if not note or not note.markdown: + logger.warning(f"任务 {task_id} 执行失败,跳过保存") + NoteGenerator()._update_status(task_id, TaskStatus.FAILED, message="任务执行失败,未生成有效笔记") + return + save_note_to_file(task_id, note) + NoteGenerator()._update_status(task_id, TaskStatus.SUCCESS) + + # 自动建立向量索引(用于 AI 问答),失败不影响笔记生成 + try: + from app.services.vector_store import VectorStoreManager + VectorStoreManager().index_task(task_id) + except Exception as e: + logger.warning(f"向量索引失败(不影响笔记): {e}") + except Exception as exc: + logger.error(f"任务执行异常 (task_id={task_id}): {exc}", exc_info=True) + NoteGenerator()._update_status(task_id, TaskStatus.FAILED, message=str(exc)) @router.post('/delete_task') @@ -140,7 +161,7 @@ async def upload(file: UploadFile = File(...)): @router.post("/generate_note") -def generate_note(data: VideoRequest, background_tasks: BackgroundTasks): +def generate_note(data: VideoRequest): try: video_id = extract_video_id(data.video_url, data.platform) @@ -163,9 +184,23 @@ def generate_note(data: VideoRequest, background_tasks: BackgroundTasks): # 统一先写入 PENDING,表示已进入队列等待串行执行 NoteGenerator()._update_status(task_id, TaskStatus.PENDING) - background_tasks.add_task(run_note_task, task_id, data.video_url, data.platform, data.quality, data.link, - data.screenshot, data.model_name, data.provider_id, data.format, data.style, - data.extras, data.video_understanding, data.video_interval, data.grid_size) + task_serial_executor.submit( + run_note_task, + task_id, + data.video_url, + data.platform, + data.quality, + data.link, + data.screenshot, + data.model_name, + data.provider_id, + data.format, + data.style, + data.extras, + data.video_understanding, + data.video_interval, + data.grid_size, + ) return R.success({"task_id": task_id}) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -174,37 +209,56 @@ def generate_note(data: VideoRequest, background_tasks: BackgroundTasks): @router.get("/task_status/{task_id}") def get_task_status(task_id: str): status_path = os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.status.json") - result_path = os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.json") + + # Result JSON is the source of truth. If it exists and parses, return + # SUCCESS even if a stale status file says DOWNLOADING/SUMMARIZING. + result_content = _read_result_file(task_id) + if result_content is not None: + return R.success({ + "status": TaskStatus.SUCCESS.value, + "result": result_content, + "message": "", + "task_id": task_id + }) # 优先读状态文件 if os.path.exists(status_path): - with open(status_path, "r", encoding="utf-8") as f: + with open(status_path, "r", encoding="utf-8-sig") as f: status_content = json.load(f) status = status_content.get("status") message = status_content.get("message", "") if status == TaskStatus.SUCCESS.value: - # 成功状态的话,继续读取最终笔记内容 - if os.path.exists(result_path): - with open(result_path, "r", encoding="utf-8") as rf: - result_content = json.load(rf) - return R.success({ - "status": status, - "result": result_content, - "message": message, - "task_id": task_id - }) - else: - # 理论上不会出现,保险处理 - return R.success({ - "status": TaskStatus.PENDING.value, - "message": "任务完成,但结果文件未找到", - "task_id": task_id - }) + return R.success({ + "status": TaskStatus.SAVING.value, + "message": "结果文件写入中", + "task_id": task_id + }) if status == TaskStatus.FAILED.value: - return R.error(message or "任务失败", code=500) + return R.success({ + "status": TaskStatus.FAILED.value, + "message": message or "任务失败", + "task_id": task_id + }) + + if ( + status in { + TaskStatus.PARSING.value, + TaskStatus.DOWNLOADING.value, + TaskStatus.TRANSCRIBING.value, + TaskStatus.SUMMARIZING.value, + TaskStatus.FORMATTING.value, + TaskStatus.SAVING.value, + } + and not task_serial_executor.has_task(task_id) + ): + return R.success({ + "status": TaskStatus.FAILED.value, + "message": f"任务已停止但状态停留在 {status},请重新生成", + "task_id": task_id + }) # 处理中状态 return R.success({ @@ -213,16 +267,6 @@ def get_task_status(task_id: str): "task_id": task_id }) - # 没有状态文件,但有结果 - if os.path.exists(result_path): - with open(result_path, "r", encoding="utf-8") as f: - result_content = json.load(f) - return R.success({ - "status": TaskStatus.SUCCESS.value, - "result": result_content, - "task_id": task_id - }) - # 什么都没有,默认PENDING return R.success({ "status": TaskStatus.PENDING.value, @@ -231,6 +275,11 @@ def get_task_status(task_id: str): }) +@router.get("/task_queue_status") +def get_task_queue_status(): + return R.success(data=task_serial_executor.stats()) + + @router.get("/image_proxy") async def image_proxy(request: Request, url: str): headers = { diff --git a/backend/app/services/note.py b/backend/app/services/note.py index ebbe83a6..9e3e00da 100644 --- a/backend/app/services/note.py +++ b/backend/app/services/note.py @@ -140,14 +140,10 @@ def generate( if transcript_cache_file.exists(): logger.info(f"检测到转写缓存 ({transcript_cache_file}),尝试读取") try: - data = json.loads(transcript_cache_file.read_text(encoding="utf-8")) - segments = [TranscriptSegment(**seg) for seg in data.get("segments", [])] - transcript = TranscriptResult( - language=data.get("language"), - full_text=data["full_text"], - segments=segments, - ) - logger.info(f"已从缓存加载转写结果,共 {len(segments)} 段") + transcript = self._load_transcript_cache(transcript_cache_file) + if transcript is None: + raise ValueError("转写缓存为空或格式无效") + logger.info(f"已从缓存加载转写结果,共 {len(transcript.segments)} 段") except Exception as e: logger.warning(f"加载转写缓存失败: {e}") @@ -230,7 +226,6 @@ def generate( self._save_metadata(video_id=audio_meta.video_id, platform=platform, task_id=task_id) # 6. 完成 - self._update_status(task_id, TaskStatus.SUCCESS) logger.info(f"笔记生成成功 (task_id={task_id})") return NoteResult(markdown=markdown, transcript=transcript, audio_meta=audio_meta) @@ -397,7 +392,7 @@ def _download_media( if audio_cache_file.exists(): logger.info(f"检测到音频缓存 ({audio_cache_file}),直接读取") try: - data = json.loads(audio_cache_file.read_text(encoding="utf-8")) + data = json.loads(audio_cache_file.read_text(encoding="utf-8-sig")) return AudioDownloadResult(**data) except Exception as e: logger.warning(f"读取音频缓存失败,将重新下载:{e}") @@ -431,7 +426,7 @@ def _download_media( if need_video: try: logger.info("开始下载视频") - video_path_str = downloader.download_video(video_url) + video_path_str = downloader.download_video(video_url, output_dir=output_path) self.video_path = Path(video_path_str) logger.info(f"视频下载完成:{self.video_path}") @@ -469,6 +464,106 @@ def _download_media( raise + @staticmethod + def _env_enabled(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() not in {"0", "false", "no", "off"} + + def _build_bilibili_page_transcript(self, audio_meta: AudioDownloadResult) -> Optional[TranscriptResult]: + """Use Bilibili page metadata captured by the CDP fallback as a fast transcript draft.""" + # Disabled by default because page metadata has no real speech timeline. + # Enable only as emergency fallback with BILIBILI_PAGE_TRANSCRIPT_FALLBACK=1. + if not self._env_enabled("BILIBILI_PAGE_TRANSCRIPT_FALLBACK", False): + return None + if not audio_meta or not isinstance(audio_meta.raw_info, dict): + return None + if audio_meta.raw_info.get("source") != "chrome-cdp-playinfo": + return None + title = (audio_meta.title or audio_meta.raw_info.get("title") or audio_meta.video_id or "").strip() + desc = (audio_meta.raw_info.get("description") or "").strip() + tags = audio_meta.raw_info.get("tags") or [] + owner = (audio_meta.raw_info.get("owner") or "").strip() + parts = [title] + if owner: + parts.append(f"UP??{owner}") + if desc: + parts.append(desc) + if tags: + parts.append("???" + "?".join(str(tag) for tag in tags if tag)) + full_text = "\n\n".join(part for part in parts if part).strip() + if not full_text: + return None + return TranscriptResult( + language="zh", + full_text=full_text, + segments=[TranscriptSegment(start=0, end=float(audio_meta.duration or 0), text=full_text)], + raw={"source": "bilibili_page_metadata"}, + ) + + @staticmethod + def _load_transcript_cache(cache_file: Path) -> Optional[TranscriptResult]: + try: + data = json.loads(cache_file.read_text(encoding="utf-8-sig")) + segments = [TranscriptSegment(**seg) for seg in data.get("segments", [])] + if not data.get("full_text") or not segments: + return None + return TranscriptResult( + language=data.get("language"), + full_text=data["full_text"], + segments=segments, + raw=data.get("raw"), + ) + except Exception as exc: + logger.warning("读取转写缓存失败 (%s): %s", cache_file, exc) + return None + + def _reuse_prior_transcript( + self, + audio_meta: AudioDownloadResult, + transcript_cache_file: Path, + ) -> Optional[TranscriptResult]: + if not self._env_enabled("BILIBILI_REUSE_TRANSCRIPT_CACHE", True): + return None + if not audio_meta or audio_meta.platform != "bilibili" or not audio_meta.video_id: + return None + + current_audio_file = transcript_cache_file.with_name( + transcript_cache_file.name.replace("_transcript.json", "_audio.json") + ) + candidates: list[Path] = [] + for audio_file in NOTE_OUTPUT_DIR.glob("*_audio.json"): + if audio_file == current_audio_file: + continue + try: + audio_data = json.loads(audio_file.read_text(encoding="utf-8-sig")) + except Exception: + continue + if audio_data.get("video_id") != audio_meta.video_id: + continue + transcript_file = audio_file.with_name(audio_file.name.replace("_audio.json", "_transcript.json")) + if transcript_file.exists(): + candidates.append(transcript_file) + + candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True) + for candidate in candidates: + transcript = self._load_transcript_cache(candidate) + if transcript is None: + continue + transcript_cache_file.write_text( + json.dumps(asdict(transcript), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + logger.info( + "复用同视频历史转写缓存 video_id=%s source=%s -> %s", + audio_meta.video_id, + candidate, + transcript_cache_file, + ) + return transcript + return None + def _get_transcript( self, downloader: Downloader, @@ -494,12 +589,9 @@ def _get_transcript( # 已有缓存,直接返回 if transcript_cache_file.exists(): logger.info(f"检测到转写缓存 ({transcript_cache_file}),尝试读取") - try: - data = json.loads(transcript_cache_file.read_text(encoding="utf-8")) - segments = [TranscriptSegment(**seg) for seg in data.get("segments", [])] - return TranscriptResult(language=data.get("language"), full_text=data["full_text"], segments=segments) - except Exception as e: - logger.warning(f"加载转写缓存失败,将重新获取:{e}") + cached_transcript = self._load_transcript_cache(transcript_cache_file) + if cached_transcript is not None: + return cached_transcript # 1. 先尝试获取平台字幕 logger.info("尝试获取平台字幕...") @@ -518,7 +610,33 @@ def _get_transcript( except Exception as e: logger.warning(f"获取平台字幕失败: {e},将使用音频转写") - # 2. Fallback 到音频转写 + # 2. If CDP audio fallback captured page metadata, use it as a fast transcript draft. + try: + audio_cache_file = transcript_cache_file.with_name(transcript_cache_file.name.replace("_transcript.json", "_audio.json")) + if audio_cache_file.exists(): + audio_data = json.loads(audio_cache_file.read_text(encoding="utf-8-sig")) + page_transcript = self._build_bilibili_page_transcript(AudioDownloadResult(**audio_data)) + if page_transcript is not None: + transcript_cache_file.write_text( + json.dumps(asdict(page_transcript), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + logger.info("?? B ???????????") + return page_transcript + except Exception as e: + logger.warning(f"B ???????????????????: {e}") + + try: + audio_cache_file = transcript_cache_file.with_name(transcript_cache_file.name.replace("_transcript.json", "_audio.json")) + if audio_cache_file.exists(): + audio_meta = AudioDownloadResult(**json.loads(audio_cache_file.read_text(encoding="utf-8-sig"))) + cached_transcript = self._reuse_prior_transcript(audio_meta, transcript_cache_file) + if cached_transcript is not None: + return cached_transcript + except Exception as e: + logger.warning("同视频历史转写复用失败: %s", e) + + # 3. Fallback ????? return self._transcribe_audio( audio_file=audio_file, transcript_cache_file=transcript_cache_file, @@ -546,12 +664,9 @@ def _transcribe_audio( # 已有缓存,尝试加载 if transcript_cache_file.exists(): logger.info(f"检测到转写缓存 ({transcript_cache_file}),尝试读取") - try: - data = json.loads(transcript_cache_file.read_text(encoding="utf-8")) - segments = [TranscriptSegment(**seg) for seg in data.get("segments", [])] - return TranscriptResult(language=data["language"], full_text=data["full_text"], segments=segments) - except Exception as e: - logger.warning(f"加载转写缓存失败,将重新转写:{e}") + cached_transcript = self._load_transcript_cache(transcript_cache_file) + if cached_transcript is not None: + return cached_transcript # 调用转写器 try: diff --git a/backend/app/services/task_serial_executor.py b/backend/app/services/task_serial_executor.py index f4017f92..601052da 100644 --- a/backend/app/services/task_serial_executor.py +++ b/backend/app/services/task_serial_executor.py @@ -1,23 +1,84 @@ -import os -from concurrent.futures import ThreadPoolExecutor, Future +import threading +from concurrent.futures import Future, ThreadPoolExecutor from typing import Any, Callable -class ConcurrentTaskExecutor: - """使用线程池并发执行任务,替代原来的串行锁。""" +class SerialTaskExecutor: + """Run note generation tasks with bounded concurrency. - def __init__(self, max_workers: int | None = None): - self._max_workers = max_workers or int(os.getenv("TASK_MAX_WORKERS", "3")) - self._pool = ThreadPoolExecutor(max_workers=self._max_workers) + Heavy note tasks can be slow, so the worker count is configurable. Keep the + default conservative because Bilibili/CDP and online ASR providers may rate + limit or fail when too many jobs are started at once. + """ + + def __init__(self, max_workers: int = 1): + self.max_workers = max(1, int(max_workers)) + self._state_lock = threading.Lock() + self._executor = ThreadPoolExecutor(max_workers=self.max_workers, thread_name_prefix="note-worker") + self._queued = 0 + self._active = 0 + self._queued_task_ids: list[str] = [] + self._active_task_ids: list[str] = [] def run(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: - future: Future = self._pool.submit(fn, *args, **kwargs) - return future.result() + return fn(*args, **kwargs) + + def submit(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Future: + task_id = args[0] if args and isinstance(args[0], str) else None + with self._state_lock: + self._queued += 1 + if task_id: + self._queued_task_ids.append(task_id) + + def _wrapped(): + with self._state_lock: + self._queued = max(0, self._queued - 1) + self._active += 1 + if task_id and task_id in self._queued_task_ids: + self._queued_task_ids.remove(task_id) + if task_id: + self._active_task_ids.append(task_id) + try: + return fn(*args, **kwargs) + finally: + with self._state_lock: + self._active = max(0, self._active - 1) + if task_id and task_id in self._active_task_ids: + self._active_task_ids.remove(task_id) + + return self._executor.submit(_wrapped) + + def queue_size(self) -> int: + with self._state_lock: + return self._queued + + def active_count(self) -> int: + with self._state_lock: + return self._active + + def stats(self) -> dict[str, int]: + with self._state_lock: + return { + "max_workers": self.max_workers, + "queued": self._queued, + "active": self._active, + "queued_task_ids": list(self._queued_task_ids), + "active_task_ids": list(self._active_task_ids), + } + + def has_task(self, task_id: str) -> bool: + with self._state_lock: + return task_id in self._queued_task_ids or task_id in self._active_task_ids def shutdown(self, wait: bool = True): - self._pool.shutdown(wait=wait) + self._executor.shutdown(wait=wait) + + +def _env_int(name: str, default: int) -> int: + try: + return int(__import__("os").getenv(name, str(default))) + except Exception: + return default -# 保持向后兼容的导出名 -SerialTaskExecutor = ConcurrentTaskExecutor -task_serial_executor = ConcurrentTaskExecutor() +task_serial_executor = SerialTaskExecutor(max_workers=_env_int("NOTE_TASK_MAX_WORKERS", 2)) diff --git a/backend/app/transcriber/bcut.py b/backend/app/transcriber/bcut.py index 88592914..ff489584 100644 --- a/backend/app/transcriber/bcut.py +++ b/backend/app/transcriber/bcut.py @@ -1,5 +1,6 @@ import json import logging +import os import time from typing import Optional, List, Dict, Union @@ -118,19 +119,31 @@ def __commit_upload(self) -> None: "UploadId": self.__upload_id, "model_id": "8", }) - resp = self.session.post( - API_COMMIT_UPLOAD, - data=data, - headers=self.headers - ) - resp.raise_for_status() - resp = resp.json() - print('Bili',resp) - if resp.get("code") != 0: - error_msg = f"上传提交失败: {resp.get('message', '未知错误')}" - logger.error(error_msg) - raise Exception(error_msg) - + max_attempts = max(1, int(os.getenv("BCUT_COMMIT_RETRY_ATTEMPTS", "4"))) + last_error = None + for attempt in range(1, max_attempts + 1): + try: + resp = self.session.post( + API_COMMIT_UPLOAD, + data=data, + headers=self.headers + ) + resp.raise_for_status() + resp = resp.json() + if resp.get("code") == 0: + break + last_error = f"上传提交失败: {resp.get('message', '未知错误')}" + logger.warning("%s (attempt %s/%s)", last_error, attempt, max_attempts) + except Exception as exc: + last_error = exc + logger.warning("上传提交异常 (attempt %s/%s): %s", attempt, max_attempts, exc) + + if attempt < max_attempts: + time.sleep(min(3 * attempt, 10)) + else: + logger.error(str(last_error)) + raise Exception(str(last_error)) + self.__download_url = resp["data"]["download_url"] logger.info(f"提交成功,下载链接: {self.__download_url}") @@ -248,4 +261,4 @@ def on_finish(self, video_path: str, result: TranscriptResult) -> None: logger.info(f"B站ASR转写完成: {video_path}") transcription_finished.send({ "file_path": video_path, - }) \ No newline at end of file + }) diff --git a/backend/app/transcriber/transcriber_provider.py b/backend/app/transcriber/transcriber_provider.py index 0440bc84..a604a9bc 100644 --- a/backend/app/transcriber/transcriber_provider.py +++ b/backend/app/transcriber/transcriber_provider.py @@ -58,7 +58,11 @@ def get_whisper_transcriber(model_size="base", device="cuda"): return _init_transcriber(TranscriberType.FAST_WHISPER, WhisperTranscriber, model_size=model_size, device=device) def get_bcut_transcriber(): - return _init_transcriber(TranscriberType.BCUT, BcutTranscriber) + # BcutTranscriber keeps per-upload state such as resource_id, upload_id, + # etags and task_id on the instance. Reusing one singleton across parallel + # note jobs lets those fields overwrite each other, so create a fresh + # instance per task when note concurrency is enabled. + return BcutTranscriber() def get_kuaishou_transcriber(): return _init_transcriber(TranscriberType.KUAISHOU, KuaishouTranscriber) diff --git a/backend/app/utils/video_reader.py b/backend/app/utils/video_reader.py index 100dbff6..03205198 100644 --- a/backend/app/utils/video_reader.py +++ b/backend/app/utils/video_reader.py @@ -2,8 +2,11 @@ import hashlib import os import re +import shutil import subprocess +import uuid from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path import ffmpeg from PIL import Image, ImageDraw, ImageFont @@ -11,6 +14,18 @@ from app.utils.path_helper import get_app_dir logger = get_logger(__name__) + + +def _find_ffmpeg_tool(binary_name: str) -> str: + configured_dir = os.getenv("FFMPEG_BIN_PATH") + exe_name = f"{binary_name}.exe" if os.name == "nt" else binary_name + if configured_dir: + candidate = os.path.join(configured_dir, exe_name) + if os.path.exists(candidate): + return candidate + return shutil.which(binary_name) or shutil.which(exe_name) or binary_name + + class VideoReader: def __init__(self, video_path: str, @@ -22,17 +37,20 @@ def __init__(self, save_quality=90, font_path="fonts/arial.ttf", frame_dir=None, - grid_dir=None): + grid_dir=None, + max_frames=None): self.video_path = video_path self.grid_size = grid_size - self.frame_interval = frame_interval + self.frame_interval = max(1, int(frame_interval or 1)) self.dedupe_enabled = dedupe_enabled self.unit_width = unit_width self.unit_height = unit_height self.save_quality = save_quality - self.frame_dir = frame_dir or get_app_dir("output_frames") - self.grid_dir = grid_dir or get_app_dir("grid_output") - print(f"视频路径:{video_path}",self.frame_dir,self.grid_dir) + suffix = f"{Path(video_path).stem}_{uuid.uuid4().hex[:8]}" + self.frame_dir = frame_dir or os.path.join(get_app_dir("output_frames"), suffix) + self.grid_dir = grid_dir or os.path.join(get_app_dir("grid_output"), suffix) + self.max_frames = self._resolve_max_frames(max_frames) + logger.info("视频路径:%s frame_dir=%s grid_dir=%s max_frames=%s", video_path, self.frame_dir, self.grid_dir, self.max_frames) self.font_path = font_path @staticmethod @@ -43,23 +61,79 @@ def _calculate_file_md5(file_path: str) -> str: hasher.update(chunk) return hasher.hexdigest() + @staticmethod + def _resolve_max_frames(value) -> int: + if value is None: + value = os.getenv("VIDEO_UNDERSTANDING_MAX_FRAMES", "80") + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = 80 + return max(1, parsed) + + def _grid_group_size(self) -> int: + if not self.grid_size or len(self.grid_size) < 2: + return 1 + return max(1, int(self.grid_size[0]) * int(self.grid_size[1])) + + def _target_frame_count(self) -> int: + group_size = self._grid_group_size() + groups = max(1, self.max_frames // group_size) + return groups * group_size + + @staticmethod + def _format_timestamp_token(seconds: float) -> str: + whole_seconds = max(0, int(round(seconds))) + hours = whole_seconds // 3600 + minutes = (whole_seconds % 3600) // 60 + secs = whole_seconds % 60 + return f"{hours:02d}_{minutes:02d}_{secs:02d}" + def format_time(self, seconds: float) -> str: - mm = int(seconds // 60) - ss = int(seconds % 60) - return f"{mm:02d}_{ss:02d}" + return self._format_timestamp_token(seconds) def extract_time_from_filename(self, filename: str) -> float: - match = re.search(r"frame_(\d{2})_(\d{2})\.jpg", filename) + match = re.search(r"frame_(\d{2})_(\d{2})_(\d{2})\.jpg", filename) if match: - mm, ss = map(int, match.groups()) + hh, mm, ss = map(int, match.groups()) + return hh * 3600 + mm * 60 + ss + legacy_match = re.search(r"frame_(\d{2})_(\d{2})\.jpg", filename) + if legacy_match: + mm, ss = map(int, legacy_match.groups()) return mm * 60 + ss return float('inf') + def _build_timestamps(self, duration: float, max_frames: int) -> list[int]: + duration_int = max(0, int(duration)) + if duration_int <= 0: + return [0] + + interval_timestamps = list(range(0, duration_int, self.frame_interval)) + if not interval_timestamps: + interval_timestamps = [0] + + if len(interval_timestamps) <= max_frames: + return interval_timestamps + + # 均匀抽样整个视频,而不是只截前 max_frames 帧;这样长视频也能覆盖末尾。 + if max_frames == 1: + return [0] + last_idx = len(interval_timestamps) - 1 + sampled = [] + seen = set() + for i in range(max_frames): + idx = round(i * last_idx / (max_frames - 1)) + ts = interval_timestamps[idx] + if ts not in seen: + sampled.append(ts) + seen.add(ts) + return sampled + def _extract_single_frame(self, ts: int) -> str | None: """提取单帧,返回输出路径或 None(失败时)。""" time_label = self.format_time(ts) output_path = os.path.join(self.frame_dir, f"frame_{time_label}.jpg") - cmd = ["ffmpeg", "-ss", str(ts), "-i", self.video_path, "-frames:v", "1", "-q:v", "2", "-y", output_path, + cmd = [_find_ffmpeg_tool("ffmpeg"), "-ss", str(ts), "-i", self.video_path, "-frames:v", "1", "-q:v", "2", "-y", output_path, "-hide_banner", "-loglevel", "error"] try: subprocess.run(cmd, check=True) @@ -67,12 +141,23 @@ def _extract_single_frame(self, ts: int) -> str | None: except subprocess.CalledProcessError: return None - def extract_frames(self, max_frames=1000) -> list[str]: + def extract_frames(self, max_frames=None) -> list[str]: try: os.makedirs(self.frame_dir, exist_ok=True) - duration = float(ffmpeg.probe(self.video_path)["format"]["duration"]) - timestamps = [i for i in range(0, int(duration), self.frame_interval)][:max_frames] + duration = float(ffmpeg.probe(self.video_path, cmd=_find_ffmpeg_tool("ffprobe"))["format"]["duration"]) + effective_max_frames = self._resolve_max_frames(max_frames) if max_frames is not None else self._target_frame_count() + timestamps = self._build_timestamps(duration, effective_max_frames) + logger.info( + "视频理解抽帧:duration=%.2fs interval=%ss frames=%s cap=%s", + duration, + self.frame_interval, + len(timestamps), + effective_max_frames, + ) + + if not timestamps: + return [] # 并行提取帧 max_workers = min(os.cpu_count() or 4, 8, len(timestamps)) @@ -104,12 +189,14 @@ def extract_frames(self, max_frames=1000) -> list[str]: logger.error(f"分割帧发生错误:{str(e)}") raise ValueError("视频处理失败") - def group_images(self) -> list[list[str]]: - image_files = [os.path.join(self.frame_dir, f) for f in os.listdir(self.frame_dir) if - f.startswith("frame_") and f.endswith(".jpg")] + def group_images(self, image_files: list[str] | None = None) -> list[list[str]]: + if image_files is None: + image_files = [os.path.join(self.frame_dir, f) for f in os.listdir(self.frame_dir) if + f.startswith("frame_") and f.endswith(".jpg")] image_files.sort(key=lambda f: self.extract_time_from_filename(os.path.basename(f))) - group_size = self.grid_size[0] * self.grid_size[1] - return [image_files[i:i + group_size] for i in range(0, len(image_files), group_size)] + group_size = self._grid_group_size() + complete_count = (len(image_files) // group_size) * group_size + return [image_files[i:i + group_size] for i in range(0, complete_count, group_size)] def concat_images(self, image_paths: list[str], name: str) -> str: os.makedirs(self.grid_dir, exist_ok=True) @@ -118,8 +205,14 @@ def concat_images(self, image_paths: list[str], name: str) -> str: for path in image_paths: img = Image.open(path).convert("RGB").resize((self.unit_width, self.unit_height), Image.Resampling.LANCZOS) - timestamp = re.search(r"frame_(\d{2})_(\d{2})\.jpg", os.path.basename(path)) - time_text = f"{timestamp.group(1)}:{timestamp.group(2)}" if timestamp else "" + seconds = self.extract_time_from_filename(os.path.basename(path)) + if seconds == float("inf"): + time_text = "" + else: + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + time_text = f"{hours:02d}:{minutes:02d}:{secs:02d}" if hours else f"{minutes:02d}:{secs:02d}" draw = ImageDraw.Draw(img) draw.text((10, 10), time_text, fill="yellow", font=font, stroke_width=1, stroke_fill="black") images.append(img) @@ -148,32 +241,28 @@ def run(self)->list[str]: logger.info("开始提取视频帧...") try: # 确保目录存在 - print(self.frame_dir,self.grid_dir) os.makedirs(self.frame_dir, exist_ok=True) os.makedirs(self.grid_dir, exist_ok=True) - #清空帧文件夹 + # 清空帧文件夹 for file in os.listdir(self.frame_dir): if file.startswith("frame_"): os.remove(os.path.join(self.frame_dir, file)) - print(self.frame_dir,self.grid_dir) - #清空网格文件夹 + # 清空网格文件夹 for file in os.listdir(self.grid_dir): if file.startswith("grid_"): os.remove(os.path.join(self.grid_dir, file)) - print(self.frame_dir,self.grid_dir) - self.extract_frames() - print("2#3",self.frame_dir,self.grid_dir) + extracted_frames = self.extract_frames() logger.info("开始拼接网格图...") image_paths = [] - groups = self.group_images() + groups = self.group_images(extracted_frames) for idx, group in enumerate(groups, start=1): - if len(group) < self.grid_size[0] * self.grid_size[1]: - logger.warning(f"⚠️ 跳过第 {idx} 组,图片不足 {self.grid_size[0] * self.grid_size[1]} 张") + if len(group) < self._grid_group_size(): + logger.warning(f"⚠️ 跳过第 {idx} 组,图片不足 {self._grid_group_size()} 张") continue out_path = self.concat_images(group, f"grid_{idx}") image_paths.append(out_path) - logger.info("📤 开始编码图像...") + logger.info("开始编码图像...") urls = self.encode_images_to_base64(image_paths) return urls except Exception as e: diff --git a/backend/main.py b/backend/main.py index 3acf7d20..4493dd61 100644 --- a/backend/main.py +++ b/backend/main.py @@ -57,7 +57,8 @@ async def lifespan(app: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=origins, # 加上 Tauri 的 origin + allow_origins=origins, + allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?", # 加上 Tauri 的 origin allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -79,4 +80,4 @@ async def lifespan(app: FastAPI): port = int(os.getenv("BACKEND_PORT", 8483)) host = os.getenv("BACKEND_HOST", "0.0.0.0") logger.info(f"Starting server on {host}:{port}") - uvicorn.run(app, host=host, port=port, reload=False) \ No newline at end of file + uvicorn.run(app, host=host, port=port, reload=False) diff --git a/run.bat b/run.bat index 77083a85..59586796 100644 --- a/run.bat +++ b/run.bat @@ -1,7 +1,67 @@ @echo off +setlocal cd /d "%~dp0" +set "ROOT=%~dp0" +set "BACKEND_PORT=18483" +set "FRONTEND_PORT=13015" +set "NODE_OPTIONS=--max-old-space-size=4096" -start "Backend" powershell -NoExit -Command "cd backend; conda activate bili; python main.py" -start "Frontend" powershell -NoExit -Command "cd BillNote_frontend; npm run dev" +set "BACKEND_HOST=0.0.0.0" +set "APP_PORT=%FRONTEND_PORT%" +set "VITE_FRONTEND_PORT=%FRONTEND_PORT%" +set "VITE_API_BASE_URL=http://127.0.0.1:%BACKEND_PORT%/api" +set "VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:%BACKEND_PORT%/static/screenshots" +set "API_BASE_URL=http://127.0.0.1:%BACKEND_PORT%" +set "SCREENSHOT_BASE_URL=http://127.0.0.1:%BACKEND_PORT%/static/screenshots" +set "NOTE_TASK_MAX_WORKERS=2" -start http://localhost:3015/ \ No newline at end of file + +echo [BiliNote v2] root: %ROOT% +echo [BiliNote v2] backend: http://127.0.0.1:%BACKEND_PORT% +echo [BiliNote v2] frontend: http://127.0.0.1:%FRONTEND_PORT% + +echo [1/3] stopping old BiliNote v2 isolated ports if any... +powershell -NoProfile -ExecutionPolicy Bypass -Command "foreach($port in @(18483,13015)){ $listeners=Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue; foreach($listener in $listeners){ $pidToStop=[int]$listener.OwningProcess; Write-Host ('Stopping PID '+$pidToStop+' on port '+$port); Stop-Process -Id $pidToStop -Force -ErrorAction SilentlyContinue } }; Start-Sleep -Seconds 1" + +echo [2/3] starting backend... +if not exist "%ROOT%.venv\Scripts\python.exe" ( + echo ERROR: Python venv not found: %ROOT%.venv\Scripts\python.exe + pause + exit /b 1 +) +if not exist "%ROOT%backend\logs" mkdir "%ROOT%backend\logs" +( + echo @echo off + echo cd /d "%%~dp0backend" + echo set "BACKEND_PORT=18483" + echo set "BACKEND_HOST=0.0.0.0" + echo set "VIDEO_UNDERSTANDING_MAX_FRAMES=8" + echo set "BILIBILI_CDP_FIRST=1" + echo set "BILIBILI_LOWEST_VIDEO_FIRST=1" + echo set "BILIBILI_LOWEST_AUDIO_FIRST=1" + echo set "BILIBILI_MEDIA_RETRY_ATTEMPTS=8" + echo set "BILIBILI_SKIP_YTDLP_SUBTITLES=1" + echo set "NOTE_TASK_MAX_WORKERS=2" + echo "..\.venv\Scripts\python.exe" main.py +) > "%ROOT%start_backend_18483.cmd" +start "BiliNote v2 Backend 18483" /D "%ROOT%" "%ROOT%start_backend_18483.cmd" + +echo [3/3] starting frontend static server... +if not exist "%ROOT%BillNote_frontend\dist\index.html" ( + echo ERROR: frontend dist not found. Run npm run build first. + pause + exit /b 1 +) +start "BiliNote v2 Frontend 13015" /D "%ROOT%" "%ROOT%.venv\Scripts\python.exe" "%ROOT%serve_frontend_13015.py" + +echo Waiting for services... +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ok=$false; for($i=0;$i -lt 90;$i++){ Start-Sleep -Seconds 2; try{$b=Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 http://127.0.0.1:18483/api/sys_check; $f=Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 http://127.0.0.1:13015/; if($b.StatusCode -eq 200 -and $f.StatusCode -eq 200){$ok=$true; break}}catch{} }; if(-not $ok){ Write-Host 'ERROR: services not ready'; exit 1 }" +if errorlevel 1 ( + echo Backend or frontend did not become ready. Check the opened windows. + pause + exit /b 1 +) + +echo Ready. Opening http://127.0.0.1:13015/ +start http://127.0.0.1:13015/ +endlocal diff --git a/serve_frontend_13015.py b/serve_frontend_13015.py new file mode 100644 index 00000000..0065775f --- /dev/null +++ b/serve_frontend_13015.py @@ -0,0 +1,14 @@ +import os +import http.server +import socketserver +os.chdir(r"C:\codex\bilinote_latest_v2.0.0\BillNote_frontend\dist") +class Handler(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header("Cache-Control", "no-store") + super().end_headers() + def do_GET(self): + if not os.path.exists(self.translate_path(self.path)): + self.path = "/index.html" + return super().do_GET() +with socketserver.TCPServer(("0.0.0.0", 13015), Handler) as httpd: + httpd.serve_forever() diff --git a/start_backend_18483.cmd b/start_backend_18483.cmd new file mode 100644 index 00000000..91c237d9 --- /dev/null +++ b/start_backend_18483.cmd @@ -0,0 +1,12 @@ +@echo off +cd /d "%~dp0backend" +set "BACKEND_PORT=18483" +set "BACKEND_HOST=0.0.0.0" +set "VIDEO_UNDERSTANDING_MAX_FRAMES=8" +set "BILIBILI_CDP_FIRST=1" +set "BILIBILI_LOWEST_VIDEO_FIRST=1" +set "BILIBILI_LOWEST_AUDIO_FIRST=1" +set "BILIBILI_MEDIA_RETRY_ATTEMPTS=8" +set "BILIBILI_SKIP_YTDLP_SUBTITLES=1" +set "NOTE_TASK_MAX_WORKERS=2" +"..\.venv\Scripts\python.exe" main.py diff --git a/stop_v2_ports.bat b/stop_v2_ports.bat new file mode 100644 index 00000000..9d7106d2 --- /dev/null +++ b/stop_v2_ports.bat @@ -0,0 +1,2 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -Command "foreach($port in @(18483,13015)){ $listeners = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue; foreach($listener in $listeners){ $procId = [int]$listener.OwningProcess; Write-Host ('Stopping PID ' + $procId + ' on port ' + $port); Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue } }"