This commit is contained in:
2025-12-19 13:48:48 +01:00
parent 9bde07b134
commit f601d27a68
72 changed files with 9885 additions and 290 deletions

View File

@ -24,11 +24,17 @@ const config: CapacitorConfig = {
androidScaleType: 'CENTER_CROP', androidScaleType: 'CENTER_CROP',
showSpinner: false, showSpinner: false,
}, },
CapacitorNodeJS: {
nodeDir: 'nodejs-v3',
startMode: 'manual',
},
}, },
android: { android: {
allowMixedContent: true, allowMixedContent: true,
captureInput: true, captureInput: true,
webContentsDebuggingEnabled: true, webContentsDebuggingEnabled: true,
// Allow cleartext traffic for localhost server
cleartext: true,
}, },
ios: { ios: {
contentInset: 'automatic', contentInset: 'automatic',

585
package-lock.json generated
View File

@ -1,14 +1,15 @@
{ {
"name": "bestream", "name": "bestream",
"version": "1.0.2", "version": "2.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bestream", "name": "bestream",
"version": "1.0.2", "version": "2.4.0",
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
"capacitor-nodejs": "github:hampoelz/Capacitor-NodeJS",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.8", "dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7", "dexie-react-hooks": "^1.1.7",
@ -22,13 +23,16 @@
"zustand": "^5.0.1" "zustand": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/android": "^6.2.0", "@capacitor/android": "^7.4.4",
"@capacitor/cli": "^6.2.0", "@capacitor/cli": "^7.4.4",
"@capacitor/core": "^6.2.0", "@capacitor/core": "^7.4.4",
"@capacitor/filesystem": "^6.0.2", "@capacitor/filesystem": "^7.1.6",
"@capacitor/ios": "^6.2.0", "@capacitor/ios": "^7.4.4",
"@capacitor/network": "^6.0.2", "@capacitor/network": "^7.0.3",
"@capacitor/status-bar": "^6.0.2", "@capacitor/status-bar": "^7.0.4",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/cli": "^2.9.6",
"@tauri-apps/plugin-shell": "^2.3.3",
"@types/node": "^22.9.0", "@types/node": "^22.9.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
@ -363,98 +367,122 @@
} }
}, },
"node_modules/@capacitor/android": { "node_modules/@capacitor/android": {
"version": "6.2.1", "version": "7.4.4",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-6.2.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-7.4.4.tgz",
"integrity": "sha512-8gd4CIiQO5LAIlPIfd5mCuodBRxMMdZZEdj8qG8m+dQ1sQ2xyemVpzHmRK8qSCHorsBUCg3D62j2cp6bEBAkdw==", "integrity": "sha512-y8knfV1JXNrd6XZZLZireGT+EBCN0lvOo+HZ/s7L8LkrPBu4nY5UZn0Wxz4yOezItEII9rqYJSHsS5fMJG9gdw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@capacitor/core": "^6.2.0" "@capacitor/core": "^7.4.0"
} }
}, },
"node_modules/@capacitor/cli": { "node_modules/@capacitor/cli": {
"version": "6.2.1", "version": "7.4.4",
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-6.2.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-7.4.4.tgz",
"integrity": "sha512-JKl0FpFge8PgQNInw12kcKieQ4BmOyazQ4JGJOfEpVXlgrX1yPhSZTPjngupzTCiK3I7q7iGG5kjun0fDqgSCA==", "integrity": "sha512-J7ciBE7GlJ70sr2s8oz1+H4ZdNk4MGG41fsakUlDHWva5UWgFIZYMiEdDvGbYazAYTaxN3lVZpH9zil9FfZj+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ionic/cli-framework-output": "^2.2.5", "@ionic/cli-framework-output": "^2.2.8",
"@ionic/utils-fs": "^3.1.6", "@ionic/utils-subprocess": "^3.0.1",
"@ionic/utils-subprocess": "2.1.11", "@ionic/utils-terminal": "^2.3.5",
"@ionic/utils-terminal": "^2.3.3", "commander": "^12.1.0",
"commander": "^9.3.0", "debug": "^4.4.0",
"debug": "^4.3.4",
"env-paths": "^2.2.0", "env-paths": "^2.2.0",
"kleur": "^4.1.4", "fs-extra": "^11.2.0",
"native-run": "^2.0.0", "kleur": "^4.1.5",
"native-run": "^2.0.1",
"open": "^8.4.0", "open": "^8.4.0",
"plist": "^3.0.5", "plist": "^3.1.0",
"prompts": "^2.4.2", "prompts": "^2.4.2",
"rimraf": "^4.4.1", "rimraf": "^6.0.1",
"semver": "^7.3.7", "semver": "^7.6.3",
"tar": "^6.1.11", "tar": "^6.1.11",
"tslib": "^2.4.0", "tslib": "^2.8.1",
"xml2js": "^0.5.0" "xml2js": "^0.6.2"
}, },
"bin": { "bin": {
"cap": "bin/capacitor", "cap": "bin/capacitor",
"capacitor": "bin/capacitor" "capacitor": "bin/capacitor"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=20.0.0"
}
},
"node_modules/@capacitor/cli/node_modules/fs-extra": {
"version": "11.3.2",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz",
"integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
} }
}, },
"node_modules/@capacitor/core": { "node_modules/@capacitor/core": {
"version": "6.2.1", "version": "7.4.4",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.4.tgz",
"integrity": "sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==", "integrity": "sha512-xzjxpr+d2zwTpCaN0k+C6wKSZzWFAb9OVEUtmO72ihjr/NEDoLvsGl4WLfjWPcCO2zOy0b2X52tfRWjECFUjtw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
}, },
"node_modules/@capacitor/filesystem": { "node_modules/@capacitor/filesystem": {
"version": "6.0.4", "version": "7.1.6",
"resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-6.0.4.tgz", "resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-7.1.6.tgz",
"integrity": "sha512-eFlg/ZrwYA4Y6ClLRRikudVu2XvuZxfX/XC0ky9MgfbC9dyqTnVkkEoWM6vr1xR89YNY4mB0EeVTet1m1Jcumw==", "integrity": "sha512-7NGrmp9v/ejR2C2QKr66na5IJMCBH78TEX2AwqQyq2MCR3yM2PsWvFPAnNOYlBHPgBzzxEC+sjPRBk1bDsXJvg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"@capacitor/synapse": "^1.0.3"
},
"peerDependencies": { "peerDependencies": {
"@capacitor/core": "^6.0.0" "@capacitor/core": ">=7.0.0"
} }
}, },
"node_modules/@capacitor/ios": { "node_modules/@capacitor/ios": {
"version": "6.2.1", "version": "7.4.4",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-6.2.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-7.4.4.tgz",
"integrity": "sha512-tbMlQdQjxe1wyaBvYVU1yTojKJjgluZQsJkALuJxv/6F8QTw5b6vd7X785O/O7cMpIAZfUWo/vtAHzFkRV+kXw==", "integrity": "sha512-Xp3bGWlSQAwsZGngRMWTdoD2agdMV12Whnm+/xsYPxfQSj+Tksbr7r/8Mso7VWkpnTKO4iMlx762g3PjW+wi4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@capacitor/core": "^6.2.0" "@capacitor/core": "^7.4.0"
} }
}, },
"node_modules/@capacitor/network": { "node_modules/@capacitor/network": {
"version": "6.0.4", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/@capacitor/network/-/network-6.0.4.tgz", "resolved": "https://registry.npmjs.org/@capacitor/network/-/network-7.0.3.tgz",
"integrity": "sha512-ywtlM3wJ3evci0T9zl3aGXGvd96pkiQDeoxrGR3rAqcBpf9DQubMGdFRmHma3frn2Fd/MBDYlgleu+nWDXunAg==", "integrity": "sha512-v1dP2GN7Vwwc6W1jJnzTE9jdXNVz/vMscqT3Gvc2jJy6v4Kpw3vHnc1JUfM4g78VkbqdwO/ProR3glTamZ9MDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@capacitor/core": "^6.0.0" "@capacitor/core": ">=7.0.0"
} }
}, },
"node_modules/@capacitor/status-bar": { "node_modules/@capacitor/status-bar": {
"version": "6.0.3", "version": "7.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-6.0.3.tgz", "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.4.tgz",
"integrity": "sha512-nFlgSmtx6Zwaw0tEvZgQsWHBeOfWWB/AvEoCApopLT4mHkBVoSrwkLvy2PjZs5wxCbsmqvQczr3XCyTwaDZVQg==", "integrity": "sha512-2BszlCqIlBZxHLjRyQbumKyuuisutkeJH+5eSKAEJKaDVJcfmAzr2v3MXWsRLrAHJFteLzRXkOlce5msSy28tQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@capacitor/core": "^6.0.0" "@capacitor/core": ">=7.0.0"
} }
}, },
"node_modules/@capacitor/synapse": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
"dev": true,
"license": "ISC"
},
"node_modules/@develar/schema-utils": { "node_modules/@develar/schema-utils": {
"version": "2.6.5", "version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@ -1440,9 +1468,9 @@
} }
}, },
"node_modules/@ionic/utils-array": { "node_modules/@ionic/utils-array": {
"version": "2.1.5", "version": "2.1.6",
"resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz",
"integrity": "sha512-HD72a71IQVBmQckDwmA8RxNVMTbxnaLbgFOl+dO5tbvW9CkkSFCv41h6fUuNsSEVgngfkn0i98HDuZC8mk+lTA==", "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1450,7 +1478,7 @@
"tslib": "^2.0.1" "tslib": "^2.0.1"
}, },
"engines": { "engines": {
"node": ">=10.3.0" "node": ">=16.0.0"
} }
}, },
"node_modules/@ionic/utils-fs": { "node_modules/@ionic/utils-fs": {
@ -1470,9 +1498,9 @@
} }
}, },
"node_modules/@ionic/utils-object": { "node_modules/@ionic/utils-object": {
"version": "2.1.5", "version": "2.1.6",
"resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz",
"integrity": "sha512-XnYNSwfewUqxq+yjER1hxTKggftpNjFLJH0s37jcrNDwbzmbpFTQTVAp4ikNK4rd9DOebX/jbeZb8jfD86IYxw==", "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1480,52 +1508,31 @@
"tslib": "^2.0.1" "tslib": "^2.0.1"
}, },
"engines": { "engines": {
"node": ">=10.3.0" "node": ">=16.0.0"
} }
}, },
"node_modules/@ionic/utils-process": { "node_modules/@ionic/utils-process": {
"version": "2.1.10", "version": "2.1.12",
"resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.10.tgz", "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz",
"integrity": "sha512-mZ7JEowcuGQK+SKsJXi0liYTcXd2bNMR3nE0CyTROpMECUpJeAvvaBaPGZf5ERQUPeWBVuwqAqjUmIdxhz5bxw==", "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ionic/utils-object": "2.1.5", "@ionic/utils-object": "2.1.6",
"@ionic/utils-terminal": "2.3.3", "@ionic/utils-terminal": "2.3.5",
"debug": "^4.0.0", "debug": "^4.0.0",
"signal-exit": "^3.0.3", "signal-exit": "^3.0.3",
"tree-kill": "^1.2.2", "tree-kill": "^1.2.2",
"tslib": "^2.0.1" "tslib": "^2.0.1"
}, },
"engines": { "engines": {
"node": ">=10.3.0" "node": ">=16.0.0"
}
},
"node_modules/@ionic/utils-process/node_modules/@ionic/utils-terminal": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.3.tgz",
"integrity": "sha512-RnuSfNZ5fLEyX3R5mtcMY97cGD1A0NVBbarsSQ6yMMfRJ5YHU7hHVyUfvZeClbqkBC/pAqI/rYJuXKCT9YeMCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/slice-ansi": "^4.0.0",
"debug": "^4.0.0",
"signal-exit": "^3.0.3",
"slice-ansi": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0",
"tslib": "^2.0.1",
"untildify": "^4.0.0",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=10.3.0"
} }
}, },
"node_modules/@ionic/utils-stream": { "node_modules/@ionic/utils-stream": {
"version": "3.1.5", "version": "3.1.7",
"resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.5.tgz", "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz",
"integrity": "sha512-hkm46uHvEC05X/8PHgdJi4l4zv9VQDELZTM+Kz69odtO9zZYfnt8DkfXHJqJ+PxmtiE5mk/ehJWLnn/XAczTUw==", "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1533,64 +1540,27 @@
"tslib": "^2.0.1" "tslib": "^2.0.1"
}, },
"engines": { "engines": {
"node": ">=10.3.0" "node": ">=16.0.0"
} }
}, },
"node_modules/@ionic/utils-subprocess": { "node_modules/@ionic/utils-subprocess": {
"version": "2.1.11", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-2.1.11.tgz", "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz",
"integrity": "sha512-6zCDixNmZCbMCy5np8klSxOZF85kuDyzZSTTQKQP90ZtYNCcPYmuFSzaqDwApJT4r5L3MY3JrqK1gLkc6xiUPw==", "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ionic/utils-array": "2.1.5", "@ionic/utils-array": "2.1.6",
"@ionic/utils-fs": "3.1.6", "@ionic/utils-fs": "3.1.7",
"@ionic/utils-process": "2.1.10", "@ionic/utils-process": "2.1.12",
"@ionic/utils-stream": "3.1.5", "@ionic/utils-stream": "3.1.7",
"@ionic/utils-terminal": "2.3.3", "@ionic/utils-terminal": "2.3.5",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"debug": "^4.0.0", "debug": "^4.0.0",
"tslib": "^2.0.1" "tslib": "^2.0.1"
}, },
"engines": { "engines": {
"node": ">=10.3.0" "node": ">=16.0.0"
}
},
"node_modules/@ionic/utils-subprocess/node_modules/@ionic/utils-fs": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.6.tgz",
"integrity": "sha512-eikrNkK89CfGPmexjTfSWl4EYqsPSBh0Ka7by4F0PLc1hJZYtJxUZV3X4r5ecA8ikjicUmcbU7zJmAjmqutG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/fs-extra": "^8.0.0",
"debug": "^4.0.0",
"fs-extra": "^9.0.0",
"tslib": "^2.0.1"
},
"engines": {
"node": ">=10.3.0"
}
},
"node_modules/@ionic/utils-subprocess/node_modules/@ionic/utils-terminal": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.3.tgz",
"integrity": "sha512-RnuSfNZ5fLEyX3R5mtcMY97cGD1A0NVBbarsSQ6yMMfRJ5YHU7hHVyUfvZeClbqkBC/pAqI/rYJuXKCT9YeMCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/slice-ansi": "^4.0.0",
"debug": "^4.0.0",
"signal-exit": "^3.0.3",
"slice-ansi": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0",
"tslib": "^2.0.1",
"untildify": "^4.0.0",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=10.3.0"
} }
}, },
"node_modules/@ionic/utils-terminal": { "node_modules/@ionic/utils-terminal": {
@ -2281,6 +2251,244 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@tauri-apps/api": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz",
"integrity": "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.9.6",
"@tauri-apps/cli-darwin-x64": "2.9.6",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6",
"@tauri-apps/cli-linux-arm64-gnu": "2.9.6",
"@tauri-apps/cli-linux-arm64-musl": "2.9.6",
"@tauri-apps/cli-linux-riscv64-gnu": "2.9.6",
"@tauri-apps/cli-linux-x64-gnu": "2.9.6",
"@tauri-apps/cli-linux-x64-musl": "2.9.6",
"@tauri-apps/cli-win32-arm64-msvc": "2.9.6",
"@tauri-apps/cli-win32-ia32-msvc": "2.9.6",
"@tauri-apps/cli-win32-x64-msvc": "2.9.6"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.6.tgz",
"integrity": "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.6.tgz",
"integrity": "sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.6.tgz",
"integrity": "sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.6.tgz",
"integrity": "sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.6.tgz",
"integrity": "sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.6.tgz",
"integrity": "sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.6.tgz",
"integrity": "sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.6.tgz",
"integrity": "sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.6.tgz",
"integrity": "sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.6.tgz",
"integrity": "sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.6.tgz",
"integrity": "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.3.tgz",
"integrity": "sha512-Xod+pRcFxmOWFWEnqH5yZcA7qwAMuaaDkMR1Sply+F8VfBj++CGnj2xf5UoialmjZ2Cvd8qrvSCbU+7GgNVsKQ==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tootallnate/once": { "node_modules/@tootallnate/once": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@ -3536,6 +3744,14 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/capacitor-nodejs": {
"version": "1.0.0-beta.9",
"resolved": "git+ssh://git@github.com/hampoelz/Capacitor-NodeJS.git#f32ceab7221bcc74934a463ae96ac967a8b027a1",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -3797,13 +4013,13 @@
} }
}, },
"node_modules/commander": { "node_modules/commander": {
"version": "9.5.0", "version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^12.20.0 || >=14" "node": ">=18"
} }
}, },
"node_modules/compare-version": { "node_modules/compare-version": {
@ -7839,77 +8055,78 @@
} }
}, },
"node_modules/rimraf": { "node_modules/rimraf": {
"version": "4.4.1", "version": "6.1.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.4.1.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz",
"integrity": "sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==", "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==",
"dev": true, "dev": true,
"license": "ISC", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"glob": "^9.2.0" "glob": "^13.0.0",
"package-json-from-dist": "^1.0.1"
}, },
"bin": { "bin": {
"rimraf": "dist/cjs/src/bin.js" "rimraf": "dist/esm/bin.mjs"
}, },
"engines": { "engines": {
"node": ">=14" "node": "20 || >=22"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/rimraf/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/rimraf/node_modules/glob": { "node_modules/rimraf/node_modules/glob": {
"version": "9.3.5", "version": "13.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
"integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
"dev": true, "dev": true,
"license": "ISC", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"fs.realpath": "^1.0.0", "minimatch": "^10.1.1",
"minimatch": "^8.0.2", "minipass": "^7.1.2",
"minipass": "^4.2.4", "path-scurry": "^2.0.0"
"path-scurry": "^1.6.1"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": "20 || >=22"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/rimraf/node_modules/minimatch": { "node_modules/rimraf/node_modules/lru-cache": {
"version": "8.0.4", "version": "11.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
"integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
"dev": true, "dev": true,
"license": "ISC", "license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/rimraf/node_modules/minipass": { "node_modules/rimraf/node_modules/minipass": {
"version": "4.2.8", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=8" "node": ">=16 || 14 >=14.17"
}
},
"node_modules/rimraf/node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/roarr": { "node_modules/roarr": {
@ -9075,9 +9292,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/xml2js": { "node_modules/xml2js": {
"version": "0.5.0", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -21,11 +21,16 @@
"build:mac": "npm run build:electron && vite build && electron-builder --mac", "build:mac": "npm run build:electron && vite build && electron-builder --mac",
"build:linux": "npm run build:electron && vite build && electron-builder --linux", "build:linux": "npm run build:electron && vite build && electron-builder --linux",
"cap:sync": "npx cap sync", "cap:sync": "npx cap sync",
"build:android": "vite build && npx cap sync android", "build:android": "npm run build && npm run server:install && npm run copy-server-to-android && npx cap sync android",
"android:build": "tauri android build",
"build:android:apk": "npm run build:android && cd android && gradlew.bat assembleDebug && cd ..",
"build:android:release": "npm run build:android && cd android && gradlew.bat assembleRelease && cd ..",
"copy-server-to-android": "node scripts/copy-server-to-android.js",
"build:ios": "vite build && npx cap sync ios" "build:ios": "vite build && npx cap sync ios"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
"capacitor-nodejs": "github:hampoelz/Capacitor-NodeJS",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.8", "dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7", "dexie-react-hooks": "^1.1.7",
@ -39,13 +44,16 @@
"zustand": "^5.0.1" "zustand": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/android": "^6.2.0", "@capacitor/android": "^7.4.4",
"@capacitor/cli": "^6.2.0", "@capacitor/cli": "^7.4.4",
"@capacitor/core": "^6.2.0", "@capacitor/core": "^7.4.4",
"@capacitor/filesystem": "^6.0.2", "@capacitor/filesystem": "^7.1.6",
"@capacitor/ios": "^6.2.0", "@capacitor/ios": "^7.4.4",
"@capacitor/network": "^6.0.2", "@capacitor/network": "^7.0.3",
"@capacitor/status-bar": "^6.0.2", "@capacitor/status-bar": "^7.0.4",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/cli": "^2.9.6",
"@tauri-apps/plugin-shell": "^2.3.3",
"@types/node": "^22.9.0", "@types/node": "^22.9.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",

View File

@ -0,0 +1,102 @@
// ... imports
import { copyFileSync, mkdirSync, existsSync, rmSync, writeFileSync, readFileSync, readdirSync, statSync } from 'fs';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootDir = join(__dirname, '..');
const serverDir = join(rootDir, 'server');
const androidAssetsDir = join(rootDir, 'android', 'app', 'src', 'main', 'assets', 'nodejs-v3');
const distNodejsDir = join(rootDir, 'dist', 'nodejs-v3');
function copyServerToAndroid() {
console.log('Starting Bundle & Copy Process...');
try {
// 1. Ensure dist directory exists
if (!existsSync(distNodejsDir)) {
mkdirSync(distNodejsDir, { recursive: true });
}
// 2. Run the bundle script
console.log('Bundling server with esbuild...');
execSync('node bundle.js', { cwd: serverDir, stdio: 'inherit' });
// 3. Prepare Android assets directory
console.log(`Preparing Android assets: ${androidAssetsDir}`);
if (existsSync(androidAssetsDir)) {
// Clean up previous install to ensure no stale files
rmSync(androidAssetsDir, { recursive: true, force: true });
}
mkdirSync(androidAssetsDir, { recursive: true });
// 4. Copy the bundled index.js
const bundledFile = join(distNodejsDir, 'index.js');
if (existsSync(bundledFile)) {
console.log('Copying bundled index.js to Android assets...');
copyFileSync(bundledFile, join(androidAssetsDir, 'index.js'));
} else {
throw new Error('Bundled file not found! Bundling must have failed.');
}
// 5. Copy package.json (still good to have for versioning/metadata)
// We modify it to point to index.js as main if it doesn't already
const serverPackageJson = join(serverDir, 'package.json');
if (existsSync(serverPackageJson)) {
console.log('Copying package.json to Android assets...');
// We don't really need to parse/edit it if the bundle is index.js and the plugin defaults to it,
// but it's cleaner to ensure 'main' is 'index.js'.
const pkg = JSON.parse(readFileSync(serverPackageJson, 'utf8'));
pkg.main = 'index.js';
writeFileSync(join(androidAssetsDir, 'package.json'), JSON.stringify(pkg, null, 2));
// Also copy to dist for reference
writeFileSync(join(distNodejsDir, 'package.json'), JSON.stringify(pkg, null, 2));
}
// 6. Handle builtin_modules (Capacitor requirement)
const pluginBuiltinModules = join(rootDir, 'node_modules', 'capacitor-nodejs', 'android', 'src', 'main', 'assets', 'builtin_modules');
const androidBuiltinModules = join(rootDir, 'android', 'app', 'src', 'main', 'assets', 'builtin_modules');
if (!existsSync(dirname(androidBuiltinModules))) {
mkdirSync(dirname(androidBuiltinModules), { recursive: true });
}
if (existsSync(androidBuiltinModules)) {
rmSync(androidBuiltinModules, { recursive: true, force: true });
}
if (existsSync(pluginBuiltinModules)) {
console.log('Copying builtin_modules...');
// Simple recursive copy for builtin_modules
const copyRecursive = (src, dest) => {
const stats = statSync(src);
if (stats.isDirectory()) {
mkdirSync(dest, { recursive: true });
readdirSync(src).forEach(child => {
copyRecursive(join(src, child), join(dest, child));
});
} else {
copyFileSync(src, dest);
}
};
copyRecursive(pluginBuiltinModules, androidBuiltinModules);
} else {
mkdirSync(androidBuiltinModules, { recursive: true });
writeFileSync(join(androidBuiltinModules, 'README.txt'), 'Placeholder for builtin_modules');
}
console.log('✓ Server bundled and copied successfully to android/assets/nodejs-v3');
console.log(' NOTE: node_modules were explicitely SKIPPED because we are using a bundle.');
} catch (error) {
console.error('✗ Error:', error);
process.exit(1);
}
}
copyServerToAndroid();

51
server/bundle.js Normal file
View File

@ -0,0 +1,51 @@
import * as esbuild from 'esbuild';
import { resolve } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
try {
await esbuild.build({
entryPoints: [resolve(__dirname, 'src/index.js')],
bundle: true,
platform: 'node',
target: 'node16', // Capacitor NodeJS plugin likely uses a somewhat recent Node version
outfile: resolve(__dirname, '../dist/nodejs-v3/index.js'),
external: [
// Mark native modules as external if necessary.
// fluent-ffmpeg spawns a process, so it's fine as long as the binary is there (or it fails gracefully).
// It doesn't need to be bundled, but the package wrapper code should be bundled.
// We explicitly bundle everything by default.
// If we encounter specific issues with native addons, we add them here.
'fsevents', // macOS only
'electron', // Not available in this environment
],
loader: {
'.node': 'file', // Handle .node files if any
},
// Minimization isn't strictly necessary but reduces file size to copy
minify: false,
sourcemap: 'inline',
format: 'cjs', // The plugin might expect CommonJS or ESM. It loads 'index.js'.
// Given the previous 'nodejs-entry.js' effectively did `import(...)`, ESM is supported.
// However, for a single bundle, CJS is often safer/simpler if top-level await isn't used.
// Let's stick to ESM if possible, or CJS if not.
// The original project was type: module. Let's use ESM.
format: 'esm',
banner: {
js: `
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
`,
},
});
console.log('⚡ Bundle build complete: ../dist/nodejs-v3/index.js');
} catch (e) {
console.error('Build failed:', e);
process.exit(1);
}

11
server/nodejs-entry.js Normal file
View File

@ -0,0 +1,11 @@
// Entry point for Node.js server in Capacitor app
// This file is copied to dist/nodejs-project/index.js during build
// Import and start the server
import('./src/index.js').then(() => {
console.log('[Node.js] Server module loaded and started');
}).catch((error) => {
console.error('[Node.js] Error starting server:', error);
// Don't exit - let the error be handled by the app
});

536
server/package-lock.json generated
View File

@ -1,20 +1,466 @@
{ {
"name": "bestream-server", "name": "bestream-server",
"version": "1.0.0", "version": "2.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bestream-server", "name": "bestream-server",
"version": "1.0.0", "version": "2.1.1",
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.1", "express": "^4.21.1",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"mime": "^3.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"webtorrent": "^2.5.1", "webtorrent": "^2.5.1",
"ws": "^8.18.0" "ws": "^8.18.0"
},
"devDependencies": {
"esbuild": "^0.27.2"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
} }
}, },
"node_modules/@silentbot1/nat-api": { "node_modules/@silentbot1/nat-api": {
@ -1185,6 +1631,48 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.2",
"@esbuild/android-arm": "0.27.2",
"@esbuild/android-arm64": "0.27.2",
"@esbuild/android-x64": "0.27.2",
"@esbuild/darwin-arm64": "0.27.2",
"@esbuild/darwin-x64": "0.27.2",
"@esbuild/freebsd-arm64": "0.27.2",
"@esbuild/freebsd-x64": "0.27.2",
"@esbuild/linux-arm": "0.27.2",
"@esbuild/linux-arm64": "0.27.2",
"@esbuild/linux-ia32": "0.27.2",
"@esbuild/linux-loong64": "0.27.2",
"@esbuild/linux-mips64el": "0.27.2",
"@esbuild/linux-ppc64": "0.27.2",
"@esbuild/linux-riscv64": "0.27.2",
"@esbuild/linux-s390x": "0.27.2",
"@esbuild/linux-x64": "0.27.2",
"@esbuild/netbsd-arm64": "0.27.2",
"@esbuild/netbsd-x64": "0.27.2",
"@esbuild/openbsd-arm64": "0.27.2",
"@esbuild/openbsd-x64": "0.27.2",
"@esbuild/openharmony-arm64": "0.27.2",
"@esbuild/sunos-x64": "0.27.2",
"@esbuild/win32-arm64": "0.27.2",
"@esbuild/win32-ia32": "0.27.2",
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -1970,15 +2458,15 @@
} }
}, },
"node_modules/mime": { "node_modules/mime": {
"version": "1.6.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"mime": "cli.js" "mime": "cli.js"
}, },
"engines": { "engines": {
"node": ">=4" "node": ">=10.0.0"
} }
}, },
"node_modules/mime-db": { "node_modules/mime-db": {
@ -2685,6 +3173,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/send/node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/send/node_modules/ms": { "node_modules/send/node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -2731,6 +3231,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/serve-static/node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/serve-static/node_modules/ms": { "node_modules/serve-static/node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3513,18 +4025,6 @@
} }
} }
}, },
"node_modules/webtorrent/node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/webtorrent/node_modules/ms": { "node_modules/webtorrent/node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "bestream-server", "name": "bestream-server",
"version": "2.1.0", "version": "2.1.1",
"description": "Streaming backend for beStream", "description": "Streaming backend for beStream",
"main": "src/index.js", "main": "src/index.js",
"type": "module", "type": "module",
@ -12,10 +12,13 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.1", "express": "^4.21.1",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"mime": "^3.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"webtorrent": "^2.5.1", "webtorrent": "^2.5.1",
"ws": "^8.18.0" "ws": "^8.18.0"
},
"devDependencies": {
"esbuild": "^0.27.2"
} }
} }

View File

@ -73,11 +73,15 @@ process.on('SIGTERM', async () => {
process.exit(0); process.exit(0);
}); });
server.listen(PORT, () => { // On Android, bind to 0.0.0.0 to allow connections from the WebView
// On other platforms, localhost is fine
const HOST = process.platform === 'android' ? '0.0.0.0' : 'localhost';
server.listen(PORT, HOST, () => {
console.log(` console.log(`
╔═══════════════════════════════════════════════════════╗ ╔═══════════════════════════════════════════════════════╗
║ ║ ║ ║
║ 🎬 beStream Server running on port ${PORT} ║ 🎬 beStream Server running on ${HOST}:${PORT}
║ ║ ║ ║
║ API: http://localhost:${PORT}/api ║ ║ API: http://localhost:${PORT}/api ║
║ WebSocket: ws://localhost:${PORT}/ws ║ ║ WebSocket: ws://localhost:${PORT}/ws ║

4
src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

6579
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

41
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,41 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.9.5", features = [] }
tauri-plugin-log = "2"
librqbit = "8.1.1"
actix-web = "4.12.1"
actix-cors = "0.7.1"
tokio = { version = "1.48.0", features = ["full"] }
tauri-plugin-shell = "2.3.3"
base64 = "0.22.1"
urlencoding = "2.1"
uuid = { version = "1.10", features = ["v4"] }
actix-web-actors = "4.2"
actix = "0.13"
mime = "0.3"
mime_guess = "2.0"
tokio-util = { version = "0.7", features = ["codec", "io"] }
futures = "0.3"
actix-rt = "2.10"
bytes = "1.7"

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}

Binary file not shown.

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

16
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,16 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

379
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,379 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
mod torrent_server;
mod session_manager;
mod video_utils;
mod streaming;
mod websocket;
mod transcoder;
use actix_web::{web, App, HttpServer, HttpResponse, Responder, HttpRequest};
use actix_cors::Cors;
use torrent_server::TorrentServer;
use std::sync::Arc;
use std::collections::HashMap;
use tauri::Manager;
use serde_json::json;
use streaming::{get_video_stream};
use websocket::{WsHub, ws_index};
use transcoder::Transcoder;
use uuid::Uuid;
struct AppState {
server: Arc<TorrentServer>,
sessions: Arc<tokio::sync::RwLock<HashMap<String, usize>>>, // session_id -> torrent_id
session_hashes: Arc<tokio::sync::RwLock<HashMap<String, String>>>, // session_id -> hash
hub: Arc<tokio::sync::RwLock<WsHub>>,
transcoder: Arc<Transcoder>,
}
// Trackers for magnet links
const TRACKERS: &[&str] = &[
"udp://open.demonii.com:1337/announce",
"udp://tracker.openbittorrent.com:80",
"udp://tracker.coppersurfer.tk:6969",
"udp://glotorrents.pw:6969/announce",
"udp://tracker.opentrackr.org:1337/announce",
"udp://torrent.gresille.org:80/announce",
"udp://p4p.arenabg.com:1337",
"udp://tracker.leechers-paradise.org:6969",
];
// Generate magnet URI from hash and name
fn generate_magnet_uri(hash: &str, name: &str) -> String {
let encoded_name = urlencoding::encode(name);
let tracker_params: String = TRACKERS.iter()
.map(|t| format!("&tr={}", urlencoding::encode(t)))
.collect();
format!("magnet:?xt=urn:btih:{}&dn={}{}", hash, encoded_name, tracker_params)
}
async fn start_stream(
data: web::Data<Arc<AppState>>,
body: web::Json<serde_json::Value>,
) -> impl Responder {
let hash = body["hash"].as_str().unwrap_or("");
let name = body["name"].as_str().unwrap_or("Unknown");
if hash.is_empty() {
return HttpResponse::BadRequest().json(json!({
"error": "hash is required"
}));
}
// Check if we already have a session for this hash
let existing_session = {
let session_hashes = data.session_hashes.read().await;
let sessions = data.sessions.read().await;
session_hashes.iter()
.find(|(_, h)| **h == hash)
.and_then(|(session_id, _)| {
sessions.get(session_id.as_str()).map(|torrent_id| (session_id.clone(), *torrent_id))
})
};
let (session_id, torrent_id) = if let Some((sid, tid)) = existing_session {
log::info!("Reusing existing session {} for {}", sid, name);
(sid, tid)
} else {
// Check if torrent already exists in server
// For now, we'll skip this check since list_torrents is not fully implemented
// In a production version, we'd check for existing torrents here
let existing_torrent_id = None;
let torrent_id = if let Some(id) = existing_torrent_id {
log::info!("Reusing existing torrent {} for {}", id, name);
id
} else {
let magnet = generate_magnet_uri(hash, name);
match data.server.add_torrent(&magnet).await {
Ok(id) => id,
Err(e) => {
return HttpResponse::InternalServerError().json(json!({
"error": e
}));
}
}
};
let session_id = Uuid::new_v4().to_string();
{
let mut sessions = data.sessions.write().await;
let mut session_hashes = data.session_hashes.write().await;
sessions.insert(session_id.clone(), torrent_id);
session_hashes.insert(session_id.clone(), hash.to_string());
}
(session_id, torrent_id)
};
// Start progress tracking in background
let state = data.get_ref().clone();
let session_id_clone = session_id.clone();
tokio::spawn(async move {
track_progress(state, session_id_clone, torrent_id).await;
});
HttpResponse::Ok().json(json!({
"sessionId": session_id,
"status": "connecting"
}))
}
async fn track_progress(state: Arc<AppState>, session_id: String, torrent_id: usize) {
let mut last_progress = 0.0;
loop {
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
if let Some(stats) = state.server.get_stats(torrent_id).await {
// Broadcast progress updates via WebSocket
if stats.progress != last_progress {
let hub = state.hub.read().await;
let _ = hub.broadcast(&session_id, websocket::WsMessage::Progress {
data: json!({
"sessionId": session_id,
"status": if stats.progress > 0.0 { "downloading" } else { "connecting" },
"progress": stats.progress,
"downloadSpeed": stats.download_speed.parse::<f64>().unwrap_or(0.0),
"uploadSpeed": stats.upload_speed.parse::<f64>().unwrap_or(0.0),
"peers": stats.num_peers,
"downloaded": stats.downloaded_bytes,
"total": stats.total_bytes,
"videoFile": {
"path": stats.video_file_path,
"size": stats.video_file_size,
}
}),
}).await;
last_progress = stats.progress;
}
} else {
// Torrent removed, stop tracking
break;
}
}
}
async fn get_status(
data: web::Data<Arc<AppState>>,
path: web::Path<String>,
) -> impl Responder {
let session_id = path.into_inner();
let sessions = data.sessions.read().await;
if let Some(&torrent_id) = sessions.get(&session_id) {
if let Some(stats) = data.server.get_stats(torrent_id).await {
HttpResponse::Ok().json(json!({
"sessionId": session_id,
"status": if stats.progress > 0.0 { "downloading" } else { "connecting" },
"progress": stats.progress,
"downloadSpeed": stats.download_speed.parse::<f64>().unwrap_or(0.0),
"uploadSpeed": stats.upload_speed.parse::<f64>().unwrap_or(0.0),
"peers": stats.num_peers,
"downloaded": stats.downloaded_bytes,
"total": stats.total_bytes,
"videoFile": {
"path": stats.video_file_path,
"size": stats.video_file_size,
}
}))
} else {
HttpResponse::NotFound().json(json!({
"error": "Torrent not found"
}))
}
} else {
HttpResponse::NotFound().json(json!({
"error": "Session not found"
}))
}
}
async fn get_video(
data: web::Data<Arc<AppState>>,
path: web::Path<String>,
req: HttpRequest,
) -> impl Responder {
let session_id = path.into_inner();
let sessions = data.sessions.read().await;
if let Some(&torrent_id) = sessions.get(&session_id) {
get_video_stream(data.server.clone(), torrent_id, req).await
} else {
HttpResponse::NotFound().json(json!({"error": "Session not found"}))
}
}
async fn start_hls(
data: web::Data<Arc<AppState>>,
path: web::Path<String>,
) -> impl Responder {
let session_id = path.into_inner();
let sessions = data.sessions.read().await;
if let Some(&torrent_id) = sessions.get(&session_id) {
streaming::start_hls_transcode(
data.server.clone(),
torrent_id,
session_id,
data.transcoder.clone(),
data.hub.clone(),
).await
} else {
HttpResponse::NotFound().json(json!({"error": "Session not found"}))
}
}
async fn get_hls_playlist(
data: web::Data<Arc<AppState>>,
path: web::Path<String>,
) -> impl Responder {
let session_id = path.into_inner();
let sessions = data.sessions.read().await;
if let Some(&torrent_id) = sessions.get(&session_id) {
streaming::get_hls_playlist_file(
data.server.clone(),
torrent_id,
session_id,
data.transcoder.clone(),
).await
} else {
HttpResponse::NotFound().json(json!({"error": "Session not found"}))
}
}
async fn get_hls_segment(
data: web::Data<Arc<AppState>>,
path: web::Path<(String, String)>,
) -> impl Responder {
let (session_id, segment_name) = path.into_inner();
let sessions = data.sessions.read().await;
if let Some(&torrent_id) = sessions.get(&session_id) {
streaming::get_hls_segment_file(
data.server.clone(),
torrent_id,
session_id,
segment_name,
data.transcoder.clone(),
).await
} else {
HttpResponse::NotFound().json(json!({"error": "Session not found"}))
}
}
async fn get_video_info(
data: web::Data<Arc<AppState>>,
path: web::Path<String>,
) -> impl Responder {
let session_id = path.into_inner();
let sessions = data.sessions.read().await;
if let Some(&torrent_id) = sessions.get(&session_id) {
streaming::get_video_metadata(
data.server.clone(),
torrent_id,
data.transcoder.clone(),
).await
} else {
HttpResponse::NotFound().json(json!({"error": "Session not found"}))
}
}
async fn stop_stream(
data: web::Data<Arc<AppState>>,
path: web::Path<String>,
) -> impl Responder {
let session_id = path.into_inner();
let mut sessions = data.sessions.write().await;
let mut session_hashes = data.session_hashes.write().await;
if let Some(torrent_id) = sessions.remove(&session_id) {
// Check if other sessions are using this torrent
let _hash = session_hashes.remove(&session_id);
let other_sessions_using_torrent = sessions.values().any(|&tid| tid == torrent_id);
// Only stop torrent if no other sessions are using it
if !other_sessions_using_torrent {
let _ = data.server.stop_torrent(torrent_id).await;
}
// Cleanup transcoding
data.transcoder.cleanup_session(&session_id).await;
HttpResponse::Ok().json(json!({
"status": "stopped"
}))
} else {
HttpResponse::NotFound().json(json!({
"error": "Session not found"
}))
}
}
async fn health_check(
data: web::Data<Arc<AppState>>,
) -> impl Responder {
let active_torrents = data.server.get_active_torrent_count().await;
HttpResponse::Ok().json(json!({
"status": "ok",
"activeTorrents": active_torrents
}))
}
fn main() {
let context = tauri::generate_context!();
tauri::Builder::default()
.setup(|app| {
let app_data_dir = app.path().app_data_dir().unwrap_or(std::path::PathBuf::from("."));
// Start Actix Web Server in a separate thread
tauri::async_runtime::spawn(async move {
let server = TorrentServer::new(app_data_dir.clone()).await;
let sessions = Arc::new(tokio::sync::RwLock::new(HashMap::new()));
let session_hashes = Arc::new(tokio::sync::RwLock::new(HashMap::new()));
let hub = Arc::new(tokio::sync::RwLock::new(WsHub::new()));
let transcoder = Arc::new(Transcoder::new(app_data_dir));
let state = Arc::new(AppState {
server: Arc::new(server),
sessions,
session_hashes,
hub: hub.clone(),
transcoder: transcoder.clone(),
});
let server = HttpServer::new(move || {
App::new()
.app_data(web::Data::new(state.clone()))
.app_data(web::Data::new(hub.clone()))
.wrap(Cors::permissive())
.route("/ws", web::get().to(ws_index))
.route("/api/stream/start", web::post().to(start_stream))
.route("/api/stream/{session_id}/status", web::get().to(get_status))
.route("/api/stream/{session_id}/video", web::get().to(get_video))
.route("/api/stream/{session_id}/hls", web::post().to(start_hls))
.route("/api/stream/{session_id}/hls/playlist.m3u8", web::get().to(get_hls_playlist))
.route("/api/stream/{session_id}/hls/{segment_name}", web::get().to(get_hls_segment))
.route("/api/stream/{session_id}/info", web::get().to(get_video_info))
.route("/api/stream/{session_id}", web::delete().to(stop_stream))
.route("/health", web::get().to(health_check))
})
.bind(("0.0.0.0", 3001))
.expect("Failed to bind to port 3001")
.run();
log::info!("🎬 beStream Server running on 0.0.0.0:3001");
log::info!(" API: http://localhost:3001/api");
log::info!(" WebSocket: ws://localhost:3001/ws");
log::info!(" Health: http://localhost:3001/health");
server.await.expect("Failed to run server");
});
Ok(())
})
.plugin(tauri_plugin_shell::init())
.run(context)
.expect("error while running tauri application");
}

View File

@ -0,0 +1,122 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use serde::{Serialize, Deserialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoFile {
pub name: String,
pub path: PathBuf,
pub size: u64,
pub index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SessionStatus {
Connecting,
Downloading,
Ready,
Error(String),
}
#[derive(Debug, Clone)]
pub struct StreamSession {
pub id: String,
pub hash: String,
pub name: String,
pub torrent_id: usize,
pub status: SessionStatus,
pub progress: f64,
pub download_speed: u64,
pub upload_speed: u64,
pub peers: usize,
pub downloaded: u64,
pub total: u64,
pub video_file: Option<VideoFile>,
pub started_at: u64,
pub error: Option<String>,
}
pub struct SessionManager {
sessions: Arc<RwLock<HashMap<String, StreamSession>>>,
}
impl SessionManager {
pub fn new() -> Self {
Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn create_session(
&self,
hash: String,
name: String,
torrent_id: usize,
) -> String {
let session_id = Uuid::new_v4().to_string();
let session = StreamSession {
id: session_id.clone(),
hash,
name,
torrent_id,
status: SessionStatus::Connecting,
progress: 0.0,
download_speed: 0,
upload_speed: 0,
peers: 0,
downloaded: 0,
total: 0,
video_file: None,
started_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
error: None,
};
let mut sessions = self.sessions.write().await;
sessions.insert(session_id.clone(), session);
session_id
}
pub async fn get_session(&self, session_id: &str) -> Option<StreamSession> {
let sessions = self.sessions.read().await;
sessions.get(session_id).cloned()
}
pub async fn update_session(&self, session_id: &str, update: impl FnOnce(&mut StreamSession)) {
let mut sessions = self.sessions.write().await;
if let Some(session) = sessions.get_mut(session_id) {
update(session);
}
}
pub async fn remove_session(&self, session_id: &str) {
let mut sessions = self.sessions.write().await;
sessions.remove(session_id);
}
pub async fn get_active_count(&self) -> usize {
let sessions = self.sessions.read().await;
sessions.len()
}
pub async fn find_session_by_hash(&self, hash: &str) -> Option<String> {
let sessions = self.sessions.read().await;
sessions
.values()
.find(|s| s.hash == hash)
.map(|s| s.id.clone())
}
}
impl Default for SessionManager {
fn default() -> Self {
Self::new()
}
}

349
src-tauri/src/streaming.rs Normal file
View File

@ -0,0 +1,349 @@
// This module will contain streaming-related functionality
// Including video file serving, HLS transcoding, etc.
use std::path::PathBuf;
use actix_web::{HttpRequest, HttpResponse};
use actix_web::http::header::{ContentRange, ContentType, ACCEPT_RANGES, CONTENT_LENGTH};
use mime_guess::from_path;
use crate::torrent_server;
use std::sync::Arc;
use tokio::{fs::File, io::AsyncReadExt};
use tokio::io::AsyncSeekExt;
use std::io::SeekFrom;
pub struct StreamingService {
hls_path: PathBuf,
}
impl StreamingService {
pub fn new(base_path: PathBuf) -> Self {
let hls_path = base_path.join("hls");
std::fs::create_dir_all(&hls_path).ok();
Self { hls_path }
}
pub fn get_hls_path(&self, session_id: &str) -> PathBuf {
self.hls_path.join(session_id)
}
pub fn get_playlist_path(&self, session_id: &str) -> PathBuf {
self.get_hls_path(session_id).join("playlist.m3u8")
}
pub fn get_segment_path(&self, session_id: &str, segment: &str) -> PathBuf {
self.get_hls_path(session_id).join(segment)
}
pub fn is_hls_ready(&self, session_id: &str) -> bool {
self.get_playlist_path(session_id).exists()
}
}
/// Stream video file from torrent with range request support
pub async fn get_video_stream(
server: Arc<torrent_server::TorrentServer>,
torrent_id: usize,
req: HttpRequest,
) -> HttpResponse {
// Get video file path
let video_path = match server.get_video_file_path(torrent_id).await {
Some(path) => path,
None => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": "Video file not found"
}));
}
};
// Get file metadata
let file_size = match tokio::fs::metadata(&video_path).await {
Ok(meta) => meta.len(),
Err(_) => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": "File not found"
}));
}
};
let mime_type = from_path(&video_path)
.first_or_octet_stream()
.to_string();
// Parse range header
if let Some(range_header) = req.headers().get("range") {
if let Ok(range_str) = range_header.to_str() {
if let Some(range) = parse_range(range_str, file_size) {
// Open file and seek to start position
let mut file = match File::open(&video_path).await {
Ok(f) => f,
Err(_) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Cannot open file"
}));
}
};
if let Err(_) = file.seek(SeekFrom::Start(range.start)).await {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Cannot seek file"
}));
}
let chunk_size = range.end - range.start + 1;
let mut buffer = vec![0u8; chunk_size as usize];
if let Err(_) = file.read_exact(&mut buffer).await {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Cannot read file"
}));
}
return HttpResponse::PartialContent()
.insert_header((
"Content-Range",
format!("bytes {}-{}/{}", range.start, range.end, file_size)
))
.insert_header((ACCEPT_RANGES, "bytes"))
.insert_header((CONTENT_LENGTH, chunk_size))
.insert_header(ContentType(mime_type.parse().unwrap()))
.body(buffer);
}
}
}
// No range request - serve full file (not recommended for large files)
// For large files, we should still use streaming
match tokio::fs::read(&video_path).await {
Ok(data) => {
HttpResponse::Ok()
.insert_header((ACCEPT_RANGES, "bytes"))
.insert_header((CONTENT_LENGTH, file_size))
.insert_header(ContentType(mime_type.parse().unwrap()))
.body(data)
}
Err(_) => {
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Cannot read file"
}))
}
}
}
struct ByteRange {
start: u64,
end: u64,
}
fn parse_range(range_header: &str, file_size: u64) -> Option<ByteRange> {
if !range_header.starts_with("bytes=") {
return None;
}
let range = &range_header[6..];
let parts: Vec<&str> = range.split('-').collect();
if parts.len() != 2 {
return None;
}
let start = if parts[0].is_empty() {
0
} else {
parts[0].parse::<u64>().ok()?
};
let end = if parts[1].is_empty() {
file_size - 1
} else {
parts[1].parse::<u64>().ok()?
};
// Validate range
if start > end || end >= file_size {
return None;
}
Some(ByteRange { start, end })
}
/// Start HLS transcoding
pub async fn start_hls_transcode(
server: Arc<crate::torrent_server::TorrentServer>,
torrent_id: usize,
session_id: String,
transcoder: Arc<crate::transcoder::Transcoder>,
hub: Arc<tokio::sync::RwLock<crate::websocket::WsHub>>,
) -> HttpResponse {
// Get video file path
let video_path = match server.get_video_file_path(torrent_id).await {
Some(path) => path,
None => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": "Video file not found"
}));
}
};
// Check if video file exists
if !video_path.exists() {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Video file not available yet"
}));
}
// Check if already transcoded
if transcoder.is_hls_ready(&session_id) {
return HttpResponse::Ok().json(serde_json::json!({
"status": "ready",
"playlistUrl": format!("/api/stream/{}/hls/playlist.m3u8", session_id),
}));
}
// Start transcoding in background
let transcoder_clone = transcoder.clone();
let session_id_clone = session_id.clone();
let hub_clone = hub.clone();
match transcoder.start_hls_transcode(session_id.clone(), video_path).await {
Ok(_) => {
// Monitor transcoding progress in background
tokio::spawn(async move {
let playlist_path = transcoder_clone.get_playlist_path(&session_id_clone);
let mut last_check = std::time::Instant::now();
// Wait for playlist to be created (with timeout)
let timeout = std::time::Duration::from_secs(300); // 5 minutes
let start = std::time::Instant::now();
while start.elapsed() < timeout {
if playlist_path.exists() {
let hub = hub_clone.read().await;
let _ = hub.broadcast(&session_id_clone, crate::websocket::WsMessage::Transcode {
data: serde_json::json!({
"status": "ready",
"playlistUrl": format!("/api/stream/{}/hls/playlist.m3u8", session_id_clone),
}),
}).await;
break;
}
// Send progress updates every 2 seconds
if last_check.elapsed() >= std::time::Duration::from_secs(2) {
let hub = hub_clone.read().await;
let _ = hub.broadcast(&session_id_clone, crate::websocket::WsMessage::Transcode {
data: serde_json::json!({
"progress": 0.0, // We can't easily get progress from ffmpeg without parsing stderr
}),
}).await;
last_check = std::time::Instant::now();
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
});
HttpResponse::Ok().json(serde_json::json!({
"status": "transcoding",
"message": "HLS transcoding started",
}))
}
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({
"error": e
}))
}
}
}
/// Get HLS playlist file
pub async fn get_hls_playlist_file(
_server: Arc<crate::torrent_server::TorrentServer>,
_torrent_id: usize,
session_id: String,
transcoder: Arc<crate::transcoder::Transcoder>,
) -> HttpResponse {
let playlist_path = transcoder.get_playlist_path(&session_id);
if !playlist_path.exists() {
return HttpResponse::NotFound().json(serde_json::json!({
"error": "Playlist not ready"
}));
}
match tokio::fs::read_to_string(&playlist_path).await {
Ok(content) => {
HttpResponse::Ok()
.content_type("application/vnd.apple.mpegurl")
.insert_header(("Cache-Control", "no-cache"))
.body(content)
}
Err(_) => {
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to read playlist"
}))
}
}
}
/// Get HLS segment file
pub async fn get_hls_segment_file(
_server: Arc<crate::torrent_server::TorrentServer>,
_torrent_id: usize,
session_id: String,
segment_name: String,
transcoder: Arc<crate::transcoder::Transcoder>,
) -> HttpResponse {
let segment_path = transcoder.get_segment_path(&session_id, &segment_name);
if !segment_path.exists() {
return HttpResponse::NotFound().json(serde_json::json!({
"error": "Segment not found"
}));
}
match tokio::fs::read(&segment_path).await {
Ok(data) => {
HttpResponse::Ok()
.content_type("video/mp2t")
.insert_header(("Cache-Control", "max-age=3600"))
.body(data)
}
Err(_) => {
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to read segment"
}))
}
}
}
/// Get video metadata
pub async fn get_video_metadata(
server: Arc<crate::torrent_server::TorrentServer>,
torrent_id: usize,
transcoder: Arc<crate::transcoder::Transcoder>,
) -> HttpResponse {
let video_path = match server.get_video_file_path(torrent_id).await {
Some(path) => path,
None => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": "Video not available"
}));
}
};
if !video_path.exists() {
return HttpResponse::NotFound().json(serde_json::json!({
"error": "Video file not found"
}));
}
match transcoder.get_video_info(&video_path).await {
Ok(info) => HttpResponse::Ok().json(info),
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({
"error": e
}))
}
}
}

View File

@ -0,0 +1,174 @@
use librqbit::{Session, AddTorrentOptions, AddTorrentResponse, AddTorrent, api::TorrentIdOrHash};
use std::sync::Arc;
use std::path::PathBuf;
use serde::Serialize;
use std::io::{self, SeekFrom};
use tokio::{fs::File, io::{AsyncReadExt, AsyncSeekExt}};
#[derive(Clone)]
pub struct TorrentServer {
session: Arc<Session>,
}
#[derive(Serialize, Clone)]
pub struct TorrentStats {
pub progress: f64,
pub download_speed: String,
pub upload_speed: String,
pub num_peers: usize,
pub state: String,
pub downloaded_bytes: u64,
pub total_bytes: u64,
pub video_file_path: Option<String>,
pub video_file_size: Option<u64>,
}
impl TorrentServer {
pub async fn new(config_path: PathBuf) -> Self {
let session = Session::new(config_path).await.expect("Failed to create session");
Self {
session,
}
}
pub async fn add_torrent(&self, magnet: &str) -> Result<usize, String> {
let opts = AddTorrentOptions::default();
let add_torrent = AddTorrent::Url(magnet.into());
let handle = self.session.add_torrent(add_torrent, Some(opts)).await
.map_err(|e| e.to_string())?;
match handle {
AddTorrentResponse::AlreadyManaged(id, _) => Ok(id),
AddTorrentResponse::Added(id, _) => Ok(id),
_ => Err("Unknown response".to_string()),
}
}
pub async fn get_stats(&self, id: usize) -> Option<TorrentStats> {
let torrent_id = TorrentIdOrHash::Id(id);
if let Some(handle) = self.session.get(torrent_id) {
let stats = handle.stats();
let live = stats.live.as_ref();
let (downloaded_bytes, total_bytes) = if let Some(live) = live {
(live.snapshot.downloaded_and_checked_bytes, live.snapshot.fetched_bytes)
} else {
(0, 0)
};
// Calculate progress
let progress = if total_bytes > 0 {
downloaded_bytes as f64 / total_bytes as f64
} else {
0.0
};
// Get download speed from live stats
let download_speed = if let Some(live) = live {
let bytes_per_sec = (live.download_speed.mbps * 1_000_000.0) as u64;
format!("{}", bytes_per_sec)
} else {
"0".to_string()
};
// Get upload speed from live stats
let upload_speed = if let Some(live) = live {
let bytes_per_sec = (live.upload_speed.mbps * 1_000_000.0) as u64;
format!("{}", bytes_per_sec)
} else {
"0".to_string()
};
// Get peer count
let num_peers = if let Some(live) = live {
// Try to get peer count from snapshot
// The actual structure may differ - this is a placeholder
0
} else {
0
};
let state = format!("{:?}", stats.state);
// Find video file - try to get from torrent state
// For now, we'll set these to None and find the file path differently
let video_file_path = None;
let video_file_size = None;
Some(TorrentStats {
progress,
download_speed,
upload_speed,
num_peers,
state,
downloaded_bytes,
total_bytes,
video_file_path,
video_file_size,
})
} else {
None
}
}
/// Find the largest video file in a torrent
/// This is a helper that works with the handle returned from session.get()
pub async fn find_video_file_path_from_handle(handle: &impl std::fmt::Debug) -> Option<PathBuf> {
// This is a placeholder - we'll need to implement proper file discovery
// The actual implementation would use handle methods to get file list
None
}
/// Get the path to the video file for a torrent
pub async fn get_video_file_path(&self, id: usize) -> Option<PathBuf> {
let torrent_id = TorrentIdOrHash::Id(id);
if let Some(handle) = self.session.get(torrent_id) {
let stats = handle.stats();
// Try to find video file using the handle
// For now, we'll need to implement file discovery differently
// This could involve checking the download directory
None
} else {
None
}
}
/// Create an async read stream for a video file with range support
pub async fn create_read_stream(&self, id: usize, start: u64, end: Option<u64>) -> io::Result<File> {
let torrent_id = TorrentIdOrHash::Id(id);
if let Some(video_path) = self.get_video_file_path(id).await {
let mut file = File::open(&video_path).await?;
// Seek to start position
file.seek(SeekFrom::Start(start)).await?;
Ok(file)
} else {
Err(io::Error::new(io::ErrorKind::NotFound, "Video file not found"))
}
}
/// Stop and remove a torrent
pub async fn stop_torrent(&self, id: usize) -> Result<(), String> {
let torrent_id = TorrentIdOrHash::Id(id);
// Note: The actual method name may differ - this is a placeholder
// We may need to use a different approach to remove torrents
// For now, return Ok to avoid compilation errors
// TODO: Implement proper torrent removal
Ok(())
}
/// Get the number of active torrents
pub async fn get_active_torrent_count(&self) -> usize {
// Use list method if available, otherwise return 0
// This is a placeholder - we'll need to check the actual API
0
}
/// List all torrents with their IDs and info hashes
pub async fn list_torrents(&self) -> Vec<(usize, String)> {
// This is a placeholder - we'll need to implement proper listing
// For now, return empty vector
Vec::new()
}
}

257
src-tauri/src/transcoder.rs Normal file
View File

@ -0,0 +1,257 @@
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use tokio::process::Command;
use tokio::sync::RwLock;
use serde_json::{Value, json};
use std::collections::HashMap;
pub struct Transcoder {
hls_path: PathBuf,
active_transcodes: Arc<RwLock<HashMap<String, tokio::process::Child>>>,
}
impl Transcoder {
pub fn new(base_path: PathBuf) -> Self {
let hls_path = base_path.join("hls");
std::fs::create_dir_all(&hls_path).ok();
Self {
hls_path,
active_transcodes: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn get_hls_path(&self, session_id: &str) -> PathBuf {
self.hls_path.join(session_id)
}
pub fn get_playlist_path(&self, session_id: &str) -> PathBuf {
self.get_hls_path(session_id).join("playlist.m3u8")
}
pub fn get_segment_path(&self, session_id: &str, segment: &str) -> PathBuf {
self.get_hls_path(session_id).join(segment)
}
pub fn is_hls_ready(&self, session_id: &str) -> bool {
self.get_playlist_path(session_id).exists()
}
/// Get video metadata using ffprobe
pub async fn get_video_info(&self, input_path: &Path) -> Result<Value, String> {
let output = Command::new("ffprobe")
.args(&[
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
input_path.to_str().ok_or("Invalid path")?,
])
.output()
.await
.map_err(|e| format!("Failed to run ffprobe: {}", e))?;
if !output.status.success() {
return Err(format!("ffprobe failed: {}", String::from_utf8_lossy(&output.stderr)));
}
let json: Value = serde_json::from_slice(&output.stdout)
.map_err(|e| format!("Failed to parse ffprobe output: {}", e))?;
let format = json.get("format").ok_or("No format in ffprobe output")?;
let streams = json.get("streams")
.and_then(|s| s.as_array())
.ok_or("No streams in ffprobe output")?;
let video_stream = streams.iter()
.find(|s| s.get("codec_type").and_then(|v| v.as_str()) == Some("video"));
let audio_stream = streams.iter()
.find(|s| s.get("codec_type").and_then(|v| v.as_str()) == Some("audio"));
let duration = format.get("duration")
.and_then(|d| d.as_str())
.and_then(|d| d.parse::<f64>().ok())
.unwrap_or(0.0);
let size = format.get("size")
.and_then(|s| s.as_str())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let bitrate = format.get("bit_rate")
.and_then(|b| b.as_str())
.and_then(|b| b.parse::<u64>().ok())
.unwrap_or(0);
let mut result = json!({
"duration": duration,
"size": size,
"bitrate": bitrate,
});
if let Some(video) = video_stream {
let codec = video.get("codec_name").and_then(|c| c.as_str()).unwrap_or("unknown");
let width = video.get("width").and_then(|w| w.as_u64()).unwrap_or(0);
let height = video.get("height").and_then(|h| h.as_u64()).unwrap_or(0);
let fps = video.get("r_frame_rate")
.and_then(|f| f.as_str())
.and_then(|f| {
let parts: Vec<&str> = f.split('/').collect();
if parts.len() == 2 {
let num = parts[0].parse::<f64>().ok()?;
let den = parts[1].parse::<f64>().ok()?;
if den != 0.0 {
Some(num / den)
} else {
None
}
} else {
None
}
})
.unwrap_or(0.0);
result["video"] = json!({
"codec": codec,
"width": width,
"height": height,
"fps": fps,
});
} else {
result["video"] = Value::Null;
}
if let Some(audio) = audio_stream {
let codec = audio.get("codec_name").and_then(|c| c.as_str()).unwrap_or("unknown");
let channels = audio.get("channels").and_then(|c| c.as_u64()).unwrap_or(0);
let sample_rate = audio.get("sample_rate")
.and_then(|s| s.as_str())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
result["audio"] = json!({
"codec": codec,
"channels": channels,
"sampleRate": sample_rate,
});
} else {
result["audio"] = Value::Null;
}
Ok(result)
}
/// Start HLS transcoding
pub async fn start_hls_transcode(
&self,
session_id: String,
input_path: PathBuf,
) -> Result<(), String> {
let output_dir = self.get_hls_path(&session_id);
let playlist_path = self.get_playlist_path(&session_id);
// Create output directory
std::fs::create_dir_all(&output_dir)
.map_err(|e| format!("Failed to create output directory: {}", e))?;
// Check if already transcoded
if playlist_path.exists() {
log::info!("Using cached HLS for session {}", session_id);
return Ok(());
}
log::info!("Starting HLS transcode for session {}", session_id);
let segment_pattern = output_dir.join("segment_%03d.ts").to_string_lossy().to_string();
let mut cmd = Command::new("ffmpeg");
cmd.args(&[
"-i", input_path.to_str().ok_or("Invalid input path")?,
// HLS options
"-hls_time", "4",
"-hls_list_size", "0",
"-hls_flags", "delete_segments+append_list+split_by_time",
"-hls_segment_type", "mpegts",
"-hls_segment_filename", &segment_pattern,
// Video encoding
"-c:v", "libx264",
"-preset", "ultrafast",
"-tune", "zerolatency",
"-crf", "23",
"-maxrate", "4M",
"-bufsize", "8M",
"-g", "48",
// Audio encoding
"-c:a", "aac",
"-b:a", "128k",
"-ac", "2",
// General
"-movflags", "+faststart",
"-f", "hls",
playlist_path.to_str().ok_or("Invalid playlist path")?,
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
let child = cmd.spawn()
.map_err(|e| format!("Failed to start ffmpeg: {}", e))?;
{
let mut active = self.active_transcodes.write().await;
active.insert(session_id.clone(), child);
}
// Monitor transcoding in background
let transcodes = self.active_transcodes.clone();
let session_id_clone = session_id.clone();
tokio::spawn(async move {
let mut child_opt = {
let mut active = transcodes.write().await;
active.remove(&session_id_clone)
};
if let Some(mut child) = child_opt {
match child.wait().await {
Ok(status) => {
if status.success() {
log::info!("HLS transcode complete for session {}", session_id_clone);
} else {
log::error!("HLS transcode failed for session {}", session_id_clone);
}
}
Err(e) => {
log::error!("Error waiting for ffmpeg: {}", e);
}
}
}
});
Ok(())
}
/// Stop transcoding for a session
pub async fn stop_transcode(&self, session_id: &str) {
let mut active = self.active_transcodes.write().await;
if let Some(mut child) = active.remove(session_id) {
let _ = child.kill().await;
log::info!("Stopped transcode for session {}", session_id);
}
}
/// Clean up HLS files for a session
pub async fn cleanup_session(&self, session_id: &str) {
self.stop_transcode(session_id).await;
let output_dir = self.get_hls_path(session_id);
if output_dir.exists() {
if let Err(e) = std::fs::remove_dir_all(&output_dir) {
log::error!("Failed to cleanup HLS directory for {}: {}", session_id, e);
} else {
log::info!("Cleaned up HLS directory for session {}", session_id);
}
}
}
}

View File

@ -0,0 +1,36 @@
use std::path::{Path, PathBuf};
const VIDEO_EXTENSIONS: &[&str] = &[".mp4", ".mkv", ".avi", ".mov", ".webm", ".m4v"];
pub fn is_video_file(path: &Path) -> bool {
if let Some(ext) = path.extension() {
if let Some(ext_str) = ext.to_str() {
return VIDEO_EXTENSIONS.contains(&ext_str.to_lowercase().as_str());
}
}
false
}
pub fn find_largest_video_file(files: &[(String, u64)]) -> Option<(usize, String, u64)> {
let video_files: Vec<_> = files
.iter()
.enumerate()
.filter(|(_, (name, _))| {
let path = Path::new(name);
is_video_file(path)
})
.map(|(idx, (name, size))| (idx, name.clone(), *size))
.collect();
if video_files.is_empty() {
return None;
}
// Find the largest video file
video_files
.into_iter()
.max_by_key(|(_, _, size)| *size)
.map(|(idx, name, size)| (idx, name, size))
}

154
src-tauri/src/websocket.rs Normal file
View File

@ -0,0 +1,154 @@
use actix::prelude::*;
use actix_web::{web, Error, HttpRequest, HttpResponse};
use actix_web_actors::ws;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// WebSocket message types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum WsMessage {
#[serde(rename = "subscribe")]
Subscribe { sessionId: String },
#[serde(rename = "progress")]
Progress {
#[serde(flatten)]
data: serde_json::Value,
},
#[serde(rename = "transcode")]
Transcode {
#[serde(flatten)]
data: serde_json::Value,
},
#[serde(rename = "error")]
Error { message: String },
}
/// WebSocket connection actor
pub struct WsConnection {
session_id: Option<String>,
hub: Arc<RwLock<WsHub>>,
}
impl Actor for WsConnection {
type Context = ws::WebsocketContext<Self>;
fn started(&mut self, _ctx: &mut Self::Context) {
log::info!("WebSocket client connected");
}
fn stopping(&mut self, _: &mut Self::Context) -> Running {
if let Some(ref session_id) = self.session_id {
let hub = self.hub.clone();
let session_id = session_id.clone();
tokio::spawn(async move {
let mut hub = hub.write().await;
hub.remove_connection(&session_id);
});
}
Running::Stop
}
}
impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for WsConnection {
fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
match msg {
Ok(ws::Message::Ping(msg)) => ctx.pong(&msg),
Ok(ws::Message::Pong(_)) => {}
Ok(ws::Message::Text(text)) => {
if let Ok(message) = serde_json::from_str::<WsMessage>(&text) {
match message {
WsMessage::Subscribe { sessionId } => {
log::info!("WebSocket subscribed to session: {}", sessionId);
self.session_id = Some(sessionId.clone());
let hub = self.hub.clone();
let addr = ctx.address();
tokio::spawn(async move {
let mut hub = hub.write().await;
hub.add_connection(sessionId, addr);
});
}
_ => {
log::warn!("Unknown WebSocket message type");
}
}
}
}
Ok(ws::Message::Binary(_)) => {}
Ok(ws::Message::Close(_)) => {
ctx.stop();
}
_ => ctx.stop(),
}
}
}
/// Hub to manage WebSocket connections
pub struct WsHub {
connections: HashMap<String, Vec<Addr<WsConnection>>>,
}
impl WsHub {
pub fn new() -> Self {
Self {
connections: HashMap::new(),
}
}
pub fn add_connection(&mut self, session_id: String, addr: Addr<WsConnection>) {
self.connections.entry(session_id).or_insert_with(Vec::new).push(addr);
}
pub fn remove_connection(&mut self, _session_id: &str) {
// Connections will be removed when they close
// We could implement a more sophisticated cleanup here
}
pub async fn broadcast(&self, session_id: &str, message: WsMessage) {
if let Some(connections) = self.connections.get(session_id) {
let message_json = serde_json::to_string(&message).unwrap_or_default();
for addr in connections {
let _ = addr.do_send(WsBroadcast {
message: message_json.clone(),
});
}
}
}
}
/// Message to broadcast to a WebSocket connection
struct WsBroadcast {
message: String,
}
impl Message for WsBroadcast {
type Result = ();
}
impl Handler<WsBroadcast> for WsConnection {
type Result = ();
fn handle(&mut self, msg: WsBroadcast, ctx: &mut Self::Context) {
ctx.text(msg.message);
}
}
/// Start WebSocket connection
pub async fn ws_index(
req: HttpRequest,
stream: web::Payload,
hub: web::Data<Arc<RwLock<WsHub>>>,
) -> Result<HttpResponse, Error> {
let resp = ws::start(
WsConnection {
session_id: None,
hub: hub.get_ref().clone(),
},
&req,
stream,
)?;
Ok(resp)
}

37
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,37 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "beStream",
"version": "0.1.0",
"identifier": "com.bestream.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "beStream",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View File

@ -15,7 +15,6 @@ import {
Plus, Plus,
} from 'lucide-react'; } from 'lucide-react';
import { useIntegrationStore } from '../../stores/integrationStore'; import { useIntegrationStore } from '../../stores/integrationStore';
import type { ServiceConnection } from '../../types/unified';
import Button from '../ui/Button'; import Button from '../ui/Button';
import Input from '../ui/Input'; import Input from '../ui/Input';
import Badge from '../ui/Badge'; import Badge from '../ui/Badge';

View File

@ -52,8 +52,8 @@ export default function Navbar() {
useEffect(() => { useEffect(() => {
if (isElectronApp && window.electron) { if (isElectronApp && window.electron) {
const checkMaximized = async () => { const checkMaximized = async () => {
const maximized = await window.electron.isMaximized(); const maximized = await window.electron?.isMaximized();
setIsMaximized(maximized); setIsMaximized(maximized ?? false);
}; };
checkMaximized(); checkMaximized();

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Download, Copy, ExternalLink, Play, HardDrive, Users, Check } from 'lucide-react'; import { Download, Copy, ExternalLink, Play, HardDrive, Users, Check } from 'lucide-react';
import type { Movie, Torrent } from '../../types'; import type { Movie, Torrent } from '../../types';
import { generateMagnetUri, copyMagnetLink, openInExternalClient } from '../../services/torrent/webtorrent'; import { copyMagnetLink, openInExternalClient } from '../../services/torrent/webtorrent';
import Button from '../ui/Button'; import Button from '../ui/Button';
import Badge from '../ui/Badge'; import Badge from '../ui/Badge';

View File

@ -10,7 +10,6 @@ import {
X X
} from 'lucide-react'; } from 'lucide-react';
import type { UnifiedTrack } from '../../types/unified'; import type { UnifiedTrack } from '../../types/unified';
import ProgressBar from '../ui/ProgressBar';
interface MiniPlayerProps { interface MiniPlayerProps {
track: UnifiedTrack | null; track: UnifiedTrack | null;

View File

@ -1,8 +1,8 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Play, Pause, Download, Check, Clock, MoreHorizontal } from 'lucide-react'; import { Play, Pause, Download, Check, MoreHorizontal } from 'lucide-react';
import type { UnifiedTrack } from '../../types/unified'; import type { UnifiedTrack } from '../../types/unified';
import Badge from '../ui/Badge'; import Badge from '../ui/Badge';
import { formatDuration, formatBytes } from '../../utils/helpers'; import { formatDuration } from '../../utils/helpers';
interface TrackListProps { interface TrackListProps {
tracks: UnifiedTrack[]; tracks: UnifiedTrack[];
@ -60,7 +60,7 @@ export default function TrackList({
</span> </span>
</div> </div>
)} )}
{discTracks.map((track, index) => { {discTracks.map((track) => {
const isCurrentTrack = currentTrackId === track.id; const isCurrentTrack = currentTrackId === track.id;
const isTrackPlaying = isCurrentTrack && isPlaying; const isTrackPlaying = isCurrentTrack && isPlaying;

View File

@ -123,7 +123,7 @@ export default function StreamingPlayer({
video.play().catch(console.error); video.play().catch(console.error);
}); });
hls.on(Hls.Events.ERROR, (event, data) => { hls.on(Hls.Events.ERROR, (_event, data) => {
console.error('HLS error:', data); console.error('HLS error:', data);
if (data.fatal) { if (data.fatal) {
switch (data.type) { switch (data.type) {

View File

@ -15,10 +15,8 @@ import {
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { clsx } from 'clsx';
import type { Movie, TorrentInfo } from '../../types'; import type { Movie, TorrentInfo } from '../../types';
import { formatBytes, formatSpeed } from '../../services/torrent/webtorrent'; import { formatBytes, formatSpeed } from '../../services/torrent/webtorrent';
import ProgressBar from '../ui/ProgressBar';
interface VideoPlayerProps { interface VideoPlayerProps {
movie: Movie; movie: Movie;

View File

@ -78,7 +78,7 @@ export default function SeasonList({
progress={progress} progress={progress}
size="sm" size="sm"
showLabel={false} showLabel={false}
color={progress === 100 ? 'success' : 'primary'} variant={progress === 100 ? 'success' : 'default'}
/> />
</div> </div>
{/* Download button */} {/* Download button */}

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { ytsApi } from '../services/api/yts'; import { ytsApi } from '../services/api/yts';
import type { Movie, ListMoviesParams, ListMoviesData } from '../types'; import type { Movie, ListMoviesParams } from '../types';
interface UseMoviesOptions extends ListMoviesParams { interface UseMoviesOptions extends ListMoviesParams {
enabled?: boolean; enabled?: boolean;

View File

@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client';
import { HashRouter } from 'react-router-dom'; import { HashRouter } from 'react-router-dom';
import App from './App'; import App from './App';
import './index.css'; import './index.css';
import { isCapacitor } from './utils/platform';
import serverManager from './services/server/serverManager';
// Error boundary for better debugging // Error boundary for better debugging
window.addEventListener('error', (event) => { window.addEventListener('error', (event) => {
@ -14,7 +16,7 @@ window.addEventListener('unhandledrejection', (event) => {
}); });
// Wait for DOM to be ready // Wait for DOM to be ready
function mountApp() { async function mountApp() {
try { try {
// Ensure body exists // Ensure body exists
if (!document.body) { if (!document.body) {
@ -22,6 +24,17 @@ function mountApp() {
return; return;
} }
// On Capacitor platforms (Android/iOS), start the server in background
// Don't block app mounting - server can start asynchronously
if (isCapacitor()) {
console.log('Capacitor platform detected, starting server in background...');
// Start server asynchronously without blocking app mount
serverManager.startServer().catch((error) => {
console.error('Error starting server:', error);
// Server will be started when user tries to stream if it's not running
});
}
// Create root element if it doesn't exist // Create root element if it doesn't exist
let rootElement = document.getElementById('root'); let rootElement = document.getElementById('root');
if (!rootElement) { if (!rootElement) {
@ -43,6 +56,13 @@ function mountApp() {
console.log('React app mounted successfully'); console.log('React app mounted successfully');
// Cleanup: Stop server on app unload (Capacitor only)
if (isCapacitor()) {
window.addEventListener('beforeunload', () => {
serverManager.stopServer();
});
}
} catch (error) { } catch (error) {
console.error('Failed to mount React app:', error); console.error('Failed to mount React app:', error);
const errorDiv = document.createElement('div'); const errorDiv = document.createElement('div');
@ -51,9 +71,17 @@ function mountApp() {
<h1>Failed to Load App</h1> <h1>Failed to Load App</h1>
<p>Error: ${error instanceof Error ? error.message : String(error)}</p> <p>Error: ${error instanceof Error ? error.message : String(error)}</p>
<pre style="background: #f0f0f0; padding: 10px; overflow: auto;">${error instanceof Error ? error.stack : ''}</pre> <pre style="background: #f0f0f0; padding: 10px; overflow: auto;">${error instanceof Error ? error.stack : ''}</pre>
<p>Check console for more details</p>
`; `;
if (document.body) { if (document.body) {
document.body.appendChild(errorDiv); document.body.appendChild(errorDiv);
} else {
// If body doesn't exist, wait for it
document.addEventListener('DOMContentLoaded', () => {
if (document.body) {
document.body.appendChild(errorDiv);
}
});
} }
} }
} }

View File

@ -159,17 +159,23 @@ export default function AlbumDetails() {
variant="ghost" variant="ghost"
size="lg" size="lg"
leftIcon={<Heart size={20} />} leftIcon={<Heart size={20} />}
/> >
<span className="sr-only">Like</span>
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="lg" size="lg"
leftIcon={<Download size={20} />} leftIcon={<Download size={20} />}
/> >
<span className="sr-only">Download</span>
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="lg" size="lg"
leftIcon={<MoreHorizontal size={20} />} leftIcon={<MoreHorizontal size={20} />}
/> >
<span className="sr-only">More</span>
</Button>
</motion.div> </motion.div>
{/* Genres */} {/* Genres */}

View File

@ -1,4 +1,3 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
@ -9,7 +8,6 @@ import {
Clock, Clock,
Download, Download,
RefreshCw, RefreshCw,
ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
import { useArtistDetails, useAlbums } from '../hooks/useMusic'; import { useArtistDetails, useAlbums } from '../hooks/useMusic';
import { AlbumGrid } from '../components/music'; import { AlbumGrid } from '../components/music';

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Filter, SortAsc, X } from 'lucide-react'; import { Filter, X } from 'lucide-react';
import MovieGrid from '../components/movie/MovieGrid'; import MovieGrid from '../components/movie/MovieGrid';
import Select from '../components/ui/Select'; import Select from '../components/ui/Select';
import Button from '../components/ui/Button'; import Button from '../components/ui/Button';
@ -51,7 +51,7 @@ export default function Browse() {
limit: 20, limit: 20,
}); });
const { movies, isLoading, totalCount, hasMore, loadMore, refresh } = useMovies(filters); const { movies, isLoading, totalCount, hasMore, loadMore } = useMovies(filters);
// Update URL when filters change // Update URL when filters change
useEffect(() => { useEffect(() => {

View File

@ -5,7 +5,7 @@ import Button from '../components/ui/Button';
import ProgressBar from '../components/ui/ProgressBar'; import ProgressBar from '../components/ui/ProgressBar';
import Badge from '../components/ui/Badge'; import Badge from '../components/ui/Badge';
import { useDownloadStore } from '../stores/downloadStore'; import { useDownloadStore } from '../stores/downloadStore';
import { formatBytes, formatSpeed } from '../services/torrent/webtorrent'; import { formatSpeed } from '../services/torrent/webtorrent';
export default function Downloads() { export default function Downloads() {
const { const {

View File

@ -1,8 +1,7 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import Hero from '../components/movie/Hero'; import Hero from '../components/movie/Hero';
import MovieRow from '../components/movie/MovieRow'; import MovieRow from '../components/movie/MovieRow';
import { HeroSkeleton, MovieRowSkeleton } from '../components/ui/Skeleton'; import { HeroSkeleton } from '../components/ui/Skeleton';
import { useTrending, useLatest, useTopRated, useByGenre } from '../hooks/useMovies'; import { useTrending, useLatest, useTopRated, useByGenre } from '../hooks/useMovies';
export default function Home() { export default function Home() {

View File

@ -209,11 +209,11 @@ export default function MovieDetails() {
</div> </div>
{/* Cast */} {/* Cast */}
{movie.cast && movie.cast.length > 0 && ( {(movie as any).cast && Array.isArray((movie as any).cast) && (movie as any).cast.length > 0 && (
<div className="mb-8"> <div className="mb-8">
<h3 className="text-xl font-semibold mb-4">Cast</h3> <h3 className="text-xl font-semibold mb-4">Cast</h3>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
{movie.cast.map((member) => ( {(movie as any).cast.map((member: any) => (
<div key={member.imdb_code} className="flex items-center gap-3"> <div key={member.imdb_code} className="flex items-center gap-3">
{member.url_small_image ? ( {member.url_small_image ? (
<img <img

View File

@ -1,6 +1,6 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Music as MusicIcon, Search, RefreshCw, AlertCircle, Plus, Play, Disc } from 'lucide-react'; import { Music as MusicIcon, Search, RefreshCw, AlertCircle, Plus, Play } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useArtists, useAlbums } from '../hooks/useMusic'; import { useArtists, useAlbums } from '../hooks/useMusic';
import { useHasConnectedServices } from '../hooks/useIntegration'; import { useHasConnectedServices } from '../hooks/useIntegration';
@ -23,7 +23,7 @@ function MusicHero({ artists }: { artists: UnifiedArtist[] }) {
<div className="absolute inset-0"> <div className="absolute inset-0">
<img <img
src={current.fanart || current.poster || '/placeholder-backdrop.jpg'} src={current.fanart || current.poster || '/placeholder-backdrop.jpg'}
alt={current.name} alt={current.title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/60 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/60 to-transparent" />
@ -42,7 +42,7 @@ function MusicHero({ artists }: { artists: UnifiedArtist[] }) {
<MusicIcon className="text-purple-400" size={24} /> <MusicIcon className="text-purple-400" size={24} />
<span className="text-purple-400 font-semibold">ARTIST</span> <span className="text-purple-400 font-semibold">ARTIST</span>
</div> </div>
<h1 className="text-4xl md:text-6xl font-bold mb-4">{current.name}</h1> <h1 className="text-4xl md:text-6xl font-bold mb-4">{current.title}</h1>
<div className="flex items-center gap-4 text-sm text-gray-300 mb-4"> <div className="flex items-center gap-4 text-sm text-gray-300 mb-4">
{current.genres && current.genres.length > 0 && ( {current.genres && current.genres.length > 0 && (
<span className="text-purple-400">{current.genres.slice(0, 2).join(', ')}</span> <span className="text-purple-400">{current.genres.slice(0, 2).join(', ')}</span>
@ -117,7 +117,7 @@ function ArtistRow({
<div className="relative aspect-square rounded-full overflow-hidden mb-3"> <div className="relative aspect-square rounded-full overflow-hidden mb-3">
<img <img
src={artist.poster || '/placeholder-artist.jpg'} src={artist.poster || '/placeholder-artist.jpg'}
alt={artist.name} alt={artist.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110" className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
/> />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center"> <div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
@ -127,7 +127,7 @@ function ArtistRow({
/> />
</div> </div>
</div> </div>
<h3 className="font-medium text-sm text-center truncate">{artist.name}</h3> <h3 className="font-medium text-sm text-center truncate">{artist.title}</h3>
<p className="text-xs text-gray-400 text-center"> <p className="text-xs text-gray-400 text-center">
{artist.albumCount} Album{artist.albumCount !== 1 ? 's' : ''} {artist.albumCount} Album{artist.albumCount !== 1 ? 's' : ''}
</p> </p>
@ -175,7 +175,7 @@ function AlbumRow({
> >
<div className="relative aspect-square rounded-lg overflow-hidden mb-2"> <div className="relative aspect-square rounded-lg overflow-hidden mb-2">
<img <img
src={album.cover || '/placeholder-album.jpg'} src={album.poster || '/placeholder-album.jpg'}
alt={album.title} alt={album.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110" className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
/> />
@ -232,7 +232,7 @@ export default function Music() {
const filteredArtists = useMemo(() => { const filteredArtists = useMemo(() => {
if (!searchQuery) return artists; if (!searchQuery) return artists;
return artists.filter((a) => return artists.filter((a) =>
a.name.toLowerCase().includes(searchQuery.toLowerCase()) a.title.toLowerCase().includes(searchQuery.toLowerCase())
); );
}, [artists, searchQuery]); }, [artists, searchQuery]);

View File

@ -6,8 +6,10 @@ import StreamingPlayer from '../components/player/StreamingPlayer';
import { useMovieDetails } from '../hooks/useMovies'; import { useMovieDetails } from '../hooks/useMovies';
import { useHistoryStore } from '../stores/historyStore'; import { useHistoryStore } from '../stores/historyStore';
import streamingService, { type StreamSession } from '../services/streaming/streamingService'; import streamingService, { type StreamSession } from '../services/streaming/streamingService';
import { getApiUrl } from '../utils/platform';
import type { Torrent } from '../types'; import type { Torrent } from '../types';
import Button from '../components/ui/Button'; import Button from '../components/ui/Button';
import serverManager from '../services/server/serverManager';
export default function Player() { export default function Player() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@ -31,18 +33,36 @@ export default function Player() {
const historyItem = movie ? getProgress(movie.id) : undefined; const historyItem = movie ? getProgress(movie.id) : undefined;
const initialTime = historyItem?.progress || 0; const initialTime = historyItem?.progress || 0;
// Check server health // Check server health and start if needed
useEffect(() => { useEffect(() => {
const checkServer = async () => { const checkAndStartServer = async () => {
try { try {
// First check if server is running
await streamingService.checkHealth(); await streamingService.checkHealth();
setStatus('connecting'); setStatus('connecting');
} catch { } catch {
setError('Streaming server is not running. Please start the server first.'); // Server not running, try to start it
console.log('Server not running, attempting to start...');
try {
const started = await serverManager.startServer();
if (started) {
// Wait a bit for server to be ready
await new Promise(resolve => setTimeout(resolve, 2000));
// Check again
await streamingService.checkHealth();
setStatus('connecting');
} else {
setError('Failed to start streaming server. Please try again.');
setStatus('error'); setStatus('error');
} }
} catch (err) {
console.error('Error starting server:', err);
setError('Streaming server is not running. Please try again.');
setStatus('error');
}
}
}; };
checkServer(); checkAndStartServer();
}, []); }, []);
// Select the best torrent based on preference // Select the best torrent based on preference
@ -96,7 +116,7 @@ export default function Player() {
} else if (update.type === 'transcode') { } else if (update.type === 'transcode') {
const transcodeUpdate = update as { status?: string; playlistUrl?: string }; const transcodeUpdate = update as { status?: string; playlistUrl?: string };
if (transcodeUpdate.status === 'ready' && transcodeUpdate.playlistUrl) { if (transcodeUpdate.status === 'ready' && transcodeUpdate.playlistUrl) {
setHlsUrl(`http://localhost:3001${transcodeUpdate.playlistUrl}`); setHlsUrl(`${getApiUrl()}${transcodeUpdate.playlistUrl}`);
} }
} }
}); });
@ -117,7 +137,7 @@ export default function Player() {
try { try {
const hlsResult = await streamingService.startHls(result.sessionId); const hlsResult = await streamingService.startHls(result.sessionId);
if (hlsResult.playlistUrl) { if (hlsResult.playlistUrl) {
setHlsUrl(`http://localhost:3001${hlsResult.playlistUrl}`); setHlsUrl(`${getApiUrl()}${hlsResult.playlistUrl}`);
} }
} catch (e) { } catch (e) {
console.log('HLS not available, using direct stream'); console.log('HLS not available, using direct stream');
@ -166,14 +186,40 @@ export default function Player() {
); );
// Retry function // Retry function
const handleRetry = () => { const handleRetry = useCallback(async () => {
setError(null); setError(null);
setStatus('checking'); setStatus('checking');
setSessionId(null); setSessionId(null);
setStreamSession(null); setStreamSession(null);
setStreamUrl(null); setStreamUrl(null);
setHlsUrl(null); setHlsUrl(null);
};
try {
// First check if server is running
await streamingService.checkHealth();
setStatus('connecting');
} catch {
// Server not running, try to start it
console.log('Server not running, attempting to start on retry...');
try {
const started = await serverManager.startServer();
if (started) {
// Wait a bit for server to be ready
await new Promise(resolve => setTimeout(resolve, 3000));
// Check again
await streamingService.checkHealth();
setStatus('connecting');
} else {
setError('Failed to start streaming server. Please try again.');
setStatus('error');
}
} catch (err) {
console.error('Error starting server on retry:', err);
setError('Streaming server is not running. Please try again.');
setStatus('error');
}
}
}, []);
if (movieLoading) { if (movieLoading) {
return ( return (

View File

@ -12,7 +12,6 @@ import {
Clock, Clock,
HardDrive HardDrive
} from 'lucide-react'; } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useQueue } from '../hooks/useCalendar'; import { useQueue } from '../hooks/useCalendar';
import Button from '../components/ui/Button'; import Button from '../components/ui/Button';
import Badge from '../components/ui/Badge'; import Badge from '../components/ui/Badge';
@ -22,7 +21,6 @@ import type { QueueItem } from '../types/unified';
export default function Queue() { export default function Queue() {
const { items, isLoading, error, refetch } = useQueue(); const { items, isLoading, error, refetch } = useQueue();
const { hasAny } = useHasConnectedServices();
const getTypeIcon = (type: QueueItem['mediaType']) => { const getTypeIcon = (type: QueueItem['mediaType']) => {
switch (type) { switch (type) {
@ -69,7 +67,7 @@ export default function Queue() {
return `${minutes}m`; return `${minutes}m`;
}; };
if (!hasAny) { if (items.length === 0 && !isLoading) {
return ( return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@ -79,13 +77,10 @@ export default function Queue() {
<div className="max-w-7xl mx-auto px-4 md:px-8"> <div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="flex flex-col items-center justify-center py-16 text-center"> <div className="flex flex-col items-center justify-center py-16 text-center">
<AlertCircle size={64} className="text-gray-500 mb-4" /> <AlertCircle size={64} className="text-gray-500 mb-4" />
<h1 className="text-2xl font-bold mb-2">No Services Connected</h1> <h1 className="text-2xl font-bold mb-2">No Downloads</h1>
<p className="text-gray-400 mb-6 max-w-md"> <p className="text-gray-400 mb-6 max-w-md">
Connect to Radarr, Sonarr, or Lidarr to view your download queue. Your download queue is empty.
</p> </p>
<Link to="/settings">
<Button>Go to Settings</Button>
</Link>
</div> </div>
</div> </div>
</motion.div> </motion.div>
@ -175,7 +170,7 @@ export default function Queue() {
progress={item.progress} progress={item.progress}
size="sm" size="sm"
showLabel showLabel
color="primary" variant="default"
/> />
</div> </div>
)} )}

View File

@ -135,15 +135,15 @@ export default function SeriesDetails() {
{/* Stats */} {/* Stats */}
<div className="flex flex-wrap gap-3 mb-6"> <div className="flex flex-wrap gap-3 mb-6">
<Badge size="lg" className="bg-white/10"> <Badge size="md" className="bg-white/10">
<Tv size={14} className="mr-1" /> <Tv size={14} className="mr-1" />
{series.seasonCount} Season{series.seasonCount !== 1 ? 's' : ''} {series.seasonCount} Season{series.seasonCount !== 1 ? 's' : ''}
</Badge> </Badge>
<Badge size="lg" className="bg-white/10"> <Badge size="md" className="bg-white/10">
{series.episodeFileCount}/{series.episodeCount} Episodes {series.episodeFileCount}/{series.episodeCount} Episodes
</Badge> </Badge>
{series.nextAiring && ( {series.nextAiring && (
<Badge size="lg" variant="info"> <Badge size="md" variant="info">
Next: {new Date(series.nextAiring).toLocaleDateString()} Next: {new Date(series.nextAiring).toLocaleDateString()}
</Badge> </Badge>
)} )}

View File

@ -1,10 +1,12 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Loader2, AlertCircle, Server, RefreshCw, Tv } from 'lucide-react'; import { Loader2, AlertCircle, Server, RefreshCw } from 'lucide-react';
import StreamingPlayer from '../components/player/StreamingPlayer'; import StreamingPlayer from '../components/player/StreamingPlayer';
import streamingService, { type StreamSession } from '../services/streaming/streamingService'; import streamingService, { type StreamSession } from '../services/streaming/streamingService';
import { getApiUrl } from '../utils/platform';
import Button from '../components/ui/Button'; import Button from '../components/ui/Button';
import type { Movie } from '../types';
interface TVEpisodeInfo { interface TVEpisodeInfo {
showTitle: string; showTitle: string;
@ -98,7 +100,7 @@ export default function TVPlayer() {
} else if (update.type === 'transcode') { } else if (update.type === 'transcode') {
const transcodeUpdate = update as { status?: string; playlistUrl?: string }; const transcodeUpdate = update as { status?: string; playlistUrl?: string };
if (transcodeUpdate.status === 'ready' && transcodeUpdate.playlistUrl) { if (transcodeUpdate.status === 'ready' && transcodeUpdate.playlistUrl) {
setHlsUrl(`http://localhost:3001${transcodeUpdate.playlistUrl}`); setHlsUrl(`${getApiUrl()}${transcodeUpdate.playlistUrl}`);
} }
} }
}); });
@ -118,7 +120,7 @@ export default function TVPlayer() {
try { try {
const hlsResult = await streamingService.startHls(result.sessionId); const hlsResult = await streamingService.startHls(result.sessionId);
if (hlsResult.playlistUrl) { if (hlsResult.playlistUrl) {
setHlsUrl(`http://localhost:3001${hlsResult.playlistUrl}`); setHlsUrl(`${getApiUrl()}${hlsResult.playlistUrl}`);
} }
} catch (e) { } catch (e) {
console.log('HLS not available, using direct stream'); console.log('HLS not available, using direct stream');
@ -280,16 +282,21 @@ export default function TVPlayer() {
} }
// Create a mock movie object for the StreamingPlayer // Create a mock movie object for the StreamingPlayer
const mockMedia = { const mockMedia: Movie = {
id: parseInt(showId || '0'), id: parseInt(showId || '0'),
url: '',
imdb_code: '',
title: `${showTitle} - S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`, title: `${showTitle} - S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`,
title_english: episodeTitle || `Season ${season}, Episode ${episode}`,
title_long: episodeTitle || `Season ${season}, Episode ${episode}`, title_long: episodeTitle || `Season ${season}, Episode ${episode}`,
slug: '',
year: new Date().getFullYear(), year: new Date().getFullYear(),
rating: 0, rating: 0,
runtime: 0, runtime: 0,
genres: [], genres: [],
summary: '', summary: '',
description_full: '', description_full: '',
synopsis: '',
yt_trailer_code: '', yt_trailer_code: '',
language: 'en', language: 'en',
mpa_rating: '', mpa_rating: '',
@ -298,7 +305,10 @@ export default function TVPlayer() {
small_cover_image: episodeInfo.poster || '', small_cover_image: episodeInfo.poster || '',
medium_cover_image: episodeInfo.poster || '', medium_cover_image: episodeInfo.poster || '',
large_cover_image: episodeInfo.poster || '', large_cover_image: episodeInfo.poster || '',
state: '',
torrents: [], torrents: [],
date_uploaded: new Date().toISOString(),
date_uploaded_unix: Math.floor(Date.now() / 1000),
}; };
return ( return (

View File

@ -8,7 +8,6 @@ import {
Calendar, Calendar,
Tv, Tv,
Clock, Clock,
Download,
ChevronDown, ChevronDown,
Loader, Loader,
AlertCircle, AlertCircle,
@ -52,7 +51,7 @@ export default function TVShowDetails() {
const [isLoadingTorrents, setIsLoadingTorrents] = useState(false); const [isLoadingTorrents, setIsLoadingTorrents] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showSeasonDropdown, setShowSeasonDropdown] = useState(false); const [showSeasonDropdown, setShowSeasonDropdown] = useState(false);
const [streamingEpisode, setStreamingEpisode] = useState<{ season: number; episode: number } | null>(null); const [streamingEpisode] = useState<{ season: number; episode: number } | null>(null);
// Load show details // Load show details
useEffect(() => { useEffect(() => {

View File

@ -162,7 +162,7 @@ function ShowRow({
</div> </div>
<div <div
className="flex gap-3 overflow-x-auto pb-4 scrollbar-hide scroll-smooth" className="flex gap-3 overflow-x-auto pb-4 scrollbar-hide scroll-smooth"
ref={(el) => scrollContainer} ref={scrollContainer}
> >
{shows.map((show) => ( {shows.map((show) => (
<Link <Link

87
src/plugins/NodeServer.ts Normal file
View File

@ -0,0 +1,87 @@
import { Capacitor } from '@capacitor/core';
import { registerPlugin } from '@capacitor/core';
export interface NodeServerPlugin {
/**
* Start the Node.js server
* @returns Promise that resolves when server is started
*/
startServer(options?: { scriptPath?: string; port?: number }): Promise<{ success: boolean; message?: string }>;
/**
* Stop the Node.js server
* @returns Promise that resolves when server is stopped
*/
stopServer(): Promise<{ success: boolean }>;
/**
* Check if server is running
* @returns Promise that resolves with server status
*/
isServerRunning(): Promise<{ running: boolean }>;
}
/**
* NodeServer Capacitor plugin
* Provides interface to start/stop Node.js server on mobile platforms
*/
const NodeServer = registerPlugin<NodeServerPlugin>('NodeServer', {
web: () => import('./NodeServer.web').then(m => new m.NodeServerWeb()),
});
/**
* Start the Node.js server (Android/iOS only)
*/
export const startNodeServer = async (options?: { scriptPath?: string; port?: number }): Promise<boolean> => {
if (Capacitor.getPlatform() === 'web' || Capacitor.getPlatform() === 'electron') {
// Server is managed by Electron or external process on web
return true;
}
try {
const result = await NodeServer.startServer(options);
return result.success;
} catch (error) {
console.error('Failed to start Node.js server:', error);
return false;
}
};
/**
* Stop the Node.js server (Android/iOS only)
*/
export const stopNodeServer = async (): Promise<boolean> => {
if (Capacitor.getPlatform() === 'web' || Capacitor.getPlatform() === 'electron') {
// Server is managed by Electron or external process on web
return true;
}
try {
const result = await NodeServer.stopServer();
return result.success;
} catch (error) {
console.error('Failed to stop Node.js server:', error);
return false;
}
};
/**
* Check if server is running (Android/iOS only)
*/
export const isNodeServerRunning = async (): Promise<boolean> => {
if (Capacitor.getPlatform() === 'web' || Capacitor.getPlatform() === 'electron') {
// On web/Electron, check via HTTP health endpoint
return true;
}
try {
const result = await NodeServer.isServerRunning();
return result.running;
} catch (error) {
console.error('Failed to check server status:', error);
return false;
}
};
export default NodeServer;

View File

@ -0,0 +1,24 @@
import { WebPlugin } from '@capacitor/core';
import type { NodeServerPlugin } from './NodeServer';
/**
* Web implementation of NodeServer plugin
* No-op for web platform (server runs externally)
*/
export class NodeServerWeb extends WebPlugin implements NodeServerPlugin {
async startServer(): Promise<{ success: boolean; message?: string }> {
// On web, server is managed externally
return { success: true, message: 'Server managed externally on web platform' };
}
async stopServer(): Promise<{ success: boolean }> {
// On web, server is managed externally
return { success: true };
}
async isServerRunning(): Promise<{ running: boolean }> {
// On web, assume server is available (check via HTTP)
return { running: true };
}
}

View File

@ -0,0 +1,253 @@
import { isCapacitor } from '../../utils/platform';
import streamingService from '../streaming/streamingService';
// Get NodeJS plugin from Capacitor runtime
// Capacitor plugins are accessed through Capacitor.Plugins at runtime
const getNodeJS = () => {
if (!isCapacitor()) {
console.log('Not a Capacitor platform, NodeJS plugin not needed');
return null;
}
try {
// Access the plugin through Capacitor's runtime API
// The plugin is registered as 'CapacitorNodeJS' (see log: "Registering plugin instance: CapacitorNodeJS")
// @ts-ignore - CapacitorNodeJS is registered at runtime
const Capacitor = (window as any).Capacitor;
if (!Capacitor || !Capacitor.Plugins) {
console.error('Capacitor or Capacitor.Plugins not available');
return null;
}
const NodeJS = Capacitor.Plugins.CapacitorNodeJS;
if (!NodeJS) {
console.error('NodeJS plugin not found in Capacitor.Plugins');
console.log('Available plugins:', Object.keys(Capacitor.Plugins || {}));
return null;
}
console.log('NodeJS plugin found in Capacitor.Plugins');
return NodeJS;
} catch (error) {
console.error('Failed to access NodeJS plugin:', error);
console.error('Error details:', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
return null;
}
};
/**
* Server lifecycle manager for mobile platforms
* On Electron, server is managed by electron/main.ts
* On Android/iOS, server is managed via Capacitor plugin
*/
class ServerManager {
private isStarting = false;
private isRunning = false;
private healthCheckInterval: NodeJS.Timeout | null = null;
private readonly HEALTH_CHECK_INTERVAL = 5000; // 5 seconds
private readonly MAX_STARTUP_RETRIES = 3;
private startupRetries = 0;
/**
* Start the server (Android/iOS only)
* Returns true if server started successfully, false otherwise
*/
async startServer(): Promise<boolean> {
// Only manage server on Capacitor platforms
if (!isCapacitor()) {
// On Electron, server is managed by electron/main.ts
// Just check if it's available
return this.checkServerHealth();
}
if (this.isStarting || this.isRunning) {
return this.isRunning;
}
this.isStarting = true;
this.startupRetries = 0;
try {
// Get NodeJS plugin from Capacitor runtime
const NodeJS = getNodeJS();
if (!NodeJS) {
console.error('NodeJS plugin not available - is capacitor-nodejs installed?');
throw new Error('NodeJS plugin not available');
}
console.log('NodeJS plugin found, starting runtime...');
// Start Node.js runtime using Capacitor NodeJS plugin
// If it's already started, that's fine - we'll just wait for it to be ready
try {
await NodeJS.start({
nodeDir: 'nodejs-v3',
script: 'index.js',
env: {
PORT: '3001',
NODE_ENV: 'production'
}
});
console.log('NodeJS.start() called successfully');
} catch (startError: any) {
// If the engine is already started, that's okay - just continue
if (startError?.message?.includes('already been started')) {
console.log('Node.js engine already started, continuing...');
} else {
console.error('Error calling NodeJS.start():', startError);
throw startError;
}
}
// Wait for Node.js runtime to be ready
console.log('Waiting for Node.js runtime to be ready...');
try {
await NodeJS.whenReady();
console.log('Node.js runtime is ready - server should be starting...');
} catch (readyError) {
console.error('Error waiting for Node.js runtime:', readyError);
throw readyError;
}
// Server starts automatically when the entry point (index.js) is executed
// Wait a bit for server to start
try {
await this.waitForServer();
this.isRunning = true;
} catch (error) {
console.warn('Server not ready yet, will retry on first use:', error);
this.isRunning = false;
}
this.isStarting = false;
this.startHealthCheck();
return true;
} catch (error) {
console.error('Failed to start server:', error);
this.isStarting = false;
// Retry if we haven't exceeded max retries
if (this.startupRetries < this.MAX_STARTUP_RETRIES) {
this.startupRetries++;
console.log(`Retrying server startup (attempt ${this.startupRetries}/${this.MAX_STARTUP_RETRIES})...`);
await new Promise(resolve => setTimeout(resolve, 2000));
return this.startServer();
}
return false;
}
}
/**
* Stop the server (Android/iOS only)
*/
async stopServer(): Promise<void> {
if (!isCapacitor()) {
// On Electron, server is managed by electron/main.ts
return;
}
this.stopHealthCheck();
this.isRunning = false;
this.isStarting = false;
// Note: Capacitor NodeJS doesn't support stopping/restarting
// The runtime will be stopped when the app is closed
console.log('Server stop requested (runtime will continue until app closes)');
}
/**
* Check if server is running
*/
isServerRunning(): boolean {
return this.isRunning;
}
/**
* Wait for server to be ready
*/
private async waitForServer(maxWaitTime = 10000): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
try {
await streamingService.checkHealth();
return;
} catch (error) {
// Server not ready yet, wait and retry
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Don't throw error - server might start later
// Just log a warning and continue
console.warn('Server not ready yet, but continuing...');
}
/**
* Check server health
*/
async checkServerHealth(): Promise<boolean> {
try {
await streamingService.checkHealth();
if (!this.isRunning && isCapacitor()) {
this.isRunning = true;
this.startHealthCheck();
}
return true;
} catch (error) {
if (this.isRunning) {
this.isRunning = false;
this.stopHealthCheck();
}
return false;
}
}
/**
* Start periodic health checks
*/
private startHealthCheck(): void {
if (this.healthCheckInterval) {
return;
}
this.healthCheckInterval = setInterval(async () => {
const isHealthy = await this.checkServerHealth();
if (!isHealthy && isCapacitor()) {
console.warn('Server health check failed, attempting restart...');
// Attempt to restart server
this.isRunning = false;
await this.startServer();
}
}, this.HEALTH_CHECK_INTERVAL);
}
/**
* Stop periodic health checks
*/
private stopHealthCheck(): void {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
}
/**
* Restart the server
*/
async restartServer(): Promise<boolean> {
await this.stopServer();
await new Promise(resolve => setTimeout(resolve, 1000));
return this.startServer();
}
}
export const serverManager = new ServerManager();
export default serverManager;

View File

@ -1,6 +1,9 @@
import axios from 'axios'; import axios from 'axios';
import { getApiUrl } from '../../utils/platform';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; // Get API URL using platform-aware resolution
// Defaults to http://localhost:3001 for both Electron and Android
const getApiUrlValue = () => getApiUrl();
export interface StreamSession { export interface StreamSession {
sessionId: string; sessionId: string;
@ -39,7 +42,9 @@ class StreamingService {
connect(sessionId: string): Promise<void> { connect(sessionId: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
this.ws = new WebSocket(`ws://localhost:3001/ws`); const apiUrl = getApiUrlValue();
const wsUrl = apiUrl.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws';
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => { this.ws.onopen = () => {
console.log('WebSocket connected'); console.log('WebSocket connected');
@ -124,7 +129,8 @@ class StreamingService {
name: string, name: string,
quality?: string quality?: string
): Promise<{ sessionId: string; status: string; videoFile?: { name: string; size: number } }> { ): Promise<{ sessionId: string; status: string; videoFile?: { name: string; size: number } }> {
const response = await axios.post(`${API_URL}/api/stream/start`, { const apiUrl = getApiUrlValue();
const response = await axios.post(`${apiUrl}/api/stream/start`, {
hash, hash,
name, name,
quality, quality,
@ -136,7 +142,8 @@ class StreamingService {
* Get session status * Get session status
*/ */
async getStatus(sessionId: string): Promise<StreamSession> { async getStatus(sessionId: string): Promise<StreamSession> {
const response = await axios.get(`${API_URL}/api/stream/${sessionId}/status`); const apiUrl = getApiUrlValue();
const response = await axios.get(`${apiUrl}/api/stream/${sessionId}/status`);
return response.data; return response.data;
} }
@ -144,14 +151,16 @@ class StreamingService {
* Get direct video stream URL * Get direct video stream URL
*/ */
getVideoUrl(sessionId: string): string { getVideoUrl(sessionId: string): string {
return `${API_URL}/api/stream/${sessionId}/video`; const apiUrl = getApiUrlValue();
return `${apiUrl}/api/stream/${sessionId}/video`;
} }
/** /**
* Start HLS transcoding * Start HLS transcoding
*/ */
async startHls(sessionId: string): Promise<{ status: string; playlistUrl?: string }> { async startHls(sessionId: string): Promise<{ status: string; playlistUrl?: string }> {
const response = await axios.post(`${API_URL}/api/stream/${sessionId}/hls`); const apiUrl = getApiUrlValue();
const response = await axios.post(`${apiUrl}/api/stream/${sessionId}/hls`);
return response.data; return response.data;
} }
@ -159,7 +168,8 @@ class StreamingService {
* Get HLS playlist URL * Get HLS playlist URL
*/ */
getHlsUrl(sessionId: string): string { getHlsUrl(sessionId: string): string {
return `${API_URL}/api/stream/${sessionId}/hls/playlist.m3u8`; const apiUrl = getApiUrlValue();
return `${apiUrl}/api/stream/${sessionId}/hls/playlist.m3u8`;
} }
/** /**
@ -170,7 +180,8 @@ class StreamingService {
video?: { codec: string; width: number; height: number }; video?: { codec: string; width: number; height: number };
audio?: { codec: string; channels: number }; audio?: { codec: string; channels: number };
}> { }> {
const response = await axios.get(`${API_URL}/api/stream/${sessionId}/info`); const apiUrl = getApiUrlValue();
const response = await axios.get(`${apiUrl}/api/stream/${sessionId}/info`);
return response.data; return response.data;
} }
@ -178,7 +189,8 @@ class StreamingService {
* Stop streaming session * Stop streaming session
*/ */
async stopStream(sessionId: string): Promise<void> { async stopStream(sessionId: string): Promise<void> {
await axios.delete(`${API_URL}/api/stream/${sessionId}`); const apiUrl = getApiUrlValue();
await axios.delete(`${apiUrl}/api/stream/${sessionId}`);
} }
/** /**
@ -186,7 +198,8 @@ class StreamingService {
*/ */
async checkHealth(): Promise<{ status: string; activeTorrents: number }> { async checkHealth(): Promise<{ status: string; activeTorrents: number }> {
try { try {
const response = await axios.get(`${API_URL}/health`); const apiUrl = getApiUrlValue();
const response = await axios.get(`${apiUrl}/health`);
return response.data; return response.data;
} catch { } catch {
throw new Error('Streaming server is not available'); throw new Error('Streaming server is not available');

23
src/types/capacitor-nodejs.d.ts vendored Normal file
View File

@ -0,0 +1,23 @@
declare module 'capacitor-nodejs' {
export interface StartOptions {
nodeDir?: string;
script?: string;
args?: string[];
env?: Record<string, string>;
}
export interface ChannelPayloadData {
eventName: string;
args: any[];
}
export class NodeJS {
static start(options?: StartOptions): Promise<void>;
static whenReady(): Promise<void>;
static send(data: ChannelPayloadData): Promise<void>;
static addListener(eventName: string, listener: (data: { args: any[] }) => void): Promise<any>;
static removeListener(handle: any): Promise<void>;
static removeAllListeners(eventName?: string): Promise<void>;
}
}

View File

@ -57,9 +57,16 @@ export const getPlatform = (): string => {
return 'web'; return 'web';
}; };
export const isTauri = (): boolean => {
return typeof window !== 'undefined' && (
'__TAURI__' in window ||
'__TAURI_INTERNALS__' in window
);
};
export const supportsFullTorrent = (): boolean => { export const supportsFullTorrent = (): boolean => {
// Full torrent support (with DHT) only works in Electron/Node.js // Full torrent support (with DHT) works in Electron and Android (via nodejs-mobile or Tauri)
return isElectron(); return isElectron() || (isCapacitor() && isAndroid()) || (isTauri() && isAndroid());
}; };
export const canDownloadToFilesystem = (): boolean => { export const canDownloadToFilesystem = (): boolean => {
@ -87,3 +94,26 @@ export const openExternal = async (url: string): Promise<void> => {
} }
}; };
/**
* Get the API URL for the streaming server
* Returns the same default (localhost:3001) for both Electron and Android
*/
export const getApiUrl = (): string => {
// Allow environment variable override
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL;
}
// Default to localhost:3001 for all platforms
// Electron: Server runs via child_process.fork()
// Android: Server runs via nodejs-mobile/Capacitor plugin
return 'http://localhost:3001';
};
/**
* Check if the server is embedded (runs within the app)
* Returns true for Capacitor (Android/iOS), false for Electron/web
*/
export const isServerEmbedded = (): boolean => {
return isCapacitor();
};

21
src/vite-env.d.ts vendored
View File

@ -4,3 +4,24 @@ declare module 'srt-webvtt' {
export default function srtToVtt(srtBlob: Blob): Promise<string>; export default function srtToVtt(srtBlob: Blob): Promise<string>;
} }
// Electron type definitions
declare global {
interface Window {
electron?: {
getAppPath: () => Promise<string>;
getDownloadsPath: () => Promise<string>;
showItemInFolder: (path: string) => Promise<void>;
openExternal: (url: string) => Promise<void>;
minimizeWindow: () => Promise<void>;
maximizeWindow: () => Promise<void>;
closeWindow: () => Promise<void>;
isMaximized: () => Promise<boolean>;
onNavigate: (callback: (path: string) => void) => void;
platform?: string;
isElectron?: boolean;
};
}
}
export {};

View File

@ -21,6 +21,6 @@
"@/*": ["src/*"] "@/*": ["src/*"]
} }
}, },
"include": ["src"] "include": ["src", "src/vite-env.d.ts"]
} }

View File

@ -13,6 +13,9 @@ export default defineConfig({
build: { build: {
outDir: 'dist', outDir: 'dist',
emptyOutDir: true, emptyOutDir: true,
rollupOptions: {
external: ['capacitor-nodejs'],
},
}, },
server: { server: {
port: 5173, port: 5173,