1
@ -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
@ -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": {
|
||||||
|
|||||||
26
package.json
@ -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",
|
||||||
@ -96,4 +104,4 @@
|
|||||||
"allowToChangeInstallationDirectory": true
|
"allowToChangeInstallationDirectory": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
102
scripts/copy-server-to-android.js
Normal 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
@ -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
@ -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
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
@ -0,0 +1,4 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
6579
src-tauri/Cargo.lock
generated
Normal file
41
src-tauri/Cargo.toml
Normal 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
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
11
src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "enables the default permissions",
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
src-tauri/compile_errors.txt
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
16
src-tauri/src/lib.rs
Normal 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
@ -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");
|
||||||
|
}
|
||||||
122
src-tauri/src/session_manager.rs
Normal 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
@ -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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
174
src-tauri/src/torrent_server.rs
Normal 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
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
36
src-tauri/src/video_utils.rs
Normal 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
@ -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
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
30
src/main.tsx
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
setStatus('error');
|
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');
|
||||||
|
}
|
||||||
|
} 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 (
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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
@ -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;
|
||||||
|
|
||||||
24
src/plugins/NodeServer.web.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
253
src/services/server/serverManager.ts
Normal 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;
|
||||||
|
|
||||||
@ -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
@ -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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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
@ -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 {};
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,6 @@
|
|||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "src/vite-env.d.ts"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||