refactor with TypeScript

This commit is contained in:
Peifan Li
2025-11-22 11:16:15 -05:00
parent 129a92729e
commit 11bd2f37af
51 changed files with 3561 additions and 3170 deletions

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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,
};

View 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");

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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();

View File

@@ -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();

View File

@@ -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,
};

View File

@@ -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,
};

View 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("---");
});

View File

@@ -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,
};

View File

@@ -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
View 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"
]
}

View File

@@ -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>

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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;

View File

@@ -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
View 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;

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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
View 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>,
);
}

View File

@@ -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}>
&larr; 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;

View 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}>
&larr; 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;

View File

@@ -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}>
&larr; 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;

View 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}>
&larr; 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;

View File

@@ -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
View 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;

View File

@@ -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}`;
}

View File

@@ -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;

View 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;

View File

@@ -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;

View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

33
frontend/tsconfig.json Normal file
View 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"
}
]
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.js"
]
}