refactor with TypeScript
This commit is contained in:
375
backend/package-lock.json
generated
375
backend/package-lock.json
generated
@@ -20,7 +20,241 @@
|
||||
"youtube-dl-exec": "^2.4.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.3"
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"nodemon": "^3.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz",
|
||||
"integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/serve-static": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
|
||||
"integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/fs-extra": {
|
||||
"version": "11.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz",
|
||||
"integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jsonfile": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonfile": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz",
|
||||
"integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*",
|
||||
"@types/send": "<1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static/node_modules/@types/send": {
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
@@ -36,6 +270,32 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
||||
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
||||
@@ -104,6 +364,13 @@
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -655,6 +922,13 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -770,6 +1044,16 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
@@ -1622,6 +1906,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -2706,6 +2997,50 @@
|
||||
"nodetouch": "bin/nodetouch.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
"@tsconfig/node12": "^1.0.7",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@tsconfig/node16": "^1.0.2",
|
||||
"acorn": "^8.4.1",
|
||||
"acorn-walk": "^8.1.1",
|
||||
"arg": "^4.1.0",
|
||||
"create-require": "^1.1.0",
|
||||
"diff": "^4.0.1",
|
||||
"make-error": "^1.1.1",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"yn": "3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"ts-node": "dist/bin.js",
|
||||
"ts-node-cwd": "dist/bin-cwd.js",
|
||||
"ts-node-esm": "dist/bin-esm.js",
|
||||
"ts-node-script": "dist/bin-script.js",
|
||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||
"ts-script": "dist/bin-script-deprecated.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": ">=1.2.50",
|
||||
"@swc/wasm": ">=1.2.50",
|
||||
"@types/node": "*",
|
||||
"typescript": ">=2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -2743,6 +3078,20 @@
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
@@ -2759,6 +3108,13 @@
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
@@ -2807,6 +3163,13 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -2902,6 +3265,16 @@
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/youtube-dl-exec": {
|
||||
"version": "2.5.8",
|
||||
"resolved": "https://registry.npmjs.org/youtube-dl-exec/-/youtube-dl-exec-2.5.8.tgz",
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"start": "ts-node src/server.ts",
|
||||
"dev": "nodemon src/server.ts",
|
||||
"build": "tsc",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -23,6 +24,13 @@
|
||||
"youtube-dl-exec": "^2.4.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.3"
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"nodemon": "^3.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
const path = require("path");
|
||||
|
||||
// Assuming the application is started from the 'backend' directory
|
||||
const ROOT_DIR = process.cwd();
|
||||
|
||||
const UPLOADS_DIR = path.join(ROOT_DIR, "uploads");
|
||||
const VIDEOS_DIR = path.join(UPLOADS_DIR, "videos");
|
||||
const IMAGES_DIR = path.join(UPLOADS_DIR, "images");
|
||||
const DATA_DIR = path.join(ROOT_DIR, "data");
|
||||
|
||||
const VIDEOS_DATA_PATH = path.join(DATA_DIR, "videos.json");
|
||||
const STATUS_DATA_PATH = path.join(DATA_DIR, "status.json");
|
||||
const COLLECTIONS_DATA_PATH = path.join(DATA_DIR, "collections.json");
|
||||
|
||||
module.exports = {
|
||||
ROOT_DIR,
|
||||
UPLOADS_DIR,
|
||||
VIDEOS_DIR,
|
||||
IMAGES_DIR,
|
||||
DATA_DIR,
|
||||
VIDEOS_DATA_PATH,
|
||||
STATUS_DATA_PATH,
|
||||
COLLECTIONS_DATA_PATH,
|
||||
};
|
||||
13
backend/src/config/paths.ts
Normal file
13
backend/src/config/paths.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import path from "path";
|
||||
|
||||
// Assuming the application is started from the 'backend' directory
|
||||
export const ROOT_DIR: string = process.cwd();
|
||||
|
||||
export const UPLOADS_DIR: string = path.join(ROOT_DIR, "uploads");
|
||||
export const VIDEOS_DIR: string = path.join(UPLOADS_DIR, "videos");
|
||||
export const IMAGES_DIR: string = path.join(UPLOADS_DIR, "images");
|
||||
export const DATA_DIR: string = path.join(ROOT_DIR, "data");
|
||||
|
||||
export const VIDEOS_DATA_PATH: string = path.join(DATA_DIR, "videos.json");
|
||||
export const STATUS_DATA_PATH: string = path.join(DATA_DIR, "status.json");
|
||||
export const COLLECTIONS_DATA_PATH: string = path.join(DATA_DIR, "collections.json");
|
||||
@@ -1,7 +1,9 @@
|
||||
const storageService = require("../services/storageService");
|
||||
import { Request, Response } from "express";
|
||||
import * as storageService from "../services/storageService";
|
||||
import { Collection } from "../services/storageService";
|
||||
|
||||
// Get all collections
|
||||
const getCollections = (req, res) => {
|
||||
export const getCollections = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const collections = storageService.getCollections();
|
||||
res.json(collections);
|
||||
@@ -14,7 +16,7 @@ const getCollections = (req, res) => {
|
||||
};
|
||||
|
||||
// Create a new collection
|
||||
const createCollection = (req, res) => {
|
||||
export const createCollection = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { name, videoId } = req.body;
|
||||
|
||||
@@ -25,11 +27,12 @@ const createCollection = (req, res) => {
|
||||
}
|
||||
|
||||
// Create a new collection
|
||||
const newCollection = {
|
||||
const newCollection: Collection = {
|
||||
id: Date.now().toString(),
|
||||
name,
|
||||
videos: videoId ? [videoId] : [],
|
||||
createdAt: new Date().toISOString(),
|
||||
title: name, // Ensure title is also set as it's required by the interface
|
||||
};
|
||||
|
||||
// Save the new collection
|
||||
@@ -45,7 +48,7 @@ const createCollection = (req, res) => {
|
||||
};
|
||||
|
||||
// Update a collection
|
||||
const updateCollection = (req, res) => {
|
||||
export const updateCollection = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, videoId, action } = req.body;
|
||||
@@ -57,6 +60,7 @@ const updateCollection = (req, res) => {
|
||||
// Update the collection
|
||||
if (name) {
|
||||
collection.name = name;
|
||||
collection.title = name; // Update title as well
|
||||
}
|
||||
|
||||
// Add or remove a video
|
||||
@@ -92,7 +96,7 @@ const updateCollection = (req, res) => {
|
||||
};
|
||||
|
||||
// Delete a collection
|
||||
const deleteCollection = (req, res) => {
|
||||
export const deleteCollection = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { deleteVideos } = req.query;
|
||||
@@ -101,7 +105,7 @@ const deleteCollection = (req, res) => {
|
||||
if (deleteVideos === 'true') {
|
||||
const collection = storageService.getCollectionById(id);
|
||||
if (collection && collection.videos && collection.videos.length > 0) {
|
||||
collection.videos.forEach(videoId => {
|
||||
collection.videos.forEach((videoId) => {
|
||||
storageService.deleteVideo(videoId);
|
||||
});
|
||||
}
|
||||
@@ -123,10 +127,3 @@ const deleteCollection = (req, res) => {
|
||||
.json({ success: false, error: "Failed to delete collection" });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getCollections,
|
||||
createCollection,
|
||||
updateCollection,
|
||||
deleteCollection,
|
||||
};
|
||||
@@ -1,16 +1,18 @@
|
||||
const storageService = require("../services/storageService");
|
||||
const downloadService = require("../services/downloadService");
|
||||
const {
|
||||
isValidUrl,
|
||||
extractUrlFromText,
|
||||
resolveShortUrl,
|
||||
isBilibiliUrl,
|
||||
trimBilibiliUrl,
|
||||
extractBilibiliVideoId,
|
||||
} = require("../utils/helpers");
|
||||
import { Request, Response } from "express";
|
||||
import downloadManager from "../services/downloadManager";
|
||||
import * as downloadService from "../services/downloadService";
|
||||
import * as storageService from "../services/storageService";
|
||||
import {
|
||||
extractBilibiliVideoId,
|
||||
extractUrlFromText,
|
||||
isBilibiliUrl,
|
||||
isValidUrl,
|
||||
resolveShortUrl,
|
||||
trimBilibiliUrl,
|
||||
} from "../utils/helpers";
|
||||
|
||||
// Search for videos
|
||||
const searchVideos = async (req, res) => {
|
||||
export const searchVideos = async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { query } = req.query;
|
||||
|
||||
@@ -18,9 +20,9 @@ const searchVideos = async (req, res) => {
|
||||
return res.status(400).json({ error: "Search query is required" });
|
||||
}
|
||||
|
||||
const results = await downloadService.searchYouTube(query);
|
||||
const results = await downloadService.searchYouTube(query as string);
|
||||
res.status(200).json({ results });
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Error searching for videos:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to search for videos",
|
||||
@@ -29,12 +31,8 @@ const searchVideos = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
const downloadManager = require("../services/downloadManager");
|
||||
|
||||
// ... (imports remain the same)
|
||||
|
||||
// Download video
|
||||
const downloadVideo = async (req, res) => {
|
||||
export const downloadVideo = async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { youtubeUrl, downloadAllParts, collectionName, downloadCollection, collectionInfo } = req.body;
|
||||
let videoUrl = youtubeUrl;
|
||||
@@ -122,16 +120,17 @@ const downloadVideo = async (req, res) => {
|
||||
const { videosNumber, title } = partsInfo;
|
||||
|
||||
// Update title in storage
|
||||
storageService.addActiveDownload(downloadId, title);
|
||||
storageService.addActiveDownload(downloadId, title || "Bilibili Video");
|
||||
|
||||
// Create a collection for the multi-part video if collectionName is provided
|
||||
let collectionId = null;
|
||||
let collectionId: string | null = null;
|
||||
if (collectionName) {
|
||||
const newCollection = {
|
||||
id: Date.now().toString(),
|
||||
name: collectionName,
|
||||
videos: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
title: collectionName,
|
||||
};
|
||||
storageService.saveCollection(newCollection);
|
||||
collectionId = newCollection.id;
|
||||
@@ -146,28 +145,27 @@ const downloadVideo = async (req, res) => {
|
||||
firstPartUrl,
|
||||
1,
|
||||
videosNumber,
|
||||
title
|
||||
title || "Bilibili Video"
|
||||
);
|
||||
|
||||
// Add to collection if needed
|
||||
if (collectionId && firstPartResult.videoData) {
|
||||
storageService.atomicUpdateCollection(collectionId, (collection) => {
|
||||
collection.videos.push(firstPartResult.videoData.id);
|
||||
collection.videos.push(firstPartResult.videoData!.id);
|
||||
return collection;
|
||||
});
|
||||
}
|
||||
|
||||
// Set up background download for remaining parts
|
||||
// Note: We don't await this, it runs in background
|
||||
// But we should probably track it? For now, let's keep it simple
|
||||
// and only track the first part as the "main" download
|
||||
if (videosNumber > 1) {
|
||||
downloadService.downloadRemainingBilibiliParts(
|
||||
baseUrl,
|
||||
2,
|
||||
videosNumber,
|
||||
title,
|
||||
collectionId
|
||||
title || "Bilibili Video",
|
||||
collectionId!,
|
||||
downloadId // Pass downloadId to track progress
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,26 +201,22 @@ const downloadVideo = async (req, res) => {
|
||||
};
|
||||
|
||||
// Add to download manager
|
||||
// We don't await the result here because we want to return immediately
|
||||
// that the download has been queued/started
|
||||
downloadManager.addDownload(downloadTask, downloadId, initialTitle)
|
||||
.then(result => {
|
||||
.then((result: any) => {
|
||||
console.log("Download completed successfully:", result);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error: any) => {
|
||||
console.error("Download failed:", error);
|
||||
});
|
||||
|
||||
// Return success immediately indicating the download is queued/started
|
||||
// We can't return the video object yet because it hasn't been downloaded
|
||||
// The frontend will need to refresh or listen for updates
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Download queued",
|
||||
downloadId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Error queuing download:", error);
|
||||
res
|
||||
.status(500)
|
||||
@@ -231,7 +225,7 @@ const downloadVideo = async (req, res) => {
|
||||
};
|
||||
|
||||
// Get all videos
|
||||
const getVideos = (req, res) => {
|
||||
export const getVideos = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const videos = storageService.getVideos();
|
||||
res.status(200).json(videos);
|
||||
@@ -242,7 +236,7 @@ const getVideos = (req, res) => {
|
||||
};
|
||||
|
||||
// Get video by ID
|
||||
const getVideoById = (req, res) => {
|
||||
export const getVideoById = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const video = storageService.getVideoById(id);
|
||||
@@ -259,7 +253,7 @@ const getVideoById = (req, res) => {
|
||||
};
|
||||
|
||||
// Delete video
|
||||
const deleteVideo = (req, res) => {
|
||||
export const deleteVideo = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const success = storageService.deleteVideo(id);
|
||||
@@ -278,7 +272,7 @@ const deleteVideo = (req, res) => {
|
||||
};
|
||||
|
||||
// Get download status
|
||||
const getDownloadStatus = (req, res) => {
|
||||
export const getDownloadStatus = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const status = storageService.getDownloadStatus();
|
||||
res.status(200).json(status);
|
||||
@@ -289,7 +283,7 @@ const getDownloadStatus = (req, res) => {
|
||||
};
|
||||
|
||||
// Check Bilibili parts
|
||||
const checkBilibiliParts = async (req, res) => {
|
||||
export const checkBilibiliParts = async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { url } = req.query;
|
||||
|
||||
@@ -297,12 +291,12 @@ const checkBilibiliParts = async (req, res) => {
|
||||
return res.status(400).json({ error: "URL is required" });
|
||||
}
|
||||
|
||||
if (!isBilibiliUrl(url)) {
|
||||
if (!isBilibiliUrl(url as string)) {
|
||||
return res.status(400).json({ error: "Not a valid Bilibili URL" });
|
||||
}
|
||||
|
||||
// Resolve shortened URLs (like b23.tv)
|
||||
let videoUrl = url;
|
||||
let videoUrl = url as string;
|
||||
if (videoUrl.includes("b23.tv")) {
|
||||
videoUrl = await resolveShortUrl(videoUrl);
|
||||
console.log("Resolved shortened URL to:", videoUrl);
|
||||
@@ -323,7 +317,7 @@ const checkBilibiliParts = async (req, res) => {
|
||||
const result = await downloadService.checkBilibiliVideoParts(videoId);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Error checking Bilibili video parts:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to check Bilibili video parts",
|
||||
@@ -333,7 +327,7 @@ const checkBilibiliParts = async (req, res) => {
|
||||
};
|
||||
|
||||
// Check if Bilibili URL is a collection or series
|
||||
const checkBilibiliCollection = async (req, res) => {
|
||||
export const checkBilibiliCollection = async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { url } = req.query;
|
||||
|
||||
@@ -341,12 +335,12 @@ const checkBilibiliCollection = async (req, res) => {
|
||||
return res.status(400).json({ error: "URL is required" });
|
||||
}
|
||||
|
||||
if (!isBilibiliUrl(url)) {
|
||||
if (!isBilibiliUrl(url as string)) {
|
||||
return res.status(400).json({ error: "Not a valid Bilibili URL" });
|
||||
}
|
||||
|
||||
// Resolve shortened URLs (like b23.tv)
|
||||
let videoUrl = url;
|
||||
let videoUrl = url as string;
|
||||
if (videoUrl.includes("b23.tv")) {
|
||||
videoUrl = await resolveShortUrl(videoUrl);
|
||||
console.log("Resolved shortened URL to:", videoUrl);
|
||||
@@ -368,7 +362,7 @@ const checkBilibiliCollection = async (req, res) => {
|
||||
const result = await downloadService.checkBilibiliCollectionOrSeries(videoId);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Error checking Bilibili collection/series:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to check Bilibili collection/series",
|
||||
@@ -376,14 +370,3 @@ const checkBilibiliCollection = async (req, res) => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
searchVideos,
|
||||
downloadVideo,
|
||||
getVideos,
|
||||
getVideoById,
|
||||
deleteVideo,
|
||||
getDownloadStatus,
|
||||
checkBilibiliParts,
|
||||
checkBilibiliCollection,
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
const express = require("express");
|
||||
import express from "express";
|
||||
import * as collectionController from "../controllers/collectionController";
|
||||
import * as videoController from "../controllers/videoController";
|
||||
|
||||
const router = express.Router();
|
||||
const videoController = require("../controllers/videoController");
|
||||
const collectionController = require("../controllers/collectionController");
|
||||
|
||||
// Video routes
|
||||
router.get("/search", videoController.searchVideos);
|
||||
@@ -19,4 +20,4 @@ router.post("/collections", collectionController.createCollection);
|
||||
router.put("/collections/:id", collectionController.updateCollection);
|
||||
router.delete("/collections/:id", collectionController.deleteCollection);
|
||||
|
||||
module.exports = router;
|
||||
export default router;
|
||||
@@ -1,13 +1,13 @@
|
||||
// Load environment variables from .env file
|
||||
require("dotenv").config();
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
const express = require("express");
|
||||
const cors = require("cors");
|
||||
const path = require("path");
|
||||
const VERSION = require("./version");
|
||||
const apiRoutes = require("./src/routes/api");
|
||||
const storageService = require("./src/services/storageService");
|
||||
const { VIDEOS_DIR, IMAGES_DIR } = require("./src/config/paths");
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import { IMAGES_DIR, VIDEOS_DIR } from "./config/paths";
|
||||
import apiRoutes from "./routes/api";
|
||||
import * as storageService from "./services/storageService";
|
||||
import { VERSION } from "./version";
|
||||
|
||||
// Display version information
|
||||
VERSION.displayVersion();
|
||||
@@ -1,6 +1,18 @@
|
||||
const storageService = require("./storageService");
|
||||
import * as storageService from "./storageService";
|
||||
|
||||
interface DownloadTask {
|
||||
downloadFn: () => Promise<any>;
|
||||
id: string;
|
||||
title: string;
|
||||
resolve: (value: any) => void;
|
||||
reject: (reason?: any) => void;
|
||||
}
|
||||
|
||||
class DownloadManager {
|
||||
private queue: DownloadTask[];
|
||||
private activeDownloads: number;
|
||||
private maxConcurrentDownloads: number;
|
||||
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.activeDownloads = 0;
|
||||
@@ -9,14 +21,18 @@ class DownloadManager {
|
||||
|
||||
/**
|
||||
* Add a download task to the manager
|
||||
* @param {Function} downloadFn - Async function that performs the download
|
||||
* @param {string} id - Unique ID for the download
|
||||
* @param {string} title - Title of the video being downloaded
|
||||
* @returns {Promise} - Resolves when the download is complete
|
||||
* @param downloadFn - Async function that performs the download
|
||||
* @param id - Unique ID for the download
|
||||
* @param title - Title of the video being downloaded
|
||||
* @returns - Resolves when the download is complete
|
||||
*/
|
||||
async addDownload(downloadFn, id, title) {
|
||||
async addDownload(
|
||||
downloadFn: () => Promise<any>,
|
||||
id: string,
|
||||
title: string
|
||||
): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const task = {
|
||||
const task: DownloadTask = {
|
||||
downloadFn,
|
||||
id,
|
||||
title,
|
||||
@@ -32,7 +48,7 @@ class DownloadManager {
|
||||
/**
|
||||
* Process the download queue
|
||||
*/
|
||||
async processQueue() {
|
||||
private async processQueue(): Promise<void> {
|
||||
if (
|
||||
this.activeDownloads >= this.maxConcurrentDownloads ||
|
||||
this.queue.length === 0
|
||||
@@ -41,6 +57,8 @@ class DownloadManager {
|
||||
}
|
||||
|
||||
const task = this.queue.shift();
|
||||
if (!task) return;
|
||||
|
||||
this.activeDownloads++;
|
||||
|
||||
// Update status in storage
|
||||
@@ -49,14 +67,14 @@ class DownloadManager {
|
||||
try {
|
||||
console.log(`Starting download: ${task.title} (${task.id})`);
|
||||
const result = await task.downloadFn();
|
||||
|
||||
|
||||
// Download complete
|
||||
storageService.removeActiveDownload(task.id);
|
||||
this.activeDownloads--;
|
||||
task.resolve(result);
|
||||
} catch (error) {
|
||||
console.error(`Error downloading ${task.title}:`, error);
|
||||
|
||||
|
||||
// Download failed
|
||||
storageService.removeActiveDownload(task.id);
|
||||
this.activeDownloads--;
|
||||
@@ -70,7 +88,7 @@ class DownloadManager {
|
||||
/**
|
||||
* Get current status
|
||||
*/
|
||||
getStatus() {
|
||||
getStatus(): { active: number; queued: number } {
|
||||
return {
|
||||
active: this.activeDownloads,
|
||||
queued: this.queue.length,
|
||||
@@ -79,4 +97,4 @@ class DownloadManager {
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
module.exports = new DownloadManager();
|
||||
export default new DownloadManager();
|
||||
@@ -1,22 +1,75 @@
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
const youtubedl = require("youtube-dl-exec");
|
||||
const axios = require("axios");
|
||||
const { downloadByVedioPath } = require("bilibili-save-nodejs");
|
||||
const { VIDEOS_DIR, IMAGES_DIR } = require("../config/paths");
|
||||
const {
|
||||
sanitizeFilename,
|
||||
extractBilibiliVideoId,
|
||||
} = require("../utils/helpers");
|
||||
const storageService = require("./storageService");
|
||||
import axios from "axios";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import youtubedl from "youtube-dl-exec";
|
||||
// @ts-ignore
|
||||
import { downloadByVedioPath } from "bilibili-save-nodejs";
|
||||
import { IMAGES_DIR, VIDEOS_DIR } from "../config/paths";
|
||||
import {
|
||||
extractBilibiliVideoId,
|
||||
sanitizeFilename,
|
||||
} from "../utils/helpers";
|
||||
import * as storageService from "./storageService";
|
||||
import { Collection, Video } from "./storageService";
|
||||
|
||||
interface BilibiliVideoInfo {
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
thumbnailUrl: string | null;
|
||||
thumbnailSaved: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface BilibiliPartsCheckResult {
|
||||
success: boolean;
|
||||
videosNumber: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface BilibiliCollectionCheckResult {
|
||||
success: boolean;
|
||||
type: 'collection' | 'series' | 'none';
|
||||
id?: number;
|
||||
title?: string;
|
||||
count?: number;
|
||||
mid?: number;
|
||||
}
|
||||
|
||||
interface BilibiliVideoItem {
|
||||
bvid: string;
|
||||
title: string;
|
||||
aid: number;
|
||||
}
|
||||
|
||||
interface BilibiliVideosResult {
|
||||
success: boolean;
|
||||
videos: BilibiliVideoItem[];
|
||||
}
|
||||
|
||||
interface DownloadResult {
|
||||
success: boolean;
|
||||
videoData?: Video;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface CollectionDownloadResult {
|
||||
success: boolean;
|
||||
collectionId?: string;
|
||||
videosDownloaded?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Helper function to download Bilibili video
|
||||
async function downloadBilibiliVideo(url, videoPath, thumbnailPath) {
|
||||
export async function downloadBilibiliVideo(
|
||||
url: string,
|
||||
videoPath: string,
|
||||
thumbnailPath: string
|
||||
): Promise<BilibiliVideoInfo> {
|
||||
const tempDir = path.join(VIDEOS_DIR, `temp_${Date.now()}_${Math.floor(Math.random() * 10000)}`);
|
||||
|
||||
try {
|
||||
// Create a unique temporary directory for the download
|
||||
const timestamp = Date.now();
|
||||
const random = Math.floor(Math.random() * 10000);
|
||||
const tempDir = path.join(VIDEOS_DIR, `temp_${timestamp}_${random}`);
|
||||
fs.ensureDirSync(tempDir);
|
||||
|
||||
console.log("Downloading Bilibili video to temp directory:", tempDir);
|
||||
@@ -34,7 +87,7 @@ async function downloadBilibiliVideo(url, videoPath, thumbnailPath) {
|
||||
const files = fs.readdirSync(tempDir);
|
||||
console.log("Files in temp directory:", files);
|
||||
|
||||
const videoFile = files.find((file) => file.endsWith(".mp4"));
|
||||
const videoFile = files.find((file: string) => file.endsWith(".mp4"));
|
||||
|
||||
if (!videoFile) {
|
||||
throw new Error("Downloaded video file not found");
|
||||
@@ -56,7 +109,7 @@ async function downloadBilibiliVideo(url, videoPath, thumbnailPath) {
|
||||
|
||||
// Try to get thumbnail from Bilibili
|
||||
let thumbnailSaved = false;
|
||||
let thumbnailUrl = null;
|
||||
let thumbnailUrl: string | null = null;
|
||||
const videoId = extractBilibiliVideoId(url);
|
||||
|
||||
console.log("Extracted video ID:", videoId);
|
||||
@@ -92,7 +145,7 @@ async function downloadBilibiliVideo(url, videoPath, thumbnailPath) {
|
||||
const thumbnailWriter = fs.createWriteStream(thumbnailPath);
|
||||
thumbnailResponse.data.pipe(thumbnailWriter);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
thumbnailWriter.on("finish", () => {
|
||||
thumbnailSaved = true;
|
||||
resolve();
|
||||
@@ -126,7 +179,7 @@ async function downloadBilibiliVideo(url, videoPath, thumbnailPath) {
|
||||
thumbnailUrl: null,
|
||||
thumbnailSaved: false,
|
||||
};
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Error in downloadBilibiliVideo:", error);
|
||||
|
||||
// Make sure we clean up the temp directory if it exists
|
||||
@@ -147,7 +200,7 @@ async function downloadBilibiliVideo(url, videoPath, thumbnailPath) {
|
||||
}
|
||||
|
||||
// Helper function to check if a Bilibili video has multiple parts
|
||||
async function checkBilibiliVideoParts(videoId) {
|
||||
export async function checkBilibiliVideoParts(videoId: string): Promise<BilibiliPartsCheckResult> {
|
||||
try {
|
||||
// Try to get video info from Bilibili API
|
||||
const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
|
||||
@@ -176,7 +229,7 @@ async function checkBilibiliVideoParts(videoId) {
|
||||
}
|
||||
|
||||
// Helper function to check if a Bilibili video belongs to a collection or series
|
||||
async function checkBilibiliCollectionOrSeries(videoId) {
|
||||
export async function checkBilibiliCollectionOrSeries(videoId: string): Promise<BilibiliCollectionCheckResult> {
|
||||
try {
|
||||
const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
|
||||
console.log("Checking if video belongs to collection/series:", apiUrl);
|
||||
@@ -218,9 +271,9 @@ async function checkBilibiliCollectionOrSeries(videoId) {
|
||||
}
|
||||
|
||||
// Helper function to get all videos from a Bilibili collection
|
||||
async function getBilibiliCollectionVideos(mid, seasonId) {
|
||||
export async function getBilibiliCollectionVideos(mid: number, seasonId: number): Promise<BilibiliVideosResult> {
|
||||
try {
|
||||
const allVideos = [];
|
||||
const allVideos: BilibiliVideoItem[] = [];
|
||||
let pageNum = 1;
|
||||
const pageSize = 30;
|
||||
let hasMore = true;
|
||||
@@ -253,7 +306,7 @@ async function getBilibiliCollectionVideos(mid, seasonId) {
|
||||
|
||||
console.log(`Got ${archives.length} videos from page ${pageNum}`);
|
||||
|
||||
archives.forEach(video => {
|
||||
archives.forEach((video: any) => {
|
||||
allVideos.push({
|
||||
bvid: video.bvid,
|
||||
title: video.title,
|
||||
@@ -279,9 +332,9 @@ async function getBilibiliCollectionVideos(mid, seasonId) {
|
||||
}
|
||||
|
||||
// Helper function to get all videos from a Bilibili series
|
||||
async function getBilibiliSeriesVideos(mid, seriesId) {
|
||||
export async function getBilibiliSeriesVideos(mid: number, seriesId: number): Promise<BilibiliVideosResult> {
|
||||
try {
|
||||
const allVideos = [];
|
||||
const allVideos: BilibiliVideoItem[] = [];
|
||||
let pageNum = 1;
|
||||
const pageSize = 30;
|
||||
let hasMore = true;
|
||||
@@ -313,7 +366,7 @@ async function getBilibiliSeriesVideos(mid, seriesId) {
|
||||
|
||||
console.log(`Got ${archives.length} videos from page ${pageNum}`);
|
||||
|
||||
archives.forEach(video => {
|
||||
archives.forEach((video: any) => {
|
||||
allVideos.push({
|
||||
bvid: video.bvid,
|
||||
title: video.title,
|
||||
@@ -339,12 +392,12 @@ async function getBilibiliSeriesVideos(mid, seriesId) {
|
||||
}
|
||||
|
||||
// Helper function to download a single Bilibili part
|
||||
async function downloadSingleBilibiliPart(
|
||||
url,
|
||||
partNumber,
|
||||
totalParts,
|
||||
seriesTitle
|
||||
) {
|
||||
export async function downloadSingleBilibiliPart(
|
||||
url: string,
|
||||
partNumber: number,
|
||||
totalParts: number,
|
||||
seriesTitle: string
|
||||
): Promise<DownloadResult> {
|
||||
try {
|
||||
console.log(
|
||||
`Downloading Bilibili part ${partNumber}/${totalParts}: ${url}`
|
||||
@@ -416,7 +469,7 @@ async function downloadSingleBilibiliPart(
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
const videoData = {
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
title: videoTitle,
|
||||
author: videoAuthor,
|
||||
@@ -424,8 +477,8 @@ async function downloadSingleBilibiliPart(
|
||||
source: "bilibili",
|
||||
sourceUrl: url,
|
||||
videoFilename: finalVideoFilename,
|
||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : null,
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
|
||||
thumbnailUrl: thumbnailUrl || undefined,
|
||||
videoPath: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
@@ -434,6 +487,7 @@ async function downloadSingleBilibiliPart(
|
||||
partNumber: partNumber,
|
||||
totalParts: totalParts,
|
||||
seriesTitle: seriesTitle,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Save the video using storage service
|
||||
@@ -442,7 +496,7 @@ async function downloadSingleBilibiliPart(
|
||||
console.log(`Part ${partNumber}/${totalParts} added to database`);
|
||||
|
||||
return { success: true, videoData };
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error downloading Bilibili part ${partNumber}/${totalParts}:`,
|
||||
error
|
||||
@@ -452,11 +506,11 @@ async function downloadSingleBilibiliPart(
|
||||
}
|
||||
|
||||
// Helper function to download all videos from a Bilibili collection or series
|
||||
async function downloadBilibiliCollection(
|
||||
collectionInfo,
|
||||
collectionName,
|
||||
downloadId
|
||||
) {
|
||||
export async function downloadBilibiliCollection(
|
||||
collectionInfo: BilibiliCollectionCheckResult,
|
||||
collectionName: string,
|
||||
downloadId: string
|
||||
): Promise<CollectionDownloadResult> {
|
||||
try {
|
||||
const { type, id, mid, title, count } = collectionInfo;
|
||||
|
||||
@@ -471,10 +525,10 @@ async function downloadBilibiliCollection(
|
||||
}
|
||||
|
||||
// Fetch all videos from the collection/series
|
||||
let videosResult;
|
||||
if (type === 'collection') {
|
||||
let videosResult: BilibiliVideosResult;
|
||||
if (type === 'collection' && mid && id) {
|
||||
videosResult = await getBilibiliCollectionVideos(mid, id);
|
||||
} else if (type === 'series') {
|
||||
} else if (type === 'series' && mid && id) {
|
||||
videosResult = await getBilibiliSeriesVideos(mid, id);
|
||||
} else {
|
||||
throw new Error(`Unknown type: ${type}`);
|
||||
@@ -488,11 +542,12 @@ async function downloadBilibiliCollection(
|
||||
console.log(`Found ${videos.length} videos to download`);
|
||||
|
||||
// Create a MyTube collection for these videos
|
||||
const mytubeCollection = {
|
||||
const mytubeCollection: Collection = {
|
||||
id: Date.now().toString(),
|
||||
name: collectionName || title,
|
||||
name: collectionName || title || "Collection",
|
||||
videos: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
title: collectionName || title || "Collection",
|
||||
};
|
||||
storageService.saveCollection(mytubeCollection);
|
||||
const mytubeCollectionId = mytubeCollection.id;
|
||||
@@ -523,13 +578,13 @@ async function downloadBilibiliCollection(
|
||||
videoUrl,
|
||||
videoNumber,
|
||||
videos.length,
|
||||
title
|
||||
title || "Collection"
|
||||
);
|
||||
|
||||
// If download was successful, add to collection
|
||||
if (result.success && result.videoData) {
|
||||
storageService.atomicUpdateCollection(mytubeCollectionId, (collection) => {
|
||||
collection.videos.push(result.videoData.id);
|
||||
collection.videos.push(result.videoData!.id);
|
||||
return collection;
|
||||
});
|
||||
|
||||
@@ -558,7 +613,7 @@ async function downloadBilibiliCollection(
|
||||
collectionId: mytubeCollectionId,
|
||||
videosDownloaded: videos.length
|
||||
};
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`Error downloading ${collectionInfo.type}:`, error);
|
||||
if (downloadId) {
|
||||
storageService.removeActiveDownload(downloadId);
|
||||
@@ -571,14 +626,14 @@ async function downloadBilibiliCollection(
|
||||
}
|
||||
|
||||
// Helper function to download remaining Bilibili parts in sequence
|
||||
async function downloadRemainingBilibiliParts(
|
||||
baseUrl,
|
||||
startPart,
|
||||
totalParts,
|
||||
seriesTitle,
|
||||
collectionId,
|
||||
downloadId
|
||||
) {
|
||||
export async function downloadRemainingBilibiliParts(
|
||||
baseUrl: string,
|
||||
startPart: number,
|
||||
totalParts: number,
|
||||
seriesTitle: string,
|
||||
collectionId: string,
|
||||
downloadId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Add to active downloads if ID is provided
|
||||
if (downloadId) {
|
||||
@@ -609,7 +664,7 @@ async function downloadRemainingBilibiliParts(
|
||||
if (result.success && collectionId && result.videoData) {
|
||||
try {
|
||||
storageService.atomicUpdateCollection(collectionId, (collection) => {
|
||||
collection.videos.push(result.videoData.id);
|
||||
collection.videos.push(result.videoData!.id);
|
||||
return collection;
|
||||
});
|
||||
|
||||
@@ -644,7 +699,7 @@ async function downloadRemainingBilibiliParts(
|
||||
}
|
||||
|
||||
// Search for videos on YouTube
|
||||
async function searchYouTube(query) {
|
||||
export async function searchYouTube(query: string): Promise<any[]> {
|
||||
console.log("Processing search request for query:", query);
|
||||
|
||||
// Use youtube-dl to search for videos
|
||||
@@ -654,14 +709,14 @@ async function searchYouTube(query) {
|
||||
noCallHome: true,
|
||||
skipDownload: true,
|
||||
playlistEnd: 5, // Limit to 5 results
|
||||
});
|
||||
} as any);
|
||||
|
||||
if (!searchResults || !searchResults.entries) {
|
||||
if (!searchResults || !(searchResults as any).entries) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Format the search results
|
||||
const formattedResults = searchResults.entries.map((entry) => ({
|
||||
const formattedResults = (searchResults as any).entries.map((entry: any) => ({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
author: entry.uploader,
|
||||
@@ -680,9 +735,8 @@ async function searchYouTube(query) {
|
||||
}
|
||||
|
||||
// Download YouTube video
|
||||
async function downloadYouTubeVideo(videoUrl) {
|
||||
export async function downloadYouTubeVideo(videoUrl: string): Promise<Video> {
|
||||
console.log("Detected YouTube URL");
|
||||
// storageService.updateDownloadStatus(true, "Downloading YouTube video..."); // Removed
|
||||
|
||||
// Create a safe base filename (without extension)
|
||||
const timestamp = Date.now();
|
||||
@@ -705,10 +759,10 @@ async function downloadYouTubeVideo(videoUrl) {
|
||||
const info = await youtubedl(videoUrl, {
|
||||
dumpSingleJson: true,
|
||||
noWarnings: true,
|
||||
noCallHome: true,
|
||||
callHome: false,
|
||||
preferFreeFormats: true,
|
||||
youtubeSkipDashManifest: true,
|
||||
});
|
||||
} as any);
|
||||
|
||||
console.log("YouTube video info:", {
|
||||
title: info.title,
|
||||
@@ -723,9 +777,6 @@ async function downloadYouTubeVideo(videoUrl) {
|
||||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
thumbnailUrl = info.thumbnail;
|
||||
|
||||
// Update download status with actual title
|
||||
// storageService.updateDownloadStatus(true, `Downloading: ${videoTitle}`); // Removed
|
||||
|
||||
// Update the safe base filename with the actual title
|
||||
const newSafeBaseFilename = `${sanitizeFilename(
|
||||
videoTitle
|
||||
@@ -768,7 +819,7 @@ async function downloadYouTubeVideo(videoUrl) {
|
||||
const thumbnailWriter = fs.createWriteStream(newThumbnailPath);
|
||||
thumbnailResponse.data.pipe(thumbnailWriter);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
thumbnailWriter.on("finish", () => {
|
||||
thumbnailSaved = true;
|
||||
resolve();
|
||||
@@ -784,13 +835,11 @@ async function downloadYouTubeVideo(videoUrl) {
|
||||
}
|
||||
} catch (youtubeError) {
|
||||
console.error("Error in YouTube download process:", youtubeError);
|
||||
// Set download status to false on error
|
||||
// storageService.updateDownloadStatus(false); // Removed
|
||||
throw youtubeError;
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
const videoData = {
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
title: videoTitle || "Video",
|
||||
author: videoAuthor || "Unknown",
|
||||
@@ -799,13 +848,14 @@ async function downloadYouTubeVideo(videoUrl) {
|
||||
source: "youtube",
|
||||
sourceUrl: videoUrl,
|
||||
videoFilename: finalVideoFilename,
|
||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : null,
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
|
||||
thumbnailUrl: thumbnailUrl || undefined,
|
||||
videoPath: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
addedAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Save the video
|
||||
@@ -813,21 +863,5 @@ async function downloadYouTubeVideo(videoUrl) {
|
||||
|
||||
console.log("Video added to database");
|
||||
|
||||
// Set download status to false when complete
|
||||
// storageService.updateDownloadStatus(false); // Removed
|
||||
|
||||
return videoData;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
downloadBilibiliVideo,
|
||||
checkBilibiliVideoParts,
|
||||
checkBilibiliCollectionOrSeries,
|
||||
getBilibiliCollectionVideos,
|
||||
getBilibiliSeriesVideos,
|
||||
downloadBilibiliCollection,
|
||||
downloadSingleBilibiliPart,
|
||||
downloadRemainingBilibiliParts,
|
||||
searchYouTube,
|
||||
downloadYouTubeVideo,
|
||||
};
|
||||
@@ -1,23 +1,51 @@
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
const {
|
||||
UPLOADS_DIR,
|
||||
VIDEOS_DIR,
|
||||
IMAGES_DIR,
|
||||
DATA_DIR,
|
||||
VIDEOS_DATA_PATH,
|
||||
STATUS_DATA_PATH,
|
||||
COLLECTIONS_DATA_PATH,
|
||||
} = require("../config/paths");
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import {
|
||||
COLLECTIONS_DATA_PATH,
|
||||
DATA_DIR,
|
||||
IMAGES_DIR,
|
||||
STATUS_DATA_PATH,
|
||||
UPLOADS_DIR,
|
||||
VIDEOS_DATA_PATH,
|
||||
VIDEOS_DIR,
|
||||
} from "../config/paths";
|
||||
|
||||
export interface Video {
|
||||
id: string;
|
||||
title: string;
|
||||
sourceUrl: string;
|
||||
videoFilename?: string;
|
||||
thumbnailFilename?: string;
|
||||
createdAt: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: string;
|
||||
title: string;
|
||||
videos: string[];
|
||||
updatedAt?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface DownloadInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface DownloadStatus {
|
||||
activeDownloads: DownloadInfo[];
|
||||
}
|
||||
|
||||
// Initialize storage directories and files
|
||||
function initializeStorage() {
|
||||
export function initializeStorage(): void {
|
||||
fs.ensureDirSync(UPLOADS_DIR);
|
||||
fs.ensureDirSync(VIDEOS_DIR);
|
||||
fs.ensureDirSync(IMAGES_DIR);
|
||||
fs.ensureDirSync(DATA_DIR);
|
||||
|
||||
// Initialize status.json if it doesn't exist
|
||||
// Initialize status.json if it doesn't exist
|
||||
if (!fs.existsSync(STATUS_DATA_PATH)) {
|
||||
fs.writeFileSync(
|
||||
STATUS_DATA_PATH,
|
||||
@@ -27,15 +55,15 @@ function initializeStorage() {
|
||||
}
|
||||
|
||||
// Add an active download
|
||||
function addActiveDownload(id, title) {
|
||||
export function addActiveDownload(id: string, title: string): void {
|
||||
try {
|
||||
const status = getDownloadStatus();
|
||||
const existingIndex = status.activeDownloads.findIndex(d => d.id === id);
|
||||
|
||||
const downloadInfo = {
|
||||
id,
|
||||
title,
|
||||
timestamp: Date.now()
|
||||
const existingIndex = status.activeDownloads.findIndex((d) => d.id === id);
|
||||
|
||||
const downloadInfo: DownloadInfo = {
|
||||
id,
|
||||
title,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
@@ -52,11 +80,11 @@ function addActiveDownload(id, title) {
|
||||
}
|
||||
|
||||
// Remove an active download
|
||||
function removeActiveDownload(id) {
|
||||
export function removeActiveDownload(id: string): void {
|
||||
try {
|
||||
const status = getDownloadStatus();
|
||||
const initialLength = status.activeDownloads.length;
|
||||
status.activeDownloads = status.activeDownloads.filter(d => d.id !== id);
|
||||
status.activeDownloads = status.activeDownloads.filter((d) => d.id !== id);
|
||||
|
||||
if (status.activeDownloads.length !== initialLength) {
|
||||
fs.writeFileSync(STATUS_DATA_PATH, JSON.stringify(status, null, 2));
|
||||
@@ -68,16 +96,18 @@ function removeActiveDownload(id) {
|
||||
}
|
||||
|
||||
// Get download status
|
||||
function getDownloadStatus() {
|
||||
export function getDownloadStatus(): DownloadStatus {
|
||||
if (!fs.existsSync(STATUS_DATA_PATH)) {
|
||||
const initialStatus = { activeDownloads: [] };
|
||||
const initialStatus: DownloadStatus = { activeDownloads: [] };
|
||||
fs.writeFileSync(STATUS_DATA_PATH, JSON.stringify(initialStatus, null, 2));
|
||||
return initialStatus;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = JSON.parse(fs.readFileSync(STATUS_DATA_PATH, "utf8"));
|
||||
|
||||
const status: DownloadStatus = JSON.parse(
|
||||
fs.readFileSync(STATUS_DATA_PATH, "utf8")
|
||||
);
|
||||
|
||||
// Ensure activeDownloads exists
|
||||
if (!status.activeDownloads) {
|
||||
status.activeDownloads = [];
|
||||
@@ -85,8 +115,8 @@ function getDownloadStatus() {
|
||||
|
||||
// Check for stale downloads (older than 30 minutes)
|
||||
const now = Date.now();
|
||||
const validDownloads = status.activeDownloads.filter(d => {
|
||||
return d.timestamp && (now - d.timestamp < 30 * 60 * 1000);
|
||||
const validDownloads = status.activeDownloads.filter((d) => {
|
||||
return d.timestamp && now - d.timestamp < 30 * 60 * 1000;
|
||||
});
|
||||
|
||||
if (validDownloads.length !== status.activeDownloads.length) {
|
||||
@@ -103,7 +133,7 @@ function getDownloadStatus() {
|
||||
}
|
||||
|
||||
// Get all videos
|
||||
function getVideos() {
|
||||
export function getVideos(): Video[] {
|
||||
if (!fs.existsSync(VIDEOS_DATA_PATH)) {
|
||||
return [];
|
||||
}
|
||||
@@ -111,13 +141,13 @@ function getVideos() {
|
||||
}
|
||||
|
||||
// Get video by ID
|
||||
function getVideoById(id) {
|
||||
export function getVideoById(id: string): Video | undefined {
|
||||
const videos = getVideos();
|
||||
return videos.find((v) => v.id === id);
|
||||
}
|
||||
|
||||
// Save a new video
|
||||
function saveVideo(videoData) {
|
||||
export function saveVideo(videoData: Video): Video {
|
||||
let videos = getVideos();
|
||||
videos.unshift(videoData);
|
||||
fs.writeFileSync(VIDEOS_DATA_PATH, JSON.stringify(videos, null, 2));
|
||||
@@ -125,7 +155,7 @@ function saveVideo(videoData) {
|
||||
}
|
||||
|
||||
// Delete a video
|
||||
function deleteVideo(id) {
|
||||
export function deleteVideo(id: string): boolean {
|
||||
let videos = getVideos();
|
||||
const videoToDelete = videos.find((v) => v.id === id);
|
||||
|
||||
@@ -162,7 +192,7 @@ function deleteVideo(id) {
|
||||
}
|
||||
|
||||
// Get all collections
|
||||
function getCollections() {
|
||||
export function getCollections(): Collection[] {
|
||||
if (!fs.existsSync(COLLECTIONS_DATA_PATH)) {
|
||||
return [];
|
||||
}
|
||||
@@ -170,13 +200,13 @@ function getCollections() {
|
||||
}
|
||||
|
||||
// Get collection by ID
|
||||
function getCollectionById(id) {
|
||||
export function getCollectionById(id: string): Collection | undefined {
|
||||
const collections = getCollections();
|
||||
return collections.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
// Save a new collection
|
||||
function saveCollection(collection) {
|
||||
export function saveCollection(collection: Collection): Collection {
|
||||
let collections = getCollections();
|
||||
collections.push(collection);
|
||||
fs.writeFileSync(COLLECTIONS_DATA_PATH, JSON.stringify(collections, null, 2));
|
||||
@@ -184,7 +214,10 @@ function saveCollection(collection) {
|
||||
}
|
||||
|
||||
// Atomic update for a collection
|
||||
function atomicUpdateCollection(id, updateFn) {
|
||||
export function atomicUpdateCollection(
|
||||
id: string,
|
||||
updateFn: (collection: Collection) => Collection | null
|
||||
): Collection | null {
|
||||
let collections = getCollections();
|
||||
const index = collections.findIndex((c) => c.id === id);
|
||||
|
||||
@@ -194,7 +227,9 @@ function atomicUpdateCollection(id, updateFn) {
|
||||
|
||||
// Create a deep copy of the collection to avoid reference issues
|
||||
const originalCollection = collections[index];
|
||||
const collectionCopy = JSON.parse(JSON.stringify(originalCollection));
|
||||
const collectionCopy: Collection = JSON.parse(
|
||||
JSON.stringify(originalCollection)
|
||||
);
|
||||
|
||||
// Apply the update function
|
||||
const updatedCollection = updateFn(collectionCopy);
|
||||
@@ -216,7 +251,7 @@ function atomicUpdateCollection(id, updateFn) {
|
||||
}
|
||||
|
||||
// Delete a collection
|
||||
function deleteCollection(id) {
|
||||
export function deleteCollection(id: string): boolean {
|
||||
let collections = getCollections();
|
||||
const updatedCollections = collections.filter((c) => c.id !== id);
|
||||
|
||||
@@ -230,19 +265,3 @@ function deleteCollection(id) {
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initializeStorage,
|
||||
addActiveDownload,
|
||||
removeActiveDownload,
|
||||
getDownloadStatus,
|
||||
getVideos,
|
||||
getVideoById,
|
||||
saveVideo,
|
||||
deleteVideo,
|
||||
getCollections,
|
||||
getCollectionById,
|
||||
saveCollection,
|
||||
atomicUpdateCollection,
|
||||
deleteCollection,
|
||||
};
|
||||
19
backend/src/test_sanitize.ts
Normal file
19
backend/src/test_sanitize.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { sanitizeFilename } from './utils/helpers';
|
||||
|
||||
const testCases = [
|
||||
"Video Title #hashtag",
|
||||
"Video #cool #viral Title",
|
||||
"Just a Title",
|
||||
"Title with # and space",
|
||||
"Title with #tag1 #tag2",
|
||||
"Chinese Title #你好",
|
||||
"Title with #1",
|
||||
"Title with #",
|
||||
];
|
||||
|
||||
console.log("Testing sanitizeFilename:");
|
||||
testCases.forEach(title => {
|
||||
console.log(`Original: "${title}"`);
|
||||
console.log(`Sanitized: "${sanitizeFilename(title)}"`);
|
||||
console.log("---");
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
const axios = require("axios");
|
||||
import axios from "axios";
|
||||
|
||||
// Helper function to check if a string is a valid URL
|
||||
function isValidUrl(string) {
|
||||
export function isValidUrl(string: string): boolean {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
@@ -11,12 +11,12 @@ function isValidUrl(string) {
|
||||
}
|
||||
|
||||
// Helper function to check if a URL is from Bilibili
|
||||
function isBilibiliUrl(url) {
|
||||
export function isBilibiliUrl(url: string): boolean {
|
||||
return url.includes("bilibili.com") || url.includes("b23.tv");
|
||||
}
|
||||
|
||||
// Helper function to extract URL from text that might contain a title and URL
|
||||
function extractUrlFromText(text) {
|
||||
export function extractUrlFromText(text: string): string {
|
||||
// Regular expression to find URLs in text
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
const matches = text.match(urlRegex);
|
||||
@@ -29,7 +29,7 @@ function extractUrlFromText(text) {
|
||||
}
|
||||
|
||||
// Helper function to resolve shortened URLs (like b23.tv)
|
||||
async function resolveShortUrl(url) {
|
||||
export async function resolveShortUrl(url: string): Promise<string> {
|
||||
try {
|
||||
console.log(`Resolving shortened URL: ${url}`);
|
||||
|
||||
@@ -44,14 +44,14 @@ async function resolveShortUrl(url) {
|
||||
console.log(`Resolved to: ${resolvedUrl}`);
|
||||
|
||||
return resolvedUrl;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`Error resolving shortened URL: ${error.message}`);
|
||||
return url; // Return original URL if resolution fails
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to trim Bilibili URL by removing query parameters
|
||||
function trimBilibiliUrl(url) {
|
||||
export function trimBilibiliUrl(url: string): string {
|
||||
try {
|
||||
// First, extract the video ID (BV or av format)
|
||||
const videoIdMatch = url.match(/\/video\/(BV[\w]+|av\d+)/i);
|
||||
@@ -85,7 +85,7 @@ function trimBilibiliUrl(url) {
|
||||
}
|
||||
|
||||
// Helper function to extract video ID from Bilibili URL
|
||||
function extractBilibiliVideoId(url) {
|
||||
export function extractBilibiliVideoId(url: string): string | null {
|
||||
// Extract BV ID from URL - works for both desktop and mobile URLs
|
||||
const bvMatch = url.match(/\/video\/(BV[\w]+)/i);
|
||||
if (bvMatch && bvMatch[1]) {
|
||||
@@ -102,16 +102,19 @@ function extractBilibiliVideoId(url) {
|
||||
}
|
||||
|
||||
// Helper function to create a safe filename that preserves non-Latin characters
|
||||
function sanitizeFilename(filename) {
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
// Remove hashtags (e.g. #tag)
|
||||
const withoutHashtags = filename.replace(/#\S+/g, "").trim();
|
||||
|
||||
// Replace only unsafe characters for filesystems
|
||||
// This preserves non-Latin characters like Chinese, Japanese, Korean, etc.
|
||||
return filename
|
||||
return withoutHashtags
|
||||
.replace(/[\/\\:*?"<>|]/g, "_") // Replace unsafe filesystem characters
|
||||
.replace(/\s+/g, "_"); // Replace spaces with underscores
|
||||
}
|
||||
|
||||
// Helper function to extract user mid from Bilibili URL
|
||||
function extractBilibiliMid(url) {
|
||||
export function extractBilibiliMid(url: string): string | null {
|
||||
// Try to extract from space URL pattern: space.bilibili.com/{mid}
|
||||
const spaceMatch = url.match(/space\.bilibili\.com\/(\d+)/i);
|
||||
if (spaceMatch && spaceMatch[1]) {
|
||||
@@ -129,7 +132,7 @@ function extractBilibiliMid(url) {
|
||||
}
|
||||
|
||||
// Helper function to extract season_id from Bilibili URL
|
||||
function extractBilibiliSeasonId(url) {
|
||||
export function extractBilibiliSeasonId(url: string): string | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const seasonId = urlObj.searchParams.get('season_id');
|
||||
@@ -140,7 +143,7 @@ function extractBilibiliSeasonId(url) {
|
||||
}
|
||||
|
||||
// Helper function to extract series_id from Bilibili URL
|
||||
function extractBilibiliSeriesId(url) {
|
||||
export function extractBilibiliSeriesId(url: string): string | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const seriesId = urlObj.searchParams.get('series_id');
|
||||
@@ -149,16 +152,3 @@ function extractBilibiliSeriesId(url) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isValidUrl,
|
||||
isBilibiliUrl,
|
||||
extractUrlFromText,
|
||||
resolveShortUrl,
|
||||
trimBilibiliUrl,
|
||||
extractBilibiliVideoId,
|
||||
sanitizeFilename,
|
||||
extractBilibiliMid,
|
||||
extractBilibiliSeasonId,
|
||||
extractBilibiliSeriesId,
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
* MyTube Backend Version Information
|
||||
*/
|
||||
|
||||
const VERSION = {
|
||||
export const VERSION = {
|
||||
number: "1.1.0",
|
||||
buildDate: new Date().toISOString().split("T")[0],
|
||||
name: "MyTube Backend Server",
|
||||
@@ -18,5 +18,3 @@ const VERSION = {
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = VERSION;
|
||||
18
backend/tsconfig.json
Normal file
18
backend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
61
frontend/package-lock.json
generated
61
frontend/package-lock.json
generated
@@ -16,13 +16,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
},
|
||||
@@ -1338,24 +1340,34 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
|
||||
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz",
|
||||
"integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
@@ -1641,9 +1653,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2904,6 +2916,27 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
|
||||
@@ -18,13 +18,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,733 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
|
||||
import './App.css';
|
||||
import BilibiliPartsModal from './components/BilibiliPartsModal';
|
||||
import Header from './components/Header';
|
||||
import AuthorVideos from './pages/AuthorVideos';
|
||||
import CollectionPage from './pages/CollectionPage';
|
||||
import Home from './pages/Home';
|
||||
import ManagePage from './pages/ManagePage';
|
||||
import SearchResults from './pages/SearchResults';
|
||||
import VideoPlayer from './pages/VideoPlayer';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
const DOWNLOAD_STATUS_KEY = 'mytube_download_status';
|
||||
const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
const COLLECTIONS_KEY = 'mytube_collections';
|
||||
|
||||
// Helper function to get download status from localStorage
|
||||
const getStoredDownloadStatus = () => {
|
||||
try {
|
||||
const savedStatus = localStorage.getItem(DOWNLOAD_STATUS_KEY);
|
||||
if (!savedStatus) return null;
|
||||
|
||||
const parsedStatus = JSON.parse(savedStatus);
|
||||
|
||||
// Check if the saved status is too old (stale)
|
||||
if (parsedStatus.timestamp && Date.now() - parsedStatus.timestamp > DOWNLOAD_TIMEOUT) {
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedStatus;
|
||||
} catch (error) {
|
||||
console.error('Error parsing download status from localStorage:', error);
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [videos, setVideos] = useState([]);
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [localSearchResults, setLocalSearchResults] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [youtubeLoading, setYoutubeLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [collections, setCollections] = useState([]);
|
||||
|
||||
// Bilibili multi-part video state
|
||||
const [showBilibiliPartsModal, setShowBilibiliPartsModal] = useState(false);
|
||||
const [bilibiliPartsInfo, setBilibiliPartsInfo] = useState({
|
||||
videosNumber: 0,
|
||||
title: '',
|
||||
url: '',
|
||||
type: 'parts', // 'parts', 'collection', or 'series'
|
||||
collectionInfo: null // For collection/series, stores the API response
|
||||
});
|
||||
const [isCheckingParts, setIsCheckingParts] = useState(false);
|
||||
|
||||
// Reference to the current search request's abort controller
|
||||
const searchAbortController = useRef(null);
|
||||
|
||||
// Get initial download status from localStorage
|
||||
const initialStatus = getStoredDownloadStatus();
|
||||
const [activeDownloads, setActiveDownloads] = useState(
|
||||
initialStatus ? initialStatus.activeDownloads || [] : []
|
||||
);
|
||||
|
||||
// Fetch collections from the server
|
||||
const fetchCollections = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/collections`);
|
||||
setCollections(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching collections:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add a function to check download status from the backend
|
||||
const checkBackendDownloadStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/download-status`);
|
||||
|
||||
if (response.data.activeDownloads && response.data.activeDownloads.length > 0) {
|
||||
// If backend has active downloads, update the local status
|
||||
setActiveDownloads(response.data.activeDownloads);
|
||||
|
||||
// Save to localStorage for persistence
|
||||
const statusData = {
|
||||
activeDownloads: response.data.activeDownloads,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
|
||||
} else {
|
||||
// If backend says no downloads are in progress, clear the status
|
||||
if (activeDownloads.length > 0) {
|
||||
console.log('Backend says downloads are complete, clearing status');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
setActiveDownloads([]);
|
||||
// Refresh videos list when downloads complete
|
||||
fetchVideos();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking backend download status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Check backend download status periodically
|
||||
useEffect(() => {
|
||||
// Check immediately on mount
|
||||
checkBackendDownloadStatus();
|
||||
|
||||
// Then check every 2 seconds (faster polling for better UX)
|
||||
const statusCheckInterval = setInterval(checkBackendDownloadStatus, 2000);
|
||||
|
||||
return () => {
|
||||
clearInterval(statusCheckInterval);
|
||||
};
|
||||
}, [activeDownloads.length]); // Depend on length to trigger refresh when downloads finish
|
||||
|
||||
// Fetch collections on component mount
|
||||
useEffect(() => {
|
||||
fetchCollections();
|
||||
}, []);
|
||||
|
||||
// Fetch videos on component mount
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, []);
|
||||
|
||||
// Set up localStorage and event listeners
|
||||
useEffect(() => {
|
||||
console.log('Setting up localStorage and event listeners');
|
||||
|
||||
// Set up event listener for storage changes (for multi-tab support)
|
||||
const handleStorageChange = (e) => {
|
||||
if (e.key === DOWNLOAD_STATUS_KEY) {
|
||||
try {
|
||||
const newStatus = e.newValue ? JSON.parse(e.newValue) : { activeDownloads: [] };
|
||||
console.log('Storage changed, new status:', newStatus);
|
||||
setActiveDownloads(newStatus.activeDownloads || []);
|
||||
} catch (error) {
|
||||
console.error('Error handling storage change:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
// Set up periodic check for stale download status
|
||||
const checkDownloadStatus = () => {
|
||||
const status = getStoredDownloadStatus();
|
||||
if (!status && activeDownloads.length > 0) {
|
||||
console.log('Clearing stale download status');
|
||||
setActiveDownloads([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Check every minute
|
||||
const statusCheckInterval = setInterval(checkDownloadStatus, 60000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
clearInterval(statusCheckInterval);
|
||||
};
|
||||
}, [activeDownloads]);
|
||||
|
||||
// Update localStorage whenever activeDownloads changes
|
||||
useEffect(() => {
|
||||
console.log('Active downloads changed:', activeDownloads);
|
||||
|
||||
if (activeDownloads.length > 0) {
|
||||
const statusData = {
|
||||
activeDownloads,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
console.log('Saving to localStorage:', statusData);
|
||||
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
|
||||
} else {
|
||||
console.log('Removing from localStorage');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
}
|
||||
}, [activeDownloads]);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`${API_URL}/videos`);
|
||||
setVideos(response.data);
|
||||
setError(null);
|
||||
|
||||
// Check if we need to clear a stale download status
|
||||
if (activeDownloads.length > 0) {
|
||||
const status = getStoredDownloadStatus();
|
||||
if (!status) {
|
||||
console.log('Clearing download status after fetching videos');
|
||||
setActiveDownloads([]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching videos:', err);
|
||||
setError('Failed to load videos. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoSubmit = async (videoUrl, skipCollectionCheck = false) => {
|
||||
try {
|
||||
// Check if it's a Bilibili URL
|
||||
if (videoUrl.includes('bilibili.com') || videoUrl.includes('b23.tv')) {
|
||||
setIsCheckingParts(true);
|
||||
try {
|
||||
// Only check for collection/series if not explicitly skipped
|
||||
if (!skipCollectionCheck) {
|
||||
// First, check if it's a collection or series
|
||||
const collectionResponse = await axios.get(`${API_URL}/check-bilibili-collection`, {
|
||||
params: { url: videoUrl }
|
||||
});
|
||||
|
||||
if (collectionResponse.data.success && collectionResponse.data.type !== 'none') {
|
||||
// It's a collection or series
|
||||
const { type, title, count, id, mid } = collectionResponse.data;
|
||||
|
||||
console.log(`Detected Bilibili ${type}:`, title, `with ${count} videos`);
|
||||
|
||||
setBilibiliPartsInfo({
|
||||
videosNumber: count,
|
||||
title: title,
|
||||
url: videoUrl,
|
||||
type: type,
|
||||
collectionInfo: { type, id, mid, title, count }
|
||||
});
|
||||
setShowBilibiliPartsModal(true);
|
||||
setIsCheckingParts(false);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
// If not a collection/series (or check was skipped), check if it has multiple parts
|
||||
const partsResponse = await axios.get(`${API_URL}/check-bilibili-parts`, {
|
||||
params: { url: videoUrl }
|
||||
});
|
||||
|
||||
if (partsResponse.data.success && partsResponse.data.videosNumber > 1) {
|
||||
// Show modal to ask user if they want to download all parts
|
||||
setBilibiliPartsInfo({
|
||||
videosNumber: partsResponse.data.videosNumber,
|
||||
title: partsResponse.data.title,
|
||||
url: videoUrl,
|
||||
type: 'parts',
|
||||
collectionInfo: null
|
||||
});
|
||||
setShowBilibiliPartsModal(true);
|
||||
setIsCheckingParts(false);
|
||||
return { success: true };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking Bilibili parts/collection:', err);
|
||||
// Continue with normal download if check fails
|
||||
} finally {
|
||||
setIsCheckingParts(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Normal download flow
|
||||
setLoading(true);
|
||||
|
||||
// We don't set activeDownloads here immediately because the backend will queue it
|
||||
// and we'll pick it up via polling
|
||||
|
||||
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
|
||||
|
||||
// If the response contains a downloadId, it means it was queued/started
|
||||
if (response.data.downloadId) {
|
||||
// Trigger an immediate status check
|
||||
checkBackendDownloadStatus();
|
||||
} else if (response.data.video) {
|
||||
// If it returned a video immediately (shouldn't happen with new logic but safe to keep)
|
||||
setVideos(prevVideos => [response.data.video, ...prevVideos]);
|
||||
}
|
||||
|
||||
setIsSearchMode(false);
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error downloading video:', err);
|
||||
|
||||
// Check if the error is because the input is a search term
|
||||
if (err.response?.data?.isSearchTerm) {
|
||||
// Handle as search term
|
||||
return await handleSearch(err.response.data.searchTerm);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.response?.data?.error || 'Failed to download video. Please try again.'
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const searchLocalVideos = (query) => {
|
||||
if (!query || !videos.length) return [];
|
||||
|
||||
const searchTermLower = query.toLowerCase();
|
||||
|
||||
return videos.filter(video =>
|
||||
video.title.toLowerCase().includes(searchTermLower) ||
|
||||
video.author.toLowerCase().includes(searchTermLower)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSearch = async (query) => {
|
||||
// Don't enter search mode if the query is empty
|
||||
if (!query || query.trim() === '') {
|
||||
resetSearch();
|
||||
return { success: false, error: 'Please enter a search term' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Cancel any previous search request
|
||||
if (searchAbortController.current) {
|
||||
searchAbortController.current.abort();
|
||||
}
|
||||
|
||||
// Create a new abort controller for this request
|
||||
searchAbortController.current = new AbortController();
|
||||
const signal = searchAbortController.current.signal;
|
||||
|
||||
// Set search mode and term immediately
|
||||
setIsSearchMode(true);
|
||||
setSearchTerm(query);
|
||||
|
||||
// Search local videos first (synchronously)
|
||||
const localResults = searchLocalVideos(query);
|
||||
setLocalSearchResults(localResults);
|
||||
|
||||
// Set loading state only for YouTube results
|
||||
setYoutubeLoading(true);
|
||||
|
||||
// Then search YouTube asynchronously
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/search`, {
|
||||
params: { query },
|
||||
signal: signal // Pass the abort signal to axios
|
||||
});
|
||||
|
||||
// Only update results if the request wasn't aborted
|
||||
if (!signal.aborted) {
|
||||
setSearchResults(response.data.results);
|
||||
}
|
||||
} catch (youtubeErr) {
|
||||
// Don't handle if it's an abort error
|
||||
if (youtubeErr.name !== 'CanceledError' && youtubeErr.name !== 'AbortError') {
|
||||
console.error('Error searching YouTube:', youtubeErr);
|
||||
}
|
||||
// Don't set overall error if only YouTube search fails
|
||||
} finally {
|
||||
// Only update loading state if the request wasn't aborted
|
||||
if (!signal.aborted) {
|
||||
setYoutubeLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Don't handle if it's an abort error
|
||||
if (err.name !== 'CanceledError' && err.name !== 'AbortError') {
|
||||
console.error('Error in search process:', err);
|
||||
|
||||
// Even if there's an error in the overall process,
|
||||
// we still want to show local results if available
|
||||
const localResults = searchLocalVideos(query);
|
||||
if (localResults.length > 0) {
|
||||
setLocalSearchResults(localResults);
|
||||
setIsSearchMode(true);
|
||||
setSearchTerm(query);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to search. Please try again.'
|
||||
};
|
||||
}
|
||||
return { success: false, error: 'Search was cancelled' };
|
||||
} finally {
|
||||
// Only update loading state if the request wasn't aborted
|
||||
if (searchAbortController.current && !searchAbortController.current.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a video
|
||||
const handleDeleteVideo = async (id) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// First, remove the video from any collections
|
||||
await handleRemoveFromCollection(id);
|
||||
|
||||
// Then delete the video
|
||||
await axios.delete(`${API_URL}/videos/${id}`);
|
||||
|
||||
// Update the videos state
|
||||
setVideos(prevVideos => prevVideos.filter(video => video.id !== id));
|
||||
|
||||
setLoading(false);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting video:', error);
|
||||
setError('Failed to delete video');
|
||||
setLoading(false);
|
||||
return { success: false, error: 'Failed to delete video' };
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFromSearch = async (videoUrl, title) => {
|
||||
try {
|
||||
// Abort any ongoing search request
|
||||
if (searchAbortController.current) {
|
||||
searchAbortController.current.abort();
|
||||
searchAbortController.current = null;
|
||||
}
|
||||
|
||||
setIsSearchMode(false);
|
||||
|
||||
const result = await handleVideoSubmit(videoUrl);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error in handleDownloadFromSearch:', error);
|
||||
return { success: false, error: 'Failed to download video' };
|
||||
}
|
||||
};
|
||||
|
||||
// For debugging
|
||||
useEffect(() => {
|
||||
console.log('Current download status:', {
|
||||
activeDownloads,
|
||||
count: activeDownloads.length,
|
||||
localStorage: localStorage.getItem(DOWNLOAD_STATUS_KEY)
|
||||
});
|
||||
}, [activeDownloads]);
|
||||
|
||||
// Cleanup effect to abort any pending search requests when unmounting
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Abort any ongoing search request when component unmounts
|
||||
if (searchAbortController.current) {
|
||||
searchAbortController.current.abort();
|
||||
searchAbortController.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update the resetSearch function to abort any ongoing search
|
||||
const resetSearch = () => {
|
||||
// Abort any ongoing search request
|
||||
if (searchAbortController.current) {
|
||||
searchAbortController.current.abort();
|
||||
searchAbortController.current = null;
|
||||
}
|
||||
|
||||
// Reset search-related state
|
||||
setIsSearchMode(false);
|
||||
setSearchTerm('');
|
||||
setSearchResults([]);
|
||||
setLocalSearchResults([]);
|
||||
setYoutubeLoading(false);
|
||||
};
|
||||
|
||||
// Create a new collection
|
||||
const handleCreateCollection = async (name, videoId = null) => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/collections`, {
|
||||
name,
|
||||
videoId
|
||||
});
|
||||
|
||||
// Update the collections state with the new collection from the server
|
||||
setCollections(prevCollections => [...prevCollections, response.data]);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error creating collection:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Add a video to a collection
|
||||
const handleAddToCollection = async (collectionId, videoId) => {
|
||||
try {
|
||||
// If videoId is provided, remove it from any other collections first
|
||||
// This is handled on the server side now
|
||||
|
||||
// Add the video to the selected collection
|
||||
const response = await axios.put(`${API_URL}/collections/${collectionId}`, {
|
||||
videoId,
|
||||
action: "add"
|
||||
});
|
||||
|
||||
// Update the collections state with the updated collection from the server
|
||||
setCollections(prevCollections => prevCollections.map(collection =>
|
||||
collection.id === collectionId ? response.data : collection
|
||||
));
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error adding video to collection:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Remove a video from a collection
|
||||
const handleRemoveFromCollection = async (videoId) => {
|
||||
try {
|
||||
// Get all collections
|
||||
const collectionsWithVideo = collections.filter(collection =>
|
||||
collection.videos.includes(videoId)
|
||||
);
|
||||
|
||||
// For each collection that contains the video, remove it
|
||||
for (const collection of collectionsWithVideo) {
|
||||
await axios.put(`${API_URL}/collections/${collection.id}`, {
|
||||
videoId,
|
||||
action: "remove"
|
||||
});
|
||||
}
|
||||
|
||||
// Update the collections state
|
||||
setCollections(prevCollections => prevCollections.map(collection => ({
|
||||
...collection,
|
||||
videos: collection.videos.filter(v => v !== videoId)
|
||||
})));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error removing video from collection:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a collection
|
||||
const handleDeleteCollection = async (collectionId, deleteVideos = false) => {
|
||||
try {
|
||||
// Delete the collection with optional video deletion
|
||||
await axios.delete(`${API_URL}/collections/${collectionId}`, {
|
||||
params: { deleteVideos: deleteVideos ? 'true' : 'false' }
|
||||
});
|
||||
|
||||
// Update the collections state
|
||||
setCollections(prevCollections =>
|
||||
prevCollections.filter(collection => collection.id !== collectionId)
|
||||
);
|
||||
|
||||
// If videos were deleted, refresh the videos list
|
||||
if (deleteVideos) {
|
||||
await fetchVideos();
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting collection:', error);
|
||||
return { success: false, error: 'Failed to delete collection' };
|
||||
}
|
||||
};
|
||||
|
||||
// Handle downloading all parts of a Bilibili video OR all videos from a collection/series
|
||||
const handleDownloadAllBilibiliParts = async (collectionName) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setShowBilibiliPartsModal(false);
|
||||
|
||||
const isCollection = bilibiliPartsInfo.type === 'collection' || bilibiliPartsInfo.type === 'series';
|
||||
|
||||
const response = await axios.post(`${API_URL}/download`, {
|
||||
youtubeUrl: bilibiliPartsInfo.url,
|
||||
downloadAllParts: !isCollection, // Only set this for multi-part videos
|
||||
downloadCollection: isCollection, // Set this for collections/series
|
||||
collectionInfo: isCollection ? bilibiliPartsInfo.collectionInfo : null,
|
||||
collectionName
|
||||
});
|
||||
|
||||
// Trigger immediate status check
|
||||
checkBackendDownloadStatus();
|
||||
|
||||
// If a collection was created, refresh collections
|
||||
if (response.data.collectionId) {
|
||||
await fetchCollections();
|
||||
}
|
||||
|
||||
setIsSearchMode(false);
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error downloading Bilibili parts/collection:', err);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.response?.data?.error || 'Failed to download. Please try again.'
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle downloading only the current part of a Bilibili video
|
||||
const handleDownloadCurrentBilibiliPart = async () => {
|
||||
setShowBilibiliPartsModal(false);
|
||||
// Pass true to skip collection/series check since we already know about it
|
||||
return await handleVideoSubmit(bilibiliPartsInfo.url, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="app">
|
||||
<Header
|
||||
onSearch={handleSearch}
|
||||
onSubmit={handleVideoSubmit}
|
||||
activeDownloads={activeDownloads}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
onResetSearch={resetSearch}
|
||||
/>
|
||||
|
||||
{/* Bilibili Parts Modal */}
|
||||
<BilibiliPartsModal
|
||||
isOpen={showBilibiliPartsModal}
|
||||
onClose={() => setShowBilibiliPartsModal(false)}
|
||||
videosNumber={bilibiliPartsInfo.videosNumber}
|
||||
videoTitle={bilibiliPartsInfo.title}
|
||||
onDownloadAll={handleDownloadAllBilibiliParts}
|
||||
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
|
||||
isLoading={loading || isCheckingParts}
|
||||
type={bilibiliPartsInfo.type}
|
||||
/>
|
||||
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
videos={videos}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
localSearchResults={localSearchResults}
|
||||
youtubeLoading={youtubeLoading}
|
||||
searchResults={searchResults}
|
||||
onDownload={handleDownloadFromSearch}
|
||||
onResetSearch={resetSearch}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/video/:id"
|
||||
element={
|
||||
<VideoPlayer
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onCreateCollection={handleCreateCollection}
|
||||
onRemoveFromCollection={handleRemoveFromCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/author/:author"
|
||||
element={
|
||||
<AuthorVideos
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collection/:id"
|
||||
element={
|
||||
<CollectionPage
|
||||
collections={collections}
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/search"
|
||||
element={
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
localResults={localSearchResults}
|
||||
loading={youtubeLoading}
|
||||
searchTerm={searchTerm}
|
||||
onDownload={handleDownloadFromSearch}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
onResetSearch={resetSearch}
|
||||
collections={collections}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/manage"
|
||||
element={
|
||||
<ManagePage
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,469 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
|
||||
import './App.css';
|
||||
import AuthorVideos from './pages/AuthorVideos';
|
||||
import CollectionPage from './pages/CollectionPage';
|
||||
import Home from './pages/Home';
|
||||
import SearchResults from './pages/SearchResults';
|
||||
import VideoPlayer from './pages/VideoPlayer';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
const DOWNLOAD_STATUS_KEY = 'mytube_download_status';
|
||||
const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
const COLLECTIONS_KEY = 'mytube_collections';
|
||||
|
||||
// Helper function to get download status from localStorage
|
||||
const getStoredDownloadStatus = () => {
|
||||
try {
|
||||
const savedStatus = localStorage.getItem(DOWNLOAD_STATUS_KEY);
|
||||
if (!savedStatus) return null;
|
||||
|
||||
const parsedStatus = JSON.parse(savedStatus);
|
||||
|
||||
// Check if the saved status is too old (stale)
|
||||
if (parsedStatus.timestamp && Date.now() - parsedStatus.timestamp > DOWNLOAD_TIMEOUT) {
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedStatus;
|
||||
} catch (error) {
|
||||
console.error('Error parsing download status from localStorage:', error);
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [videos, setVideos] = useState([]);
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [localSearchResults, setLocalSearchResults] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [youtubeLoading, setYoutubeLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [collections, setCollections] = useState([]);
|
||||
|
||||
// Reference to the current search request's abort controller
|
||||
const searchAbortController = useRef(null);
|
||||
|
||||
// Get initial download status from localStorage
|
||||
const initialStatus = getStoredDownloadStatus();
|
||||
const [downloadingTitle, setDownloadingTitle] = useState(
|
||||
initialStatus ? initialStatus.title || '' : ''
|
||||
);
|
||||
|
||||
// Fetch collections from the server
|
||||
const fetchCollections = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/collections`);
|
||||
setCollections(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching collections:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add a function to check download status from the backend
|
||||
const checkBackendDownloadStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/download-status`);
|
||||
|
||||
if (response.data.isDownloading) {
|
||||
// If backend is downloading, update the local status
|
||||
setDownloadingTitle(response.data.title || 'Downloading...');
|
||||
|
||||
// Save to localStorage for persistence
|
||||
const statusData = {
|
||||
title: response.data.title || 'Downloading...',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
|
||||
} else if (downloadingTitle && !response.data.isDownloading) {
|
||||
// If we think we're downloading but backend says no, clear the status
|
||||
console.log('Backend says download is complete, clearing status');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
setDownloadingTitle('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking backend download status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Check backend download status periodically
|
||||
useEffect(() => {
|
||||
// Check immediately on mount
|
||||
checkBackendDownloadStatus();
|
||||
|
||||
// Then check every 5 seconds
|
||||
const statusCheckInterval = setInterval(checkBackendDownloadStatus, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(statusCheckInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch collections on component mount
|
||||
useEffect(() => {
|
||||
fetchCollections();
|
||||
}, []);
|
||||
|
||||
// Fetch videos on component mount
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, []);
|
||||
|
||||
// Set up localStorage and event listeners
|
||||
useEffect(() => {
|
||||
console.log('Setting up localStorage and event listeners');
|
||||
|
||||
// Set up event listener for storage changes (for multi-tab support)
|
||||
const handleStorageChange = (e) => {
|
||||
if (e.key === DOWNLOAD_STATUS_KEY) {
|
||||
try {
|
||||
const newStatus = e.newValue ? JSON.parse(e.newValue) : { title: '' };
|
||||
console.log('Storage changed, new status:', newStatus);
|
||||
setDownloadingTitle(newStatus.title || '');
|
||||
} catch (error) {
|
||||
console.error('Error handling storage change:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
// Set up periodic check for stale download status
|
||||
const checkDownloadStatus = () => {
|
||||
const status = getStoredDownloadStatus();
|
||||
if (!status && downloadingTitle) {
|
||||
console.log('Clearing stale download status');
|
||||
setDownloadingTitle('');
|
||||
}
|
||||
};
|
||||
|
||||
// Check every minute
|
||||
const statusCheckInterval = setInterval(checkDownloadStatus, 60000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
clearInterval(statusCheckInterval);
|
||||
};
|
||||
}, [downloadingTitle]);
|
||||
|
||||
// Update localStorage whenever downloadingTitle changes
|
||||
useEffect(() => {
|
||||
console.log('Download title changed:', downloadingTitle);
|
||||
|
||||
if (downloadingTitle) {
|
||||
const statusData = {
|
||||
title: downloadingTitle,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
console.log('Saving to localStorage:', statusData);
|
||||
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
|
||||
} else {
|
||||
console.log('Removing from localStorage');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
}
|
||||
}, [downloadingTitle]);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`${API_URL}/videos`);
|
||||
setVideos(response.data);
|
||||
setError(null);
|
||||
|
||||
// Check if we need to clear a stale download status
|
||||
if (downloadingTitle) {
|
||||
const status = getStoredDownloadStatus();
|
||||
if (!status) {
|
||||
console.log('Clearing download status after fetching videos');
|
||||
setDownloadingTitle('');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching videos:', err);
|
||||
setError('Failed to load videos. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoSubmit = async (videoUrl) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Extract title from URL for display during download
|
||||
let displayTitle = videoUrl;
|
||||
if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
|
||||
displayTitle = 'YouTube video';
|
||||
} else if (videoUrl.includes('bilibili.com') || videoUrl.includes('b23.tv')) {
|
||||
displayTitle = 'Bilibili video';
|
||||
}
|
||||
|
||||
// Set download status before making the API call
|
||||
setDownloadingTitle(displayTitle);
|
||||
|
||||
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
|
||||
setVideos(prevVideos => [response.data.video, ...prevVideos]);
|
||||
setIsSearchMode(false);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error downloading video:', err);
|
||||
|
||||
// Check if the error is because the input is a search term
|
||||
if (err.response?.data?.isSearchTerm) {
|
||||
// Handle as search term
|
||||
return await handleSearch(err.response.data.searchTerm);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.response?.data?.error || 'Failed to download video. Please try again.'
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDownloadingTitle('');
|
||||
}
|
||||
};
|
||||
|
||||
const searchLocalVideos = (query) => {
|
||||
if (!query || !videos.length) return [];
|
||||
|
||||
const searchTermLower = query.toLowerCase();
|
||||
|
||||
return videos.filter(video =>
|
||||
video.title.toLowerCase().includes(searchTermLower) ||
|
||||
video.author.toLowerCase().includes(searchTermLower)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSearch = async (query) => {
|
||||
// Don't enter search mode if the query is empty
|
||||
if (!query || query.trim() === '') {
|
||||
resetSearch();
|
||||
return { success: false, error: 'Please enter a search term' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Cancel any previous search request
|
||||
if (searchAbortController.current) {
|
||||
searchAbortController.current.abort();
|
||||
}
|
||||
|
||||
// Create a new abort controller for this request
|
||||
searchAbortController.current = new AbortController();
|
||||
const signal = searchAbortController.current.signal;
|
||||
|
||||
// Set search mode and term immediately
|
||||
setIsSearchMode(true);
|
||||
setSearchTerm(query);
|
||||
|
||||
// Search local videos first (synchronously)
|
||||
const localResults = searchLocalVideos(query);
|
||||
setLocalSearchResults(localResults);
|
||||
|
||||
// Set loading state only for YouTube results
|
||||
setYoutubeLoading(true);
|
||||
|
||||
// Then search YouTube asynchronously
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/search`, {
|
||||
params: { query },
|
||||
signal: signal // Pass the abort signal to axios
|
||||
});
|
||||
|
||||
// Only update results if the request wasn't aborted
|
||||
if (!signal.aborted) {
|
||||
setSearchResults(response.data.results);
|
||||
}
|
||||
} catch (youtubeErr) {
|
||||
// Don't handle if it's an abort error
|
||||
if (youtubeErr.name !== 'CanceledError' && youtubeErr.name !== 'AbortError') {
|
||||
console.error('Error searching YouTube:', youtubeErr);
|
||||
}
|
||||
// Don't set overall error if only YouTube search fails
|
||||
} finally {
|
||||
// Only update loading state if the request wasn't aborted
|
||||
if (!signal.aborted) {
|
||||
setYoutubeLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Don't handle if it's an abort error
|
||||
if (err.name !== 'CanceledError' && err.name !== 'AbortError') {
|
||||
console.error('Error in search process:', err);
|
||||
|
||||
// Even if there's an error in the overall process,
|
||||
// we still want to show local results if available
|
||||
const localResults = searchLocalVideos(query);
|
||||
if (localResults.length > 0) {
|
||||
setLocalSearchResults(localResults);
|
||||
setIsSearchMode(true);
|
||||
setSearchTerm(query);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to search. Please try again.'
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
// Only update loading state if the request wasn't aborted
|
||||
if (searchAbortController.current && !searchAbortController.current.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a video
|
||||
const handleDeleteVideo = async (id) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// First, remove the video from any collections
|
||||
await handleRemoveFromCollection(id);
|
||||
|
||||
// Then delete the video
|
||||
await axios.delete(`${API_URL}/videos/${id}`);
|
||||
|
||||
// Update the videos state
|
||||
setVideos(prevVideos => prevVideos.filter(video => video.id !== id));
|
||||
|
||||
setLoading(false);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting video:', error);
|
||||
setError('Failed to delete video');
|
||||
setLoading(false);
|
||||
return { success: false, error: 'Failed to delete video' };
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFromSearch = async (videoUrl, title) => {
|
||||
// Abort any ongoing search request
|
||||
if (searchAbortController.current) {
|
||||
searchAbortController.current.abort();
|
||||
searchAbortController.current = null;
|
||||
}
|
||||
|
||||
setIsSearchMode(false);
|
||||
// If title is provided, use it for the downloading message
|
||||
if (title) {
|
||||
setDownloadingTitle(title);
|
||||
}
|
||||
return await handleVideoSubmit(videoUrl);
|
||||
};
|
||||
|
||||
// For debugging
|
||||
useEffect(() => {
|
||||
console.log('Current download status:', {
|
||||
downloadingTitle,
|
||||
isDownloading: !!downloadingTitle,
|
||||
localStorage: localStorage.getItem(DOWNLOAD_STATUS_KEY)
|
||||
});
|
||||
}, [downloadingTitle]);
|
||||
|
||||
// Cleanup effect to abort any pending search requests when unmounting
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Abort any ongoing search request when component unmounts
|
||||
if (searchAbortController.current) {
|
||||
searchAbortController.current.abort();
|
||||
searchAbortController.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update the resetSearch function to abort any ongoing search
|
||||
const resetSearch = () => {
|
||||
// Abort any ongoing search request
|
||||
if (searchAbortController.current) {
|
||||
searchAbortController.current.abort();
|
||||
searchAbortController.current = null;
|
||||
}
|
||||
|
||||
// Reset search-related state
|
||||
setIsSearchMode(false);
|
||||
setSearchTerm('');
|
||||
setSearchResults([]);
|
||||
setLocalSearchResults([]);
|
||||
setYoutubeLoading(false);
|
||||
};
|
||||
|
||||
// Create a new collection
|
||||
const handleCreateCollection = async (name, videoId = null) => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/collections`, {
|
||||
name,
|
||||
videoId
|
||||
});
|
||||
|
||||
// Update the collections state with the new collection from the server
|
||||
setCollections(prevCollections => [...prevCollections, response.data]);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error creating collection:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Add a video to a collection
|
||||
const handleAddToCollection = async (collectionId, videoId) => {
|
||||
try {
|
||||
// If videoId is provided, remove it from any other collections first
|
||||
// This is handled on the server side now
|
||||
|
||||
// Add the video to the selected collection
|
||||
const response = await axios.put(`${API_URL}/collections/${collectionId}/videos/${videoId}`);
|
||||
|
||||
// Update the collections state with the new video
|
||||
setCollections(prevCollections => prevCollections.map(collection =>
|
||||
collection.id === collectionId ? { ...collection, videos: [...collection.videos, response.data] } : collection
|
||||
));
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error adding video to collection:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Remove a video from a collection
|
||||
const handleRemoveFromCollection = async (videoId) => {
|
||||
try {
|
||||
// Remove the video from all collections
|
||||
await axios.delete(`${API_URL}/collections/videos/${videoId}`);
|
||||
|
||||
// Update the collections state
|
||||
setCollections(prevCollections => prevCollections.map(collection => ({
|
||||
...collection,
|
||||
videos: collection.videos.filter(v => v.id !== videoId)
|
||||
})));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error removing video from collection:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/author-videos" element={<AuthorVideos />} />
|
||||
<Route path="/collection-page" element={<CollectionPage />} />
|
||||
<Route path="/search-results" element={<SearchResults />} />
|
||||
<Route path="/video-player" element={<VideoPlayer />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
742
frontend/src/App.tsx
Normal file
742
frontend/src/App.tsx
Normal file
@@ -0,0 +1,742 @@
|
||||
import axios from 'axios';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
|
||||
import './App.css';
|
||||
import BilibiliPartsModal from './components/BilibiliPartsModal';
|
||||
import Header from './components/Header';
|
||||
import AuthorVideos from './pages/AuthorVideos';
|
||||
import CollectionPage from './pages/CollectionPage';
|
||||
import Home from './pages/Home';
|
||||
import ManagePage from './pages/ManagePage';
|
||||
import SearchResults from './pages/SearchResults';
|
||||
import VideoPlayer from './pages/VideoPlayer';
|
||||
import { Collection, DownloadInfo, Video } from './types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
const DOWNLOAD_STATUS_KEY = 'mytube_download_status';
|
||||
const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
|
||||
// Helper function to get download status from localStorage
|
||||
const getStoredDownloadStatus = () => {
|
||||
try {
|
||||
const savedStatus = localStorage.getItem(DOWNLOAD_STATUS_KEY);
|
||||
if (!savedStatus) return null;
|
||||
|
||||
const parsedStatus = JSON.parse(savedStatus);
|
||||
|
||||
// Check if the saved status is too old (stale)
|
||||
if (parsedStatus.timestamp && Date.now() - parsedStatus.timestamp > DOWNLOAD_TIMEOUT) {
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedStatus;
|
||||
} catch (error) {
|
||||
console.error('Error parsing download status from localStorage:', error);
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface BilibiliPartsInfo {
|
||||
videosNumber: number;
|
||||
title: string;
|
||||
url: string;
|
||||
type: 'parts' | 'collection' | 'series';
|
||||
collectionInfo: any;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
const [localSearchResults, setLocalSearchResults] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [youtubeLoading, setYoutubeLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSearchMode, setIsSearchMode] = useState<boolean>(false);
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
|
||||
// Bilibili multi-part video state
|
||||
const [showBilibiliPartsModal, setShowBilibiliPartsModal] = useState<boolean>(false);
|
||||
const [bilibiliPartsInfo, setBilibiliPartsInfo] = useState<BilibiliPartsInfo>({
|
||||
videosNumber: 0,
|
||||
title: '',
|
||||
url: '',
|
||||
type: 'parts', // 'parts', 'collection', or 'series'
|
||||
collectionInfo: null // For collection/series, stores the API response
|
||||
});
|
||||
const [isCheckingParts, setIsCheckingParts] = useState<boolean>(false);
|
||||
|
||||
// Reference to the current search request's abort controller
|
||||
const searchAbortController = useRef<AbortController | null>(null);
|
||||
|
||||
// Get initial download status from localStorage
|
||||
const initialStatus = getStoredDownloadStatus();
|
||||
const [activeDownloads, setActiveDownloads] = useState<DownloadInfo[]>(
|
||||
initialStatus ? initialStatus.activeDownloads || [] : []
|
||||
);
|
||||
|
||||
// Fetch collections from the server
|
||||
const fetchCollections = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/collections`);
|
||||
setCollections(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching collections:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add a function to check download status from the backend
|
||||
const checkBackendDownloadStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/download-status`);
|
||||
|
||||
if (response.data.activeDownloads && response.data.activeDownloads.length > 0) {
|
||||
// If backend has active downloads, update the local status
|
||||
setActiveDownloads(response.data.activeDownloads);
|
||||
|
||||
// Save to localStorage for persistence
|
||||
const statusData = {
|
||||
activeDownloads: response.data.activeDownloads,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
|
||||
} else {
|
||||
// If backend says no downloads are in progress, clear the status
|
||||
if (activeDownloads.length > 0) {
|
||||
console.log('Backend says downloads are complete, clearing status');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
setActiveDownloads([]);
|
||||
// Refresh videos list when downloads complete
|
||||
fetchVideos();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking backend download status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Check backend download status periodically
|
||||
useEffect(() => {
|
||||
// Check immediately on mount
|
||||
checkBackendDownloadStatus();
|
||||
|
||||
// Then check every 2 seconds (faster polling for better UX)
|
||||
const statusCheckInterval = setInterval(checkBackendDownloadStatus, 2000);
|
||||
|
||||
return () => {
|
||||
clearInterval(statusCheckInterval);
|
||||
};
|
||||
}, [activeDownloads.length]); // Depend on length to trigger refresh when downloads finish
|
||||
|
||||
// Fetch collections on component mount
|
||||
useEffect(() => {
|
||||
fetchCollections();
|
||||
}, []);
|
||||
|
||||
// Fetch videos on component mount
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, []);
|
||||
|
||||
// Set up localStorage and event listeners
|
||||
useEffect(() => {
|
||||
console.log('Setting up localStorage and event listeners');
|
||||
|
||||
// Set up event listener for storage changes (for multi-tab support)
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === DOWNLOAD_STATUS_KEY) {
|
||||
try {
|
||||
const newStatus = e.newValue ? JSON.parse(e.newValue) : { activeDownloads: [] };
|
||||
console.log('Storage changed, new status:', newStatus);
|
||||
setActiveDownloads(newStatus.activeDownloads || []);
|
||||
} catch (error) {
|
||||
console.error('Error handling storage change:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
// Set up periodic check for stale download status
|
||||
const checkDownloadStatus = () => {
|
||||
const status = getStoredDownloadStatus();
|
||||
if (!status && activeDownloads.length > 0) {
|
||||
console.log('Clearing stale download status');
|
||||
setActiveDownloads([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Check every minute
|
||||
const statusCheckInterval = setInterval(checkDownloadStatus, 60000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
clearInterval(statusCheckInterval);
|
||||
};
|
||||
}, [activeDownloads]);
|
||||
|
||||
// Update localStorage whenever activeDownloads changes
|
||||
useEffect(() => {
|
||||
console.log('Active downloads changed:', activeDownloads);
|
||||
|
||||
if (activeDownloads.length > 0) {
|
||||
const statusData = {
|
||||
activeDownloads,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
console.log('Saving to localStorage:', statusData);
|
||||
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
|
||||
} else {
|
||||
console.log('Removing from localStorage');
|
||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||
}
|
||||
}, [activeDownloads]);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`${API_URL}/videos`);
|
||||
setVideos(response.data);
|
||||
setError(null);
|
||||
|
||||
// Check if we need to clear a stale download status
|
||||
if (activeDownloads.length > 0) {
|
||||
const status = getStoredDownloadStatus();
|
||||
if (!status) {
|
||||
console.log('Clearing download status after fetching videos');
|
||||
setActiveDownloads([]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching videos:', err);
|
||||
setError('Failed to load videos. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false): Promise<any> => {
|
||||
try {
|
||||
// Check if it's a Bilibili URL
|
||||
if (videoUrl.includes('bilibili.com') || videoUrl.includes('b23.tv')) {
|
||||
setIsCheckingParts(true);
|
||||
try {
|
||||
// Only check for collection/series if not explicitly skipped
|
||||
if (!skipCollectionCheck) {
|
||||
// First, check if it's a collection or series
|
||||
const collectionResponse = await axios.get(`${API_URL}/check-bilibili-collection`, {
|
||||
params: { url: videoUrl }
|
||||
});
|
||||
|
||||
if (collectionResponse.data.success && collectionResponse.data.type !== 'none') {
|
||||
// It's a collection or series
|
||||
const { type, title, count, id, mid } = collectionResponse.data;
|
||||
|
||||
console.log(`Detected Bilibili ${type}:`, title, `with ${count} videos`);
|
||||
|
||||
setBilibiliPartsInfo({
|
||||
videosNumber: count,
|
||||
title: title,
|
||||
url: videoUrl,
|
||||
type: type,
|
||||
collectionInfo: { type, id, mid, title, count }
|
||||
});
|
||||
setShowBilibiliPartsModal(true);
|
||||
setIsCheckingParts(false);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
// If not a collection/series (or check was skipped), check if it has multiple parts
|
||||
const partsResponse = await axios.get(`${API_URL}/check-bilibili-parts`, {
|
||||
params: { url: videoUrl }
|
||||
});
|
||||
|
||||
if (partsResponse.data.success && partsResponse.data.videosNumber > 1) {
|
||||
// Show modal to ask user if they want to download all parts
|
||||
setBilibiliPartsInfo({
|
||||
videosNumber: partsResponse.data.videosNumber,
|
||||
title: partsResponse.data.title,
|
||||
url: videoUrl,
|
||||
type: 'parts',
|
||||
collectionInfo: null
|
||||
});
|
||||
setShowBilibiliPartsModal(true);
|
||||
setIsCheckingParts(false);
|
||||
return { success: true };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking Bilibili parts/collection:', err);
|
||||
// Continue with normal download if check fails
|
||||
} finally {
|
||||
setIsCheckingParts(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Normal download flow
|
||||
setLoading(true);
|
||||
|
||||
// We don't set activeDownloads here immediately because the backend will queue it
|
||||
// and we'll pick it up via polling
|
||||
|
||||
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
|
||||
|
||||
// If the response contains a downloadId, it means it was queued/started
|
||||
if (response.data.downloadId) {
|
||||
// Trigger an immediate status check
|
||||
checkBackendDownloadStatus();
|
||||
} else if (response.data.video) {
|
||||
// If it returned a video immediately (shouldn't happen with new logic but safe to keep)
|
||||
setVideos(prevVideos => [response.data.video, ...prevVideos]);
|
||||
}
|
||||
|
||||
setIsSearchMode(false);
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('Error downloading video:', err);
|
||||
|
||||
// Check if the error is because the input is a search term
|
||||
if (err.response?.data?.isSearchTerm) {
|
||||
// Handle as search term
|
||||
return await handleSearch(err.response.data.searchTerm);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.response?.data?.error || 'Failed to download video. Please try again.'
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const searchLocalVideos = (query: string) => {
|
||||
if (!query || !videos.length) return [];
|
||||
|
||||
const searchTermLower = query.toLowerCase();
|
||||
|
||||
return videos.filter(video =>
|
||||
video.title.toLowerCase().includes(searchTermLower) ||
|
||||
video.author.toLowerCase().includes(searchTermLower)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSearch = async (query: string): Promise<any> => {
|
||||
// Don't enter search mode if the query is empty
|
||||
if (!query || query.trim() === '') {
|
||||
resetSearch();
|
||||
return { success: false, error: 'Please enter a search term' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Cancel any previous search request
|
||||
if (searchAbortController.current) {
|
||||
searchAbortController.current.abort();
|
||||
}
|
||||
|
||||
// Create a new abort controller for this request
|
||||
searchAbortController.current = new AbortController();
|
||||
const signal = searchAbortController.current.signal;
|
||||
|
||||
// Set search mode and term immediately
|
||||
setIsSearchMode(true);
|
||||
setSearchTerm(query);
|
||||
|
||||
// Search local videos first (synchronously)
|
||||
const localResults = searchLocalVideos(query);
|
||||
setLocalSearchResults(localResults);
|
||||
|
||||
// Set loading state only for YouTube results
|
||||
setYoutubeLoading(true);
|
||||
|
||||
// Then search YouTube asynchronously
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/search`, {
|
||||
params: { query },
|
||||
signal: signal // Pass the abort signal to axios
|
||||
});
|
||||
|
||||
// Only update results if the request wasn't aborted
|
||||
if (!signal.aborted) {
|
||||
setSearchResults(response.data.results);
|
||||
}
|
||||
} catch (youtubeErr: any) {
|
||||
// Don't handle if it's an abort error
|
||||
if (youtubeErr.name !== 'CanceledError' && youtubeErr.name !== 'AbortError') {
|
||||
console.error('Error searching YouTube:', youtubeErr);
|
||||
}
|
||||
// Don't set overall error if only YouTube search fails
|
||||
} finally {
|
||||
// Only update loading state if the request wasn't aborted
|
||||
if (!signal.aborted) {
|
||||
setYoutubeLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
// Don't handle if it's an abort error
|
||||
if (err.name !== 'CanceledError' && err.name !== 'AbortError') {
|
||||
console.error('Error in search process:', err);
|
||||
|
||||
// Even if there's an error in the overall process,
|
||||
// we still want to show local results if available
|
||||
const localResults = searchLocalVideos(query);
|
||||
if (localResults.length > 0) {
|
||||
setLocalSearchResults(localResults);
|
||||
setIsSearchMode(true);
|
||||
setSearchTerm(query);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to search. Please try again.'
|
||||
};
|
||||
}
|
||||
return { success: false, error: 'Search was cancelled' };
|
||||
} finally {
|
||||
// Only update loading state if the request wasn't aborted
|
||||
if (searchAbortController.current && !searchAbortController.current.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a video
|
||||
const handleDeleteVideo = async (id: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// First, remove the video from any collections
|
||||
await handleRemoveFromCollection(id);
|
||||
|
||||
// Then delete the video
|
||||
await axios.delete(`${API_URL}/videos/${id}`);
|
||||
|
||||
// Update the videos state
|
||||
setVideos(prevVideos => prevVideos.filter(video => video.id !== id));
|
||||
|
||||
setLoading(false);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting video:', error);
|
||||
setError('Failed to delete video');
|
||||
setLoading(false);
|
||||
return { success: false, error: 'Failed to delete video' };
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFromSearch = async (videoUrl: string) => {
|
||||
try {
|
||||
// Abort any ongoing search request
|
||||
if (searchAbortController.current) {
|
||||
searchAbortController.current.abort();
|
||||
searchAbortController.current = null;
|
||||
}
|
||||
|
||||
setIsSearchMode(false);
|
||||
|
||||
const result = await handleVideoSubmit(videoUrl);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error in handleDownloadFromSearch:', error);
|
||||
return { success: false, error: 'Failed to download video' };
|
||||
}
|
||||
};
|
||||
|
||||
// For debugging
|
||||
useEffect(() => {
|
||||
console.log('Current download status:', {
|
||||
activeDownloads,
|
||||
count: activeDownloads.length,
|
||||
localStorage: localStorage.getItem(DOWNLOAD_STATUS_KEY)
|
||||
});
|
||||
}, [activeDownloads]);
|
||||
|
||||
// Cleanup effect to abort any pending search requests when unmounting
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Abort any ongoing search request when component unmounts
|
||||
if (searchAbortController.current) {
|
||||
searchAbortController.current.abort();
|
||||
searchAbortController.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update the resetSearch function to abort any ongoing search
|
||||
const resetSearch = () => {
|
||||
// Abort any ongoing search request
|
||||
if (searchAbortController.current) {
|
||||
searchAbortController.current.abort();
|
||||
searchAbortController.current = null;
|
||||
}
|
||||
|
||||
// Reset search-related state
|
||||
setIsSearchMode(false);
|
||||
setSearchTerm('');
|
||||
setSearchResults([]);
|
||||
setLocalSearchResults([]);
|
||||
setYoutubeLoading(false);
|
||||
};
|
||||
|
||||
// Create a new collection
|
||||
const handleCreateCollection = async (name: string, videoId: string) => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/collections`, {
|
||||
name,
|
||||
videoId
|
||||
});
|
||||
|
||||
// Update the collections state with the new collection from the server
|
||||
setCollections(prevCollections => [...prevCollections, response.data]);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error creating collection:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Add a video to a collection
|
||||
const handleAddToCollection = async (collectionId: string, videoId: string) => {
|
||||
try {
|
||||
// If videoId is provided, remove it from any other collections first
|
||||
// This is handled on the server side now
|
||||
|
||||
// Add the video to the selected collection
|
||||
const response = await axios.put(`${API_URL}/collections/${collectionId}`, {
|
||||
videoId,
|
||||
action: "add"
|
||||
});
|
||||
|
||||
// Update the collections state with the updated collection from the server
|
||||
setCollections(prevCollections => prevCollections.map(collection =>
|
||||
collection.id === collectionId ? response.data : collection
|
||||
));
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error adding video to collection:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Remove a video from a collection
|
||||
const handleRemoveFromCollection = async (videoId: string) => {
|
||||
try {
|
||||
// Get all collections
|
||||
const collectionsWithVideo = collections.filter(collection =>
|
||||
collection.videos.includes(videoId)
|
||||
);
|
||||
|
||||
// For each collection that contains the video, remove it
|
||||
for (const collection of collectionsWithVideo) {
|
||||
await axios.put(`${API_URL}/collections/${collection.id}`, {
|
||||
videoId,
|
||||
action: "remove"
|
||||
});
|
||||
}
|
||||
|
||||
// Update the collections state
|
||||
setCollections(prevCollections => prevCollections.map(collection => ({
|
||||
...collection,
|
||||
videos: collection.videos.filter(v => v !== videoId)
|
||||
})));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error removing video from collection:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a collection
|
||||
const handleDeleteCollection = async (collectionId: string, deleteVideos = false) => {
|
||||
try {
|
||||
// Delete the collection with optional video deletion
|
||||
await axios.delete(`${API_URL}/collections/${collectionId}`, {
|
||||
params: { deleteVideos: deleteVideos ? 'true' : 'false' }
|
||||
});
|
||||
|
||||
// Update the collections state
|
||||
setCollections(prevCollections =>
|
||||
prevCollections.filter(collection => collection.id !== collectionId)
|
||||
);
|
||||
|
||||
// If videos were deleted, refresh the videos list
|
||||
if (deleteVideos) {
|
||||
await fetchVideos();
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting collection:', error);
|
||||
return { success: false, error: 'Failed to delete collection' };
|
||||
}
|
||||
};
|
||||
|
||||
// Handle downloading all parts of a Bilibili video OR all videos from a collection/series
|
||||
const handleDownloadAllBilibiliParts = async (collectionName: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setShowBilibiliPartsModal(false);
|
||||
|
||||
const isCollection = bilibiliPartsInfo.type === 'collection' || bilibiliPartsInfo.type === 'series';
|
||||
|
||||
const response = await axios.post(`${API_URL}/download`, {
|
||||
youtubeUrl: bilibiliPartsInfo.url,
|
||||
downloadAllParts: !isCollection, // Only set this for multi-part videos
|
||||
downloadCollection: isCollection, // Set this for collections/series
|
||||
collectionInfo: isCollection ? bilibiliPartsInfo.collectionInfo : null,
|
||||
collectionName
|
||||
});
|
||||
|
||||
// Trigger immediate status check
|
||||
checkBackendDownloadStatus();
|
||||
|
||||
// If a collection was created, refresh collections
|
||||
if (response.data.collectionId) {
|
||||
await fetchCollections();
|
||||
}
|
||||
|
||||
setIsSearchMode(false);
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('Error downloading Bilibili parts/collection:', err);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.response?.data?.error || 'Failed to download. Please try again.'
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle downloading only the current part of a Bilibili video
|
||||
const handleDownloadCurrentBilibiliPart = async () => {
|
||||
setShowBilibiliPartsModal(false);
|
||||
// Pass true to skip collection/series check since we already know about it
|
||||
return await handleVideoSubmit(bilibiliPartsInfo.url, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="app">
|
||||
<Header
|
||||
onSearch={handleSearch}
|
||||
onSubmit={handleVideoSubmit}
|
||||
activeDownloads={activeDownloads}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
onResetSearch={resetSearch}
|
||||
/>
|
||||
|
||||
{/* Bilibili Parts Modal */}
|
||||
<BilibiliPartsModal
|
||||
isOpen={showBilibiliPartsModal}
|
||||
onClose={() => setShowBilibiliPartsModal(false)}
|
||||
videosNumber={bilibiliPartsInfo.videosNumber}
|
||||
videoTitle={bilibiliPartsInfo.title}
|
||||
onDownloadAll={handleDownloadAllBilibiliParts}
|
||||
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
|
||||
isLoading={loading || isCheckingParts}
|
||||
type={bilibiliPartsInfo.type}
|
||||
/>
|
||||
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
videos={videos}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
localSearchResults={localSearchResults}
|
||||
youtubeLoading={youtubeLoading}
|
||||
searchResults={searchResults}
|
||||
onDownload={handleDownloadFromSearch}
|
||||
onResetSearch={resetSearch}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/video/:id"
|
||||
element={
|
||||
<VideoPlayer
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onCreateCollection={handleCreateCollection}
|
||||
onRemoveFromCollection={handleRemoveFromCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/author/:author"
|
||||
element={
|
||||
<AuthorVideos
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collection/:id"
|
||||
element={
|
||||
<CollectionPage
|
||||
collections={collections}
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/search"
|
||||
element={
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
localResults={localSearchResults}
|
||||
loading={loading}
|
||||
youtubeLoading={youtubeLoading}
|
||||
searchTerm={searchTerm}
|
||||
onDownload={handleDownloadFromSearch}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
onResetSearch={resetSearch}
|
||||
collections={collections}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/manage"
|
||||
element={
|
||||
<ManagePage
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const AuthorsList = ({ videos }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [authors, setAuthors] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Extract unique authors from videos
|
||||
if (videos && videos.length > 0) {
|
||||
const uniqueAuthors = [...new Set(videos.map(video => video.author))]
|
||||
.filter(author => author) // Filter out null/undefined authors
|
||||
.sort((a, b) => a.localeCompare(b)); // Sort alphabetically
|
||||
|
||||
setAuthors(uniqueAuthors);
|
||||
} else {
|
||||
setAuthors([]);
|
||||
}
|
||||
}, [videos]);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
if (!authors.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="authors-container">
|
||||
{/* Mobile dropdown toggle */}
|
||||
<div className="authors-dropdown-toggle" onClick={toggleDropdown}>
|
||||
<h3>Authors</h3>
|
||||
<span className={`dropdown-arrow ${isOpen ? 'open' : ''}`}>▼</span>
|
||||
</div>
|
||||
|
||||
{/* Authors list - visible on desktop or when dropdown is open on mobile */}
|
||||
<div className={`authors-list ${isOpen ? 'open' : ''}`}>
|
||||
<h3 className="authors-title">Authors</h3>
|
||||
<ul>
|
||||
{authors.map(author => (
|
||||
<li key={author} className="author-item">
|
||||
<Link
|
||||
to={`/author/${encodeURIComponent(author)}`}
|
||||
className="author-link"
|
||||
onClick={() => setIsOpen(false)} // Close dropdown when an author is selected
|
||||
>
|
||||
{author}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthorsList;
|
||||
64
frontend/src/components/AuthorsList.tsx
Normal file
64
frontend/src/components/AuthorsList.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Video } from '../types';
|
||||
|
||||
interface AuthorsListProps {
|
||||
videos: Video[];
|
||||
}
|
||||
|
||||
const AuthorsList: React.FC<AuthorsListProps> = ({ videos }) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [authors, setAuthors] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Extract unique authors from videos
|
||||
if (videos && videos.length > 0) {
|
||||
const uniqueAuthors = [...new Set(videos.map(video => video.author))]
|
||||
.filter(author => author) // Filter out null/undefined authors
|
||||
.sort((a, b) => a.localeCompare(b)); // Sort alphabetically
|
||||
|
||||
setAuthors(uniqueAuthors);
|
||||
} else {
|
||||
setAuthors([]);
|
||||
}
|
||||
}, [videos]);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
if (!authors.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="authors-container">
|
||||
{/* Mobile dropdown toggle */}
|
||||
<div className="authors-dropdown-toggle" onClick={toggleDropdown}>
|
||||
<h3>Authors</h3>
|
||||
<span className={`dropdown-arrow ${isOpen ? 'open' : ''}`}>▼</span>
|
||||
</div>
|
||||
|
||||
{/* Authors list - visible on desktop or when dropdown is open on mobile */}
|
||||
<div className={`authors-list ${isOpen ? 'open' : ''}`}>
|
||||
<h3 className="authors-title">Authors</h3>
|
||||
<ul>
|
||||
{authors.map(author => (
|
||||
<li key={author} className="author-item">
|
||||
<Link
|
||||
to={`/author/${encodeURIComponent(author)}`}
|
||||
className="author-link"
|
||||
onClick={() => setIsOpen(false)} // Close dropdown when an author is selected
|
||||
>
|
||||
{author}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthorsList;
|
||||
@@ -1,123 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const BilibiliPartsModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
videosNumber,
|
||||
videoTitle,
|
||||
onDownloadAll,
|
||||
onDownloadCurrent,
|
||||
isLoading,
|
||||
type = 'parts' // 'parts', 'collection', or 'series'
|
||||
}) => {
|
||||
const [collectionName, setCollectionName] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
onDownloadAll(collectionName || videoTitle);
|
||||
};
|
||||
|
||||
// Dynamic text based on type
|
||||
const getHeaderText = () => {
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
return 'Bilibili Collection Detected';
|
||||
case 'series':
|
||||
return 'Bilibili Series Detected';
|
||||
default:
|
||||
return 'Multi-part Video Detected';
|
||||
}
|
||||
};
|
||||
|
||||
const getDescriptionText = () => {
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
return `This Bilibili collection has ${videosNumber} videos.`;
|
||||
case 'series':
|
||||
return `This Bilibili series has ${videosNumber} videos.`;
|
||||
default:
|
||||
return `This Bilibili video has ${videosNumber} parts.`;
|
||||
}
|
||||
};
|
||||
|
||||
const getDownloadAllButtonText = () => {
|
||||
if (isLoading) return 'Processing...';
|
||||
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
return `Download All ${videosNumber} Videos`;
|
||||
case 'series':
|
||||
return `Download All ${videosNumber} Videos`;
|
||||
default:
|
||||
return `Download All ${videosNumber} Parts`;
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentButtonText = () => {
|
||||
if (isLoading) return 'Processing...';
|
||||
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
return 'Download This Video Only';
|
||||
case 'series':
|
||||
return 'Download This Video Only';
|
||||
default:
|
||||
return 'Download Current Part Only';
|
||||
}
|
||||
};
|
||||
|
||||
const showCurrentButton = true; // Always show the current/single download option
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h2>{getHeaderText()}</h2>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>
|
||||
{getDescriptionText()}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Title:</strong> {videoTitle}
|
||||
</p>
|
||||
<p>Would you like to download all {type === 'parts' ? 'parts' : 'videos'}?</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="collection-name">Collection Name:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="collection-name"
|
||||
className="collection-input"
|
||||
value={collectionName}
|
||||
onChange={(e) => setCollectionName(e.target.value)}
|
||||
placeholder={videoTitle}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<small>All {type === 'parts' ? 'parts' : 'videos'} will be added to this collection</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn secondary-btn"
|
||||
onClick={onDownloadCurrent}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{getCurrentButtonText()}
|
||||
</button>
|
||||
<button
|
||||
className="btn primary-btn"
|
||||
onClick={handleDownloadAll}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{getDownloadAllButtonText()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BilibiliPartsModal;
|
||||
132
frontend/src/components/BilibiliPartsModal.tsx
Normal file
132
frontend/src/components/BilibiliPartsModal.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface BilibiliPartsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
videosNumber: number;
|
||||
videoTitle: string;
|
||||
onDownloadAll: (collectionName: string) => void;
|
||||
onDownloadCurrent: () => void;
|
||||
isLoading: boolean;
|
||||
type?: 'parts' | 'collection' | 'series';
|
||||
}
|
||||
|
||||
const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
videosNumber,
|
||||
videoTitle,
|
||||
onDownloadAll,
|
||||
onDownloadCurrent,
|
||||
isLoading,
|
||||
type = 'parts'
|
||||
}) => {
|
||||
const [collectionName, setCollectionName] = useState<string>('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
onDownloadAll(collectionName || videoTitle);
|
||||
};
|
||||
|
||||
// Dynamic text based on type
|
||||
const getHeaderText = () => {
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
return 'Bilibili Collection Detected';
|
||||
case 'series':
|
||||
return 'Bilibili Series Detected';
|
||||
default:
|
||||
return 'Multi-part Video Detected';
|
||||
}
|
||||
};
|
||||
|
||||
const getDescriptionText = () => {
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
return `This Bilibili collection has ${videosNumber} videos.`;
|
||||
case 'series':
|
||||
return `This Bilibili series has ${videosNumber} videos.`;
|
||||
default:
|
||||
return `This Bilibili video has ${videosNumber} parts.`;
|
||||
}
|
||||
};
|
||||
|
||||
const getDownloadAllButtonText = () => {
|
||||
if (isLoading) return 'Processing...';
|
||||
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
return `Download All ${videosNumber} Videos`;
|
||||
case 'series':
|
||||
return `Download All ${videosNumber} Videos`;
|
||||
default:
|
||||
return `Download All ${videosNumber} Parts`;
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentButtonText = () => {
|
||||
if (isLoading) return 'Processing...';
|
||||
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
return 'Download This Video Only';
|
||||
case 'series':
|
||||
return 'Download This Video Only';
|
||||
default:
|
||||
return 'Download Current Part Only';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h2>{getHeaderText()}</h2>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>
|
||||
{getDescriptionText()}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Title:</strong> {videoTitle}
|
||||
</p>
|
||||
<p>Would you like to download all {type === 'parts' ? 'parts' : 'videos'}?</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="collection-name">Collection Name:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="collection-name"
|
||||
className="collection-input"
|
||||
value={collectionName}
|
||||
onChange={(e) => setCollectionName(e.target.value)}
|
||||
placeholder={videoTitle}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<small>All {type === 'parts' ? 'parts' : 'videos'} will be added to this collection</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn secondary-btn"
|
||||
onClick={onDownloadCurrent}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{getCurrentButtonText()}
|
||||
</button>
|
||||
<button
|
||||
className="btn primary-btn"
|
||||
onClick={handleDownloadAll}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{getDownloadAllButtonText()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BilibiliPartsModal;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const Collections = ({ collections }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
if (!collections || collections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="collections-container">
|
||||
{/* Mobile dropdown toggle */}
|
||||
<div className="collections-dropdown-toggle" onClick={toggleDropdown}>
|
||||
<h3>Collections</h3>
|
||||
<span className={`dropdown-arrow ${isOpen ? 'open' : ''}`}>▼</span>
|
||||
</div>
|
||||
|
||||
{/* Collections list - visible on desktop or when dropdown is open on mobile */}
|
||||
<div className={`collections-list ${isOpen ? 'open' : ''}`}>
|
||||
<h3 className="collections-title">Collections</h3>
|
||||
<ul>
|
||||
{collections.map(collection => (
|
||||
<li key={collection.id} className="collection-item">
|
||||
<Link
|
||||
to={`/collection/${collection.id}`}
|
||||
className="collection-link"
|
||||
onClick={() => setIsOpen(false)} // Close dropdown when a collection is selected
|
||||
>
|
||||
{collection.name} ({collection.videos.length})
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collections;
|
||||
49
frontend/src/components/Collections.tsx
Normal file
49
frontend/src/components/Collections.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Collection } from '../types';
|
||||
|
||||
interface CollectionsProps {
|
||||
collections: Collection[];
|
||||
}
|
||||
|
||||
const Collections: React.FC<CollectionsProps> = ({ collections }) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
if (!collections || collections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="collections-container">
|
||||
{/* Mobile dropdown toggle */}
|
||||
<div className="collections-dropdown-toggle" onClick={toggleDropdown}>
|
||||
<h3>Collections</h3>
|
||||
<span className={`dropdown-arrow ${isOpen ? 'open' : ''}`}>▼</span>
|
||||
</div>
|
||||
|
||||
{/* Collections list - visible on desktop or when dropdown is open on mobile */}
|
||||
<div className={`collections-list ${isOpen ? 'open' : ''}`}>
|
||||
<h3 className="collections-title">Collections</h3>
|
||||
<ul>
|
||||
{collections.map(collection => (
|
||||
<li key={collection.id} className="collection-item">
|
||||
<Link
|
||||
to={`/collection/${collection.id}`}
|
||||
className="collection-link"
|
||||
onClick={() => setIsOpen(false)} // Close dropdown when a collection is selected
|
||||
>
|
||||
{collection.name} ({collection.videos.length})
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collections;
|
||||
@@ -1,64 +0,0 @@
|
||||
|
||||
const DeleteCollectionModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onDeleteCollectionOnly,
|
||||
onDeleteCollectionAndVideos,
|
||||
collectionName,
|
||||
videoCount
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Delete Collection</h2>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<p style={{ marginBottom: '12px', fontSize: '0.95rem' }}>
|
||||
Are you sure you want to delete the collection <strong>"{collectionName}"</strong>?
|
||||
</p>
|
||||
<p style={{ marginBottom: '20px', fontSize: '0.95rem', color: 'var(--text-secondary)' }}>
|
||||
This collection contains <strong>{videoCount}</strong> video{videoCount !== 1 ? 's' : ''}.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<button
|
||||
className="btn secondary-btn"
|
||||
onClick={onDeleteCollectionOnly}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Delete Collection Only
|
||||
</button>
|
||||
{videoCount > 0 && (
|
||||
<button
|
||||
className="btn primary-btn"
|
||||
onClick={onDeleteCollectionAndVideos}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'linear-gradient(135deg, #ff3e3e 0%, #ff6b6b 100%)'
|
||||
}}
|
||||
>
|
||||
⚠️ Delete Collection and All Videos
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn secondary-btn"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteCollectionModal;
|
||||
104
frontend/src/components/DeleteCollectionModal.tsx
Normal file
104
frontend/src/components/DeleteCollectionModal.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
interface DeleteCollectionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDeleteCollectionOnly: () => void;
|
||||
onDeleteCollectionAndVideos: () => void;
|
||||
collectionName: string;
|
||||
videoCount: number;
|
||||
}
|
||||
|
||||
const DeleteCollectionModal: React.FC<DeleteCollectionModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onDeleteCollectionOnly,
|
||||
onDeleteCollectionAndVideos,
|
||||
collectionName,
|
||||
videoCount
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Delete Collection</h2>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<p style={{ marginBottom: '12px', fontSize: '0.95rem' }}>
|
||||
Are you sure you want to delete the collection <strong>"{collectionName}"</strong>?
|
||||
</p>
|
||||
<p style={{ marginBottom: '20px', fontSize: '0.95rem', color: 'var(--text-secondary)' }}>
|
||||
This collection contains <strong>{videoCount}</strong> video{videoCount !== 1 ? 's' : ''}.
|
||||
</p>
|
||||
|
||||
<div className="modal-actions" style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<button
|
||||
className="btn secondary-btn"
|
||||
onClick={onDeleteCollectionOnly}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
color: 'var(--text-color)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseOver={(e) => e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)'}
|
||||
onMouseOut={(e) => e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'}
|
||||
>
|
||||
Delete Collection Only
|
||||
</button>
|
||||
{videoCount > 0 && (
|
||||
<button
|
||||
className="btn danger-btn"
|
||||
onClick={onDeleteCollectionAndVideos}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(90deg, #ff4b4b 0%, #ff0000 100%)',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(255, 0, 0, 0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(255, 0, 0, 0.4)';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 0, 0, 0.3)';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '1.2em' }}>⚠️</span>
|
||||
<span>Delete Collection & All {videoCount} Videos</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn secondary-btn"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteCollectionModal;
|
||||
@@ -1,145 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import logo from '../assets/logo.svg';
|
||||
|
||||
const Header = ({ onSubmit, onSearch, activeDownloads = [] }) => {
|
||||
// ... existing state ...
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showDownloads, setShowDownloads] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isDownloading = activeDownloads.length > 0;
|
||||
|
||||
// Log props for debugging
|
||||
useEffect(() => {
|
||||
console.log('Header props:', { activeDownloads });
|
||||
}, [activeDownloads]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
// ... existing submit handler ...
|
||||
e.preventDefault();
|
||||
|
||||
if (!videoUrl.trim()) {
|
||||
setError('Please enter a video URL or search term');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple validation for YouTube or Bilibili URL
|
||||
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
|
||||
const bilibiliRegex = /^(https?:\/\/)?(www\.)?(bilibili\.com|b23\.tv)\/.+$/;
|
||||
|
||||
// Check if input is a URL
|
||||
const isUrl = youtubeRegex.test(videoUrl) || bilibiliRegex.test(videoUrl);
|
||||
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (isUrl) {
|
||||
// Handle as URL for download
|
||||
const result = await onSubmit(videoUrl);
|
||||
|
||||
if (result.success) {
|
||||
setVideoUrl('');
|
||||
} else if (result.isSearchTerm) {
|
||||
// If backend determined it's a search term despite our check
|
||||
const searchResult = await onSearch(videoUrl);
|
||||
if (searchResult.success) {
|
||||
setVideoUrl('');
|
||||
navigate('/'); // Stay on homepage to show search results
|
||||
} else {
|
||||
setError(searchResult.error);
|
||||
}
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
} else {
|
||||
// Handle as search term
|
||||
const result = await onSearch(videoUrl);
|
||||
|
||||
if (result.success) {
|
||||
setVideoUrl('');
|
||||
// Stay on homepage to show search results
|
||||
navigate('/');
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred. Please try again.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-content">
|
||||
<Link to="/" className="logo">
|
||||
<img src={logo} alt="MyTube Logo" className="logo-icon" />
|
||||
<span style={{ color: '#f0f0f0' }}>MyTube</span>
|
||||
</Link>
|
||||
|
||||
<form className="url-form" onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
className="url-input"
|
||||
placeholder="Enter YouTube/Bilibili URL or search term"
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
aria-label="Video URL or search term"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="submit-btn"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Processing...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="form-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Downloads Indicator */}
|
||||
{isDownloading && (
|
||||
<div className="downloads-indicator-container">
|
||||
<div
|
||||
className="downloads-summary"
|
||||
onClick={() => setShowDownloads(!showDownloads)}
|
||||
>
|
||||
<span className="download-icon">⬇️</span>
|
||||
<span className="download-count">
|
||||
{activeDownloads.length} Downloading
|
||||
</span>
|
||||
<span className="download-arrow">{showDownloads ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
|
||||
{showDownloads && (
|
||||
<div className="downloads-dropdown">
|
||||
{activeDownloads.map((download) => (
|
||||
<div key={download.id} className="download-item">
|
||||
<div className="download-spinner"></div>
|
||||
<div className="download-title" title={download.title}>
|
||||
{download.title}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
186
frontend/src/components/Header.tsx
Normal file
186
frontend/src/components/Header.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import logo from '../assets/logo.svg';
|
||||
|
||||
interface DownloadInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
onSubmit: (url: string) => Promise<any>;
|
||||
onSearch: (term: string) => Promise<any>;
|
||||
activeDownloads?: DownloadInfo[];
|
||||
isSearchMode?: boolean;
|
||||
searchTerm?: string;
|
||||
onResetSearch?: () => void;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
onSubmit,
|
||||
onSearch,
|
||||
activeDownloads = [],
|
||||
isSearchMode = false,
|
||||
searchTerm = '',
|
||||
onResetSearch
|
||||
}) => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [showDownloads, setShowDownloads] = useState<boolean>(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isDownloading = activeDownloads.length > 0;
|
||||
|
||||
// Log props for debugging
|
||||
useEffect(() => {
|
||||
console.log('Header props:', { activeDownloads });
|
||||
}, [activeDownloads]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!videoUrl.trim()) {
|
||||
setError('Please enter a video URL or search term');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple validation for YouTube or Bilibili URL
|
||||
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
|
||||
const bilibiliRegex = /^(https?:\/\/)?(www\.)?(bilibili\.com|b23\.tv)\/.+$/;
|
||||
|
||||
// Check if input is a URL
|
||||
const isUrl = youtubeRegex.test(videoUrl) || bilibiliRegex.test(videoUrl);
|
||||
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (isUrl) {
|
||||
// Handle as URL for download
|
||||
const result = await onSubmit(videoUrl);
|
||||
|
||||
if (result.success) {
|
||||
setVideoUrl('');
|
||||
} else if (result.isSearchTerm) {
|
||||
// If backend determined it's a search term despite our check
|
||||
const searchResult = await onSearch(videoUrl);
|
||||
if (searchResult.success) {
|
||||
setVideoUrl('');
|
||||
navigate('/'); // Stay on homepage to show search results
|
||||
} else {
|
||||
setError(searchResult.error);
|
||||
}
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
} else {
|
||||
// Handle as search term
|
||||
const result = await onSearch(videoUrl);
|
||||
|
||||
if (result.success) {
|
||||
setVideoUrl('');
|
||||
// Stay on homepage to show search results
|
||||
navigate('/');
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred. Please try again.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-content">
|
||||
<Link to="/" className="logo">
|
||||
<img src={logo} alt="MyTube Logo" className="logo-icon" />
|
||||
<span style={{ color: '#f0f0f0' }}>MyTube</span>
|
||||
</Link>
|
||||
|
||||
<form className="url-form" onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
className="url-input"
|
||||
placeholder="Enter YouTube/Bilibili URL or search term"
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
aria-label="Video URL or search term"
|
||||
/>
|
||||
{isSearchMode && searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
className="clear-search-btn"
|
||||
onClick={onResetSearch}
|
||||
title="Clear search"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '100px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#aaa',
|
||||
fontSize: '1.2rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="submit-btn"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Processing...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="form-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Downloads Indicator */}
|
||||
{isDownloading && (
|
||||
<div className="downloads-indicator-container">
|
||||
<div
|
||||
className="downloads-summary"
|
||||
onClick={() => setShowDownloads(!showDownloads)}
|
||||
>
|
||||
<span className="download-icon">⬇️</span>
|
||||
<span className="download-count">
|
||||
{activeDownloads.length} Downloading
|
||||
</span>
|
||||
<span className="download-arrow">{showDownloads ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
|
||||
{showDownloads && (
|
||||
<div className="downloads-dropdown">
|
||||
{activeDownloads.map((download) => (
|
||||
<div key={download.id} className="download-item">
|
||||
<div className="download-spinner"></div>
|
||||
<div className="download-title" title={download.title}>
|
||||
{download.title}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
|
||||
const VideoCard = ({ video, collections = [] }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Format the date (assuming format YYYYMMDD from youtube-dl)
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString || dateString.length !== 8) {
|
||||
return 'Unknown date';
|
||||
}
|
||||
|
||||
const year = dateString.substring(0, 4);
|
||||
const month = dateString.substring(4, 6);
|
||||
const day = dateString.substring(6, 8);
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Use local thumbnail if available, otherwise fall back to the original URL
|
||||
const thumbnailSrc = video.thumbnailPath
|
||||
? `${BACKEND_URL}${video.thumbnailPath}`
|
||||
: video.thumbnailUrl;
|
||||
|
||||
// Handle author click
|
||||
const handleAuthorClick = (e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/author/${encodeURIComponent(video.author)}`);
|
||||
};
|
||||
|
||||
// Find collections this video belongs to
|
||||
const videoCollections = collections.filter(collection =>
|
||||
collection.videos.includes(video.id)
|
||||
);
|
||||
|
||||
// Check if this video is the first in any collection
|
||||
const isFirstInAnyCollection = videoCollections.some(collection =>
|
||||
collection.videos[0] === video.id
|
||||
);
|
||||
|
||||
// Get collection names where this video is the first
|
||||
const firstInCollectionNames = videoCollections
|
||||
.filter(collection => collection.videos[0] === video.id)
|
||||
.map(collection => collection.name);
|
||||
|
||||
// Get the first collection ID where this video is the first video
|
||||
const firstCollectionId = isFirstInAnyCollection
|
||||
? videoCollections.find(collection => collection.videos[0] === video.id)?.id
|
||||
: null;
|
||||
|
||||
// Handle video navigation
|
||||
const handleVideoNavigation = () => {
|
||||
// If this is the first video in a collection, navigate to the collection page
|
||||
if (isFirstInAnyCollection && firstCollectionId) {
|
||||
navigate(`/collection/${firstCollectionId}`);
|
||||
} else {
|
||||
// Otherwise navigate to the video player page
|
||||
navigate(`/video/${video.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Get source icon
|
||||
const getSourceIcon = () => {
|
||||
if (video.source === 'bilibili') {
|
||||
return (
|
||||
<div className="source-icon bilibili-icon" title="Bilibili">
|
||||
B
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="source-icon youtube-icon" title="YouTube">
|
||||
YT
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`video-card ${isFirstInAnyCollection ? 'collection-first' : ''}`}>
|
||||
<div
|
||||
className="thumbnail-container clickable"
|
||||
onClick={handleVideoNavigation}
|
||||
aria-label={isFirstInAnyCollection
|
||||
? `View collection: ${firstInCollectionNames[0]}${firstInCollectionNames.length > 1 ? ' and others' : ''}`
|
||||
: `Play ${video.title}`}
|
||||
>
|
||||
<img
|
||||
src={thumbnailSrc}
|
||||
alt={`${video.title} thumbnail`}
|
||||
className="thumbnail"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.target.onerror = null;
|
||||
e.target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
{getSourceIcon()}
|
||||
|
||||
{/* Show part number for multi-part videos */}
|
||||
{video.partNumber && video.totalParts > 1 && (
|
||||
<div className="part-badge">
|
||||
Part {video.partNumber}/{video.totalParts}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show collection badge if this is the first video in a collection */}
|
||||
{isFirstInAnyCollection && (
|
||||
<div className="collection-badge" title={`Collection${firstInCollectionNames.length > 1 ? 's' : ''}: ${firstInCollectionNames.join(', ')}`}>
|
||||
<span className="collection-icon">📁</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="video-info">
|
||||
<h3
|
||||
className="video-title clickable"
|
||||
onClick={handleVideoNavigation}
|
||||
>
|
||||
{isFirstInAnyCollection ? (
|
||||
<>
|
||||
{firstInCollectionNames[0]}
|
||||
{firstInCollectionNames.length > 1 && <span className="more-collections"> +{firstInCollectionNames.length - 1}</span>}
|
||||
</>
|
||||
) : (
|
||||
video.title
|
||||
)}
|
||||
</h3>
|
||||
<div className="video-meta">
|
||||
<span
|
||||
className="author-link"
|
||||
onClick={handleAuthorClick}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
aria-label={`View all videos by ${video.author}`}
|
||||
>
|
||||
{video.author}
|
||||
</span>
|
||||
<span className="video-date">{formatDate(video.date)}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoCard;
|
||||
190
frontend/src/components/VideoCard.tsx
Normal file
190
frontend/src/components/VideoCard.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
|
||||
interface VideoCardProps {
|
||||
video: Video;
|
||||
collections?: Collection[];
|
||||
onDeleteVideo?: (id: string) => Promise<void>;
|
||||
showDeleteButton?: boolean;
|
||||
}
|
||||
|
||||
const VideoCard: React.FC<VideoCardProps> = ({
|
||||
video,
|
||||
collections = [],
|
||||
onDeleteVideo,
|
||||
showDeleteButton = false
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Format the date (assuming format YYYYMMDD from youtube-dl)
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString || dateString.length !== 8) {
|
||||
return 'Unknown date';
|
||||
}
|
||||
|
||||
const year = dateString.substring(0, 4);
|
||||
const month = dateString.substring(4, 6);
|
||||
const day = dateString.substring(6, 8);
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Use local thumbnail if available, otherwise fall back to the original URL
|
||||
const thumbnailSrc = video.thumbnailPath
|
||||
? `${BACKEND_URL}${video.thumbnailPath}`
|
||||
: video.thumbnailUrl;
|
||||
|
||||
// Handle author click
|
||||
const handleAuthorClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/author/${encodeURIComponent(video.author)}`);
|
||||
};
|
||||
|
||||
// Handle delete click
|
||||
const handleDeleteClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!onDeleteVideo) return;
|
||||
|
||||
if (window.confirm(`Are you sure you want to delete "${video.title}"?`)) {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDeleteVideo(video.id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting video:', error);
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Find collections this video belongs to
|
||||
const videoCollections = collections.filter(collection =>
|
||||
collection.videos.includes(video.id)
|
||||
);
|
||||
|
||||
// Check if this video is the first in any collection
|
||||
const isFirstInAnyCollection = videoCollections.some(collection =>
|
||||
collection.videos[0] === video.id
|
||||
);
|
||||
|
||||
// Get collection names where this video is the first
|
||||
const firstInCollectionNames = videoCollections
|
||||
.filter(collection => collection.videos[0] === video.id)
|
||||
.map(collection => collection.name);
|
||||
|
||||
// Get the first collection ID where this video is the first video
|
||||
const firstCollectionId = isFirstInAnyCollection
|
||||
? videoCollections.find(collection => collection.videos[0] === video.id)?.id
|
||||
: null;
|
||||
|
||||
// Handle video navigation
|
||||
const handleVideoNavigation = () => {
|
||||
// If this is the first video in a collection, navigate to the collection page
|
||||
if (isFirstInAnyCollection && firstCollectionId) {
|
||||
navigate(`/collection/${firstCollectionId}`);
|
||||
} else {
|
||||
// Otherwise navigate to the video player page
|
||||
navigate(`/video/${video.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Get source icon
|
||||
const getSourceIcon = () => {
|
||||
if (video.source === 'bilibili') {
|
||||
return (
|
||||
<div className="source-icon bilibili-icon" title="Bilibili">
|
||||
B
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="source-icon youtube-icon" title="YouTube">
|
||||
YT
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`video-card ${isFirstInAnyCollection ? 'collection-first' : ''}`}>
|
||||
<div
|
||||
className="thumbnail-container clickable"
|
||||
onClick={handleVideoNavigation}
|
||||
aria-label={isFirstInAnyCollection
|
||||
? `View collection: ${firstInCollectionNames[0]}${firstInCollectionNames.length > 1 ? ' and others' : ''}`
|
||||
: `Play ${video.title}`}
|
||||
>
|
||||
<img
|
||||
src={thumbnailSrc || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
||||
alt={`${video.title} thumbnail`}
|
||||
className="thumbnail"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
{getSourceIcon()}
|
||||
|
||||
{/* Show part number for multi-part videos */}
|
||||
{video.partNumber && video.totalParts && video.totalParts > 1 && (
|
||||
<div className="part-badge">
|
||||
Part {video.partNumber}/{video.totalParts}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show collection badge if this is the first video in a collection */}
|
||||
{isFirstInAnyCollection && (
|
||||
<div className="collection-badge" title={`Collection${firstInCollectionNames.length > 1 ? 's' : ''}: ${firstInCollectionNames.join(', ')}`}>
|
||||
<span className="collection-icon">📁</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete button overlay */}
|
||||
{showDeleteButton && onDeleteVideo && (
|
||||
<button
|
||||
className="card-delete-btn"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={isDeleting}
|
||||
title="Delete video"
|
||||
>
|
||||
{isDeleting ? '...' : '×'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="video-info">
|
||||
<h3
|
||||
className="video-title clickable"
|
||||
onClick={handleVideoNavigation}
|
||||
>
|
||||
{isFirstInAnyCollection ? (
|
||||
<>
|
||||
{firstInCollectionNames[0]}
|
||||
{firstInCollectionNames.length > 1 && <span className="more-collections"> +{firstInCollectionNames.length - 1}</span>}
|
||||
</>
|
||||
) : (
|
||||
video.title
|
||||
)}
|
||||
</h3>
|
||||
<div className="video-meta">
|
||||
<span
|
||||
className="author-link"
|
||||
onClick={handleAuthorClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`View all videos by ${video.author}`}
|
||||
>
|
||||
{video.author}
|
||||
</span>
|
||||
<span className="video-date">{formatDate(video.date)}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoCard;
|
||||
@@ -1,14 +0,0 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
import VERSION from './version';
|
||||
|
||||
// Display version information
|
||||
VERSION.displayVersion();
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
17
frontend/src/main.tsx
Normal file
17
frontend/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import VERSION from './version';
|
||||
|
||||
// Display version information
|
||||
VERSION.displayVersion();
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (rootElement) {
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
const AuthorVideos = ({ videos: allVideos, onDeleteVideo, collections = [] }) => {
|
||||
const { author } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [authorVideos, setAuthorVideos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// If videos are passed as props, filter them
|
||||
if (allVideos && allVideos.length > 0) {
|
||||
const filteredVideos = allVideos.filter(
|
||||
video => video.author === author
|
||||
);
|
||||
setAuthorVideos(filteredVideos);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise fetch from API
|
||||
const fetchVideos = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/videos`);
|
||||
// Filter videos by author
|
||||
const filteredVideos = response.data.filter(
|
||||
video => video.author === author
|
||||
);
|
||||
setAuthorVideos(filteredVideos);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching videos:', err);
|
||||
setError('Failed to load videos. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideos();
|
||||
}, [author, allVideos]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading videos...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="error">{error}</div>;
|
||||
}
|
||||
|
||||
// Filter videos to only show the first video from each collection
|
||||
const filteredVideos = authorVideos.filter(video => {
|
||||
// If the video is not in any collection, show it
|
||||
const videoCollections = collections.filter(collection =>
|
||||
collection.videos.includes(video.id)
|
||||
);
|
||||
|
||||
if (videoCollections.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For each collection this video is in, check if it's the first video
|
||||
return videoCollections.some(collection => {
|
||||
// Get the first video ID in this collection
|
||||
const firstVideoId = collection.videos[0];
|
||||
// Show this video if it's the first in at least one collection
|
||||
return video.id === firstVideoId;
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="author-videos-container">
|
||||
<div className="author-header">
|
||||
<button className="back-button" onClick={handleBack}>
|
||||
← Back
|
||||
</button>
|
||||
<div className="author-info">
|
||||
<h2>Author: {decodeURIComponent(author)}</h2>
|
||||
<span className="video-count">{authorVideos.length} video{authorVideos.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authorVideos.length === 0 ? (
|
||||
<div className="no-videos">No videos found for this author.</div>
|
||||
) : (
|
||||
<div className="videos-grid">
|
||||
{filteredVideos.map(video => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
collections={collections}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthorVideos;
|
||||
119
frontend/src/pages/AuthorVideos.tsx
Normal file
119
frontend/src/pages/AuthorVideos.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface AuthorVideosProps {
|
||||
videos: Video[];
|
||||
onDeleteVideo: (id: string) => Promise<any>;
|
||||
collections: Collection[];
|
||||
}
|
||||
|
||||
const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDeleteVideo, collections = [] }) => {
|
||||
const { author } = useParams<{ author: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [authorVideos, setAuthorVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!author) return;
|
||||
|
||||
// If videos are passed as props, filter them
|
||||
if (allVideos && allVideos.length > 0) {
|
||||
const filteredVideos = allVideos.filter(
|
||||
video => video.author === author
|
||||
);
|
||||
setAuthorVideos(filteredVideos);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise fetch from API
|
||||
const fetchVideos = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/videos`);
|
||||
// Filter videos by author
|
||||
const filteredVideos = response.data.filter(
|
||||
(video: Video) => video.author === author
|
||||
);
|
||||
setAuthorVideos(filteredVideos);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching videos:', err);
|
||||
setError('Failed to load videos. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideos();
|
||||
}, [author, allVideos]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading videos...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="error">{error}</div>;
|
||||
}
|
||||
|
||||
// Filter videos to only show the first video from each collection
|
||||
const filteredVideos = authorVideos.filter(video => {
|
||||
// If the video is not in any collection, show it
|
||||
const videoCollections = collections.filter(collection =>
|
||||
collection.videos.includes(video.id)
|
||||
);
|
||||
|
||||
if (videoCollections.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For each collection this video is in, check if it's the first video
|
||||
return videoCollections.some(collection => {
|
||||
// Get the first video ID in this collection
|
||||
const firstVideoId = collection.videos[0];
|
||||
// Show this video if it's the first in at least one collection
|
||||
return video.id === firstVideoId;
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="author-videos-container">
|
||||
<div className="author-header">
|
||||
<button className="back-button" onClick={handleBack}>
|
||||
← Back
|
||||
</button>
|
||||
<div className="author-info">
|
||||
<h2>Author: {author ? decodeURIComponent(author) : 'Unknown'}</h2>
|
||||
<span className="video-count">{authorVideos.length} video{authorVideos.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authorVideos.length === 0 ? (
|
||||
<div className="no-videos">No videos found for this author.</div>
|
||||
) : (
|
||||
<div className="videos-grid">
|
||||
{filteredVideos.map(video => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthorVideos;
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import DeleteCollectionModal from '../components/DeleteCollectionModal';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
|
||||
const CollectionPage = ({ collections, videos, onDeleteVideo, onDeleteCollection }) => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [collection, setCollection] = useState(null);
|
||||
const [collectionVideos, setCollectionVideos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (collections && collections.length > 0) {
|
||||
const foundCollection = collections.find(c => c.id === id);
|
||||
|
||||
if (foundCollection) {
|
||||
setCollection(foundCollection);
|
||||
|
||||
// Find all videos that are in this collection
|
||||
const videosInCollection = videos.filter(video =>
|
||||
foundCollection.videos.includes(video.id)
|
||||
);
|
||||
|
||||
setCollectionVideos(videosInCollection);
|
||||
} else {
|
||||
// Collection not found, redirect to home
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, [id, collections, videos, navigate]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const handleShowDeleteModal = () => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleCloseDeleteModal = () => {
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
const handleDeleteCollectionOnly = async () => {
|
||||
const success = await onDeleteCollection(id, false);
|
||||
if (success) {
|
||||
navigate('/');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
const handleDeleteCollectionAndVideos = async () => {
|
||||
const success = await onDeleteCollection(id, true);
|
||||
if (success) {
|
||||
navigate('/');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading collection...</div>;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return <div className="error">Collection not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="collection-page">
|
||||
<div className="collection-header">
|
||||
<button className="back-button" onClick={handleBack}>
|
||||
← Back
|
||||
</button>
|
||||
<div className="collection-info">
|
||||
<h2 className="collection-title">Collection: {collection.name}</h2>
|
||||
<span className="video-count">{collectionVideos.length} video{collectionVideos.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collectionVideos.length === 0 ? (
|
||||
<div className="no-videos">
|
||||
<p>No videos in this collection.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="videos-grid">
|
||||
{collectionVideos.map(video => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteCollectionModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={handleCloseDeleteModal}
|
||||
onDeleteCollectionOnly={handleDeleteCollectionOnly}
|
||||
onDeleteCollectionAndVideos={handleDeleteCollectionAndVideos}
|
||||
collectionName={collection?.name || ''}
|
||||
videoCount={collectionVideos.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionPage;
|
||||
120
frontend/src/pages/CollectionPage.tsx
Normal file
120
frontend/src/pages/CollectionPage.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import DeleteCollectionModal from '../components/DeleteCollectionModal';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
interface CollectionPageProps {
|
||||
collections: Collection[];
|
||||
videos: Video[];
|
||||
onDeleteVideo: (id: string) => Promise<any>;
|
||||
onDeleteCollection: (id: string, deleteVideos: boolean) => Promise<any>;
|
||||
}
|
||||
|
||||
const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, onDeleteVideo, onDeleteCollection }) => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [collection, setCollection] = useState<Collection | null>(null);
|
||||
const [collectionVideos, setCollectionVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (collections && collections.length > 0 && id) {
|
||||
const foundCollection = collections.find(c => c.id === id);
|
||||
|
||||
if (foundCollection) {
|
||||
setCollection(foundCollection);
|
||||
|
||||
// Find all videos that are in this collection
|
||||
const videosInCollection = videos.filter(video =>
|
||||
foundCollection.videos.includes(video.id)
|
||||
);
|
||||
|
||||
setCollectionVideos(videosInCollection);
|
||||
} else {
|
||||
// Collection not found, redirect to home
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, [id, collections, videos, navigate]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const handleCloseDeleteModal = () => {
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
const handleDeleteCollectionOnly = async () => {
|
||||
if (!id) return;
|
||||
const success = await onDeleteCollection(id, false);
|
||||
if (success) {
|
||||
navigate('/');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
const handleDeleteCollectionAndVideos = async () => {
|
||||
if (!id) return;
|
||||
const success = await onDeleteCollection(id, true);
|
||||
if (success) {
|
||||
navigate('/');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading collection...</div>;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return <div className="error">Collection not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="collection-page">
|
||||
<div className="collection-header">
|
||||
<button className="back-button" onClick={handleBack}>
|
||||
← Back
|
||||
</button>
|
||||
<div className="collection-info">
|
||||
<h2 className="collection-title">Collection: {collection.name}</h2>
|
||||
<span className="video-count">{collectionVideos.length} video{collectionVideos.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collectionVideos.length === 0 ? (
|
||||
<div className="no-videos">
|
||||
<p>No videos in this collection.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="videos-grid">
|
||||
{collectionVideos.map(video => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteCollectionModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={handleCloseDeleteModal}
|
||||
onDeleteCollectionOnly={handleDeleteCollectionOnly}
|
||||
onDeleteCollectionAndVideos={handleDeleteCollectionAndVideos}
|
||||
collectionName={collection?.name || ''}
|
||||
videoCount={collectionVideos.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionPage;
|
||||
@@ -1,212 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import AuthorsList from '../components/AuthorsList';
|
||||
import Collections from '../components/Collections';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
|
||||
const Home = ({
|
||||
videos = [],
|
||||
loading,
|
||||
error,
|
||||
onDeleteVideo,
|
||||
collections = [],
|
||||
isSearchMode = false,
|
||||
searchTerm = '',
|
||||
localSearchResults = [],
|
||||
youtubeLoading = false,
|
||||
searchResults = [],
|
||||
onDownload,
|
||||
onResetSearch
|
||||
}) => {
|
||||
// Add default empty array to ensure videos is always an array
|
||||
const videoArray = Array.isArray(videos) ? videos : [];
|
||||
|
||||
if (loading && videoArray.length === 0 && !isSearchMode) {
|
||||
return <div className="loading">Loading videos...</div>;
|
||||
}
|
||||
|
||||
if (error && videoArray.length === 0 && !isSearchMode) {
|
||||
return <div className="error">{error}</div>;
|
||||
}
|
||||
|
||||
// Filter videos to only show the first video from each collection
|
||||
const filteredVideos = videoArray.filter(video => {
|
||||
// If the video is not in any collection, show it
|
||||
const videoCollections = collections.filter(collection =>
|
||||
collection.videos.includes(video.id)
|
||||
);
|
||||
|
||||
if (videoCollections.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For each collection this video is in, check if it's the first video
|
||||
return videoCollections.some(collection => {
|
||||
// Get the first video ID in this collection
|
||||
const firstVideoId = collection.videos[0];
|
||||
// Show this video if it's the first in at least one collection
|
||||
return video.id === firstVideoId;
|
||||
});
|
||||
});
|
||||
|
||||
// If in search mode, show search results
|
||||
if (isSearchMode) {
|
||||
const hasLocalResults = localSearchResults && localSearchResults.length > 0;
|
||||
const hasYouTubeResults = searchResults && searchResults.length > 0;
|
||||
|
||||
return (
|
||||
<div className="search-results">
|
||||
<div className="search-header">
|
||||
<h2>Search Results for "{searchTerm}"</h2>
|
||||
{onResetSearch && (
|
||||
<button className="back-to-home-btn" onClick={onResetSearch}>
|
||||
← Back to Home
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Local Video Results */}
|
||||
<div className="search-results-section">
|
||||
<h3 className="section-title">From Your Library</h3>
|
||||
{hasLocalResults ? (
|
||||
<div className="search-results-grid">
|
||||
{localSearchResults.map((video) => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
collections={collections}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="no-results">No matching videos in your library.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* YouTube Search Results */}
|
||||
<div className="search-results-section">
|
||||
<h3 className="section-title">From YouTube</h3>
|
||||
|
||||
{youtubeLoading ? (
|
||||
<div className="youtube-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading YouTube results...</p>
|
||||
</div>
|
||||
) : hasYouTubeResults ? (
|
||||
<div className="search-results-grid">
|
||||
{searchResults.map((result) => (
|
||||
<div key={result.id} className="search-result-card">
|
||||
<div className="search-result-thumbnail">
|
||||
{result.thumbnailUrl ? (
|
||||
<img
|
||||
src={result.thumbnailUrl}
|
||||
alt={result.title}
|
||||
onError={(e) => {
|
||||
e.target.onerror = null;
|
||||
e.target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="thumbnail-placeholder">No Thumbnail</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="search-result-info">
|
||||
<h3 className="search-result-title">{result.title}</h3>
|
||||
<p className="search-result-author">{result.author}</p>
|
||||
<div className="search-result-meta">
|
||||
{result.duration && (
|
||||
<span className="search-result-duration">
|
||||
{formatDuration(result.duration)}
|
||||
</span>
|
||||
)}
|
||||
{result.viewCount && (
|
||||
<span className="search-result-views">
|
||||
{formatViewCount(result.viewCount)} views
|
||||
</span>
|
||||
)}
|
||||
<span className={`source-badge ${result.source}`}>
|
||||
{result.source}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="download-btn"
|
||||
onClick={() => onDownload(result.sourceUrl, result.title)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="no-results">No YouTube results found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular home view (not in search mode)
|
||||
return (
|
||||
<div className="home-container">
|
||||
{videoArray.length === 0 ? (
|
||||
<div className="no-videos">
|
||||
<p>No videos yet. Submit a YouTube URL to download your first video!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="home-content">
|
||||
{/* Sidebar container for Collections and Authors */}
|
||||
<div className="sidebar-container">
|
||||
{/* Collections list */}
|
||||
<Collections collections={collections} />
|
||||
|
||||
{/* Authors list */}
|
||||
<AuthorsList videos={videoArray} />
|
||||
|
||||
<div className="manage-videos-link-container" style={{ marginTop: '1rem', paddingTop: '0.5rem' }}>
|
||||
<Link
|
||||
to="/manage"
|
||||
className="author-link manage-link"
|
||||
style={{ fontWeight: 'bold', color: 'var(--primary-color)', display: 'block', textAlign: 'center' }}
|
||||
>
|
||||
Manage Videos
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Videos grid */}
|
||||
<div className="videos-grid">
|
||||
{filteredVideos.map(video => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
collections={collections}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to format duration in seconds to MM:SS
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return '';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Helper function to format view count
|
||||
const formatViewCount = (count) => {
|
||||
if (!count) return '0';
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return `${(count / 1000000).toFixed(1)}M`;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
238
frontend/src/pages/Home.tsx
Normal file
238
frontend/src/pages/Home.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import AuthorsList from '../components/AuthorsList';
|
||||
import Collections from '../components/Collections';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
thumbnailUrl: string;
|
||||
duration?: number;
|
||||
viewCount?: number;
|
||||
source: 'youtube' | 'bilibili';
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
interface HomeProps {
|
||||
videos: Video[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onDeleteVideo: (id: string) => Promise<any>;
|
||||
collections: Collection[];
|
||||
isSearchMode: boolean;
|
||||
searchTerm: string;
|
||||
localSearchResults: Video[];
|
||||
youtubeLoading: boolean;
|
||||
searchResults: SearchResult[];
|
||||
onDownload: (url: string, title?: string) => void;
|
||||
onResetSearch: () => void;
|
||||
}
|
||||
|
||||
const Home: React.FC<HomeProps> = ({
|
||||
videos = [],
|
||||
loading,
|
||||
error,
|
||||
onDeleteVideo,
|
||||
collections = [],
|
||||
isSearchMode = false,
|
||||
searchTerm = '',
|
||||
localSearchResults = [],
|
||||
youtubeLoading = false,
|
||||
searchResults = [],
|
||||
onDownload,
|
||||
onResetSearch
|
||||
}) => {
|
||||
// Add default empty array to ensure videos is always an array
|
||||
const videoArray = Array.isArray(videos) ? videos : [];
|
||||
|
||||
if (loading && videoArray.length === 0 && !isSearchMode) {
|
||||
return <div className="loading">Loading videos...</div>;
|
||||
}
|
||||
|
||||
if (error && videoArray.length === 0 && !isSearchMode) {
|
||||
return <div className="error">{error}</div>;
|
||||
}
|
||||
|
||||
// Filter videos to only show the first video from each collection
|
||||
const filteredVideos = videoArray.filter(video => {
|
||||
// If the video is not in any collection, show it
|
||||
const videoCollections = collections.filter(collection =>
|
||||
collection.videos.includes(video.id)
|
||||
);
|
||||
|
||||
if (videoCollections.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For each collection this video is in, check if it's the first video
|
||||
return videoCollections.some(collection => {
|
||||
// Get the first video ID in this collection
|
||||
const firstVideoId = collection.videos[0];
|
||||
// Show this video if it's the first in at least one collection
|
||||
return video.id === firstVideoId;
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to format duration in seconds to MM:SS
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Helper function to format view count
|
||||
const formatViewCount = (count?: number) => {
|
||||
if (!count) return '0';
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return `${(count / 1000000).toFixed(1)}M`;
|
||||
};
|
||||
|
||||
// If in search mode, show search results
|
||||
if (isSearchMode) {
|
||||
const hasLocalResults = localSearchResults && localSearchResults.length > 0;
|
||||
const hasYouTubeResults = searchResults && searchResults.length > 0;
|
||||
|
||||
return (
|
||||
<div className="search-results">
|
||||
<div className="search-header">
|
||||
<h2>Search Results for "{searchTerm}"</h2>
|
||||
{onResetSearch && (
|
||||
<button className="back-to-home-btn" onClick={onResetSearch}>
|
||||
← Back to Home
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Local Video Results */}
|
||||
<div className="search-results-section">
|
||||
<h3 className="section-title">From Your Library</h3>
|
||||
{hasLocalResults ? (
|
||||
<div className="search-results-grid">
|
||||
{localSearchResults.map((video) => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="no-results">No matching videos in your library.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* YouTube Search Results */}
|
||||
<div className="search-results-section">
|
||||
<h3 className="section-title">From YouTube</h3>
|
||||
|
||||
{youtubeLoading ? (
|
||||
<div className="youtube-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading YouTube results...</p>
|
||||
</div>
|
||||
) : hasYouTubeResults ? (
|
||||
<div className="search-results-grid">
|
||||
{searchResults.map((result) => (
|
||||
<div key={result.id} className="search-result-card">
|
||||
<div className="search-result-thumbnail">
|
||||
{result.thumbnailUrl ? (
|
||||
<img
|
||||
src={result.thumbnailUrl}
|
||||
alt={result.title}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="thumbnail-placeholder">No Thumbnail</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="search-result-info">
|
||||
<h3 className="search-result-title">{result.title}</h3>
|
||||
<p className="search-result-author">{result.author}</p>
|
||||
<div className="search-result-meta">
|
||||
{result.duration && (
|
||||
<span className="search-result-duration">
|
||||
{formatDuration(result.duration)}
|
||||
</span>
|
||||
)}
|
||||
{result.viewCount && (
|
||||
<span className="search-result-views">
|
||||
{formatViewCount(result.viewCount)} views
|
||||
</span>
|
||||
)}
|
||||
<span className={`source-badge ${result.source}`}>
|
||||
{result.source}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="download-btn"
|
||||
onClick={() => onDownload(result.sourceUrl, result.title)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="no-results">No YouTube results found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular home view (not in search mode)
|
||||
return (
|
||||
<div className="home-container">
|
||||
{videoArray.length === 0 ? (
|
||||
<div className="no-videos">
|
||||
<p>No videos yet. Submit a YouTube URL to download your first video!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="home-content">
|
||||
{/* Sidebar container for Collections and Authors */}
|
||||
<div className="sidebar-container">
|
||||
{/* Collections list */}
|
||||
<Collections collections={collections} />
|
||||
|
||||
{/* Authors list */}
|
||||
<AuthorsList videos={videoArray} />
|
||||
|
||||
<div className="manage-videos-link-container" style={{ marginTop: '1rem', paddingTop: '0.5rem' }}>
|
||||
<Link
|
||||
to="/manage"
|
||||
className="author-link manage-link"
|
||||
style={{ fontWeight: 'bold', color: 'var(--primary-color)', display: 'block', textAlign: 'center' }}
|
||||
>
|
||||
Manage Videos
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Videos grid */}
|
||||
<div className="videos-grid">
|
||||
{filteredVideos.map(video => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
collections={collections}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@@ -1,20 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
|
||||
const ManagePage = ({ videos, onDeleteVideo, collections = [], onDeleteCollection }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
const [collectionToDelete, setCollectionToDelete] = useState(null);
|
||||
const [isDeletingCollection, setIsDeletingCollection] = useState(false);
|
||||
interface ManagePageProps {
|
||||
videos: Video[];
|
||||
onDeleteVideo: (id: string) => Promise<any>;
|
||||
collections: Collection[];
|
||||
onDeleteCollection: (id: string, deleteVideos: boolean) => Promise<any>;
|
||||
}
|
||||
|
||||
const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collections = [], onDeleteCollection }) => {
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [collectionToDelete, setCollectionToDelete] = useState<Collection | null>(null);
|
||||
const [isDeletingCollection, setIsDeletingCollection] = useState<boolean>(false);
|
||||
|
||||
const filteredVideos = videos.filter(video =>
|
||||
video.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
video.author.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this video?')) {
|
||||
setDeletingId(id);
|
||||
await onDeleteVideo(id);
|
||||
@@ -22,11 +30,11 @@ const ManagePage = ({ videos, onDeleteVideo, collections = [], onDeleteCollectio
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteCollection = (collection) => {
|
||||
const confirmDeleteCollection = (collection: Collection) => {
|
||||
setCollectionToDelete(collection);
|
||||
};
|
||||
|
||||
const handleCollectionDelete = async (deleteVideos) => {
|
||||
const handleCollectionDelete = async (deleteVideos: boolean) => {
|
||||
if (!collectionToDelete) return;
|
||||
|
||||
setIsDeletingCollection(true);
|
||||
@@ -35,7 +43,7 @@ const ManagePage = ({ videos, onDeleteVideo, collections = [], onDeleteCollectio
|
||||
setCollectionToDelete(null);
|
||||
};
|
||||
|
||||
const getThumbnailSrc = (video) => {
|
||||
const getThumbnailSrc = (video: Video) => {
|
||||
if (video.thumbnailPath) {
|
||||
return `${BACKEND_URL}${video.thumbnailPath}`;
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
|
||||
// Define the API base URL
|
||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
|
||||
const SearchResults = ({
|
||||
results,
|
||||
localResults,
|
||||
searchTerm,
|
||||
loading,
|
||||
youtubeLoading,
|
||||
onDownload,
|
||||
onDeleteVideo,
|
||||
onResetSearch,
|
||||
collections = []
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// If search term is empty, reset search and go back to home
|
||||
useEffect(() => {
|
||||
if (!searchTerm || searchTerm.trim() === '') {
|
||||
if (onResetSearch) {
|
||||
onResetSearch();
|
||||
}
|
||||
}
|
||||
}, [searchTerm, onResetSearch]);
|
||||
|
||||
const handleDownload = async (videoUrl, title) => {
|
||||
try {
|
||||
await onDownload(videoUrl, title);
|
||||
} catch (error) {
|
||||
console.error('Error downloading from search results:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackClick = () => {
|
||||
// Call the onResetSearch function to reset search mode
|
||||
if (onResetSearch) {
|
||||
onResetSearch();
|
||||
} else {
|
||||
// Fallback to navigate if onResetSearch is not provided
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
// If search term is empty, don't render search results
|
||||
if (!searchTerm || searchTerm.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the entire page is loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="search-results">
|
||||
<h2>Searching for "{searchTerm}"...</h2>
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasLocalResults = localResults && localResults.length > 0;
|
||||
const hasYouTubeResults = results && results.length > 0;
|
||||
const noResults = !hasLocalResults && !hasYouTubeResults && !youtubeLoading;
|
||||
|
||||
if (noResults) {
|
||||
return (
|
||||
<div className="search-results">
|
||||
<div className="search-header">
|
||||
<button className="back-button" onClick={handleBackClick}>
|
||||
<span>←</span> Back to Home
|
||||
</button>
|
||||
<h2>Search Results for "{searchTerm}"</h2>
|
||||
</div>
|
||||
<p className="no-results">No results found. Try a different search term.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="search-results">
|
||||
<div className="search-header">
|
||||
<button className="back-button" onClick={handleBackClick}>
|
||||
<span>←</span> Back to Home
|
||||
</button>
|
||||
<h2>Search Results for "{searchTerm}"</h2>
|
||||
</div>
|
||||
|
||||
{/* Local Video Results */}
|
||||
{hasLocalResults ? (
|
||||
<div className="search-results-section">
|
||||
<h3 className="section-title">From Your Library</h3>
|
||||
<div className="search-results-grid">
|
||||
{localResults.map((video) => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
collections={collections}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="search-results-section">
|
||||
<h3 className="section-title">From Your Library</h3>
|
||||
<p className="no-results">No matching videos in your library.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* YouTube Search Results */}
|
||||
<div className="search-results-section">
|
||||
<h3 className="section-title">From YouTube</h3>
|
||||
|
||||
{youtubeLoading ? (
|
||||
<div className="youtube-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading YouTube results...</p>
|
||||
</div>
|
||||
) : hasYouTubeResults ? (
|
||||
<div className="search-results-grid">
|
||||
{results.map((result) => (
|
||||
<div key={result.id} className="search-result-card">
|
||||
<div className="search-result-thumbnail">
|
||||
{result.thumbnailUrl ? (
|
||||
<img
|
||||
src={result.thumbnailUrl}
|
||||
alt={result.title}
|
||||
onError={(e) => {
|
||||
e.target.onerror = null;
|
||||
e.target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="thumbnail-placeholder">No Thumbnail</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="search-result-info">
|
||||
<h3 className="search-result-title">{result.title}</h3>
|
||||
<p className="search-result-author">{result.author}</p>
|
||||
<div className="search-result-meta">
|
||||
{result.duration && (
|
||||
<span className="search-result-duration">
|
||||
{formatDuration(result.duration)}
|
||||
</span>
|
||||
)}
|
||||
{result.viewCount && (
|
||||
<span className="search-result-views">
|
||||
{formatViewCount(result.viewCount)} views
|
||||
</span>
|
||||
)}
|
||||
<span className={`source-badge ${result.source}`}>
|
||||
{result.source}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="download-btn"
|
||||
onClick={() => handleDownload(result.sourceUrl, result.title)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="no-results">No YouTube results found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to format duration in seconds to MM:SS
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return '';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Helper function to format view count
|
||||
const formatViewCount = (count) => {
|
||||
if (!count) return '0';
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return `${(count / 1000000).toFixed(1)}M`;
|
||||
};
|
||||
|
||||
export default SearchResults;
|
||||
214
frontend/src/pages/SearchResults.tsx
Normal file
214
frontend/src/pages/SearchResults.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
thumbnailUrl: string;
|
||||
duration?: number;
|
||||
viewCount?: number;
|
||||
source: 'youtube' | 'bilibili';
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
interface SearchResultsProps {
|
||||
results: SearchResult[];
|
||||
localResults: Video[];
|
||||
searchTerm: string;
|
||||
loading: boolean;
|
||||
youtubeLoading: boolean;
|
||||
onDownload: (url: string, title?: string) => void;
|
||||
onDeleteVideo: (id: string) => Promise<any>;
|
||||
onResetSearch: () => void;
|
||||
collections: Collection[];
|
||||
}
|
||||
|
||||
const SearchResults: React.FC<SearchResultsProps> = ({
|
||||
results,
|
||||
localResults,
|
||||
searchTerm,
|
||||
loading,
|
||||
youtubeLoading,
|
||||
onDownload,
|
||||
onDeleteVideo,
|
||||
onResetSearch,
|
||||
collections = []
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// If search term is empty, reset search and go back to home
|
||||
useEffect(() => {
|
||||
if (!searchTerm || searchTerm.trim() === '') {
|
||||
if (onResetSearch) {
|
||||
onResetSearch();
|
||||
}
|
||||
}
|
||||
}, [searchTerm, onResetSearch]);
|
||||
|
||||
const handleDownload = async (videoUrl: string, title: string) => {
|
||||
try {
|
||||
await onDownload(videoUrl, title);
|
||||
} catch (error) {
|
||||
console.error('Error downloading from search results:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackClick = () => {
|
||||
// Call the onResetSearch function to reset search mode
|
||||
if (onResetSearch) {
|
||||
onResetSearch();
|
||||
} else {
|
||||
// Fallback to navigate if onResetSearch is not provided
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
// If search term is empty, don't render search results
|
||||
if (!searchTerm || searchTerm.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the entire page is loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="search-results">
|
||||
<h2>Searching for "{searchTerm}"...</h2>
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasLocalResults = localResults && localResults.length > 0;
|
||||
const hasYouTubeResults = results && results.length > 0;
|
||||
const noResults = !hasLocalResults && !hasYouTubeResults && !youtubeLoading;
|
||||
|
||||
// Helper function to format duration in seconds to MM:SS
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Helper function to format view count
|
||||
const formatViewCount = (count?: number) => {
|
||||
if (!count) return '0';
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return `${(count / 1000000).toFixed(1)}M`;
|
||||
};
|
||||
|
||||
if (noResults) {
|
||||
return (
|
||||
<div className="search-results">
|
||||
<div className="search-header">
|
||||
<button className="back-button" onClick={handleBackClick}>
|
||||
<span>←</span> Back to Home
|
||||
</button>
|
||||
<h2>Search Results for "{searchTerm}"</h2>
|
||||
</div>
|
||||
<p className="no-results">No results found. Try a different search term.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="search-results">
|
||||
<div className="search-header">
|
||||
<button className="back-button" onClick={handleBackClick}>
|
||||
<span>←</span> Back to Home
|
||||
</button>
|
||||
<h2>Search Results for "{searchTerm}"</h2>
|
||||
</div>
|
||||
|
||||
{/* Local Video Results */}
|
||||
{hasLocalResults ? (
|
||||
<div className="search-results-section">
|
||||
<h3 className="section-title">From Your Library</h3>
|
||||
<div className="search-results-grid">
|
||||
{localResults.map((video) => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="search-results-section">
|
||||
<h3 className="section-title">From Your Library</h3>
|
||||
<p className="no-results">No matching videos in your library.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* YouTube Search Results */}
|
||||
<div className="search-results-section">
|
||||
<h3 className="section-title">From YouTube</h3>
|
||||
|
||||
{youtubeLoading ? (
|
||||
<div className="youtube-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading YouTube results...</p>
|
||||
</div>
|
||||
) : hasYouTubeResults ? (
|
||||
<div className="search-results-grid">
|
||||
{results.map((result) => (
|
||||
<div key={result.id} className="search-result-card">
|
||||
<div className="search-result-thumbnail">
|
||||
{result.thumbnailUrl ? (
|
||||
<img
|
||||
src={result.thumbnailUrl}
|
||||
alt={result.title}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="thumbnail-placeholder">No Thumbnail</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="search-result-info">
|
||||
<h3 className="search-result-title">{result.title}</h3>
|
||||
<p className="search-result-author">{result.author}</p>
|
||||
<div className="search-result-meta">
|
||||
{result.duration && (
|
||||
<span className="search-result-duration">
|
||||
{formatDuration(result.duration)}
|
||||
</span>
|
||||
)}
|
||||
{result.viewCount && (
|
||||
<span className="search-result-views">
|
||||
{formatViewCount(result.viewCount)} views
|
||||
</span>
|
||||
)}
|
||||
<span className={`source-badge ${result.source}`}>
|
||||
{result.source}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="download-btn"
|
||||
onClick={() => handleDownload(result.sourceUrl, result.title)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="no-results">No YouTube results found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResults;
|
||||
@@ -1,423 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
|
||||
const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, onCreateCollection, onRemoveFromCollection }) => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [video, setVideo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState(null);
|
||||
const [showCollectionModal, setShowCollectionModal] = useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState('');
|
||||
const [selectedCollection, setSelectedCollection] = useState('');
|
||||
const [videoCollections, setVideoCollections] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't try to fetch the video if it's being deleted
|
||||
if (isDeleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchVideo = async () => {
|
||||
// First check if the video is in the videos prop
|
||||
const foundVideo = videos.find(v => v.id === id);
|
||||
|
||||
if (foundVideo) {
|
||||
setVideo(foundVideo);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not found in props, try to fetch from API
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/videos/${id}`);
|
||||
setVideo(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching video:', err);
|
||||
setError('Video not found or could not be loaded.');
|
||||
|
||||
// Redirect to home after 3 seconds if video not found
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideo();
|
||||
}, [id, videos, navigate, isDeleting]);
|
||||
|
||||
// Find collections that contain this video
|
||||
useEffect(() => {
|
||||
if (collections && collections.length > 0 && id) {
|
||||
const belongsToCollections = collections.filter(collection =>
|
||||
collection.videos.includes(id)
|
||||
);
|
||||
setVideoCollections(belongsToCollections);
|
||||
} else {
|
||||
setVideoCollections([]);
|
||||
}
|
||||
}, [collections, id]);
|
||||
|
||||
// Format the date (assuming format YYYYMMDD from youtube-dl)
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString || dateString.length !== 8) {
|
||||
return 'Unknown date';
|
||||
}
|
||||
|
||||
const year = dateString.substring(0, 4);
|
||||
const month = dateString.substring(4, 6);
|
||||
const day = dateString.substring(6, 8);
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Handle navigation to author videos page
|
||||
const handleAuthorClick = () => {
|
||||
navigate(`/author/${encodeURIComponent(video.author)}`);
|
||||
};
|
||||
|
||||
const handleCollectionClick = (collectionId) => {
|
||||
navigate(`/collection/${collectionId}`);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Are you sure you want to delete this video?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
setDeleteError(null);
|
||||
|
||||
try {
|
||||
const result = await onDeleteVideo(id);
|
||||
|
||||
if (result.success) {
|
||||
// Navigate to home immediately after successful deletion
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
setDeleteError(result.error || 'Failed to delete video');
|
||||
setIsDeleting(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setDeleteError('An unexpected error occurred while deleting the video.');
|
||||
console.error(err);
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToCollection = () => {
|
||||
setShowCollectionModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowCollectionModal(false);
|
||||
setNewCollectionName('');
|
||||
setSelectedCollection('');
|
||||
};
|
||||
|
||||
const handleCreateCollection = async () => {
|
||||
if (!newCollectionName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onCreateCollection(newCollectionName, id);
|
||||
handleCloseModal();
|
||||
} catch (error) {
|
||||
console.error('Error creating collection:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToExistingCollection = async () => {
|
||||
if (!selectedCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onAddToCollection(selectedCollection, id);
|
||||
handleCloseModal();
|
||||
} catch (error) {
|
||||
console.error('Error adding to collection:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFromCollection = async () => {
|
||||
if (!window.confirm('Are you sure you want to remove this video from the collection?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onRemoveFromCollection(id);
|
||||
handleCloseModal();
|
||||
} catch (error) {
|
||||
console.error('Error removing from collection:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading video...</div>;
|
||||
}
|
||||
|
||||
if (error || !video) {
|
||||
return <div className="error">{error || 'Video not found'}</div>;
|
||||
}
|
||||
|
||||
// Get related videos (exclude current video)
|
||||
const relatedVideos = videos.filter(v => v.id !== id).slice(0, 10);
|
||||
|
||||
return (
|
||||
<div className="video-player-page">
|
||||
{/* Main Content Column */}
|
||||
<div className="video-main-content">
|
||||
<div className="video-wrapper">
|
||||
<video
|
||||
className="video-player"
|
||||
controls
|
||||
autoPlay
|
||||
src={`${BACKEND_URL}${video.videoPath || video.url}`}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<div className="video-info-section">
|
||||
<h1 className="video-title-h1">{video.title}</h1>
|
||||
|
||||
<div className="video-actions-row">
|
||||
<div className="video-primary-actions">
|
||||
<div className="channel-row" style={{ marginBottom: 0 }}>
|
||||
<div className="channel-avatar">
|
||||
{video.author ? video.author.charAt(0).toUpperCase() : 'A'}
|
||||
</div>
|
||||
<div className="channel-info">
|
||||
<div
|
||||
className="channel-name clickable"
|
||||
onClick={handleAuthorClick}
|
||||
>
|
||||
{video.author}
|
||||
</div>
|
||||
<div className="video-stats">
|
||||
{/* Placeholder for subscribers if we had that data */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="video-primary-actions">
|
||||
<button
|
||||
className="action-btn btn-secondary"
|
||||
onClick={handleAddToCollection}
|
||||
>
|
||||
<span>+ Add to Collection</span>
|
||||
</button>
|
||||
<button
|
||||
className="action-btn btn-danger"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="channel-desc-container">
|
||||
<div className="video-stats" style={{ marginBottom: '8px', color: '#fff', fontWeight: 'bold' }}>
|
||||
{/* Views would go here */}
|
||||
{formatDate(video.date)}
|
||||
</div>
|
||||
|
||||
<div className="description-text">
|
||||
{/* We don't have a real description, so we'll show some metadata */}
|
||||
<p>Source: {video.source === 'bilibili' ? 'Bilibili' : 'YouTube'}</p>
|
||||
{video.sourceUrl && (
|
||||
<p>
|
||||
Original Link: <a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: '#3ea6ff' }}>{video.sourceUrl}</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{videoCollections.length > 0 && (
|
||||
<div className="collection-tags">
|
||||
{videoCollections.map(c => (
|
||||
<span
|
||||
key={c.id}
|
||||
className="collection-pill"
|
||||
onClick={() => handleCollectionClick(c.id)}
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Column - Up Next */}
|
||||
<div className="video-sidebar">
|
||||
<h3 className="sidebar-title">Up Next</h3>
|
||||
<div className="related-videos-list">
|
||||
{relatedVideos.map(relatedVideo => (
|
||||
<div
|
||||
key={relatedVideo.id}
|
||||
className="related-video-card"
|
||||
onClick={() => navigate(`/video/${relatedVideo.id}`)}
|
||||
>
|
||||
<div className="related-video-thumbnail">
|
||||
<img
|
||||
src={`${BACKEND_URL}${relatedVideo.thumbnailPath}`}
|
||||
alt={relatedVideo.title}
|
||||
onError={(e) => {
|
||||
e.target.onerror = null;
|
||||
e.target.src = 'https://via.placeholder.com/168x94?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
<span className="duration-badge">{relatedVideo.duration || '00:00'}</span>
|
||||
</div>
|
||||
<div className="related-video-info">
|
||||
<div className="related-video-title">{relatedVideo.title}</div>
|
||||
<div className="related-video-author">{relatedVideo.author}</div>
|
||||
<div className="related-video-meta">
|
||||
{formatDate(relatedVideo.date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{relatedVideos.length === 0 && (
|
||||
<div className="no-videos">No other videos available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collection Modal */}
|
||||
{showCollectionModal && (
|
||||
<div className="modal-overlay" onClick={handleCloseModal}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Add to Collection</h2>
|
||||
<button className="close-btn" onClick={handleCloseModal}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{videoCollections.length > 0 && (
|
||||
<div className="current-collection" style={{
|
||||
marginBottom: '1.5rem',
|
||||
padding: '1rem',
|
||||
background: 'linear-gradient(135deg, rgba(62, 166, 255, 0.1) 0%, rgba(62, 166, 255, 0.05) 100%)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(62, 166, 255, 0.3)'
|
||||
}}>
|
||||
<p style={{ margin: '0 0 0.5rem 0', color: 'var(--text-color)', fontWeight: '500' }}>
|
||||
📁 Currently in: <strong>{videoCollections[0].name}</strong>
|
||||
</p>
|
||||
<p style={{ margin: '0 0 1rem 0', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||
Adding to a different collection will remove it from the current one.
|
||||
</p>
|
||||
<button
|
||||
className="modal-btn danger-btn"
|
||||
style={{ width: '100%' }}
|
||||
onClick={handleRemoveFromCollection}
|
||||
>
|
||||
Remove from Collection
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collections && collections.length > 0 && (
|
||||
<div className="existing-collections" style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ margin: '0 0 10px 0', fontSize: '1rem', fontWeight: '600', color: 'var(--text-color)' }}>
|
||||
Add to existing collection:
|
||||
</h3>
|
||||
<select
|
||||
value={selectedCollection}
|
||||
onChange={(e) => setSelectedCollection(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--text-color)',
|
||||
fontSize: '1rem',
|
||||
marginBottom: '0.5rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<option value="">Select a collection</option>
|
||||
{collections.map(collection => (
|
||||
<option
|
||||
key={collection.id}
|
||||
value={collection.id}
|
||||
disabled={videoCollections.length > 0 && videoCollections[0].id === collection.id}
|
||||
>
|
||||
{collection.name} {videoCollections.length > 0 && videoCollections[0].id === collection.id ? '(Current)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="modal-btn primary-btn"
|
||||
style={{ width: '100%' }}
|
||||
onClick={handleAddToExistingCollection}
|
||||
disabled={!selectedCollection}
|
||||
>
|
||||
Add to Collection
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="new-collection">
|
||||
<h3 style={{ margin: '0 0 10px 0', fontSize: '1rem', fontWeight: '600', color: 'var(--text-color)' }}>
|
||||
Create new collection:
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="collection-input"
|
||||
placeholder="Collection name"
|
||||
value={newCollectionName}
|
||||
onChange={(e) => setNewCollectionName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && newCollectionName.trim() && handleCreateCollection()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--text-color)',
|
||||
fontSize: '1rem',
|
||||
marginBottom: '0.5rem',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="modal-btn primary-btn"
|
||||
style={{ width: '100%' }}
|
||||
onClick={handleCreateCollection}
|
||||
disabled={!newCollectionName.trim()}
|
||||
>
|
||||
Create Collection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="btn secondary-btn" onClick={handleCloseModal}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
494
frontend/src/pages/VideoPlayer.tsx
Normal file
494
frontend/src/pages/VideoPlayer.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videos: Video[];
|
||||
onDeleteVideo: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
collections: Collection[];
|
||||
onAddToCollection: (collectionId: string, videoId: string) => Promise<void>;
|
||||
onCreateCollection: (name: string, videoId: string) => Promise<void>;
|
||||
onRemoveFromCollection: (videoId: string) => Promise<any>;
|
||||
}
|
||||
|
||||
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
videos,
|
||||
onDeleteVideo,
|
||||
collections,
|
||||
onAddToCollection,
|
||||
onCreateCollection,
|
||||
onRemoveFromCollection
|
||||
}) => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [video, setVideo] = useState<Video | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [showCollectionModal, setShowCollectionModal] = useState<boolean>(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState<string>('');
|
||||
const [selectedCollection, setSelectedCollection] = useState<string>('');
|
||||
const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't try to fetch the video if it's being deleted
|
||||
if (isDeleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchVideo = async () => {
|
||||
if (!id) return;
|
||||
|
||||
// First check if the video is in the videos prop
|
||||
const foundVideo = videos.find(v => v.id === id);
|
||||
|
||||
if (foundVideo) {
|
||||
setVideo(foundVideo);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not found in props, try to fetch from API
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/videos/${id}`);
|
||||
setVideo(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching video:', err);
|
||||
setError('Video not found or could not be loaded.');
|
||||
|
||||
// Redirect to home after 3 seconds if video not found
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideo();
|
||||
}, [id, videos, navigate, isDeleting]);
|
||||
|
||||
// Find collections that contain this video
|
||||
useEffect(() => {
|
||||
if (collections && collections.length > 0 && id) {
|
||||
const belongsToCollections = collections.filter(collection =>
|
||||
collection.videos.includes(id)
|
||||
);
|
||||
setVideoCollections(belongsToCollections);
|
||||
} else {
|
||||
setVideoCollections([]);
|
||||
}
|
||||
}, [collections, id]);
|
||||
|
||||
// Format the date (assuming format YYYYMMDD from youtube-dl)
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString || dateString.length !== 8) {
|
||||
return 'Unknown date';
|
||||
}
|
||||
|
||||
const year = dateString.substring(0, 4);
|
||||
const month = dateString.substring(4, 6);
|
||||
const day = dateString.substring(6, 8);
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Handle navigation to author videos page
|
||||
const handleAuthorClick = () => {
|
||||
if (video) {
|
||||
navigate(`/author/${encodeURIComponent(video.author)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollectionClick = (collectionId: string) => {
|
||||
navigate(`/collection/${collectionId}`);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
|
||||
if (!window.confirm('Are you sure you want to delete this video?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
setDeleteError(null);
|
||||
|
||||
try {
|
||||
const result = await onDeleteVideo(id);
|
||||
|
||||
if (result.success) {
|
||||
// Navigate to home immediately after successful deletion
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
setDeleteError(result.error || 'Failed to delete video');
|
||||
setIsDeleting(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setDeleteError('An unexpected error occurred while deleting the video.');
|
||||
console.error(err);
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToCollection = () => {
|
||||
setShowCollectionModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowCollectionModal(false);
|
||||
setNewCollectionName('');
|
||||
setSelectedCollection('');
|
||||
};
|
||||
|
||||
const handleCreateCollection = async () => {
|
||||
if (!newCollectionName.trim() || !id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onCreateCollection(newCollectionName, id);
|
||||
handleCloseModal();
|
||||
} catch (error) {
|
||||
console.error('Error creating collection:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToExistingCollection = async () => {
|
||||
if (!selectedCollection || !id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onAddToCollection(selectedCollection, id);
|
||||
handleCloseModal();
|
||||
} catch (error) {
|
||||
console.error('Error adding to collection:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFromCollection = async () => {
|
||||
if (!id) return;
|
||||
|
||||
if (!window.confirm('Are you sure you want to remove this video from the collection?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onRemoveFromCollection(id);
|
||||
handleCloseModal();
|
||||
} catch (error) {
|
||||
console.error('Error removing from collection:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading video...</div>;
|
||||
}
|
||||
|
||||
if (error || !video) {
|
||||
return <div className="error">{error || 'Video not found'}</div>;
|
||||
}
|
||||
|
||||
// Get related videos (exclude current video)
|
||||
const relatedVideos = videos.filter(v => v.id !== id).slice(0, 10);
|
||||
|
||||
return (
|
||||
<div className="video-player-page">
|
||||
{/* Main Content Column */}
|
||||
<div className="video-main-content">
|
||||
<div className="video-wrapper">
|
||||
<video
|
||||
className="video-player"
|
||||
controls
|
||||
autoPlay
|
||||
src={`${BACKEND_URL}${video.videoPath || video.sourceUrl}`}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<div className="video-info-section">
|
||||
<h1 className="video-title-h1">{video.title}</h1>
|
||||
|
||||
<div className="video-actions-row">
|
||||
<div className="video-primary-actions">
|
||||
<div className="channel-row" style={{ marginBottom: 0 }}>
|
||||
<div className="channel-avatar">
|
||||
{video.author ? video.author.charAt(0).toUpperCase() : 'A'}
|
||||
</div>
|
||||
<div className="channel-info">
|
||||
<div
|
||||
className="channel-name clickable"
|
||||
onClick={handleAuthorClick}
|
||||
>
|
||||
{video.author}
|
||||
</div>
|
||||
<div className="video-stats">
|
||||
{/* Placeholder for subscribers if we had that data */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="video-primary-actions">
|
||||
<button
|
||||
className="action-btn btn-secondary"
|
||||
onClick={handleAddToCollection}
|
||||
>
|
||||
<span>+ Add to Collection</span>
|
||||
</button>
|
||||
<button
|
||||
className="action-btn btn-danger"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteError && (
|
||||
<div className="error-message" style={{ color: '#ff4d4d', marginTop: '10px' }}>
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="channel-desc-container">
|
||||
<div className="video-stats" style={{ marginBottom: '8px', color: '#fff', fontWeight: 'bold' }}>
|
||||
{/* Views would go here */}
|
||||
{formatDate(video.date)}
|
||||
</div>
|
||||
|
||||
<div className="description-text">
|
||||
{/* We don't have a real description, so we'll show some metadata */}
|
||||
<p>Source: {video.source === 'bilibili' ? 'Bilibili' : 'YouTube'}</p>
|
||||
{video.sourceUrl && (
|
||||
<p>
|
||||
Original Link: <a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: '#3ea6ff' }}>{video.sourceUrl}</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{videoCollections.length > 0 && (
|
||||
<div className="collection-tags">
|
||||
{videoCollections.map(c => (
|
||||
<span
|
||||
key={c.id}
|
||||
className="collection-pill"
|
||||
onClick={() => handleCollectionClick(c.id)}
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Sidebar Column - Up Next */}
|
||||
<div className="video-sidebar">
|
||||
<h3 className="sidebar-title">Up Next</h3>
|
||||
<div className="related-videos-list">
|
||||
{relatedVideos.map(relatedVideo => (
|
||||
<div
|
||||
key={relatedVideo.id}
|
||||
className="related-video-card"
|
||||
onClick={() => navigate(`/video/${relatedVideo.id}`)}
|
||||
>
|
||||
<div className="related-video-thumbnail">
|
||||
<img
|
||||
src={`${BACKEND_URL}${relatedVideo.thumbnailPath}`}
|
||||
alt={relatedVideo.title}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
target.src = 'https://via.placeholder.com/168x94?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
<span className="duration-badge">{relatedVideo.duration || '00:00'}</span>
|
||||
</div>
|
||||
<div className="related-video-info">
|
||||
<div className="related-video-title">{relatedVideo.title}</div>
|
||||
<div className="related-video-author">{relatedVideo.author}</div>
|
||||
<div className="related-video-meta">
|
||||
{formatDate(relatedVideo.date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{relatedVideos.length === 0 && (
|
||||
<div className="no-videos">No other videos available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collection Modal */}
|
||||
{
|
||||
showCollectionModal && (
|
||||
<div className="modal-overlay" onClick={handleCloseModal}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Add to Collection</h2>
|
||||
<button className="close-btn" onClick={handleCloseModal}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{videoCollections.length > 0 && (
|
||||
<div className="current-collection" style={{
|
||||
marginBottom: '1.5rem',
|
||||
padding: '1rem',
|
||||
background: 'linear-gradient(135deg, rgba(62, 166, 255, 0.1) 0%, rgba(62, 166, 255, 0.05) 100%)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(62, 166, 255, 0.3)'
|
||||
}}>
|
||||
<p style={{ margin: '0 0 0.5rem 0', color: 'var(--text-color)', fontWeight: '500' }}>
|
||||
📁 Currently in: <strong>{videoCollections[0].name}</strong>
|
||||
</p>
|
||||
<p style={{ margin: '0 0 1rem 0', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||
Adding to a different collection will remove it from the current one.
|
||||
</p>
|
||||
<button
|
||||
className="modal-btn danger-btn"
|
||||
style={{ width: '100%' }}
|
||||
onClick={handleRemoveFromCollection}
|
||||
>
|
||||
Remove from Collection
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collections && collections.length > 0 && (
|
||||
<div className="existing-collections" style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ margin: '0 0 10px 0', fontSize: '1rem', fontWeight: '600', color: 'var(--text-color)' }}>
|
||||
Add to existing collection:
|
||||
</h3>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<select
|
||||
value={selectedCollection}
|
||||
onChange={(e) => setSelectedCollection(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
paddingRight: '40px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--text-color)',
|
||||
fontSize: '1rem',
|
||||
marginBottom: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
appearance: 'none',
|
||||
backgroundImage: `url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'right 12px center',
|
||||
backgroundSize: '16px',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseOver={(e) => e.currentTarget.style.backgroundColor = 'rgba(255, 255, 255, 0.1)'}
|
||||
onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'rgba(255, 255, 255, 0.05)'}
|
||||
>
|
||||
<option value="" style={{ color: 'black' }}>Select a collection</option>
|
||||
{collections.map(collection => (
|
||||
<option
|
||||
key={collection.id}
|
||||
value={collection.id}
|
||||
disabled={videoCollections.length > 0 && videoCollections[0].id === collection.id}
|
||||
style={{ color: 'black' }}
|
||||
>
|
||||
{collection.name} {videoCollections.length > 0 && videoCollections[0].id === collection.id ? '(Current)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className="modal-btn primary-btn"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: 'linear-gradient(135deg, #00C6FF 0%, #0072FF 100%)',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
cursor: selectedCollection ? 'pointer' : 'not-allowed',
|
||||
opacity: selectedCollection ? 1 : 0.6,
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onClick={handleAddToExistingCollection}
|
||||
disabled={!selectedCollection}
|
||||
>
|
||||
Add to Collection
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="new-collection">
|
||||
<h3 style={{ margin: '0 0 10px 0', fontSize: '1rem', fontWeight: '600', color: 'var(--text-color)' }}>
|
||||
Create new collection:
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="collection-input"
|
||||
placeholder="Collection name"
|
||||
value={newCollectionName}
|
||||
onChange={(e) => setNewCollectionName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && newCollectionName.trim() && handleCreateCollection()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--text-color)',
|
||||
fontSize: '1rem',
|
||||
marginBottom: '0.8rem',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onFocus={(e) => e.currentTarget.style.borderColor = 'rgba(62, 166, 255, 0.5)'}
|
||||
onBlur={(e) => e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.1)'}
|
||||
/>
|
||||
<button
|
||||
className="modal-btn primary-btn"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: 'linear-gradient(135deg, #00C6FF 0%, #0072FF 100%)',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
cursor: newCollectionName.trim() ? 'pointer' : 'not-allowed',
|
||||
opacity: newCollectionName.trim() ? 1 : 0.6,
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onClick={handleCreateCollection}
|
||||
disabled={!newCollectionName.trim()}
|
||||
>
|
||||
Create Collection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="btn secondary-btn" onClick={handleCloseModal}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
33
frontend/src/types.ts
Normal file
33
frontend/src/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface Video {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
source: 'youtube' | 'bilibili';
|
||||
sourceUrl: string;
|
||||
videoFilename?: string;
|
||||
thumbnailFilename?: string;
|
||||
thumbnailUrl?: string;
|
||||
videoPath?: string;
|
||||
thumbnailPath?: string | null;
|
||||
addedAt: string;
|
||||
partNumber?: number;
|
||||
totalParts?: number;
|
||||
seriesTitle?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: string;
|
||||
name: string;
|
||||
videos: string[];
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface DownloadInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
33
frontend/tsconfig.json
Normal file
33
frontend/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
frontend/tsconfig.node.json
Normal file
12
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.js"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user